mirror of
https://github.com/immich-app/immich.git
synced 2025-12-12 07:41:02 -08:00
Compare commits
132 Commits
v2.2.1
...
feat/dynam
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd8ef9e1bf | ||
|
|
02de19df6d | ||
|
|
2152f20b6c | ||
|
|
a6c76e78d6 | ||
|
|
644a3bf090 | ||
|
|
42dd3315f8 | ||
|
|
3a694219bf | ||
|
|
d9fd52ea18 | ||
|
|
2a281e7906 | ||
|
|
5f987a95f5 | ||
|
|
edf577d7f7 | ||
|
|
5e482dabc6 | ||
|
|
76c73549ae | ||
|
|
271a42ac7f | ||
|
|
4462952564 | ||
|
|
38d4d1a573 | ||
|
|
d310c6f3cd | ||
|
|
c086a65fa8 | ||
|
|
7134dd29ca | ||
|
|
3e08953a43 | ||
|
|
58c3c7e26b | ||
|
|
237ddcb648 | ||
|
|
fbaeffd65c | ||
|
|
d64c339b4f | ||
|
|
69880ee165 | ||
|
|
15e00f82f0 | ||
|
|
ce82e27f4b | ||
|
|
eeee5147cc | ||
|
|
af22f9b014 | ||
|
|
1086f22166 | ||
|
|
e94eb5012f | ||
|
|
4dcc049465 | ||
|
|
d784d431d0 | ||
|
|
1200bfad13 | ||
|
|
f11bfb9581 | ||
|
|
074fdb2b96 | ||
|
|
f1f203719d | ||
|
|
f73ca9d9c0 | ||
|
|
ad3f4fb434 | ||
|
|
8001dedcbf | ||
|
|
07a39226c5 | ||
|
|
88e7e21683 | ||
|
|
2cefbf8ca3 | ||
|
|
4a6c50cd81 | ||
|
|
e0535e20e6 | ||
|
|
62580455af | ||
|
|
0e7e67efe1 | ||
|
|
2c54b506b3 | ||
|
|
8969b8bdb2 | ||
|
|
5186092faa | ||
|
|
4c9142308f | ||
|
|
bea5d4fd37 | ||
|
|
74c24bfa88 | ||
|
|
95834c68d9 | ||
|
|
09024c3558 | ||
|
|
137cb043ef | ||
|
|
edf21bae41 | ||
|
|
c958f9856d | ||
|
|
70ab8bc657 | ||
|
|
edde0f93ae | ||
|
|
896665bca9 | ||
|
|
e8e9e7830e | ||
|
|
4fd9e42ce5 | ||
|
|
337e3a8dac | ||
|
|
2dc81e28fc | ||
|
|
f915d4cc90 | ||
|
|
905f4375b0 | ||
|
|
0b3633db4f | ||
|
|
2f40f5aad8 | ||
|
|
2611e2ec20 | ||
|
|
433a3cd339 | ||
|
|
0b487897a4 | ||
|
|
d5c5bdffcb | ||
|
|
dea95ac2e6 | ||
|
|
9e2208b8dd | ||
|
|
6922a92b69 | ||
|
|
7a2c8e0662 | ||
|
|
787158247f | ||
|
|
b0a0b7c2e1 | ||
|
|
cb6d81771d | ||
|
|
8de6ec1a1b | ||
|
|
d27c01ef70 | ||
|
|
d6307b262f | ||
|
|
b2cbefe41e | ||
|
|
da5a72f6de | ||
|
|
45304f1211 | ||
|
|
a4e65a7ea8 | ||
|
|
dd393c8346 | ||
|
|
493cde9d55 | ||
|
|
7705c84b04 | ||
|
|
ce0172b8c1 | ||
|
|
718b3a7b52 | ||
|
|
8a73de018c | ||
|
|
d92df63f84 | ||
|
|
6c6b00067b | ||
|
|
9cc88ed2a6 | ||
|
|
4905bba694 | ||
|
|
853d19dc2d | ||
|
|
c935ae47d0 | ||
|
|
93ab42fa24 | ||
|
|
6913697ad1 | ||
|
|
a4ae86ce29 | ||
|
|
2c50f2e244 | ||
|
|
365abd8906 | ||
|
|
25fb43bbe3 | ||
|
|
125e8cee01 | ||
|
|
c15e9bfa72 | ||
|
|
35e188e6e7 | ||
|
|
3cc9dd126c | ||
|
|
aa69d89b9f | ||
|
|
29c14a3f58 | ||
|
|
0df70365d7 | ||
|
|
c34be73d81 | ||
|
|
f396e9e374 | ||
|
|
821a9d4691 | ||
|
|
cad654586f | ||
|
|
28eb1bc13c | ||
|
|
1e4779cf48 | ||
|
|
0647c22956 | ||
|
|
b8087b4fa2 | ||
|
|
d94cb9641b | ||
|
|
517c3e1d4c | ||
|
|
619de2a5e4 | ||
|
|
79d0e3e1ed | ||
|
|
f5ff36a1f8 | ||
|
|
b5efc9c16e | ||
|
|
1036076b0d | ||
|
|
c76324c611 | ||
|
|
0ddb92e1ec | ||
|
|
d08a520aa2 | ||
|
|
7bdf0f6c50 | ||
|
|
2b33a58448 |
@@ -29,6 +29,12 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||||
|
// https://github.com/devcontainers/features/issues/1466
|
||||||
|
"moby": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"forwardPorts": [3000, 9231, 9230, 2283],
|
"forwardPorts": [3000, 9231, 9230, 2283],
|
||||||
"portsAttributes": {
|
"portsAttributes": {
|
||||||
"3000": {
|
"3000": {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ services:
|
|||||||
- app-node_modules:/usr/src/app/node_modules
|
- app-node_modules:/usr/src/app/node_modules
|
||||||
- sveltekit:/usr/src/app/web/.svelte-kit
|
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||||
- coverage:/usr/src/app/web/coverage
|
- coverage:/usr/src/app/web/coverage
|
||||||
|
- ../plugins:/build/corePlugin
|
||||||
immich-web:
|
immich-web:
|
||||||
env_file: !reset []
|
env_file: !reset []
|
||||||
immich-machine-learning:
|
immich-machine-learning:
|
||||||
|
|||||||
10
.github/mise.toml
vendored
Normal file
10
.github/mise.toml
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[tasks.install]
|
||||||
|
run = "pnpm install --filter github --frozen-lockfile"
|
||||||
|
|
||||||
|
[tasks.format]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "prettier --check ."
|
||||||
|
|
||||||
|
[tasks."format-fix"]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "prettier --write ."
|
||||||
44
.github/workflows/build-mobile.yml
vendored
44
.github/workflows/build-mobile.yml
vendored
@@ -20,6 +20,30 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
ANDROID_STORE_PASSWORD:
|
ANDROID_STORE_PASSWORD:
|
||||||
required: true
|
required: true
|
||||||
|
APP_STORE_CONNECT_API_KEY_ID:
|
||||||
|
required: true
|
||||||
|
APP_STORE_CONNECT_API_KEY_ISSUER_ID:
|
||||||
|
required: true
|
||||||
|
APP_STORE_CONNECT_API_KEY:
|
||||||
|
required: true
|
||||||
|
IOS_CERTIFICATE_P12:
|
||||||
|
required: true
|
||||||
|
IOS_CERTIFICATE_PASSWORD:
|
||||||
|
required: true
|
||||||
|
IOS_PROVISIONING_PROFILE:
|
||||||
|
required: true
|
||||||
|
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION:
|
||||||
|
required: true
|
||||||
|
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION:
|
||||||
|
required: true
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE:
|
||||||
|
required: true
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION:
|
||||||
|
required: true
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION:
|
||||||
|
required: true
|
||||||
|
FASTLANE_TEAM_ID:
|
||||||
|
required: true
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
@@ -72,7 +96,7 @@ jobs:
|
|||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref || github.sha }}
|
ref: ${{ inputs.ref || github.sha }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -141,7 +165,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Publish Android Artifact
|
- name: Publish Android Artifact
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: release-apk-signed
|
name: release-apk-signed
|
||||||
path: mobile/build/app/outputs/flutter-apk/*.apk
|
path: mobile/build/app/outputs/flutter-apk/*.apk
|
||||||
@@ -164,13 +188,13 @@ jobs:
|
|||||||
needs: pre-job
|
needs: pre-job
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
# Run on main branch or workflow_dispatch
|
# Run on main branch or workflow_dispatch, or on PRs/other branches (build only, no upload)
|
||||||
if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true && github.ref == 'refs/heads/main' }}
|
if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref || github.sha }}
|
ref: ${{ inputs.ref || github.sha }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -279,13 +303,21 @@ jobs:
|
|||||||
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||||
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||||
ENVIRONMENT: ${{ inputs.environment || 'development' }}
|
ENVIRONMENT: ${{ inputs.environment || 'development' }}
|
||||||
|
BUNDLE_ID_SUFFIX: ${{ inputs.environment == 'production' && '' || 'development' }}
|
||||||
|
GITHUB_REF: ${{ github.ref }}
|
||||||
working-directory: ./mobile/ios
|
working-directory: ./mobile/ios
|
||||||
run: |
|
run: |
|
||||||
|
# Only upload to TestFlight on main branch
|
||||||
|
if [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
|
||||||
if [[ "$ENVIRONMENT" == "development" ]]; then
|
if [[ "$ENVIRONMENT" == "development" ]]; then
|
||||||
bundle exec fastlane gha_testflight_dev
|
bundle exec fastlane gha_testflight_dev
|
||||||
else
|
else
|
||||||
bundle exec fastlane gha_release_prod
|
bundle exec fastlane gha_release_prod
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
# Build only, no TestFlight upload for non-main branches
|
||||||
|
bundle exec fastlane gha_build_only
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Clean up keychain
|
- name: Clean up keychain
|
||||||
if: always()
|
if: always()
|
||||||
@@ -293,7 +325,7 @@ jobs:
|
|||||||
security delete-keychain build.keychain || true
|
security delete-keychain build.keychain || true
|
||||||
|
|
||||||
- name: Upload IPA artifact
|
- name: Upload IPA artifact
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: ios-release-ipa
|
name: ios-release-ipa
|
||||||
path: mobile/ios/Runner.ipa
|
path: mobile/ios/Runner.ipa
|
||||||
|
|||||||
2
.github/workflows/cache-cleanup.yml
vendored
2
.github/workflows/cache-cleanup.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|||||||
8
.github/workflows/cli.yml
vendored
8
.github/workflows/cli.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
|||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -78,13 +78,13 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||||
@@ -105,7 +105,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Generate docker image tags
|
- name: Generate docker image tags
|
||||||
id: metadata
|
id: metadata
|
||||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.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:6b8450bfc06770af1af66bce9bf2ced7d1d9b90df1a59fc4c83a17777a9f6723
|
image: ghcr.io/immich-app/mdq:main@sha256:9c905a4ff69f00c4b2f98b40b6090ab3ab18d1a15ed1379733b8691aa1fcb271
|
||||||
outputs:
|
outputs:
|
||||||
checked: ${{ steps.get_checkbox.outputs.checked }}
|
checked: ${{ steps.get_checkbox.outputs.checked }}
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
8
.github/workflows/codeql-analysis.yml
vendored
8
.github/workflows/codeql-analysis.yml
vendored
@@ -50,14 +50,14 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
# If this step fails, then you should remove it and run the build manually (see below)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||||
@@ -83,6 +83,6 @@ jobs:
|
|||||||
# ./location_of_script_within_repo/buildscript.sh
|
# ./location_of_script_within_repo/buildscript.sh
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||||
with:
|
with:
|
||||||
category: '/language:${{matrix.language}}'
|
category: '/language:${{matrix.language}}'
|
||||||
|
|||||||
4
.github/workflows/docs-build.yml
vendored
4
.github/workflows/docs-build.yml
vendored
@@ -60,7 +60,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -85,7 +85,7 @@ jobs:
|
|||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
- name: Upload build output
|
- name: Upload build output
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: docs-build-output
|
name: docs-build-output
|
||||||
path: docs/build/
|
path: docs/build/
|
||||||
|
|||||||
8
.github/workflows/docs-deploy.yml
vendored
8
.github/workflows/docs-deploy.yml
vendored
@@ -125,7 +125,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -174,7 +174,7 @@ jobs:
|
|||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
|
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
|
||||||
working-directory: 'deployment/modules/cloudflare/docs'
|
working-directory: 'deployment/modules/cloudflare/docs'
|
||||||
run: 'mise run tf apply'
|
run: 'mise run //deployment:tf apply'
|
||||||
|
|
||||||
- name: Deploy Docs Subdomain Output
|
- name: Deploy Docs Subdomain Output
|
||||||
id: docs-output
|
id: docs-output
|
||||||
@@ -186,7 +186,7 @@ jobs:
|
|||||||
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
|
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
|
||||||
working-directory: 'deployment/modules/cloudflare/docs'
|
working-directory: 'deployment/modules/cloudflare/docs'
|
||||||
run: |
|
run: |
|
||||||
mise run tf output -- -json | jq -r '
|
mise run //deployment:tf output -- -json | jq -r '
|
||||||
"projectName=\(.pages_project_name.value)",
|
"projectName=\(.pages_project_name.value)",
|
||||||
"subdomain=\(.immich_app_branch_subdomain.value)"
|
"subdomain=\(.immich_app_branch_subdomain.value)"
|
||||||
' >> $GITHUB_OUTPUT
|
' >> $GITHUB_OUTPUT
|
||||||
@@ -211,7 +211,7 @@ jobs:
|
|||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
|
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
|
||||||
working-directory: 'deployment/modules/cloudflare/docs-release'
|
working-directory: 'deployment/modules/cloudflare/docs-release'
|
||||||
run: 'mise run tf apply'
|
run: 'mise run //deployment:tf apply'
|
||||||
|
|
||||||
- name: Comment
|
- name: Comment
|
||||||
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
|
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
|
||||||
|
|||||||
4
.github/workflows/docs-destroy.yml
vendored
4
.github/workflows/docs-destroy.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
|
TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
|
||||||
working-directory: 'deployment/modules/cloudflare/docs'
|
working-directory: 'deployment/modules/cloudflare/docs'
|
||||||
run: 'mise run tf destroy -- -refresh=false'
|
run: 'mise run //deployment:tf destroy -- -refresh=false'
|
||||||
|
|
||||||
- name: Comment
|
- name: Comment
|
||||||
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
|
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
|
||||||
|
|||||||
4
.github/workflows/fix-format.yml
vendored
4
.github/workflows/fix-format.yml
vendored
@@ -22,7 +22,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@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.ref }}
|
ref: ${{ github.event.pull_request.head.ref }}
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
|
||||||
- name: Fix formatting
|
- name: Fix formatting
|
||||||
run: make install-all && make format-all
|
run: pnpm --recursive install && pnpm run --recursive --parallel fix:format
|
||||||
|
|
||||||
- name: Commit and push
|
- name: Commit and push
|
||||||
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
|
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
|
||||||
|
|||||||
24
.github/workflows/prepare-release.yml
vendored
24
.github/workflows/prepare-release.yml
vendored
@@ -55,14 +55,14 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
persist-credentials: true
|
persist-credentials: true
|
||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
|
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
@@ -99,6 +99,20 @@ jobs:
|
|||||||
ALIAS: ${{ secrets.ALIAS }}
|
ALIAS: ${{ secrets.ALIAS }}
|
||||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||||
|
# iOS secrets
|
||||||
|
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||||
|
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||||
|
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
|
||||||
|
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
|
||||||
|
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||||
|
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
|
||||||
|
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
||||||
|
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||||
|
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
|
||||||
|
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.bump_version.outputs.ref }}
|
ref: ${{ needs.bump_version.outputs.ref }}
|
||||||
environment: production
|
environment: production
|
||||||
@@ -118,19 +132,19 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Download APK
|
- name: Download APK
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: release-apk-signed
|
name: release-apk-signed
|
||||||
github-token: ${{ steps.generate-token.outputs.token }}
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
|
||||||
- name: Create draft release
|
- name: Create draft release
|
||||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1
|
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||||
with:
|
with:
|
||||||
draft: true
|
draft: true
|
||||||
tag_name: ${{ env.IMMICH_VERSION }}
|
tag_name: ${{ env.IMMICH_VERSION }}
|
||||||
|
|||||||
170
.github/workflows/release-pr.yml
vendored
Normal file
170
.github/workflows/release-pr.yml
vendored
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
name: Manage release PR
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
bump:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Generate a token
|
||||||
|
id: generate-token
|
||||||
|
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
with:
|
||||||
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
persist-credentials: true
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||||
|
with:
|
||||||
|
node-version-file: './server/.nvmrc'
|
||||||
|
cache: 'pnpm'
|
||||||
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
|
||||||
|
- name: Determine release type
|
||||||
|
id: bump-type
|
||||||
|
uses: ietf-tools/semver-action@c90370b2958652d71c06a3484129a4d423a6d8a8 # v1.11.0
|
||||||
|
with:
|
||||||
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
|
||||||
|
- name: Bump versions
|
||||||
|
env:
|
||||||
|
TYPE: ${{ steps.bump-type.outputs.bump }}
|
||||||
|
run: |
|
||||||
|
if [ "$TYPE" == "none" ]; then
|
||||||
|
exit 1 # TODO: Is there a cleaner way to abort the workflow?
|
||||||
|
fi
|
||||||
|
misc/release/pump-version.sh -s $TYPE -m true
|
||||||
|
|
||||||
|
- name: Manage Outline release document
|
||||||
|
id: outline
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
env:
|
||||||
|
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
|
||||||
|
NEXT_VERSION: ${{ steps.bump-type.outputs.next }}
|
||||||
|
with:
|
||||||
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const outlineKey = process.env.OUTLINE_API_KEY;
|
||||||
|
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9'
|
||||||
|
const collectionId = 'e2910656-714c-4871-8721-447d9353bd73';
|
||||||
|
const baseUrl = 'https://outline.immich.cloud';
|
||||||
|
|
||||||
|
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${outlineKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ parentDocumentId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!listResponse.ok) {
|
||||||
|
throw new Error(`Outline list failed: ${listResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listData = await listResponse.json();
|
||||||
|
const allDocuments = listData.data || [];
|
||||||
|
|
||||||
|
const document = allDocuments.find(doc => doc.title === 'next');
|
||||||
|
|
||||||
|
let documentId;
|
||||||
|
let documentUrl;
|
||||||
|
let documentText;
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
// Create new document
|
||||||
|
console.log('No existing document found. Creating new one...');
|
||||||
|
const notesTmpl = fs.readFileSync('misc/release/notes.tmpl', 'utf8');
|
||||||
|
const createResponse = await fetch(`${baseUrl}/api/documents.create`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${outlineKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: 'next',
|
||||||
|
text: notesTmpl,
|
||||||
|
collectionId: collectionId,
|
||||||
|
parentDocumentId: parentDocumentId,
|
||||||
|
publish: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createResponse.ok) {
|
||||||
|
throw new Error(`Failed to create document: ${createResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createData = await createResponse.json();
|
||||||
|
documentId = createData.data.id;
|
||||||
|
const urlId = createData.data.urlId;
|
||||||
|
documentUrl = `${baseUrl}/doc/next-${urlId}`;
|
||||||
|
documentText = createData.data.text || '';
|
||||||
|
console.log(`Created new document: ${documentUrl}`);
|
||||||
|
} else {
|
||||||
|
documentId = document.id;
|
||||||
|
const docPath = document.url;
|
||||||
|
documentUrl = `${baseUrl}${docPath}`;
|
||||||
|
documentText = document.text || '';
|
||||||
|
console.log(`Found existing document: ${documentUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate GitHub release notes
|
||||||
|
console.log('Generating GitHub release notes...');
|
||||||
|
const releaseNotesResponse = await github.rest.repos.generateReleaseNotes({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
tag_name: `${process.env.NEXT_VERSION}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine the content
|
||||||
|
const changelog = `
|
||||||
|
# ${process.env.NEXT_VERSION}
|
||||||
|
|
||||||
|
${documentText}
|
||||||
|
|
||||||
|
${releaseNotesResponse.data.body}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
`
|
||||||
|
|
||||||
|
const existingChangelog = fs.existsSync('CHANGELOG.md') ? fs.readFileSync('CHANGELOG.md', 'utf8') : '';
|
||||||
|
fs.writeFileSync('CHANGELOG.md', changelog + existingChangelog, 'utf8');
|
||||||
|
|
||||||
|
core.setOutput('document_url', documentUrl);
|
||||||
|
|
||||||
|
- name: Create PR
|
||||||
|
id: create-pr
|
||||||
|
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||||
|
with:
|
||||||
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
||||||
|
title: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
||||||
|
body: 'Release notes: ${{ steps.outline.outputs.document_url }}'
|
||||||
|
labels: 'changelog:skip'
|
||||||
|
branch: 'release/next'
|
||||||
|
draft: true
|
||||||
147
.github/workflows/release.yml
vendored
Normal file
147
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
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@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
with:
|
||||||
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
persist-credentials: false
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- name: Extract changelog
|
||||||
|
id: changelog
|
||||||
|
run: |
|
||||||
|
CHANGELOG_PATH=$RUNNER_TEMP/changelog.md
|
||||||
|
sed -n '1,/^---$/p' CHANGELOG.md | head -n -1 > $CHANGELOG_PATH
|
||||||
|
echo "path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT
|
||||||
|
VERSION=$(sed -n 's/^# //p' $CHANGELOG_PATH)
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Download APK
|
||||||
|
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||||
|
with:
|
||||||
|
name: release-apk-signed
|
||||||
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
|
||||||
|
- name: Create draft release
|
||||||
|
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.version.outputs.result }}
|
||||||
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
body_path: ${{ steps.changelog.outputs.path }}
|
||||||
|
files: |
|
||||||
|
docker/docker-compose.yml
|
||||||
|
docker/example.env
|
||||||
|
docker/hwaccel.ml.yml
|
||||||
|
docker/hwaccel.transcoding.yml
|
||||||
|
docker/prometheus.yml
|
||||||
|
*.apk
|
||||||
|
|
||||||
|
- name: Rename Outline document
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
|
||||||
|
VERSION: ${{ steps.changelog.outputs.version }}
|
||||||
|
with:
|
||||||
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
script: |
|
||||||
|
const outlineKey = process.env.OUTLINE_API_KEY;
|
||||||
|
const version = process.env.VERSION;
|
||||||
|
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9';
|
||||||
|
const baseUrl = 'https://outline.immich.cloud';
|
||||||
|
|
||||||
|
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${outlineKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ parentDocumentId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!listResponse.ok) {
|
||||||
|
throw new Error(`Outline list failed: ${listResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listData = await listResponse.json();
|
||||||
|
const allDocuments = listData.data || [];
|
||||||
|
const document = allDocuments.find(doc => doc.title === 'next');
|
||||||
|
|
||||||
|
if (document) {
|
||||||
|
console.log(`Found document 'next', renaming to '${version}'...`);
|
||||||
|
|
||||||
|
const updateResponse = await fetch(`${baseUrl}/api/documents.update`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${outlineKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: document.id,
|
||||||
|
title: version
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updateResponse.ok) {
|
||||||
|
throw new Error(`Failed to rename document: ${updateResponse.statusText}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('No document titled "next" found to rename');
|
||||||
|
}
|
||||||
2
.github/workflows/sdk.yml
vendored
2
.github/workflows/sdk.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
|||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|||||||
2
.github/workflows/static_analysis.yml
vendored
2
.github/workflows/static_analysis.yml
vendored
@@ -55,7 +55,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|||||||
43
.github/workflows/test.yml
vendored
43
.github/workflows/test.yml
vendored
@@ -69,7 +69,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -114,7 +114,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -161,7 +161,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -203,7 +203,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -247,7 +247,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -285,7 +285,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -333,7 +333,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -379,9 +379,10 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
submodules: 'recursive'
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
@@ -417,7 +418,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
@@ -472,7 +473,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
@@ -499,8 +500,16 @@ jobs:
|
|||||||
run: docker compose build
|
run: docker compose build
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
- name: Run e2e tests (web)
|
- name: Run e2e tests (web)
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
run: npx playwright test
|
run: npx playwright test
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
- name: Archive test results
|
||||||
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
|
if: success() || failure()
|
||||||
|
with:
|
||||||
|
name: e2e-web-test-results-${{ matrix.runner }}
|
||||||
|
path: e2e/playwright-report/
|
||||||
success-check-e2e:
|
success-check-e2e:
|
||||||
name: End-to-End Tests Success
|
name: End-to-End Tests Success
|
||||||
needs: [e2e-tests-server-cli, e2e-tests-web]
|
needs: [e2e-tests-server-cli, e2e-tests-web]
|
||||||
@@ -525,7 +534,7 @@ jobs:
|
|||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -557,12 +566,12 @@ jobs:
|
|||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
|
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
||||||
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||||
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
|
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
|
||||||
# with:
|
# with:
|
||||||
@@ -601,7 +610,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -630,7 +639,7 @@ jobs:
|
|||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -652,7 +661,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -714,7 +723,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|||||||
3
Makefile
3
Makefile
@@ -17,6 +17,9 @@ dev-docs:
|
|||||||
e2e:
|
e2e:
|
||||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
|
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
|
||||||
|
|
||||||
|
e2e-dev:
|
||||||
|
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.dev.yml up --remove-orphans
|
||||||
|
|
||||||
e2e-update:
|
e2e-update:
|
||||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
|
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
|
||||||
|
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -118,16 +118,16 @@ Read more about translations [here](https://docs.immich.app/developer/translatio
|
|||||||
|
|
||||||
## Star history
|
## Star history
|
||||||
|
|
||||||
<a href="https://star-history.com/#immich-app/immich&Date">
|
<a href="https://star-history.com/#immich-app/immich&type=date&legend=top-left">
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=date&theme=dark" />
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=date" />
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
|
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=date" width="100%" />
|
||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
<a href="https://github.com/immich-app/immich/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
|
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
29
cli/mise.toml
Normal file
29
cli/mise.toml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[tasks.install]
|
||||||
|
run = "pnpm install --filter @immich/cli --frozen-lockfile"
|
||||||
|
|
||||||
|
[tasks.build]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "vite build"
|
||||||
|
|
||||||
|
[tasks.test]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "vite"
|
||||||
|
|
||||||
|
[tasks.lint]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "eslint \"src/**/*.ts\" --max-warnings 0"
|
||||||
|
|
||||||
|
[tasks."lint-fix"]
|
||||||
|
run = { task = "lint --fix" }
|
||||||
|
|
||||||
|
[tasks.format]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "prettier --check ."
|
||||||
|
|
||||||
|
[tasks."format-fix"]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "prettier --write ."
|
||||||
|
|
||||||
|
[tasks.check]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "tsc --noEmit"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.99",
|
"version": "2.2.101",
|
||||||
"description": "Command Line Interface (CLI) for Immich",
|
"description": "Command Line Interface (CLI) for Immich",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": "./dist/index.js",
|
"exports": "./dist/index.js",
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/micromatch": "^4.0.9",
|
"@types/micromatch": "^4.0.9",
|
||||||
"@types/mock-fs": "^4.13.1",
|
"@types/mock-fs": "^4.13.1",
|
||||||
"@types/node": "^22.18.12",
|
"@types/node": "^22.19.1",
|
||||||
"@vitest/coverage-v8": "^3.0.0",
|
"@vitest/coverage-v8": "^3.0.0",
|
||||||
"byte-size": "^9.0.0",
|
"byte-size": "^9.0.0",
|
||||||
"cli-progress": "^3.12.0",
|
"cli-progress": "^3.12.0",
|
||||||
|
|||||||
20
deployment/mise.toml
Normal file
20
deployment/mise.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[tools]
|
||||||
|
terragrunt = "0.91.2"
|
||||||
|
opentofu = "1.10.6"
|
||||||
|
|
||||||
|
[tasks."tg:fmt"]
|
||||||
|
run = "terragrunt hclfmt"
|
||||||
|
description = "Format terragrunt files"
|
||||||
|
|
||||||
|
[tasks.tf]
|
||||||
|
run = "terragrunt run --all"
|
||||||
|
description = "Wrapper for terragrunt run-all"
|
||||||
|
dir = "{{cwd}}"
|
||||||
|
|
||||||
|
[tasks."tf:fmt"]
|
||||||
|
run = "tofu fmt -recursive tf/"
|
||||||
|
description = "Format terraform files"
|
||||||
|
|
||||||
|
[tasks."tf:init"]
|
||||||
|
run = { task = "tf init -- -reconfigure" }
|
||||||
|
dir = "{{cwd}}"
|
||||||
@@ -41,6 +41,7 @@ services:
|
|||||||
- app-node_modules:/usr/src/app/node_modules
|
- app-node_modules:/usr/src/app/node_modules
|
||||||
- sveltekit:/usr/src/app/web/.svelte-kit
|
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||||
- coverage:/usr/src/app/web/coverage
|
- coverage:/usr/src/app/web/coverage
|
||||||
|
- ../plugins:/build/corePlugin
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ services:
|
|||||||
container_name: immich_prometheus
|
container_name: immich_prometheus
|
||||||
ports:
|
ports:
|
||||||
- 9090:9090
|
- 9090:9090
|
||||||
image: prom/prometheus@sha256:23031bfe0e74a13004252caaa74eccd0d62b6c6e7a04711d5b8bf5b7e113adc7
|
image: prom/prometheus@sha256:49214755b6153f90a597adcbff0252cc61069f8ab69ce8411285cd4a560e8038
|
||||||
volumes:
|
volumes:
|
||||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
- prometheus-data:/prometheus
|
- prometheus-data:/prometheus
|
||||||
|
|||||||
18
docs/docs/administration/maintenance-mode.md
Normal file
18
docs/docs/administration/maintenance-mode.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Maintenance Mode
|
||||||
|
|
||||||
|
Maintenance mode is used to perform administrative tasks such as restoring backups to Immich.
|
||||||
|
|
||||||
|
You can enter maintenance mode by either:
|
||||||
|
|
||||||
|
- Selecting "enable maintenance mode" in system settings in administration.
|
||||||
|
- Running the enable maintenance mode [administration command](./server-commands.md).
|
||||||
|
|
||||||
|
## Logging in during maintenance
|
||||||
|
|
||||||
|
Maintenance mode uses a separate login system which is handled automatically behind the scenes in most cases. Enabling maintenance mode in settings will automatically log you into maintenance mode when the server comes back up.
|
||||||
|
|
||||||
|
If you find that you've been logged out, you can:
|
||||||
|
|
||||||
|
- Open the logs for the Immich server and look for _"🚧 Immich is in maintenance mode, you can log in using the following URL:"_
|
||||||
|
- Run the enable maintenance mode [administration command](./server-commands.md) again, this will give you a new URL to login with.
|
||||||
|
- Run the disable maintenance mode [administration command](./server-commands.md) then re-enter through system settings.
|
||||||
@@ -10,16 +10,19 @@ Running with a pre-existing Postgres server can unlock powerful administrative f
|
|||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
You must install `pgvector` (`>= 0.7.0, < 1.0.0`), as it is a prerequisite for `vchord`.
|
You must install pgvector as it is a prerequisite for VectorChord.
|
||||||
The easiest way to do this on Debian/Ubuntu is by adding the [PostgreSQL Apt repository][pg-apt] and then
|
The easiest way to do this on Debian/Ubuntu is by adding the [PostgreSQL Apt repository][pg-apt] and then
|
||||||
running `apt install postgresql-NN-pgvector`, where `NN` is your Postgres version (e.g., `16`).
|
running `apt install postgresql-NN-pgvector`, where `NN` is your Postgres version (e.g., `16`).
|
||||||
|
|
||||||
You must install VectorChord into your instance of Postgres using their [instructions][vchord-install]. After installation, add `shared_preload_libraries = 'vchord.so'` to your `postgresql.conf`. If you already have some `shared_preload_libraries` set, you can separate each extension with a comma. For example, `shared_preload_libraries = 'pg_stat_statements, vchord.so'`.
|
You must install VectorChord into your instance of Postgres using their [instructions][vchord-install]. After installation, add `shared_preload_libraries = 'vchord.so'` to your `postgresql.conf`. If you already have some `shared_preload_libraries` set, you can separate each extension with a comma. For example, `shared_preload_libraries = 'pg_stat_statements, vchord.so'`.
|
||||||
|
|
||||||
:::note
|
:::note Supported versions
|
||||||
Immich is known to work with Postgres versions `>= 14, < 18`.
|
Immich is known to work with Postgres versions `>= 14, < 19`.
|
||||||
|
|
||||||
Make sure the installed version of VectorChord is compatible with your version of Immich. The current accepted range for VectorChord is `>= 0.3.0, < 0.5.0`.
|
VectorChord is known to work with pgvector versions `>= 0.7, < 0.9`.
|
||||||
|
|
||||||
|
The Immich server will check the VectorChord version on startup to ensure compatibility, and refuse to start if a compatible version is not found.
|
||||||
|
The current accepted range for VectorChord is `>= 0.3, < 0.6`.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Specifying the connection URL
|
## Specifying the connection URL
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
The `immich-server` docker image comes preinstalled with an administrative CLI (`immich-admin`) that supports the following commands:
|
The `immich-server` docker image comes preinstalled with an administrative CLI (`immich-admin`) that supports the following commands:
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ------------------------ | ------------------------------------------------------------- |
|
| -------------------------- | ------------------------------------------------------------- |
|
||||||
| `help` | Display help |
|
| `help` | Display help |
|
||||||
| `reset-admin-password` | Reset the password for the admin user |
|
| `reset-admin-password` | Reset the password for the admin user |
|
||||||
| `disable-password-login` | Disable password login |
|
| `disable-password-login` | Disable password login |
|
||||||
| `enable-password-login` | Enable password login |
|
| `enable-password-login` | Enable password login |
|
||||||
|
| `disable-maintenance-mode` | Disable maintenance mode |
|
||||||
|
| `enable-maintenance-mode` | Enable maintenance mode |
|
||||||
| `enable-oauth-login` | Enable OAuth login |
|
| `enable-oauth-login` | Enable OAuth login |
|
||||||
| `disable-oauth-login` | Disable OAuth login |
|
| `disable-oauth-login` | Disable OAuth login |
|
||||||
| `list-users` | List Immich users |
|
| `list-users` | List Immich users |
|
||||||
@@ -47,6 +49,23 @@ immich-admin enable-password-login
|
|||||||
Password login has been enabled.
|
Password login has been enabled.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Disable Maintenance Mode
|
||||||
|
|
||||||
|
```
|
||||||
|
immich-admin disable-maintenace-mode
|
||||||
|
Maintenance mode has been disabled.
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable Maintenance Mode
|
||||||
|
|
||||||
|
```
|
||||||
|
immich-admin enable-maintenance-mode
|
||||||
|
Maintenance mode has been enabled.
|
||||||
|
|
||||||
|
Log in using the following URL:
|
||||||
|
https://my.immich.app/maintenance?token=<token>
|
||||||
|
```
|
||||||
|
|
||||||
Enable OAuth login
|
Enable OAuth login
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -12,3 +12,13 @@ pnpm run migrations:generate <migration-name>
|
|||||||
3. Move the migration file to folder `./server/src/schema/migrations` in your code editor.
|
3. Move the migration file to folder `./server/src/schema/migrations` in your code editor.
|
||||||
|
|
||||||
The server will automatically detect `*.ts` file changes and restart. Part of the server start-up process includes running any new migrations, so it will be applied immediately.
|
The server will automatically detect `*.ts` file changes and restart. Part of the server start-up process includes running any new migrations, so it will be applied immediately.
|
||||||
|
|
||||||
|
## Reverting a Migration
|
||||||
|
|
||||||
|
If you need to undo the most recently applied migration—for example, when developing or testing on schema changes—run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run migrations:revert
|
||||||
|
```
|
||||||
|
|
||||||
|
This command rolls back the latest migration and brings the database schema back to its previous state.
|
||||||
|
|||||||
@@ -268,12 +268,13 @@ make test-all # Runs tests for all components
|
|||||||
make test-medium-dev # End-to-end tests
|
make test-medium-dev # End-to-end tests
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Using NPM Directly
|
#### Using PNPM Directly
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Server tests
|
# Server tests
|
||||||
cd /workspaces/immich/server
|
cd /workspaces/immich/server
|
||||||
pnpm test # Run all tests
|
pnpm test # Run all tests
|
||||||
|
pnpm run test:medium # Medium tests (integration tests)
|
||||||
pnpm run test:watch # Watch mode
|
pnpm run test:watch # Watch mode
|
||||||
pnpm run test:cov # Coverage report
|
pnpm run test:cov # Coverage report
|
||||||
|
|
||||||
@@ -307,7 +308,7 @@ make check-web # Type check web
|
|||||||
make check-all # Check all components
|
make check-all # Check all components
|
||||||
|
|
||||||
# Complete hygiene check
|
# Complete hygiene check
|
||||||
make hygiene-all # Runs lint, format, check, SQL sync, and audit
|
make hygiene-all # Run lint, format, check, SQL sync, and audit
|
||||||
```
|
```
|
||||||
|
|
||||||
### Additional Make Commands
|
### Additional Make Commands
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ sidebar_position: 2
|
|||||||
# Setup
|
# Setup
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
If there's a feature you're planning to work on, just give us a heads up in [Discord](https://discord.com/channels/979116623879368755/1071165397228855327) so we can:
|
If there's a feature you're planning to work on, just give us a heads up in [#contributing](https://discord.com/channels/979116623879368755/1071165397228855327) on [our Discord](https://discord.immich.app) so we can:
|
||||||
|
|
||||||
1. Let you know if it's something we would accept into Immich
|
1. Let you know if it's something we would accept into Immich
|
||||||
2. Provide any guidance on how something like that would ideally be implemented
|
2. Provide any guidance on how something like that would ideally be implemented
|
||||||
|
|||||||
@@ -106,14 +106,14 @@ SELECT "user"."email", "asset"."type", COUNT(*) FROM "asset"
|
|||||||
|
|
||||||
```sql title="Count by tag"
|
```sql title="Count by tag"
|
||||||
SELECT "t"."value" AS "tag_name", COUNT(*) AS "number_assets" FROM "tag" "t"
|
SELECT "t"."value" AS "tag_name", COUNT(*) AS "number_assets" FROM "tag" "t"
|
||||||
JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagsId" JOIN "asset" "a" ON "ta"."assetsId" = "a"."id"
|
JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagId" JOIN "asset" "a" ON "ta"."assetId" = "a"."id"
|
||||||
WHERE "a"."visibility" != 'hidden'
|
WHERE "a"."visibility" != 'hidden'
|
||||||
GROUP BY "t"."value" ORDER BY "number_assets" DESC;
|
GROUP BY "t"."value" ORDER BY "number_assets" DESC;
|
||||||
```
|
```
|
||||||
|
|
||||||
```sql title="Count by tag (per user)"
|
```sql title="Count by tag (per user)"
|
||||||
SELECT "t"."value" AS "tag_name", "u"."email" as "user_email", COUNT(*) AS "number_assets" FROM "tag" "t"
|
SELECT "t"."value" AS "tag_name", "u"."email" as "user_email", COUNT(*) AS "number_assets" FROM "tag" "t"
|
||||||
JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagsId" JOIN "asset" "a" ON "ta"."assetsId" = "a"."id" JOIN "user" "u" ON "a"."ownerId" = "u"."id"
|
JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagId" JOIN "asset" "a" ON "ta"."assetId" = "a"."id" JOIN "user" "u" ON "a"."ownerId" = "u"."id"
|
||||||
WHERE "a"."visibility" != 'hidden'
|
WHERE "a"."visibility" != 'hidden'
|
||||||
GROUP BY "t"."value", "u"."email" ORDER BY "number_assets" DESC;
|
GROUP BY "t"."value", "u"."email" ORDER BY "number_assets" DESC;
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -16,48 +16,76 @@ The default configuration looks like this:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"ffmpeg": {
|
|
||||||
"crf": 23,
|
|
||||||
"threads": 0,
|
|
||||||
"preset": "ultrafast",
|
|
||||||
"targetVideoCodec": "h264",
|
|
||||||
"acceptedVideoCodecs": ["h264"],
|
|
||||||
"targetAudioCodec": "aac",
|
|
||||||
"acceptedAudioCodecs": ["aac", "mp3", "libopus", "pcm_s16le"],
|
|
||||||
"acceptedContainers": ["mov", "ogg", "webm"],
|
|
||||||
"targetResolution": "720",
|
|
||||||
"maxBitrate": "0",
|
|
||||||
"bframes": -1,
|
|
||||||
"refs": 0,
|
|
||||||
"gopSize": 0,
|
|
||||||
"temporalAQ": false,
|
|
||||||
"cqMode": "auto",
|
|
||||||
"twoPass": false,
|
|
||||||
"preferredHwDevice": "auto",
|
|
||||||
"transcode": "required",
|
|
||||||
"tonemap": "hable",
|
|
||||||
"accel": "disabled",
|
|
||||||
"accelDecode": false
|
|
||||||
},
|
|
||||||
"backup": {
|
"backup": {
|
||||||
"database": {
|
"database": {
|
||||||
"enabled": true,
|
|
||||||
"cronExpression": "0 02 * * *",
|
"cronExpression": "0 02 * * *",
|
||||||
|
"enabled": true,
|
||||||
"keepLastAmount": 14
|
"keepLastAmount": 14
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ffmpeg": {
|
||||||
|
"accel": "disabled",
|
||||||
|
"accelDecode": false,
|
||||||
|
"acceptedAudioCodecs": ["aac", "mp3", "libopus"],
|
||||||
|
"acceptedContainers": ["mov", "ogg", "webm"],
|
||||||
|
"acceptedVideoCodecs": ["h264"],
|
||||||
|
"bframes": -1,
|
||||||
|
"cqMode": "auto",
|
||||||
|
"crf": 23,
|
||||||
|
"gopSize": 0,
|
||||||
|
"maxBitrate": "0",
|
||||||
|
"preferredHwDevice": "auto",
|
||||||
|
"preset": "ultrafast",
|
||||||
|
"refs": 0,
|
||||||
|
"targetAudioCodec": "aac",
|
||||||
|
"targetResolution": "720",
|
||||||
|
"targetVideoCodec": "h264",
|
||||||
|
"temporalAQ": false,
|
||||||
|
"threads": 0,
|
||||||
|
"tonemap": "hable",
|
||||||
|
"transcode": "required",
|
||||||
|
"twoPass": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"colorspace": "p3",
|
||||||
|
"extractEmbedded": false,
|
||||||
|
"fullsize": {
|
||||||
|
"enabled": false,
|
||||||
|
"format": "jpeg",
|
||||||
|
"quality": 80
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"format": "jpeg",
|
||||||
|
"quality": 80,
|
||||||
|
"size": 1440
|
||||||
|
},
|
||||||
|
"thumbnail": {
|
||||||
|
"format": "webp",
|
||||||
|
"quality": 80,
|
||||||
|
"size": 250
|
||||||
|
}
|
||||||
|
},
|
||||||
"job": {
|
"job": {
|
||||||
"backgroundTask": {
|
"backgroundTask": {
|
||||||
"concurrency": 5
|
"concurrency": 5
|
||||||
},
|
},
|
||||||
"smartSearch": {
|
"faceDetection": {
|
||||||
"concurrency": 2
|
"concurrency": 2
|
||||||
},
|
},
|
||||||
|
"library": {
|
||||||
|
"concurrency": 5
|
||||||
|
},
|
||||||
"metadataExtraction": {
|
"metadataExtraction": {
|
||||||
"concurrency": 5
|
"concurrency": 5
|
||||||
},
|
},
|
||||||
"faceDetection": {
|
"migration": {
|
||||||
"concurrency": 2
|
"concurrency": 5
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"concurrency": 5
|
||||||
|
},
|
||||||
|
"ocr": {
|
||||||
|
"concurrency": 1
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"concurrency": 5
|
"concurrency": 5
|
||||||
@@ -65,20 +93,23 @@ The default configuration looks like this:
|
|||||||
"sidecar": {
|
"sidecar": {
|
||||||
"concurrency": 5
|
"concurrency": 5
|
||||||
},
|
},
|
||||||
"library": {
|
"smartSearch": {
|
||||||
"concurrency": 5
|
"concurrency": 2
|
||||||
},
|
|
||||||
"migration": {
|
|
||||||
"concurrency": 5
|
|
||||||
},
|
},
|
||||||
"thumbnailGeneration": {
|
"thumbnailGeneration": {
|
||||||
"concurrency": 3
|
"concurrency": 3
|
||||||
},
|
},
|
||||||
"videoConversion": {
|
"videoConversion": {
|
||||||
"concurrency": 1
|
"concurrency": 1
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"library": {
|
||||||
"concurrency": 5
|
"scan": {
|
||||||
|
"cronExpression": "0 0 * * *",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"watch": {
|
||||||
|
"enabled": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"logging": {
|
"logging": {
|
||||||
@@ -86,8 +117,11 @@ The default configuration looks like this:
|
|||||||
"level": "log"
|
"level": "log"
|
||||||
},
|
},
|
||||||
"machineLearning": {
|
"machineLearning": {
|
||||||
|
"availabilityChecks": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"urls": ["http://immich-machine-learning:3003"],
|
"interval": 30000,
|
||||||
|
"timeout": 2000
|
||||||
|
},
|
||||||
"clip": {
|
"clip": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"modelName": "ViT-B-32__openai"
|
"modelName": "ViT-B-32__openai"
|
||||||
@@ -96,27 +130,59 @@ The default configuration looks like this:
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"maxDistance": 0.01
|
"maxDistance": 0.01
|
||||||
},
|
},
|
||||||
|
"enabled": true,
|
||||||
"facialRecognition": {
|
"facialRecognition": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"modelName": "buffalo_l",
|
|
||||||
"minScore": 0.7,
|
|
||||||
"maxDistance": 0.5,
|
"maxDistance": 0.5,
|
||||||
"minFaces": 3
|
"minFaces": 3,
|
||||||
}
|
"minScore": 0.7,
|
||||||
|
"modelName": "buffalo_l"
|
||||||
|
},
|
||||||
|
"ocr": {
|
||||||
|
"enabled": true,
|
||||||
|
"maxResolution": 736,
|
||||||
|
"minDetectionScore": 0.5,
|
||||||
|
"minRecognitionScore": 0.8,
|
||||||
|
"modelName": "PP-OCRv5_mobile"
|
||||||
|
},
|
||||||
|
"urls": ["http://immich-machine-learning:3003"]
|
||||||
},
|
},
|
||||||
"map": {
|
"map": {
|
||||||
|
"darkStyle": "https://tiles.immich.cloud/v1/style/dark.json",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"lightStyle": "https://tiles.immich.cloud/v1/style/light.json",
|
"lightStyle": "https://tiles.immich.cloud/v1/style/light.json"
|
||||||
"darkStyle": "https://tiles.immich.cloud/v1/style/dark.json"
|
|
||||||
},
|
|
||||||
"reverseGeocoding": {
|
|
||||||
"enabled": true
|
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"faces": {
|
"faces": {
|
||||||
"import": false
|
"import": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"newVersionCheck": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"nightlyTasks": {
|
||||||
|
"clusterNewFaces": true,
|
||||||
|
"databaseCleanup": true,
|
||||||
|
"generateMemories": true,
|
||||||
|
"missingThumbnails": true,
|
||||||
|
"startTime": "00:00",
|
||||||
|
"syncQuotaUsage": true
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"smtp": {
|
||||||
|
"enabled": false,
|
||||||
|
"from": "",
|
||||||
|
"replyTo": "",
|
||||||
|
"transport": {
|
||||||
|
"host": "",
|
||||||
|
"ignoreCert": false,
|
||||||
|
"password": "",
|
||||||
|
"port": 587,
|
||||||
|
"secure": false,
|
||||||
|
"username": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"oauth": {
|
"oauth": {
|
||||||
"autoLaunch": false,
|
"autoLaunch": false,
|
||||||
"autoRegister": true,
|
"autoRegister": true,
|
||||||
@@ -128,70 +194,44 @@ The default configuration looks like this:
|
|||||||
"issuerUrl": "",
|
"issuerUrl": "",
|
||||||
"mobileOverrideEnabled": false,
|
"mobileOverrideEnabled": false,
|
||||||
"mobileRedirectUri": "",
|
"mobileRedirectUri": "",
|
||||||
|
"profileSigningAlgorithm": "none",
|
||||||
|
"roleClaim": "immich_role",
|
||||||
"scope": "openid email profile",
|
"scope": "openid email profile",
|
||||||
"signingAlgorithm": "RS256",
|
"signingAlgorithm": "RS256",
|
||||||
"profileSigningAlgorithm": "none",
|
|
||||||
"storageLabelClaim": "preferred_username",
|
"storageLabelClaim": "preferred_username",
|
||||||
"storageQuotaClaim": "immich_quota"
|
"storageQuotaClaim": "immich_quota",
|
||||||
|
"timeout": 30000,
|
||||||
|
"tokenEndpointAuthMethod": "client_secret_post"
|
||||||
},
|
},
|
||||||
"passwordLogin": {
|
"passwordLogin": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
"reverseGeocoding": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"externalDomain": "",
|
||||||
|
"loginPageMessage": "",
|
||||||
|
"publicUsers": true
|
||||||
|
},
|
||||||
"storageTemplate": {
|
"storageTemplate": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"hashVerificationEnabled": true,
|
"hashVerificationEnabled": true,
|
||||||
"template": "{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}"
|
"template": "{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}"
|
||||||
},
|
},
|
||||||
"image": {
|
"templates": {
|
||||||
"thumbnail": {
|
"email": {
|
||||||
"format": "webp",
|
"albumInviteTemplate": "",
|
||||||
"size": 250,
|
"albumUpdateTemplate": "",
|
||||||
"quality": 80
|
"welcomeTemplate": ""
|
||||||
},
|
}
|
||||||
"preview": {
|
|
||||||
"format": "jpeg",
|
|
||||||
"size": 1440,
|
|
||||||
"quality": 80
|
|
||||||
},
|
|
||||||
"colorspace": "p3",
|
|
||||||
"extractEmbedded": false
|
|
||||||
},
|
|
||||||
"newVersionCheck": {
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"trash": {
|
|
||||||
"enabled": true,
|
|
||||||
"days": 30
|
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"customCss": ""
|
"customCss": ""
|
||||||
},
|
},
|
||||||
"library": {
|
"trash": {
|
||||||
"scan": {
|
"days": 30,
|
||||||
"enabled": true,
|
"enabled": true
|
||||||
"cronExpression": "0 0 * * *"
|
|
||||||
},
|
|
||||||
"watch": {
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"server": {
|
|
||||||
"externalDomain": "",
|
|
||||||
"loginPageMessage": ""
|
|
||||||
},
|
|
||||||
"notifications": {
|
|
||||||
"smtp": {
|
|
||||||
"enabled": false,
|
|
||||||
"from": "",
|
|
||||||
"replyTo": "",
|
|
||||||
"transport": {
|
|
||||||
"ignoreCert": false,
|
|
||||||
"host": "",
|
|
||||||
"port": 587,
|
|
||||||
"username": "",
|
|
||||||
"password": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"deleteDelay": 7
|
"deleteDelay": 7
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ Redis (Sentinel) URL example JSON before encoding:
|
|||||||
## Machine Learning
|
## Machine Learning
|
||||||
|
|
||||||
| Variable | Description | Default | Containers |
|
| Variable | Description | Default | Containers |
|
||||||
| :---------------------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :-----------------------------: | :--------------- |
|
| :---------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- | :-----------------------------: | :--------------- |
|
||||||
| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning |
|
| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning |
|
||||||
| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
|
| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
|
||||||
| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
|
| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
|
||||||
@@ -169,9 +169,11 @@ Redis (Sentinel) URL example JSON before encoding:
|
|||||||
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
|
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
|
||||||
| `MACHINE_LEARNING_DEVICE_IDS`<sup>\*4</sup> | Device IDs to use in multi-GPU environments | `0` | machine learning |
|
| `MACHINE_LEARNING_DEVICE_IDS`<sup>\*4</sup> | Device IDs to use in multi-GPU environments | `0` | machine learning |
|
||||||
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning |
|
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning |
|
||||||
|
| `MACHINE_LEARNING_MAX_BATCH_SIZE__OCR` | Set the maximum number of boxes that will be processed at once by the OCR model | `6` | machine learning |
|
||||||
| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
|
| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
|
||||||
| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spinned up while inferencing. | `1` | machine learning |
|
| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spun up while inferencing. | `1` | machine learning |
|
||||||
| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning |
|
| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning |
|
||||||
|
| `MACHINE_LEARNING_OPENVINO_PRECISION` | If set to FP16, uses half-precision floating-point operations for faster inference with reduced accuracy (one of [`FP16`, `FP32`], applies only to OpenVINO) | `FP32` | machine learning |
|
||||||
|
|
||||||
\*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones.
|
\*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones.
|
||||||
|
|
||||||
|
|||||||
25
docs/mise.toml
Normal file
25
docs/mise.toml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[tasks.install]
|
||||||
|
run = "pnpm install --filter documentation --frozen-lockfile"
|
||||||
|
|
||||||
|
[tasks.start]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "docusaurus --port 3005"
|
||||||
|
|
||||||
|
[tasks.build]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = [
|
||||||
|
"jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
|
||||||
|
"docusaurus build",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tasks.preview]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "docusaurus serve"
|
||||||
|
|
||||||
|
[tasks.format]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "prettier --check ."
|
||||||
|
|
||||||
|
[tasks."format-fix"]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "prettier --write ."
|
||||||
8
docs/static/archived-versions.json
vendored
8
docs/static/archived-versions.json
vendored
@@ -1,4 +1,12 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"label": "v2.2.3",
|
||||||
|
"url": "https://docs.v2.2.3.archive.immich.app"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "v2.2.2",
|
||||||
|
"url": "https://docs.v2.2.2.archive.immich.app"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "v2.2.1",
|
"label": "v2.2.1",
|
||||||
"url": "https://docs.v2.2.1.archive.immich.app"
|
"url": "https://docs.v2.2.1.archive.immich.app"
|
||||||
|
|||||||
1
e2e/.gitignore
vendored
1
e2e/.gitignore
vendored
@@ -4,3 +4,4 @@ node_modules/
|
|||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
/dist
|
/dist
|
||||||
|
.env
|
||||||
|
|||||||
105
e2e/docker-compose.dev.yml
Normal file
105
e2e/docker-compose.dev.yml
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
name: immich-e2e
|
||||||
|
|
||||||
|
services:
|
||||||
|
immich-server:
|
||||||
|
container_name: immich-e2e-server
|
||||||
|
command: ['immich-dev']
|
||||||
|
image: immich-server-dev:latest
|
||||||
|
build:
|
||||||
|
context: ../
|
||||||
|
dockerfile: server/Dockerfile.dev
|
||||||
|
target: dev
|
||||||
|
environment:
|
||||||
|
- DB_HOSTNAME=database
|
||||||
|
- DB_USERNAME=postgres
|
||||||
|
- DB_PASSWORD=postgres
|
||||||
|
- DB_DATABASE_NAME=immich
|
||||||
|
- IMMICH_MACHINE_LEARNING_ENABLED=false
|
||||||
|
- IMMICH_TELEMETRY_INCLUDE=all
|
||||||
|
- IMMICH_ENV=testing
|
||||||
|
- IMMICH_PORT=2285
|
||||||
|
- IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true
|
||||||
|
volumes:
|
||||||
|
- ./test-assets:/test-assets
|
||||||
|
- ..:/usr/src/app
|
||||||
|
- ${UPLOAD_LOCATION}/photos:/data
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
- pnpm-store:/usr/src/app/.pnpm-store
|
||||||
|
- server-node_modules:/usr/src/app/server/node_modules
|
||||||
|
- web-node_modules:/usr/src/app/web/node_modules
|
||||||
|
- github-node_modules:/usr/src/app/.github/node_modules
|
||||||
|
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||||
|
- docs-node_modules:/usr/src/app/docs/node_modules
|
||||||
|
- e2e-node_modules:/usr/src/app/e2e/node_modules
|
||||||
|
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||||
|
- app-node_modules:/usr/src/app/node_modules
|
||||||
|
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||||
|
- coverage:/usr/src/app/web/coverage
|
||||||
|
- ../plugins:/build/corePlugin
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
database:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
immich-web:
|
||||||
|
container_name: immich-e2e-web
|
||||||
|
image: immich-web-dev:latest
|
||||||
|
build:
|
||||||
|
context: ../
|
||||||
|
dockerfile: server/Dockerfile.dev
|
||||||
|
target: dev
|
||||||
|
command: ['immich-web']
|
||||||
|
ports:
|
||||||
|
- 2285:3000
|
||||||
|
environment:
|
||||||
|
- IMMICH_SERVER_URL=http://immich-server:2285/
|
||||||
|
volumes:
|
||||||
|
- ..:/usr/src/app
|
||||||
|
- pnpm-store:/usr/src/app/.pnpm-store
|
||||||
|
- server-node_modules:/usr/src/app/server/node_modules
|
||||||
|
- web-node_modules:/usr/src/app/web/node_modules
|
||||||
|
- github-node_modules:/usr/src/app/.github/node_modules
|
||||||
|
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||||
|
- docs-node_modules:/usr/src/app/docs/node_modules
|
||||||
|
- e2e-node_modules:/usr/src/app/e2e/node_modules
|
||||||
|
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||||
|
- app-node_modules:/usr/src/app/node_modules
|
||||||
|
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||||
|
- coverage:/usr/src/app/web/coverage
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb
|
||||||
|
|
||||||
|
database:
|
||||||
|
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338
|
||||||
|
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_DB: immich
|
||||||
|
ports:
|
||||||
|
- 5435:5432
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U postgres -d immich']
|
||||||
|
interval: 1s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 30
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
model-cache:
|
||||||
|
prometheus-data:
|
||||||
|
grafana-data:
|
||||||
|
pnpm-store:
|
||||||
|
server-node_modules:
|
||||||
|
web-node_modules:
|
||||||
|
github-node_modules:
|
||||||
|
cli-node_modules:
|
||||||
|
docs-node_modules:
|
||||||
|
e2e-node_modules:
|
||||||
|
sdk-node_modules:
|
||||||
|
app-node_modules:
|
||||||
|
sveltekit:
|
||||||
|
coverage:
|
||||||
@@ -35,7 +35,7 @@ services:
|
|||||||
- 2285:2285
|
- 2285:2285
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:6.2-alpine@sha256:77697a75da9f94e9357b61fcaf8345f69e3d9d32e9d15032c8415c21263977dc
|
image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb
|
||||||
|
|
||||||
database:
|
database:
|
||||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338
|
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338
|
||||||
|
|||||||
29
e2e/mise.toml
Normal file
29
e2e/mise.toml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[tasks.install]
|
||||||
|
run = "pnpm install --filter immich-e2e --frozen-lockfile"
|
||||||
|
|
||||||
|
[tasks.test]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "vitest --run"
|
||||||
|
|
||||||
|
[tasks."test-web"]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "playwright test"
|
||||||
|
|
||||||
|
[tasks.format]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "prettier --check ."
|
||||||
|
|
||||||
|
[tasks."format-fix"]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "prettier --write ."
|
||||||
|
|
||||||
|
[tasks.lint]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "eslint \"src/**/*.ts\" --max-warnings 0"
|
||||||
|
|
||||||
|
[tasks."lint-fix"]
|
||||||
|
run = { task = "lint --fix" }
|
||||||
|
|
||||||
|
[tasks.check]
|
||||||
|
env._.path = "./node_modules/.bin"
|
||||||
|
run = "tsc --noEmit"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "2.2.1",
|
"version": "2.2.3",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -20,16 +20,18 @@
|
|||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.8.0",
|
"@eslint/js": "^9.8.0",
|
||||||
|
"@faker-js/faker": "^10.1.0",
|
||||||
"@immich/cli": "file:../cli",
|
"@immich/cli": "file:../cli",
|
||||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||||
"@playwright/test": "^1.44.1",
|
"@playwright/test": "^1.44.1",
|
||||||
"@socket.io/component-emitter": "^3.1.2",
|
"@socket.io/component-emitter": "^3.1.2",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/node": "^22.18.12",
|
"@types/node": "^22.19.1",
|
||||||
"@types/oidc-provider": "^9.0.0",
|
"@types/oidc-provider": "^9.0.0",
|
||||||
"@types/pg": "^8.15.1",
|
"@types/pg": "^8.15.1",
|
||||||
"@types/pngjs": "^6.0.4",
|
"@types/pngjs": "^6.0.4",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"eslint": "^9.14.0",
|
"eslint": "^9.14.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.1.3",
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
|
|||||||
@@ -1,23 +1,50 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices, PlaywrightTestConfig } from '@playwright/test';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import { cpus } from 'node:os';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
export default defineConfig({
|
dotenv.config({ path: resolve(import.meta.dirname, '.env') });
|
||||||
|
|
||||||
|
export const playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1';
|
||||||
|
export const playwrightDbHost = process.env.PLAYWRIGHT_DB_HOST ?? '127.0.0.1';
|
||||||
|
export const playwriteBaseUrl = process.env.PLAYWRIGHT_BASE_URL ?? `http://${playwrightHost}:2285`;
|
||||||
|
export const playwriteSlowMo = parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
|
||||||
|
export const playwrightDisableWebserver = process.env.PLAYWRIGHT_DISABLE_WEBSERVER;
|
||||||
|
|
||||||
|
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1';
|
||||||
|
|
||||||
|
const config: PlaywrightTestConfig = {
|
||||||
testDir: './src/web/specs',
|
testDir: './src/web/specs',
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 4 : 0,
|
||||||
workers: 1,
|
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://127.0.0.1:2285',
|
baseURL: playwriteBaseUrl,
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
launchOptions: {
|
||||||
|
slowMo: playwriteSlowMo,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
testMatch: /.*\.e2e-spec\.ts/,
|
testMatch: /.*\.e2e-spec\.ts/,
|
||||||
|
|
||||||
|
workers: process.env.CI ? 4 : Math.round(cpus().length * 0.75),
|
||||||
|
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
use: { ...devices['Desktop Chrome'] },
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
testMatch: /.*\.e2e-spec\.ts/,
|
||||||
|
workers: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'parallel tests',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
testMatch: /.*\.parallel-e2e-spec\.ts/,
|
||||||
|
fullyParallel: true,
|
||||||
|
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
|
||||||
},
|
},
|
||||||
|
|
||||||
// {
|
// {
|
||||||
@@ -59,4 +86,8 @@ export default defineConfig({
|
|||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
reuseExistingServer: true,
|
reuseExistingServer: true,
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
if (playwrightDisableWebserver) {
|
||||||
|
delete config.webServer;
|
||||||
|
}
|
||||||
|
export default defineConfig(config);
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { DateTime } from 'luxon';
|
|||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { readFile, writeFile } from 'node:fs/promises';
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
import { basename, join } from 'node:path';
|
import { basename, join } from 'node:path';
|
||||||
import sharp from 'sharp';
|
|
||||||
import { Socket } from 'socket.io-client';
|
import { Socket } from 'socket.io-client';
|
||||||
import { createUserDto, uuidDto } from 'src/fixtures';
|
import { createUserDto, uuidDto } from 'src/fixtures';
|
||||||
import { makeRandomImage } from 'src/generators';
|
import { makeRandomImage } from 'src/generators';
|
||||||
@@ -41,40 +40,6 @@ const today = DateTime.fromObject({
|
|||||||
}) as DateTime<true>;
|
}) as DateTime<true>;
|
||||||
const yesterday = today.minus({ days: 1 });
|
const yesterday = today.minus({ days: 1 });
|
||||||
|
|
||||||
const createTestImageWithExif = async (filename: string, exifData: Record<string, any>) => {
|
|
||||||
// Generate unique color to ensure different checksums for each image
|
|
||||||
const r = Math.floor(Math.random() * 256);
|
|
||||||
const g = Math.floor(Math.random() * 256);
|
|
||||||
const b = Math.floor(Math.random() * 256);
|
|
||||||
|
|
||||||
// Create a 100x100 solid color JPEG using Sharp
|
|
||||||
const imageBytes = await sharp({
|
|
||||||
create: {
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
channels: 3,
|
|
||||||
background: { r, g, b },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.jpeg({ quality: 90 })
|
|
||||||
.toBuffer();
|
|
||||||
|
|
||||||
// Add random suffix to filename to avoid collisions
|
|
||||||
const uniqueFilename = filename.replace('.jpg', `-${randomBytes(4).toString('hex')}.jpg`);
|
|
||||||
const filepath = join(tempDir, uniqueFilename);
|
|
||||||
await writeFile(filepath, imageBytes);
|
|
||||||
|
|
||||||
// Filter out undefined values before writing EXIF
|
|
||||||
const cleanExifData = Object.fromEntries(Object.entries(exifData).filter(([, value]) => value !== undefined));
|
|
||||||
|
|
||||||
await exiftool.write(filepath, cleanExifData);
|
|
||||||
|
|
||||||
// Re-read the image bytes after EXIF has been written
|
|
||||||
const finalImageBytes = await readFile(filepath);
|
|
||||||
|
|
||||||
return { filepath, imageBytes: finalImageBytes, filename: uniqueFilename };
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('/asset', () => {
|
describe('/asset', () => {
|
||||||
let admin: LoginResponseDto;
|
let admin: LoginResponseDto;
|
||||||
let websocket: Socket;
|
let websocket: Socket;
|
||||||
@@ -1249,411 +1214,6 @@ describe('/asset', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('EXIF metadata extraction', () => {
|
|
||||||
describe('Additional date tag extraction', () => {
|
|
||||||
describe('Date-time vs time-only tag handling', () => {
|
|
||||||
it('should fall back to file timestamps when only time-only tags are available', async () => {
|
|
||||||
const { imageBytes, filename } = await createTestImageWithExif('time-only-fallback.jpg', {
|
|
||||||
TimeCreated: '2023:11:15 14:30:00', // Time-only tag, should not be used for dateTimeOriginal
|
|
||||||
// Exclude all date-time tags to force fallback to file timestamps
|
|
||||||
SubSecDateTimeOriginal: undefined,
|
|
||||||
DateTimeOriginal: undefined,
|
|
||||||
SubSecCreateDate: undefined,
|
|
||||||
SubSecMediaCreateDate: undefined,
|
|
||||||
CreateDate: undefined,
|
|
||||||
MediaCreateDate: undefined,
|
|
||||||
CreationDate: undefined,
|
|
||||||
DateTimeCreated: undefined,
|
|
||||||
GPSDateTime: undefined,
|
|
||||||
DateTimeUTC: undefined,
|
|
||||||
SonyDateTime2: undefined,
|
|
||||||
GPSDateStamp: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const oldDate = new Date('2020-01-01T00:00:00.000Z');
|
|
||||||
const asset = await utils.createAsset(admin.accessToken, {
|
|
||||||
assetData: {
|
|
||||||
filename,
|
|
||||||
bytes: imageBytes,
|
|
||||||
},
|
|
||||||
fileCreatedAt: oldDate.toISOString(),
|
|
||||||
fileModifiedAt: oldDate.toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
|
||||||
|
|
||||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
|
||||||
|
|
||||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
|
||||||
// Should fall back to file timestamps, which we set to 2020-01-01
|
|
||||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
|
||||||
new Date('2020-01-01T00:00:00.000Z').getTime(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should prefer DateTimeOriginal over time-only tags', async () => {
|
|
||||||
const { imageBytes, filename } = await createTestImageWithExif('datetime-over-time.jpg', {
|
|
||||||
DateTimeOriginal: '2023:10:10 10:00:00', // Should be preferred
|
|
||||||
TimeCreated: '2023:11:15 14:30:00', // Should be ignored (time-only)
|
|
||||||
});
|
|
||||||
|
|
||||||
const asset = await utils.createAsset(admin.accessToken, {
|
|
||||||
assetData: {
|
|
||||||
filename,
|
|
||||||
bytes: imageBytes,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
|
||||||
|
|
||||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
|
||||||
|
|
||||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
|
||||||
// Should use DateTimeOriginal, not TimeCreated
|
|
||||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
|
||||||
new Date('2023-10-10T10:00:00.000Z').getTime(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GPSDateTime tag extraction', () => {
|
|
||||||
it('should extract GPSDateTime with GPS coordinates', async () => {
|
|
||||||
const { imageBytes, filename } = await createTestImageWithExif('gps-datetime.jpg', {
|
|
||||||
GPSDateTime: '2023:11:15 12:30:00Z',
|
|
||||||
GPSLatitude: 37.7749,
|
|
||||||
GPSLongitude: -122.4194,
|
|
||||||
// Exclude other date tags
|
|
||||||
SubSecDateTimeOriginal: undefined,
|
|
||||||
DateTimeOriginal: undefined,
|
|
||||||
SubSecCreateDate: undefined,
|
|
||||||
SubSecMediaCreateDate: undefined,
|
|
||||||
CreateDate: undefined,
|
|
||||||
MediaCreateDate: undefined,
|
|
||||||
CreationDate: undefined,
|
|
||||||
DateTimeCreated: undefined,
|
|
||||||
TimeCreated: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const asset = await utils.createAsset(admin.accessToken, {
|
|
||||||
assetData: {
|
|
||||||
filename,
|
|
||||||
bytes: imageBytes,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
|
||||||
|
|
||||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
|
||||||
|
|
||||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
|
||||||
expect(assetInfo.exifInfo?.latitude).toBeCloseTo(37.7749, 4);
|
|
||||||
expect(assetInfo.exifInfo?.longitude).toBeCloseTo(-122.4194, 4);
|
|
||||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
|
||||||
new Date('2023-11-15T12:30:00.000Z').getTime(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('CreateDate tag extraction', () => {
|
|
||||||
it('should extract CreateDate when available', async () => {
|
|
||||||
const { imageBytes, filename } = await createTestImageWithExif('create-date.jpg', {
|
|
||||||
CreateDate: '2023:11:15 10:30:00',
|
|
||||||
// Exclude other higher priority date tags
|
|
||||||
SubSecDateTimeOriginal: undefined,
|
|
||||||
DateTimeOriginal: undefined,
|
|
||||||
SubSecCreateDate: undefined,
|
|
||||||
SubSecMediaCreateDate: undefined,
|
|
||||||
MediaCreateDate: undefined,
|
|
||||||
CreationDate: undefined,
|
|
||||||
DateTimeCreated: undefined,
|
|
||||||
TimeCreated: undefined,
|
|
||||||
GPSDateTime: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const asset = await utils.createAsset(admin.accessToken, {
|
|
||||||
assetData: {
|
|
||||||
filename,
|
|
||||||
bytes: imageBytes,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
|
||||||
|
|
||||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
|
||||||
|
|
||||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
|
||||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
|
||||||
new Date('2023-11-15T10:30:00.000Z').getTime(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GPSDateStamp tag extraction', () => {
|
|
||||||
it('should fall back to file timestamps when only date-only tags are available', async () => {
|
|
||||||
const { imageBytes, filename } = await createTestImageWithExif('gps-datestamp.jpg', {
|
|
||||||
GPSDateStamp: '2023:11:15', // Date-only tag, should not be used for dateTimeOriginal
|
|
||||||
// Note: NOT including GPSTimeStamp to avoid automatic GPSDateTime creation
|
|
||||||
GPSLatitude: 51.5074,
|
|
||||||
GPSLongitude: -0.1278,
|
|
||||||
// Explicitly exclude all testable date-time tags to force fallback to file timestamps
|
|
||||||
DateTimeOriginal: undefined,
|
|
||||||
CreateDate: undefined,
|
|
||||||
CreationDate: undefined,
|
|
||||||
GPSDateTime: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const oldDate = new Date('2020-01-01T00:00:00.000Z');
|
|
||||||
const asset = await utils.createAsset(admin.accessToken, {
|
|
||||||
assetData: {
|
|
||||||
filename,
|
|
||||||
bytes: imageBytes,
|
|
||||||
},
|
|
||||||
fileCreatedAt: oldDate.toISOString(),
|
|
||||||
fileModifiedAt: oldDate.toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
|
||||||
|
|
||||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
|
||||||
|
|
||||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
|
||||||
expect(assetInfo.exifInfo?.latitude).toBeCloseTo(51.5074, 4);
|
|
||||||
expect(assetInfo.exifInfo?.longitude).toBeCloseTo(-0.1278, 4);
|
|
||||||
// Should fall back to file timestamps, which we set to 2020-01-01
|
|
||||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
|
||||||
new Date('2020-01-01T00:00:00.000Z').getTime(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/*
|
|
||||||
* NOTE: The following EXIF date tags are NOT effectively usable with JPEG test files:
|
|
||||||
*
|
|
||||||
* NOT WRITABLE to JPEG:
|
|
||||||
* - MediaCreateDate: Can be read from video files but not written to JPEG
|
|
||||||
* - DateTimeCreated: Read-only tag in JPEG format
|
|
||||||
* - DateTimeUTC: Cannot be written to JPEG files
|
|
||||||
* - SonyDateTime2: Proprietary Sony tag, not writable to JPEG
|
|
||||||
* - SubSecMediaCreateDate: Tag not defined for JPEG format
|
|
||||||
* - SourceImageCreateTime: Non-standard insta360 tag, not writable to JPEG
|
|
||||||
*
|
|
||||||
* WRITABLE but NOT READABLE from JPEG:
|
|
||||||
* - SubSecDateTimeOriginal: Can be written but not read back from JPEG
|
|
||||||
* - SubSecCreateDate: Can be written but not read back from JPEG
|
|
||||||
*
|
|
||||||
* EFFECTIVELY TESTABLE TAGS (writable and readable):
|
|
||||||
* - DateTimeOriginal ✓
|
|
||||||
* - CreateDate ✓
|
|
||||||
* - CreationDate ✓
|
|
||||||
* - GPSDateTime ✓
|
|
||||||
*
|
|
||||||
* The metadata service correctly handles non-readable tags and will fall back to
|
|
||||||
* file timestamps when only non-readable tags are present.
|
|
||||||
*/
|
|
||||||
|
|
||||||
describe('Date tag priority order', () => {
|
|
||||||
it('should respect the complete date tag priority order', async () => {
|
|
||||||
// Test cases using only EFFECTIVELY TESTABLE tags (writable AND readable from JPEG)
|
|
||||||
const testCases = [
|
|
||||||
{
|
|
||||||
name: 'DateTimeOriginal has highest priority among testable tags',
|
|
||||||
exifData: {
|
|
||||||
DateTimeOriginal: '2023:04:04 04:00:00', // TESTABLE - highest priority among readable tags
|
|
||||||
CreateDate: '2023:05:05 05:00:00', // TESTABLE
|
|
||||||
CreationDate: '2023:07:07 07:00:00', // TESTABLE
|
|
||||||
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
|
|
||||||
},
|
|
||||||
expectedDate: '2023-04-04T04:00:00.000Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'CreationDate when DateTimeOriginal missing',
|
|
||||||
exifData: {
|
|
||||||
CreationDate: '2023:05:05 05:00:00', // TESTABLE
|
|
||||||
CreateDate: '2023:07:07 07:00:00', // TESTABLE
|
|
||||||
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
|
|
||||||
},
|
|
||||||
expectedDate: '2023-05-05T05:00:00.000Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'CreationDate when standard EXIF tags missing',
|
|
||||||
exifData: {
|
|
||||||
CreationDate: '2023:07:07 07:00:00', // TESTABLE
|
|
||||||
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
|
|
||||||
},
|
|
||||||
expectedDate: '2023-07-07T07:00:00.000Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'GPSDateTime when no other testable date tags present',
|
|
||||||
exifData: {
|
|
||||||
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
|
|
||||||
Make: 'SONY',
|
|
||||||
},
|
|
||||||
expectedDate: '2023-10-10T10:00:00.000Z',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const testCase of testCases) {
|
|
||||||
const { imageBytes, filename } = await createTestImageWithExif(
|
|
||||||
`${testCase.name.replaceAll(/\s+/g, '-').toLowerCase()}.jpg`,
|
|
||||||
testCase.exifData,
|
|
||||||
);
|
|
||||||
|
|
||||||
const asset = await utils.createAsset(admin.accessToken, {
|
|
||||||
assetData: {
|
|
||||||
filename,
|
|
||||||
bytes: imageBytes,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
|
||||||
|
|
||||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
|
||||||
|
|
||||||
expect(assetInfo.exifInfo?.dateTimeOriginal, `Failed for: ${testCase.name}`).toBeDefined();
|
|
||||||
expect(
|
|
||||||
new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime(),
|
|
||||||
`Date mismatch for: ${testCase.name}`,
|
|
||||||
).toBe(new Date(testCase.expectedDate).getTime());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Edge cases for date tag handling', () => {
|
|
||||||
it('should fall back to file timestamps with GPSDateStamp alone', async () => {
|
|
||||||
const { imageBytes, filename } = await createTestImageWithExif('gps-datestamp-only.jpg', {
|
|
||||||
GPSDateStamp: '2023:08:08', // Date-only tag, should not be used for dateTimeOriginal
|
|
||||||
// Intentionally no GPSTimeStamp
|
|
||||||
// Exclude all other date tags
|
|
||||||
SubSecDateTimeOriginal: undefined,
|
|
||||||
DateTimeOriginal: undefined,
|
|
||||||
SubSecCreateDate: undefined,
|
|
||||||
SubSecMediaCreateDate: undefined,
|
|
||||||
CreateDate: undefined,
|
|
||||||
MediaCreateDate: undefined,
|
|
||||||
CreationDate: undefined,
|
|
||||||
DateTimeCreated: undefined,
|
|
||||||
TimeCreated: undefined,
|
|
||||||
GPSDateTime: undefined,
|
|
||||||
DateTimeUTC: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const oldDate = new Date('2020-01-01T00:00:00.000Z');
|
|
||||||
const asset = await utils.createAsset(admin.accessToken, {
|
|
||||||
assetData: {
|
|
||||||
filename,
|
|
||||||
bytes: imageBytes,
|
|
||||||
},
|
|
||||||
fileCreatedAt: oldDate.toISOString(),
|
|
||||||
fileModifiedAt: oldDate.toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
|
||||||
|
|
||||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
|
||||||
|
|
||||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
|
||||||
// Should fall back to file timestamps, which we set to 2020-01-01
|
|
||||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
|
||||||
new Date('2020-01-01T00:00:00.000Z').getTime(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle all testable date tags present to verify complete priority order', async () => {
|
|
||||||
const { imageBytes, filename } = await createTestImageWithExif('all-testable-date-tags.jpg', {
|
|
||||||
// All TESTABLE date tags to JPEG format (writable AND readable)
|
|
||||||
DateTimeOriginal: '2023:04:04 04:00:00', // TESTABLE - highest priority among readable tags
|
|
||||||
CreateDate: '2023:05:05 05:00:00', // TESTABLE
|
|
||||||
CreationDate: '2023:07:07 07:00:00', // TESTABLE
|
|
||||||
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
|
|
||||||
// Note: Excluded non-testable tags:
|
|
||||||
// SubSec tags: writable but not readable from JPEG
|
|
||||||
// Non-writable tags: MediaCreateDate, DateTimeCreated, DateTimeUTC, SonyDateTime2, etc.
|
|
||||||
// Time-only/date-only tags: already excluded from EXIF_DATE_TAGS
|
|
||||||
});
|
|
||||||
|
|
||||||
const asset = await utils.createAsset(admin.accessToken, {
|
|
||||||
assetData: {
|
|
||||||
filename,
|
|
||||||
bytes: imageBytes,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
|
||||||
|
|
||||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
|
||||||
|
|
||||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
|
||||||
// Should use DateTimeOriginal as it has the highest priority among testable tags
|
|
||||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
|
||||||
new Date('2023-04-04T04:00:00.000Z').getTime(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use CreationDate when SubSec tags are missing', async () => {
|
|
||||||
const { imageBytes, filename } = await createTestImageWithExif('creation-date-priority.jpg', {
|
|
||||||
CreationDate: '2023:07:07 07:00:00', // WRITABLE
|
|
||||||
GPSDateTime: '2023:10:10 10:00:00', // WRITABLE
|
|
||||||
// Note: DateTimeCreated, DateTimeUTC, SonyDateTime2 are NOT writable to JPEG
|
|
||||||
// Note: TimeCreated and GPSDateStamp are excluded from EXIF_DATE_TAGS (time-only/date-only)
|
|
||||||
// Exclude SubSec and standard EXIF tags
|
|
||||||
SubSecDateTimeOriginal: undefined,
|
|
||||||
DateTimeOriginal: undefined,
|
|
||||||
SubSecCreateDate: undefined,
|
|
||||||
CreateDate: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const asset = await utils.createAsset(admin.accessToken, {
|
|
||||||
assetData: {
|
|
||||||
filename,
|
|
||||||
bytes: imageBytes,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
|
||||||
|
|
||||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
|
||||||
|
|
||||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
|
||||||
// Should use CreationDate when available
|
|
||||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
|
||||||
new Date('2023-07-07T07:00:00.000Z').getTime(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should skip invalid date formats and use next valid tag', async () => {
|
|
||||||
const { imageBytes, filename } = await createTestImageWithExif('invalid-date-handling.jpg', {
|
|
||||||
// Note: Testing invalid date handling with only WRITABLE tags
|
|
||||||
GPSDateTime: '2023:10:10 10:00:00', // WRITABLE - Valid date
|
|
||||||
CreationDate: '2023:13:13 13:00:00', // WRITABLE - Valid date
|
|
||||||
// Note: TimeCreated excluded (time-only), DateTimeCreated not writable to JPEG
|
|
||||||
// Exclude other date tags
|
|
||||||
SubSecDateTimeOriginal: undefined,
|
|
||||||
DateTimeOriginal: undefined,
|
|
||||||
SubSecCreateDate: undefined,
|
|
||||||
CreateDate: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const asset = await utils.createAsset(admin.accessToken, {
|
|
||||||
assetData: {
|
|
||||||
filename,
|
|
||||||
bytes: imageBytes,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
|
||||||
|
|
||||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
|
||||||
|
|
||||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
|
||||||
// Should skip invalid dates and use the first valid one (GPSDateTime)
|
|
||||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
|
||||||
new Date('2023-10-10T10:00:00.000Z').getTime(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /assets/exist', () => {
|
describe('POST /assets/exist', () => {
|
||||||
it('ignores invalid deviceAssetIds', async () => {
|
it('ignores invalid deviceAssetIds', async () => {
|
||||||
const response = await utils.checkExistingAssets(user1.accessToken, {
|
const response = await utils.checkExistingAssets(user1.accessToken, {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { JobCommand, JobName, LoginResponseDto, updateConfig } from '@immich/sdk';
|
import { LoginResponseDto, QueueCommand, QueueName, updateConfig } from '@immich/sdk';
|
||||||
import { cpSync, rmSync } from 'node:fs';
|
import { cpSync, rmSync } from 'node:fs';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { basename } from 'node:path';
|
import { basename } from 'node:path';
|
||||||
@@ -17,28 +17,28 @@ describe('/jobs', () => {
|
|||||||
|
|
||||||
describe('PUT /jobs', () => {
|
describe('PUT /jobs', () => {
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
|
||||||
command: JobCommand.Resume,
|
command: QueueCommand.Resume,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||||
command: JobCommand.Resume,
|
command: QueueCommand.Resume,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.FaceDetection, {
|
await utils.queueCommand(admin.accessToken, QueueName.FaceDetection, {
|
||||||
command: JobCommand.Resume,
|
command: QueueCommand.Resume,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.SmartSearch, {
|
await utils.queueCommand(admin.accessToken, QueueName.SmartSearch, {
|
||||||
command: JobCommand.Resume,
|
command: QueueCommand.Resume,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.DuplicateDetection, {
|
await utils.queueCommand(admin.accessToken, QueueName.DuplicateDetection, {
|
||||||
command: JobCommand.Resume,
|
command: QueueCommand.Resume,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,8 +59,8 @@ describe('/jobs', () => {
|
|||||||
it('should queue metadata extraction for missing assets', async () => {
|
it('should queue metadata extraction for missing assets', async () => {
|
||||||
const path = `${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`;
|
const path = `${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`;
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
|
||||||
command: JobCommand.Pause,
|
command: QueueCommand.Pause,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,20 +77,20 @@ describe('/jobs', () => {
|
|||||||
expect(asset.exifInfo?.make).toBeNull();
|
expect(asset.exifInfo?.make).toBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
|
||||||
command: JobCommand.Empty,
|
command: QueueCommand.Empty,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
|
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
|
||||||
command: JobCommand.Resume,
|
command: QueueCommand.Resume,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
|
||||||
command: JobCommand.Start,
|
command: QueueCommand.Start,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,8 +124,8 @@ describe('/jobs', () => {
|
|||||||
|
|
||||||
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, path);
|
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, path);
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
await utils.queueCommand(admin.accessToken, QueueName.MetadataExtraction, {
|
||||||
command: JobCommand.Start,
|
command: QueueCommand.Start,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,8 +144,8 @@ describe('/jobs', () => {
|
|||||||
it('should queue thumbnail extraction for assets missing thumbs', async () => {
|
it('should queue thumbnail extraction for assets missing thumbs', async () => {
|
||||||
const path = `${testAssetDir}/albums/nature/tanners_ridge.jpg`;
|
const path = `${testAssetDir}/albums/nature/tanners_ridge.jpg`;
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||||
command: JobCommand.Pause,
|
command: QueueCommand.Pause,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,32 +153,32 @@ describe('/jobs', () => {
|
|||||||
assetData: { bytes: await readFile(path), filename: basename(path) },
|
assetData: { bytes: await readFile(path), filename: basename(path) },
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
|
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
|
||||||
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
|
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
|
||||||
|
|
||||||
const assetBefore = await utils.getAssetInfo(admin.accessToken, id);
|
const assetBefore = await utils.getAssetInfo(admin.accessToken, id);
|
||||||
expect(assetBefore.thumbhash).toBeNull();
|
expect(assetBefore.thumbhash).toBeNull();
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||||
command: JobCommand.Empty,
|
command: QueueCommand.Empty,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
|
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
|
||||||
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
|
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||||
command: JobCommand.Resume,
|
command: QueueCommand.Resume,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||||
command: JobCommand.Start,
|
command: QueueCommand.Start,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
|
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
|
||||||
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
|
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
|
||||||
|
|
||||||
const assetAfter = await utils.getAssetInfo(admin.accessToken, id);
|
const assetAfter = await utils.getAssetInfo(admin.accessToken, id);
|
||||||
expect(assetAfter.thumbhash).not.toBeNull();
|
expect(assetAfter.thumbhash).not.toBeNull();
|
||||||
@@ -193,26 +193,26 @@ describe('/jobs', () => {
|
|||||||
assetData: { bytes: await readFile(path), filename: basename(path) },
|
assetData: { bytes: await readFile(path), filename: basename(path) },
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
|
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
|
||||||
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
|
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
|
||||||
|
|
||||||
const assetBefore = await utils.getAssetInfo(admin.accessToken, id);
|
const assetBefore = await utils.getAssetInfo(admin.accessToken, id);
|
||||||
|
|
||||||
cpSync(`${testAssetDir}/albums/nature/notocactus_minimus.jpg`, path);
|
cpSync(`${testAssetDir}/albums/nature/notocactus_minimus.jpg`, path);
|
||||||
|
|
||||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||||
command: JobCommand.Resume,
|
command: QueueCommand.Resume,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// This runs the missing thumbnail job
|
// This runs the missing thumbnail job
|
||||||
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
|
await utils.queueCommand(admin.accessToken, QueueName.ThumbnailGeneration, {
|
||||||
command: JobCommand.Start,
|
command: QueueCommand.Start,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
|
await utils.waitForQueueFinish(admin.accessToken, QueueName.MetadataExtraction);
|
||||||
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
|
await utils.waitForQueueFinish(admin.accessToken, QueueName.ThumbnailGeneration);
|
||||||
|
|
||||||
const assetAfter = await utils.getAssetInfo(admin.accessToken, id);
|
const assetAfter = await utils.getAssetInfo(admin.accessToken, id);
|
||||||
|
|
||||||
|
|||||||
172
e2e/src/api/specs/maintenance.e2e-spec.ts
Normal file
172
e2e/src/api/specs/maintenance.e2e-spec.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { LoginResponseDto } from '@immich/sdk';
|
||||||
|
import { createUserDto } from 'src/fixtures';
|
||||||
|
import { errorDto } from 'src/responses';
|
||||||
|
import { app, utils } from 'src/utils';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
describe('/admin/maintenance', () => {
|
||||||
|
let cookie: string | undefined;
|
||||||
|
let admin: LoginResponseDto;
|
||||||
|
let nonAdmin: LoginResponseDto;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await utils.resetDatabase();
|
||||||
|
admin = await utils.adminSetup();
|
||||||
|
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// => outside of maintenance mode
|
||||||
|
|
||||||
|
describe('GET ~/server/config', async () => {
|
||||||
|
it('should indicate we are out of maintenance mode', async () => {
|
||||||
|
const { status, body } = await request(app).get('/server/config');
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.maintenanceMode).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /login', async () => {
|
||||||
|
it('should not work out of maintenance mode', async () => {
|
||||||
|
const { status, body } = await request(app).post('/admin/maintenance/login').send({ token: 'token' });
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(errorDto.badRequest('Not in maintenance mode'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// => enter maintenance mode
|
||||||
|
|
||||||
|
describe.sequential('POST /', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const { status, body } = await request(app).post('/admin/maintenance').send({
|
||||||
|
action: 'end',
|
||||||
|
});
|
||||||
|
expect(status).toBe(401);
|
||||||
|
expect(body).toEqual(errorDto.unauthorized);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only work for admins', async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.post('/admin/maintenance')
|
||||||
|
.set('Authorization', `Bearer ${nonAdmin.accessToken}`)
|
||||||
|
.send({ action: 'end' });
|
||||||
|
expect(status).toBe(403);
|
||||||
|
expect(body).toEqual(errorDto.forbidden);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be a no-op if try to exit maintenance mode', async () => {
|
||||||
|
const { status } = await request(app)
|
||||||
|
.post('/admin/maintenance')
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
|
.send({ action: 'end' });
|
||||||
|
expect(status).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enter maintenance mode', async () => {
|
||||||
|
const { status, headers } = await request(app)
|
||||||
|
.post('/admin/maintenance')
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
|
.send({
|
||||||
|
action: 'start',
|
||||||
|
});
|
||||||
|
expect(status).toBe(201);
|
||||||
|
|
||||||
|
cookie = headers['set-cookie'][0].split(';')[0];
|
||||||
|
expect(cookie).toEqual(
|
||||||
|
expect.stringMatching(/^immich_maintenance_token=[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*$/),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { body } = await request(app).get('/server/config');
|
||||||
|
return body.maintenanceMode;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 5e2,
|
||||||
|
timeout: 1e4,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// => in maintenance mode
|
||||||
|
|
||||||
|
describe.sequential('in maintenance mode', () => {
|
||||||
|
describe('GET ~/server/config', async () => {
|
||||||
|
it('should indicate we are in maintenance mode', async () => {
|
||||||
|
const { status, body } = await request(app).get('/server/config');
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.maintenanceMode).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /login', async () => {
|
||||||
|
it('should fail without cookie or token in body', async () => {
|
||||||
|
const { status, body } = await request(app).post('/admin/maintenance/login').send({});
|
||||||
|
expect(status).toBe(401);
|
||||||
|
expect(body).toEqual(errorDto.unauthorizedWithMessage('Missing JWT Token'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should succeed with cookie', async () => {
|
||||||
|
const { status, body } = await request(app).post('/admin/maintenance/login').set('cookie', cookie!).send({});
|
||||||
|
expect(status).toBe(201);
|
||||||
|
expect(body).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
username: 'Immich Admin',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should succeed with token', async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.post('/admin/maintenance/login')
|
||||||
|
.send({
|
||||||
|
token: cookie!.split('=')[1].trim(),
|
||||||
|
});
|
||||||
|
expect(status).toBe(201);
|
||||||
|
expect(body).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
username: 'Immich Admin',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /', async () => {
|
||||||
|
it('should be a no-op if try to enter maintenance mode', async () => {
|
||||||
|
const { status } = await request(app)
|
||||||
|
.post('/admin/maintenance')
|
||||||
|
.set('cookie', cookie!)
|
||||||
|
.send({ action: 'start' });
|
||||||
|
expect(status).toBe(201);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// => exit maintenance mode
|
||||||
|
|
||||||
|
describe.sequential('POST /', () => {
|
||||||
|
it('should exit maintenance mode', async () => {
|
||||||
|
const { status } = await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
|
||||||
|
action: 'end',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status).toBe(201);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { body } = await request(app).get('/server/config');
|
||||||
|
return body.maintenanceMode;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 5e2,
|
||||||
|
timeout: 1e4,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -136,6 +136,7 @@ describe('/server', () => {
|
|||||||
externalDomain: '',
|
externalDomain: '',
|
||||||
publicUsers: true,
|
publicUsers: true,
|
||||||
isOnboarded: false,
|
isOnboarded: false,
|
||||||
|
maintenanceMode: false,
|
||||||
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
|
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||||
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
|
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
JobName,
|
|
||||||
LoginResponseDto,
|
LoginResponseDto,
|
||||||
|
QueueName,
|
||||||
createStack,
|
createStack,
|
||||||
deleteUserAdmin,
|
deleteUserAdmin,
|
||||||
getMyUser,
|
getMyUser,
|
||||||
@@ -328,7 +328,7 @@ describe('/admin/users', () => {
|
|||||||
{ headers: asBearerAuth(user.accessToken) },
|
{ headers: asBearerAuth(user.accessToken) },
|
||||||
);
|
);
|
||||||
|
|
||||||
await utils.waitForQueueFinish(admin.accessToken, JobName.BackgroundTask);
|
await utils.waitForQueueFinish(admin.accessToken, QueueName.BackgroundTask);
|
||||||
|
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.delete(`/admin/users/${user.userId}`)
|
.delete(`/admin/users/${user.userId}`)
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Script to generate test images with additional EXIF date tags
|
|
||||||
* This creates actual JPEG images with embedded metadata for testing
|
|
||||||
* Images are generated into e2e/test-assets/metadata/dates/
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { execSync } from 'node:child_process';
|
|
||||||
import { writeFileSync } from 'node:fs';
|
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import sharp from 'sharp';
|
|
||||||
|
|
||||||
interface TestImage {
|
|
||||||
filename: string;
|
|
||||||
description: string;
|
|
||||||
exifTags: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const testImages: TestImage[] = [
|
|
||||||
{
|
|
||||||
filename: 'time-created.jpg',
|
|
||||||
description: 'Image with TimeCreated tag',
|
|
||||||
exifTags: {
|
|
||||||
TimeCreated: '2023:11:15 14:30:00',
|
|
||||||
Make: 'Canon',
|
|
||||||
Model: 'EOS R5',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
filename: 'gps-datetime.jpg',
|
|
||||||
description: 'Image with GPSDateTime and coordinates',
|
|
||||||
exifTags: {
|
|
||||||
GPSDateTime: '2023:11:15 12:30:00Z',
|
|
||||||
GPSLatitude: '37.7749',
|
|
||||||
GPSLongitude: '-122.4194',
|
|
||||||
GPSLatitudeRef: 'N',
|
|
||||||
GPSLongitudeRef: 'W',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
filename: 'datetime-utc.jpg',
|
|
||||||
description: 'Image with DateTimeUTC tag',
|
|
||||||
exifTags: {
|
|
||||||
DateTimeUTC: '2023:11:15 10:30:00',
|
|
||||||
Make: 'Nikon',
|
|
||||||
Model: 'D850',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
filename: 'gps-datestamp.jpg',
|
|
||||||
description: 'Image with GPSDateStamp and GPSTimeStamp',
|
|
||||||
exifTags: {
|
|
||||||
GPSDateStamp: '2023:11:15',
|
|
||||||
GPSTimeStamp: '08:30:00',
|
|
||||||
GPSLatitude: '51.5074',
|
|
||||||
GPSLongitude: '-0.1278',
|
|
||||||
GPSLatitudeRef: 'N',
|
|
||||||
GPSLongitudeRef: 'W',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
filename: 'sony-datetime2.jpg',
|
|
||||||
description: 'Sony camera image with SonyDateTime2 tag',
|
|
||||||
exifTags: {
|
|
||||||
SonyDateTime2: '2023:11:15 06:30:00',
|
|
||||||
Make: 'SONY',
|
|
||||||
Model: 'ILCE-7RM5',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
filename: 'date-priority-test.jpg',
|
|
||||||
description: 'Image with multiple date tags to test priority',
|
|
||||||
exifTags: {
|
|
||||||
SubSecDateTimeOriginal: '2023:01:01 01:00:00',
|
|
||||||
DateTimeOriginal: '2023:02:02 02:00:00',
|
|
||||||
SubSecCreateDate: '2023:03:03 03:00:00',
|
|
||||||
CreateDate: '2023:04:04 04:00:00',
|
|
||||||
CreationDate: '2023:05:05 05:00:00',
|
|
||||||
DateTimeCreated: '2023:06:06 06:00:00',
|
|
||||||
TimeCreated: '2023:07:07 07:00:00',
|
|
||||||
GPSDateTime: '2023:08:08 08:00:00',
|
|
||||||
DateTimeUTC: '2023:09:09 09:00:00',
|
|
||||||
GPSDateStamp: '2023:10:10',
|
|
||||||
SonyDateTime2: '2023:11:11 11:00:00',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
filename: 'new-tags-only.jpg',
|
|
||||||
description: 'Image with only additional date tags (no standard tags)',
|
|
||||||
exifTags: {
|
|
||||||
TimeCreated: '2023:12:01 15:45:30',
|
|
||||||
GPSDateTime: '2023:12:01 13:45:30Z',
|
|
||||||
DateTimeUTC: '2023:12:01 13:45:30',
|
|
||||||
GPSDateStamp: '2023:12:01',
|
|
||||||
SonyDateTime2: '2023:12:01 08:45:30',
|
|
||||||
GPSLatitude: '40.7128',
|
|
||||||
GPSLongitude: '-74.0060',
|
|
||||||
GPSLatitudeRef: 'N',
|
|
||||||
GPSLongitudeRef: 'W',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const generateTestImages = async (): Promise<void> => {
|
|
||||||
// Target directory: e2e/test-assets/metadata/dates/
|
|
||||||
// Current file is in: e2e/src/
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
const targetDir = join(__dirname, '..', 'test-assets', 'metadata', 'dates');
|
|
||||||
|
|
||||||
console.log('Generating test images with additional EXIF date tags...');
|
|
||||||
console.log(`Target directory: ${targetDir}`);
|
|
||||||
|
|
||||||
for (const image of testImages) {
|
|
||||||
try {
|
|
||||||
const imagePath = join(targetDir, image.filename);
|
|
||||||
|
|
||||||
// Create unique JPEG file using Sharp
|
|
||||||
const r = Math.floor(Math.random() * 256);
|
|
||||||
const g = Math.floor(Math.random() * 256);
|
|
||||||
const b = Math.floor(Math.random() * 256);
|
|
||||||
|
|
||||||
const jpegData = await sharp({
|
|
||||||
create: {
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
channels: 3,
|
|
||||||
background: { r, g, b },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.jpeg({ quality: 90 })
|
|
||||||
.toBuffer();
|
|
||||||
|
|
||||||
writeFileSync(imagePath, jpegData);
|
|
||||||
|
|
||||||
// Build exiftool command to add EXIF data
|
|
||||||
const exifArgs = Object.entries(image.exifTags)
|
|
||||||
.map(([tag, value]) => `-${tag}="${value}"`)
|
|
||||||
.join(' ');
|
|
||||||
|
|
||||||
const command = `exiftool ${exifArgs} -overwrite_original "${imagePath}"`;
|
|
||||||
|
|
||||||
console.log(`Creating ${image.filename}: ${image.description}`);
|
|
||||||
execSync(command, { stdio: 'pipe' });
|
|
||||||
|
|
||||||
// Verify the tags were written
|
|
||||||
const verifyCommand = `exiftool -json "${imagePath}"`;
|
|
||||||
const result = execSync(verifyCommand, { encoding: 'utf8' });
|
|
||||||
const metadata = JSON.parse(result)[0];
|
|
||||||
|
|
||||||
console.log(` ✓ Created with ${Object.keys(image.exifTags).length} EXIF tags`);
|
|
||||||
|
|
||||||
// Log first date tag found for verification
|
|
||||||
const firstDateTag = Object.keys(image.exifTags).find(
|
|
||||||
(tag) => tag.includes('Date') || tag.includes('Time') || tag.includes('Created'),
|
|
||||||
);
|
|
||||||
if (firstDateTag && metadata[firstDateTag]) {
|
|
||||||
console.log(` ✓ Verified ${firstDateTag}: ${metadata[firstDateTag]}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to create ${image.filename}:`, (error as Error).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\nTest image generation complete!');
|
|
||||||
console.log('Files created in:', targetDir);
|
|
||||||
console.log('\nTo test these images:');
|
|
||||||
console.log(`cd ${targetDir} && exiftool -time:all -gps:all *.jpg`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { generateTestImages };
|
|
||||||
|
|
||||||
// Run the generator if this file is executed directly
|
|
||||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
||||||
generateTestImages().catch(console.error);
|
|
||||||
}
|
|
||||||
37
e2e/src/generators/timeline.ts
Normal file
37
e2e/src/generators/timeline.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export { generateTimelineData } from './timeline/model-objects';
|
||||||
|
|
||||||
|
export { createDefaultTimelineConfig, validateTimelineConfig } from './timeline/timeline-config';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
MockAlbum,
|
||||||
|
MonthSpec,
|
||||||
|
SerializedTimelineData,
|
||||||
|
MockTimelineAsset as TimelineAssetConfig,
|
||||||
|
TimelineConfig,
|
||||||
|
MockTimelineData as TimelineData,
|
||||||
|
} from './timeline/timeline-config';
|
||||||
|
|
||||||
|
export {
|
||||||
|
getAlbum,
|
||||||
|
getAsset,
|
||||||
|
getTimeBucket,
|
||||||
|
getTimeBuckets,
|
||||||
|
toAssetResponseDto,
|
||||||
|
toColumnarFormat,
|
||||||
|
} from './timeline/rest-response';
|
||||||
|
|
||||||
|
export type { Changes } from './timeline/rest-response';
|
||||||
|
|
||||||
|
export { randomImage, randomImageFromString, randomPreview, randomThumbnail } from './timeline/images';
|
||||||
|
|
||||||
|
export {
|
||||||
|
SeededRandom,
|
||||||
|
getMockAsset,
|
||||||
|
parseTimeBucketKey,
|
||||||
|
selectRandom,
|
||||||
|
selectRandomDays,
|
||||||
|
selectRandomMultiple,
|
||||||
|
} from './timeline/utils';
|
||||||
|
|
||||||
|
export { ASSET_DISTRIBUTION, DAY_DISTRIBUTION } from './timeline/distribution-patterns';
|
||||||
|
export type { DayPattern, MonthDistribution } from './timeline/distribution-patterns';
|
||||||
183
e2e/src/generators/timeline/distribution-patterns.ts
Normal file
183
e2e/src/generators/timeline/distribution-patterns.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { generateConsecutiveDays, generateDayAssets } from 'src/generators/timeline/model-objects';
|
||||||
|
import { SeededRandom, selectRandomDays } from 'src/generators/timeline/utils';
|
||||||
|
import type { MockTimelineAsset } from './timeline-config';
|
||||||
|
import { GENERATION_CONSTANTS } from './timeline-config';
|
||||||
|
|
||||||
|
type AssetDistributionStrategy = (rng: SeededRandom) => number;
|
||||||
|
|
||||||
|
type DayDistributionStrategy = (
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
daysInMonth: number,
|
||||||
|
totalAssets: number,
|
||||||
|
ownerId: string,
|
||||||
|
rng: SeededRandom,
|
||||||
|
) => MockTimelineAsset[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strategies for determining total asset count per month
|
||||||
|
*/
|
||||||
|
export const ASSET_DISTRIBUTION: Record<MonthDistribution, AssetDistributionStrategy | null> = {
|
||||||
|
empty: null, // Special case - handled separately
|
||||||
|
sparse: (rng) => rng.nextInt(3, 9), // 3-8 assets
|
||||||
|
medium: (rng) => rng.nextInt(15, 31), // 15-30 assets
|
||||||
|
dense: (rng) => rng.nextInt(50, 81), // 50-80 assets
|
||||||
|
'very-dense': (rng) => rng.nextInt(80, 151), // 80-150 assets
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strategies for distributing assets across days within a month
|
||||||
|
*/
|
||||||
|
export const DAY_DISTRIBUTION: Record<DayPattern, DayDistributionStrategy> = {
|
||||||
|
'single-day': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||||
|
// All assets on one day in the middle of the month
|
||||||
|
const day = Math.floor(daysInMonth / 2);
|
||||||
|
return generateDayAssets(year, month, day, totalAssets, ownerId, rng);
|
||||||
|
},
|
||||||
|
|
||||||
|
'consecutive-large': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||||
|
// 3-5 consecutive days with evenly distributed assets
|
||||||
|
const numDays = Math.min(5, Math.floor(totalAssets / 15));
|
||||||
|
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
|
||||||
|
return generateConsecutiveDays(year, month, startDay, numDays, totalAssets, ownerId, rng);
|
||||||
|
},
|
||||||
|
|
||||||
|
'consecutive-small': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||||
|
// Multiple consecutive days with 1-3 assets each (side-by-side layout)
|
||||||
|
const assets: MockTimelineAsset[] = [];
|
||||||
|
const numDays = Math.min(totalAssets, Math.floor(daysInMonth / 2));
|
||||||
|
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
|
||||||
|
let assetIndex = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < numDays && assetIndex < totalAssets; i++) {
|
||||||
|
const dayAssets = Math.min(3, rng.nextInt(1, 4));
|
||||||
|
const actualAssets = Math.min(dayAssets, totalAssets - assetIndex);
|
||||||
|
// Create a new RNG for this day
|
||||||
|
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||||
|
assets.push(...generateDayAssets(year, month, startDay + i, actualAssets, ownerId, dayRng));
|
||||||
|
assetIndex += actualAssets;
|
||||||
|
}
|
||||||
|
return assets;
|
||||||
|
},
|
||||||
|
|
||||||
|
alternating: (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||||
|
// Alternate between large (15-25) and small (1-3) days
|
||||||
|
const assets: MockTimelineAsset[] = [];
|
||||||
|
let day = 1;
|
||||||
|
let isLarge = true;
|
||||||
|
let assetIndex = 0;
|
||||||
|
|
||||||
|
while (assetIndex < totalAssets && day <= daysInMonth) {
|
||||||
|
const dayAssets = isLarge ? Math.min(25, rng.nextInt(15, 26)) : rng.nextInt(1, 4);
|
||||||
|
|
||||||
|
const actualAssets = Math.min(dayAssets, totalAssets - assetIndex);
|
||||||
|
// Create a new RNG for this day
|
||||||
|
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||||
|
assets.push(...generateDayAssets(year, month, day, actualAssets, ownerId, dayRng));
|
||||||
|
assetIndex += actualAssets;
|
||||||
|
|
||||||
|
day += isLarge ? 1 : 1; // Could add gaps here
|
||||||
|
isLarge = !isLarge;
|
||||||
|
}
|
||||||
|
return assets;
|
||||||
|
},
|
||||||
|
|
||||||
|
'sparse-scattered': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||||
|
// Spread assets across random days with gaps
|
||||||
|
const assets: MockTimelineAsset[] = [];
|
||||||
|
const numDays = Math.min(totalAssets, Math.floor(daysInMonth * GENERATION_CONSTANTS.SPARSE_DAY_COVERAGE));
|
||||||
|
const daysWithPhotos = selectRandomDays(daysInMonth, numDays, rng);
|
||||||
|
let assetIndex = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < daysWithPhotos.length && assetIndex < totalAssets; i++) {
|
||||||
|
const dayAssets =
|
||||||
|
Math.floor(totalAssets / numDays) + (i === daysWithPhotos.length - 1 ? totalAssets % numDays : 0);
|
||||||
|
// Create a new RNG for this day
|
||||||
|
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||||
|
assets.push(...generateDayAssets(year, month, daysWithPhotos[i], dayAssets, ownerId, dayRng));
|
||||||
|
assetIndex += dayAssets;
|
||||||
|
}
|
||||||
|
return assets;
|
||||||
|
},
|
||||||
|
|
||||||
|
'start-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||||
|
// Most assets in first week
|
||||||
|
const assets: MockTimelineAsset[] = [];
|
||||||
|
const firstWeekAssets = Math.floor(totalAssets * 0.7);
|
||||||
|
const remainingAssets = totalAssets - firstWeekAssets;
|
||||||
|
|
||||||
|
// First 7 days
|
||||||
|
assets.push(...generateConsecutiveDays(year, month, 1, 7, firstWeekAssets, ownerId, rng));
|
||||||
|
|
||||||
|
// Remaining scattered
|
||||||
|
if (remainingAssets > 0) {
|
||||||
|
const midDay = Math.floor(daysInMonth / 2);
|
||||||
|
// Create a new RNG for the remaining assets
|
||||||
|
const remainingRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||||
|
assets.push(...generateDayAssets(year, month, midDay, remainingAssets, ownerId, remainingRng));
|
||||||
|
}
|
||||||
|
return assets;
|
||||||
|
},
|
||||||
|
|
||||||
|
'end-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||||
|
// Most assets in last week
|
||||||
|
const assets: MockTimelineAsset[] = [];
|
||||||
|
const lastWeekAssets = Math.floor(totalAssets * 0.7);
|
||||||
|
const remainingAssets = totalAssets - lastWeekAssets;
|
||||||
|
|
||||||
|
// Remaining at start
|
||||||
|
if (remainingAssets > 0) {
|
||||||
|
// Create a new RNG for the start assets
|
||||||
|
const startRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||||
|
assets.push(...generateDayAssets(year, month, 2, remainingAssets, ownerId, startRng));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last 7 days
|
||||||
|
const startDay = daysInMonth - 6;
|
||||||
|
assets.push(...generateConsecutiveDays(year, month, startDay, 7, lastWeekAssets, ownerId, rng));
|
||||||
|
return assets;
|
||||||
|
},
|
||||||
|
|
||||||
|
'mid-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||||
|
// Most assets in middle of month
|
||||||
|
const assets: MockTimelineAsset[] = [];
|
||||||
|
const midAssets = Math.floor(totalAssets * 0.7);
|
||||||
|
const sideAssets = Math.floor((totalAssets - midAssets) / 2);
|
||||||
|
|
||||||
|
// Start
|
||||||
|
if (sideAssets > 0) {
|
||||||
|
// Create a new RNG for the start assets
|
||||||
|
const startRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||||
|
assets.push(...generateDayAssets(year, month, 2, sideAssets, ownerId, startRng));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middle
|
||||||
|
const midStart = Math.floor(daysInMonth / 2) - 3;
|
||||||
|
assets.push(...generateConsecutiveDays(year, month, midStart, 7, midAssets, ownerId, rng));
|
||||||
|
|
||||||
|
// End
|
||||||
|
const endAssets = totalAssets - midAssets - sideAssets;
|
||||||
|
if (endAssets > 0) {
|
||||||
|
// Create a new RNG for the end assets
|
||||||
|
const endRng = new SeededRandom(rng.nextInt(0, 1_000_000));
|
||||||
|
assets.push(...generateDayAssets(year, month, daysInMonth - 1, endAssets, ownerId, endRng));
|
||||||
|
}
|
||||||
|
return assets;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export type MonthDistribution =
|
||||||
|
| 'empty' // 0 assets
|
||||||
|
| 'sparse' // 3-8 assets
|
||||||
|
| 'medium' // 15-30 assets
|
||||||
|
| 'dense' // 50-80 assets
|
||||||
|
| 'very-dense'; // 80-150 assets
|
||||||
|
|
||||||
|
export type DayPattern =
|
||||||
|
| 'single-day' // All images in one day
|
||||||
|
| 'consecutive-large' // Multiple days with 15-25 images each
|
||||||
|
| 'consecutive-small' // Multiple days with 1-3 images each (side-by-side)
|
||||||
|
| 'alternating' // Alternating large/small days
|
||||||
|
| 'sparse-scattered' // Few images scattered across month
|
||||||
|
| 'start-heavy' // Most images at start of month
|
||||||
|
| 'end-heavy' // Most images at end of month
|
||||||
|
| 'mid-heavy'; // Most images in middle of month
|
||||||
111
e2e/src/generators/timeline/images.ts
Normal file
111
e2e/src/generators/timeline/images.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import sharp from 'sharp';
|
||||||
|
import { SeededRandom } from 'src/generators/timeline/utils';
|
||||||
|
|
||||||
|
export const randomThumbnail = async (seed: string, ratio: number) => {
|
||||||
|
const height = 235;
|
||||||
|
const width = Math.round(height * ratio);
|
||||||
|
return randomImageFromString(seed, { width, height });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const randomPreview = async (seed: string, ratio: number) => {
|
||||||
|
const height = 500;
|
||||||
|
const width = Math.round(height * ratio);
|
||||||
|
return randomImageFromString(seed, { width, height });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const randomImageFromString = async (
|
||||||
|
seed: string = '',
|
||||||
|
{ width = 100, height = 100 }: { width: number; height: number },
|
||||||
|
) => {
|
||||||
|
// Convert string to number for seeding
|
||||||
|
let seedNumber = 0;
|
||||||
|
for (let i = 0; i < seed.length; i++) {
|
||||||
|
seedNumber = (seedNumber << 5) - seedNumber + (seed.codePointAt(i) ?? 0);
|
||||||
|
seedNumber = seedNumber & seedNumber; // Convert to 32bit integer
|
||||||
|
}
|
||||||
|
return randomImage(new SeededRandom(Math.abs(seedNumber)), { width, height });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const randomImage = async (rng: SeededRandom, { width, height }: { width: number; height: number }) => {
|
||||||
|
const r1 = rng.nextInt(0, 256);
|
||||||
|
const g1 = rng.nextInt(0, 256);
|
||||||
|
const b1 = rng.nextInt(0, 256);
|
||||||
|
const r2 = rng.nextInt(0, 256);
|
||||||
|
const g2 = rng.nextInt(0, 256);
|
||||||
|
const b2 = rng.nextInt(0, 256);
|
||||||
|
const patternType = rng.nextInt(0, 5);
|
||||||
|
|
||||||
|
let svgPattern = '';
|
||||||
|
|
||||||
|
switch (patternType) {
|
||||||
|
case 0: {
|
||||||
|
// Solid color
|
||||||
|
svgPattern = `<svg width="${width}" height="${height}">
|
||||||
|
<rect x="0" y="0" width="${width}" height="${height}" fill="rgb(${r1},${g1},${b1})"/>
|
||||||
|
</svg>`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 1: {
|
||||||
|
// Horizontal stripes
|
||||||
|
const stripeHeight = 10;
|
||||||
|
svgPattern = `<svg width="${width}" height="${height}">
|
||||||
|
${Array.from(
|
||||||
|
{ length: height / stripeHeight },
|
||||||
|
(_, i) =>
|
||||||
|
`<rect x="0" y="${i * stripeHeight}" width="${width}" height="${stripeHeight}"
|
||||||
|
fill="rgb(${i % 2 ? r1 : r2},${i % 2 ? g1 : g2},${i % 2 ? b1 : b2})"/>`,
|
||||||
|
).join('')}
|
||||||
|
</svg>`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 2: {
|
||||||
|
// Vertical stripes
|
||||||
|
const stripeWidth = 10;
|
||||||
|
svgPattern = `<svg width="${width}" height="${height}">
|
||||||
|
${Array.from(
|
||||||
|
{ length: width / stripeWidth },
|
||||||
|
(_, i) =>
|
||||||
|
`<rect x="${i * stripeWidth}" y="0" width="${stripeWidth}" height="${height}"
|
||||||
|
fill="rgb(${i % 2 ? r1 : r2},${i % 2 ? g1 : g2},${i % 2 ? b1 : b2})"/>`,
|
||||||
|
).join('')}
|
||||||
|
</svg>`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 3: {
|
||||||
|
// Checkerboard
|
||||||
|
const squareSize = 10;
|
||||||
|
svgPattern = `<svg width="${width}" height="${height}">
|
||||||
|
${Array.from({ length: height / squareSize }, (_, row) =>
|
||||||
|
Array.from({ length: width / squareSize }, (_, col) => {
|
||||||
|
const isEven = (row + col) % 2 === 0;
|
||||||
|
return `<rect x="${col * squareSize}" y="${row * squareSize}"
|
||||||
|
width="${squareSize}" height="${squareSize}"
|
||||||
|
fill="rgb(${isEven ? r1 : r2},${isEven ? g1 : g2},${isEven ? b1 : b2})"/>`;
|
||||||
|
}).join(''),
|
||||||
|
).join('')}
|
||||||
|
</svg>`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 4: {
|
||||||
|
// Diagonal stripes
|
||||||
|
svgPattern = `<svg width="${width}" height="${height}">
|
||||||
|
<defs>
|
||||||
|
<pattern id="diagonal" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||||
|
<rect x="0" y="0" width="10" height="20" fill="rgb(${r1},${g1},${b1})"/>
|
||||||
|
<rect x="10" y="0" width="10" height="20" fill="rgb(${r2},${g2},${b2})"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="${width}" height="${height}" fill="url(#diagonal)" transform="rotate(45 50 50)"/>
|
||||||
|
</svg>`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const svgBuffer = Buffer.from(svgPattern);
|
||||||
|
const jpegData = await sharp(svgBuffer).jpeg({ quality: 50 }).toBuffer();
|
||||||
|
return jpegData;
|
||||||
|
};
|
||||||
265
e2e/src/generators/timeline/model-objects.ts
Normal file
265
e2e/src/generators/timeline/model-objects.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* Generator functions for timeline model objects
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import { AssetVisibility } from '@immich/sdk';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
import { SeededRandom } from 'src/generators/timeline/utils';
|
||||||
|
import type { DayPattern, MonthDistribution } from './distribution-patterns';
|
||||||
|
import { ASSET_DISTRIBUTION, DAY_DISTRIBUTION } from './distribution-patterns';
|
||||||
|
import type { MockTimelineAsset, MockTimelineData, SerializedTimelineData, TimelineConfig } from './timeline-config';
|
||||||
|
import { ASPECT_RATIO_WEIGHTS, GENERATION_CONSTANTS, validateTimelineConfig } from './timeline-config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random aspect ratio based on weighted probabilities
|
||||||
|
*/
|
||||||
|
export function generateAspectRatio(rng: SeededRandom): string {
|
||||||
|
const random = rng.next();
|
||||||
|
let cumulative = 0;
|
||||||
|
|
||||||
|
for (const [ratio, weight] of Object.entries(ASPECT_RATIO_WEIGHTS)) {
|
||||||
|
cumulative += weight;
|
||||||
|
if (random < cumulative) {
|
||||||
|
return ratio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '16:9'; // Default fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateThumbhash(rng: SeededRandom): string {
|
||||||
|
return Array.from({ length: 10 }, () => rng.nextInt(0, 256).toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateDuration(rng: SeededRandom): string {
|
||||||
|
return `${rng.nextInt(GENERATION_CONSTANTS.MIN_VIDEO_DURATION_SECONDS, GENERATION_CONSTANTS.MAX_VIDEO_DURATION_SECONDS)}.${rng.nextInt(0, 1000).toString().padStart(3, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateUUID(): string {
|
||||||
|
return faker.string.uuid();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateAsset(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
day: number,
|
||||||
|
ownerId: string,
|
||||||
|
rng: SeededRandom,
|
||||||
|
): MockTimelineAsset {
|
||||||
|
const from = DateTime.fromObject({ year, month, day }).setZone('UTC');
|
||||||
|
const to = from.endOf('day');
|
||||||
|
const date = faker.date.between({ from: from.toJSDate(), to: to.toJSDate() });
|
||||||
|
const isVideo = rng.next() < GENERATION_CONSTANTS.VIDEO_PROBABILITY;
|
||||||
|
|
||||||
|
const assetId = generateUUID();
|
||||||
|
const hasGPS = rng.next() < GENERATION_CONSTANTS.GPS_PERCENTAGE;
|
||||||
|
|
||||||
|
const ratio = generateAspectRatio(rng);
|
||||||
|
|
||||||
|
const asset: MockTimelineAsset = {
|
||||||
|
id: assetId,
|
||||||
|
ownerId,
|
||||||
|
ratio: Number.parseFloat(ratio.split(':')[0]) / Number.parseFloat(ratio.split(':')[1]),
|
||||||
|
thumbhash: generateThumbhash(rng),
|
||||||
|
localDateTime: date.toISOString(),
|
||||||
|
fileCreatedAt: date.toISOString(),
|
||||||
|
isFavorite: rng.next() < GENERATION_CONSTANTS.FAVORITE_PROBABILITY,
|
||||||
|
isTrashed: false,
|
||||||
|
isVideo,
|
||||||
|
isImage: !isVideo,
|
||||||
|
duration: isVideo ? generateDuration(rng) : null,
|
||||||
|
projectionType: null,
|
||||||
|
livePhotoVideoId: null,
|
||||||
|
city: hasGPS ? faker.location.city() : null,
|
||||||
|
country: hasGPS ? faker.location.country() : null,
|
||||||
|
people: null,
|
||||||
|
latitude: hasGPS ? faker.location.latitude() : null,
|
||||||
|
longitude: hasGPS ? faker.location.longitude() : null,
|
||||||
|
visibility: AssetVisibility.Timeline,
|
||||||
|
stack: null,
|
||||||
|
fileSizeInByte: faker.number.int({ min: 510, max: 5_000_000 }),
|
||||||
|
checksum: faker.string.alphanumeric({ length: 5 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate assets for a specific day
|
||||||
|
*/
|
||||||
|
export function generateDayAssets(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
day: number,
|
||||||
|
assetCount: number,
|
||||||
|
ownerId: string,
|
||||||
|
rng: SeededRandom,
|
||||||
|
): MockTimelineAsset[] {
|
||||||
|
return Array.from({ length: assetCount }, () => generateAsset(year, month, day, ownerId, rng));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distribute assets evenly across consecutive days
|
||||||
|
*
|
||||||
|
* @returns Array of generated timeline assets
|
||||||
|
*/
|
||||||
|
export function generateConsecutiveDays(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
startDay: number,
|
||||||
|
numDays: number,
|
||||||
|
totalAssets: number,
|
||||||
|
ownerId: string,
|
||||||
|
rng: SeededRandom,
|
||||||
|
): MockTimelineAsset[] {
|
||||||
|
const assets: MockTimelineAsset[] = [];
|
||||||
|
const assetsPerDay = Math.floor(totalAssets / numDays);
|
||||||
|
|
||||||
|
for (let i = 0; i < numDays; i++) {
|
||||||
|
const dayAssets =
|
||||||
|
i === numDays - 1
|
||||||
|
? totalAssets - assetsPerDay * (numDays - 1) // Remainder on last day
|
||||||
|
: assetsPerDay;
|
||||||
|
// Create a new RNG with a different seed for each day
|
||||||
|
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000) + i * 100);
|
||||||
|
assets.push(...generateDayAssets(year, month, startDay + i, dayAssets, ownerId, dayRng));
|
||||||
|
}
|
||||||
|
|
||||||
|
return assets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate assets for a month with specified distribution pattern
|
||||||
|
*/
|
||||||
|
export function generateMonthAssets(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
ownerId: string,
|
||||||
|
distribution: MonthDistribution = 'medium',
|
||||||
|
pattern: DayPattern = 'consecutive-large',
|
||||||
|
rng: SeededRandom,
|
||||||
|
): MockTimelineAsset[] {
|
||||||
|
const daysInMonth = new Date(year, month, 0).getDate();
|
||||||
|
|
||||||
|
if (distribution === 'empty') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const distributionStrategy = ASSET_DISTRIBUTION[distribution];
|
||||||
|
if (!distributionStrategy) {
|
||||||
|
console.warn(`Unknown distribution: ${distribution}, defaulting to medium`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const totalAssets = distributionStrategy(rng);
|
||||||
|
|
||||||
|
const dayStrategy = DAY_DISTRIBUTION[pattern];
|
||||||
|
if (!dayStrategy) {
|
||||||
|
console.warn(`Unknown pattern: ${pattern}, defaulting to consecutive-large`);
|
||||||
|
// Fallback to consecutive-large pattern
|
||||||
|
const numDays = Math.min(5, Math.floor(totalAssets / 15));
|
||||||
|
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
|
||||||
|
const assets = generateConsecutiveDays(year, month, startDay, numDays, totalAssets, ownerId, rng);
|
||||||
|
assets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
|
||||||
|
return assets;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assets = dayStrategy(year, month, daysInMonth, totalAssets, ownerId, rng);
|
||||||
|
assets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
|
||||||
|
return assets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main generator function for timeline data
|
||||||
|
*/
|
||||||
|
export function generateTimelineData(config: TimelineConfig): MockTimelineData {
|
||||||
|
validateTimelineConfig(config);
|
||||||
|
|
||||||
|
const buckets = new Map<string, MockTimelineAsset[]>();
|
||||||
|
const monthStats: Record<string, { count: number; distribution: MonthDistribution; pattern: DayPattern }> = {};
|
||||||
|
|
||||||
|
const globalRng = new SeededRandom(config.seed || GENERATION_CONSTANTS.DEFAULT_SEED);
|
||||||
|
faker.seed(globalRng.nextInt(0, 1_000_000));
|
||||||
|
for (const monthConfig of config.months) {
|
||||||
|
const { year, month, distribution, pattern } = monthConfig;
|
||||||
|
|
||||||
|
const monthSeed = globalRng.nextInt(0, 1_000_000);
|
||||||
|
const monthRng = new SeededRandom(monthSeed);
|
||||||
|
|
||||||
|
const monthAssets = generateMonthAssets(
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
config.ownerId || generateUUID(),
|
||||||
|
distribution,
|
||||||
|
pattern,
|
||||||
|
monthRng,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (monthAssets.length > 0) {
|
||||||
|
const monthKey = `${year}-${month.toString().padStart(2, '0')}`;
|
||||||
|
monthStats[monthKey] = {
|
||||||
|
count: monthAssets.length,
|
||||||
|
distribution,
|
||||||
|
pattern,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create bucket key (YYYY-MM-01)
|
||||||
|
const bucketKey = `${year}-${month.toString().padStart(2, '0')}-01`;
|
||||||
|
buckets.set(bucketKey, monthAssets);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a mock album from random assets
|
||||||
|
const allAssets = [...buckets.values()].flat();
|
||||||
|
|
||||||
|
// Select 10-30 random assets for the album (or all assets if less than 10)
|
||||||
|
const albumSize = Math.min(allAssets.length, globalRng.nextInt(10, 31));
|
||||||
|
const selectedAssetConfigs: MockTimelineAsset[] = [];
|
||||||
|
const usedIndices = new Set<number>();
|
||||||
|
|
||||||
|
while (selectedAssetConfigs.length < albumSize && usedIndices.size < allAssets.length) {
|
||||||
|
const randomIndex = globalRng.nextInt(0, allAssets.length);
|
||||||
|
if (!usedIndices.has(randomIndex)) {
|
||||||
|
usedIndices.add(randomIndex);
|
||||||
|
selectedAssetConfigs.push(allAssets[randomIndex]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort selected assets by date (newest first)
|
||||||
|
selectedAssetConfigs.sort(
|
||||||
|
(a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds,
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedAssets = selectedAssetConfigs.map((asset) => asset.id);
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const album = {
|
||||||
|
id: generateUUID(),
|
||||||
|
albumName: 'Test Album',
|
||||||
|
description: 'A mock album for testing',
|
||||||
|
assetIds: selectedAssets,
|
||||||
|
thumbnailAssetId: selectedAssets.length > 0 ? selectedAssets[0] : null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write to file if configured
|
||||||
|
if (config.writeToFile) {
|
||||||
|
const outputPath = config.outputPath || '/tmp/timeline-data.json';
|
||||||
|
|
||||||
|
// Convert Map to object for serialization
|
||||||
|
const serializedData: SerializedTimelineData = {
|
||||||
|
buckets: Object.fromEntries(buckets),
|
||||||
|
album,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeFileSync(outputPath, JSON.stringify(serializedData, null, 2));
|
||||||
|
console.log(`Timeline data written to ${outputPath}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to write timeline data to ${outputPath}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { buckets, album };
|
||||||
|
}
|
||||||
436
e2e/src/generators/timeline/rest-response.ts
Normal file
436
e2e/src/generators/timeline/rest-response.ts
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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].sort((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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
139
e2e/src/mock-network/timeline-network.ts
Normal file
139
e2e/src/mock-network/timeline-network.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { BrowserContext, Page, Request, Route } from '@playwright/test';
|
||||||
|
import { basename } from 'node:path';
|
||||||
|
import {
|
||||||
|
Changes,
|
||||||
|
getAlbum,
|
||||||
|
getAsset,
|
||||||
|
getTimeBucket,
|
||||||
|
getTimeBuckets,
|
||||||
|
randomPreview,
|
||||||
|
randomThumbnail,
|
||||||
|
TimelineData,
|
||||||
|
} from 'src/generators/timeline';
|
||||||
|
import { sleep } from 'src/web/specs/timeline/utils';
|
||||||
|
|
||||||
|
export class TimelineTestContext {
|
||||||
|
slowBucket = false;
|
||||||
|
adminId = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setupTimelineMockApiRoutes = async (
|
||||||
|
context: BrowserContext,
|
||||||
|
timelineRestData: TimelineData,
|
||||||
|
changes: Changes,
|
||||||
|
testContext: TimelineTestContext,
|
||||||
|
) => {
|
||||||
|
await context.route('**/api/timeline**', async (route, request) => {
|
||||||
|
const url = new URL(request.url());
|
||||||
|
const pathname = url.pathname;
|
||||||
|
if (pathname === '/api/timeline/buckets') {
|
||||||
|
const albumId = url.searchParams.get('albumId') || undefined;
|
||||||
|
const isTrashed = url.searchParams.get('isTrashed') ? url.searchParams.get('isTrashed') === 'true' : undefined;
|
||||||
|
const isFavorite = url.searchParams.get('isFavorite') ? url.searchParams.get('isFavorite') === 'true' : undefined;
|
||||||
|
const isArchived = url.searchParams.get('visibility')
|
||||||
|
? url.searchParams.get('visibility') === 'archive'
|
||||||
|
: undefined;
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: getTimeBuckets(timelineRestData, isTrashed, isArchived, isFavorite, albumId, changes),
|
||||||
|
});
|
||||||
|
} else if (pathname === '/api/timeline/bucket') {
|
||||||
|
const timeBucket = url.searchParams.get('timeBucket');
|
||||||
|
if (!timeBucket) {
|
||||||
|
return route.continue();
|
||||||
|
}
|
||||||
|
const isTrashed = url.searchParams.get('isTrashed') ? url.searchParams.get('isTrashed') === 'true' : undefined;
|
||||||
|
const isArchived = url.searchParams.get('visibility')
|
||||||
|
? url.searchParams.get('visibility') === 'archive'
|
||||||
|
: undefined;
|
||||||
|
const isFavorite = url.searchParams.get('isFavorite') ? url.searchParams.get('isFavorite') === 'true' : undefined;
|
||||||
|
const albumId = url.searchParams.get('albumId') || undefined;
|
||||||
|
const assets = getTimeBucket(timelineRestData, timeBucket, isTrashed, isArchived, isFavorite, albumId, changes);
|
||||||
|
if (testContext.slowBucket) {
|
||||||
|
await sleep(5000);
|
||||||
|
}
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: assets,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.route('**/api/assets/**', async (route, request) => {
|
||||||
|
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
|
||||||
|
const match = request.url().match(pattern);
|
||||||
|
if (!match) {
|
||||||
|
const url = new URL(request.url());
|
||||||
|
const pathname = url.pathname;
|
||||||
|
const assetId = basename(pathname);
|
||||||
|
const asset = getAsset(timelineRestData, assetId);
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: asset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (match.groups?.size === 'preview') {
|
||||||
|
if (!route.request().serviceWorker()) {
|
||||||
|
return route.continue();
|
||||||
|
}
|
||||||
|
const asset = getAsset(timelineRestData, match.groups?.assetId);
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'image/jpeg', ETag: 'abc123', 'Cache-Control': 'public, max-age=3600' },
|
||||||
|
body: await randomPreview(
|
||||||
|
match.groups?.assetId,
|
||||||
|
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (match.groups?.size === 'thumbnail') {
|
||||||
|
if (!route.request().serviceWorker()) {
|
||||||
|
return route.continue();
|
||||||
|
}
|
||||||
|
const asset = getAsset(timelineRestData, match.groups?.assetId);
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'image/jpeg' },
|
||||||
|
body: await randomThumbnail(
|
||||||
|
match.groups?.assetId,
|
||||||
|
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
await context.route('**/api/albums/**', async (route, request) => {
|
||||||
|
const pattern = /\/api\/albums\/(?<albumId>[^/?]+)/;
|
||||||
|
const match = request.url().match(pattern);
|
||||||
|
if (!match) {
|
||||||
|
return route.continue();
|
||||||
|
}
|
||||||
|
const album = getAlbum(timelineRestData, testContext.adminId, match.groups?.albumId, changes);
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: album,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pageRoutePromise = async (
|
||||||
|
page: Page,
|
||||||
|
route: string,
|
||||||
|
callback: (route: Route, request: Request) => Promise<void>,
|
||||||
|
) => {
|
||||||
|
let resolveRequest: ((value: unknown | PromiseLike<unknown>) => void) | undefined;
|
||||||
|
const deleteRequest = new Promise((resolve) => {
|
||||||
|
resolveRequest = resolve;
|
||||||
|
});
|
||||||
|
await page.route(route, async (route, request) => {
|
||||||
|
await callback(route, request);
|
||||||
|
const requestJson = request.postDataJSON();
|
||||||
|
resolveRequest?.(requestJson);
|
||||||
|
});
|
||||||
|
return deleteRequest;
|
||||||
|
};
|
||||||
@@ -7,6 +7,12 @@ export const errorDto = {
|
|||||||
message: 'Authentication required',
|
message: 'Authentication required',
|
||||||
correlationId: expect.any(String),
|
correlationId: expect.any(String),
|
||||||
},
|
},
|
||||||
|
unauthorizedWithMessage: (message: string) => ({
|
||||||
|
error: 'Unauthorized',
|
||||||
|
statusCode: 401,
|
||||||
|
message,
|
||||||
|
correlationId: expect.any(String),
|
||||||
|
}),
|
||||||
forbidden: {
|
forbidden: {
|
||||||
error: 'Forbidden',
|
error: 'Forbidden',
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
AllJobStatusResponseDto,
|
|
||||||
AssetMediaCreateDto,
|
AssetMediaCreateDto,
|
||||||
AssetMediaResponseDto,
|
AssetMediaResponseDto,
|
||||||
AssetResponseDto,
|
AssetResponseDto,
|
||||||
@@ -7,11 +6,13 @@ import {
|
|||||||
CheckExistingAssetsDto,
|
CheckExistingAssetsDto,
|
||||||
CreateAlbumDto,
|
CreateAlbumDto,
|
||||||
CreateLibraryDto,
|
CreateLibraryDto,
|
||||||
JobCommandDto,
|
MaintenanceAction,
|
||||||
JobName,
|
|
||||||
MetadataSearchDto,
|
MetadataSearchDto,
|
||||||
Permission,
|
Permission,
|
||||||
PersonCreateDto,
|
PersonCreateDto,
|
||||||
|
QueueCommandDto,
|
||||||
|
QueueName,
|
||||||
|
QueuesResponseDto,
|
||||||
SharedLinkCreateDto,
|
SharedLinkCreateDto,
|
||||||
UpdateLibraryDto,
|
UpdateLibraryDto,
|
||||||
UserAdminCreateDto,
|
UserAdminCreateDto,
|
||||||
@@ -27,15 +28,16 @@ import {
|
|||||||
createStack,
|
createStack,
|
||||||
createUserAdmin,
|
createUserAdmin,
|
||||||
deleteAssets,
|
deleteAssets,
|
||||||
getAllJobsStatus,
|
|
||||||
getAssetInfo,
|
getAssetInfo,
|
||||||
getConfig,
|
getConfig,
|
||||||
getConfigDefaults,
|
getConfigDefaults,
|
||||||
|
getQueuesLegacy,
|
||||||
login,
|
login,
|
||||||
|
runQueueCommandLegacy,
|
||||||
scanLibrary,
|
scanLibrary,
|
||||||
searchAssets,
|
searchAssets,
|
||||||
sendJobCommand,
|
|
||||||
setBaseUrl,
|
setBaseUrl,
|
||||||
|
setMaintenanceMode,
|
||||||
signUpAdmin,
|
signUpAdmin,
|
||||||
tagAssets,
|
tagAssets,
|
||||||
updateAdminOnboarding,
|
updateAdminOnboarding,
|
||||||
@@ -52,7 +54,7 @@ import { exec, spawn } from 'node:child_process';
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import path, { dirname } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { setTimeout as setAsyncTimeout } from 'node:timers/promises';
|
import { setTimeout as setAsyncTimeout } from 'node:timers/promises';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
@@ -60,6 +62,8 @@ import { io, type Socket } from 'socket.io-client';
|
|||||||
import { loginDto, signupDto } from 'src/fixtures';
|
import { loginDto, signupDto } from 'src/fixtures';
|
||||||
import { makeRandomImage } from 'src/generators';
|
import { makeRandomImage } from 'src/generators';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
|
import { playwrightDbHost, playwrightHost, playwriteBaseUrl } from '../playwright.config';
|
||||||
|
|
||||||
export type { Emitter } from '@socket.io/component-emitter';
|
export type { Emitter } from '@socket.io/component-emitter';
|
||||||
|
|
||||||
type CommandResponse = { stdout: string; stderr: string; exitCode: number | null };
|
type CommandResponse = { stdout: string; stderr: string; exitCode: number | null };
|
||||||
@@ -68,12 +72,12 @@ type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: nu
|
|||||||
type AdminSetupOptions = { onboarding?: boolean };
|
type AdminSetupOptions = { onboarding?: boolean };
|
||||||
type FileData = { bytes?: Buffer; filename: string };
|
type FileData = { bytes?: Buffer; filename: string };
|
||||||
|
|
||||||
const dbUrl = 'postgres://postgres:postgres@127.0.0.1:5435/immich';
|
const dbUrl = `postgres://postgres:postgres@${playwrightDbHost}:5435/immich`;
|
||||||
export const baseUrl = 'http://127.0.0.1:2285';
|
export const baseUrl = playwriteBaseUrl;
|
||||||
export const shareUrl = `${baseUrl}/share`;
|
export const shareUrl = `${baseUrl}/share`;
|
||||||
export const app = `${baseUrl}/api`;
|
export const app = `${baseUrl}/api`;
|
||||||
// TODO move test assets into e2e/assets
|
// TODO move test assets into e2e/assets
|
||||||
export const testAssetDir = path.resolve('./test-assets');
|
export const testAssetDir = resolve(import.meta.dirname, '../test-assets');
|
||||||
export const testAssetDirInternal = '/test-assets';
|
export const testAssetDirInternal = '/test-assets';
|
||||||
export const tempDir = tmpdir();
|
export const tempDir = tmpdir();
|
||||||
export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` });
|
export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` });
|
||||||
@@ -477,10 +481,10 @@ export const utils = {
|
|||||||
tagAssets: (accessToken: string, tagId: string, assetIds: string[]) =>
|
tagAssets: (accessToken: string, tagId: string, assetIds: string[]) =>
|
||||||
tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }),
|
tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }),
|
||||||
|
|
||||||
jobCommand: async (accessToken: string, jobName: JobName, jobCommandDto: JobCommandDto) =>
|
queueCommand: async (accessToken: string, name: QueueName, queueCommandDto: QueueCommandDto) =>
|
||||||
sendJobCommand({ id: jobName, jobCommandDto }, { headers: asBearerAuth(accessToken) }),
|
runQueueCommandLegacy({ name, queueCommandDto }, { headers: asBearerAuth(accessToken) }),
|
||||||
|
|
||||||
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = '127.0.0.1') =>
|
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = playwrightHost) =>
|
||||||
await context.addCookies([
|
await context.addCookies([
|
||||||
{
|
{
|
||||||
name: 'immich_access_token',
|
name: 'immich_access_token',
|
||||||
@@ -514,6 +518,42 @@ export const utils = {
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
setMaintenanceAuthCookie: async (context: BrowserContext, token: string, domain = '127.0.0.1') =>
|
||||||
|
await context.addCookies([
|
||||||
|
{
|
||||||
|
name: 'immich_maintenance_token',
|
||||||
|
value: token,
|
||||||
|
domain,
|
||||||
|
path: '/',
|
||||||
|
expires: 2_058_028_213,
|
||||||
|
httpOnly: true,
|
||||||
|
secure: false,
|
||||||
|
sameSite: 'Lax',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
|
||||||
|
enterMaintenance: async (accessToken: string) => {
|
||||||
|
let setCookie: string[] | undefined;
|
||||||
|
|
||||||
|
await setMaintenanceMode(
|
||||||
|
{
|
||||||
|
setMaintenanceModeDto: {
|
||||||
|
action: MaintenanceAction.Start,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: asBearerAuth(accessToken),
|
||||||
|
fetch: (...args: Parameters<typeof fetch>) =>
|
||||||
|
fetch(...args).then((response) => {
|
||||||
|
setCookie = response.headers.getSetCookie();
|
||||||
|
return response;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return setCookie;
|
||||||
|
},
|
||||||
|
|
||||||
resetTempFolder: () => {
|
resetTempFolder: () => {
|
||||||
rmSync(`${testAssetDir}/temp`, { recursive: true, force: true });
|
rmSync(`${testAssetDir}/temp`, { recursive: true, force: true });
|
||||||
mkdirSync(`${testAssetDir}/temp`, { recursive: true });
|
mkdirSync(`${testAssetDir}/temp`, { recursive: true });
|
||||||
@@ -524,13 +564,13 @@ export const utils = {
|
|||||||
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
|
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
|
||||||
},
|
},
|
||||||
|
|
||||||
isQueueEmpty: async (accessToken: string, queue: keyof AllJobStatusResponseDto) => {
|
isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseDto) => {
|
||||||
const queues = await getAllJobsStatus({ headers: asBearerAuth(accessToken) });
|
const queues = await getQueuesLegacy({ headers: asBearerAuth(accessToken) });
|
||||||
const jobCounts = queues[queue].jobCounts;
|
const jobCounts = queues[queue].jobCounts;
|
||||||
return !jobCounts.active && !jobCounts.waiting;
|
return !jobCounts.active && !jobCounts.waiting;
|
||||||
},
|
},
|
||||||
|
|
||||||
waitForQueueFinish: (accessToken: string, queue: keyof AllJobStatusResponseDto, ms?: number) => {
|
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseDto, 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);
|
||||||
|
|||||||
51
e2e/src/web/specs/maintenance.e2e-spec.ts
Normal file
51
e2e/src/web/specs/maintenance.e2e-spec.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { LoginResponseDto } from '@immich/sdk';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { utils } from 'src/utils';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test.describe('Maintenance', () => {
|
||||||
|
let admin: LoginResponseDto;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
utils.initSdk();
|
||||||
|
await utils.resetDatabase();
|
||||||
|
admin = await utils.adminSetup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enter and exit maintenance mode', async ({ context, page }) => {
|
||||||
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
|
||||||
|
await page.goto('/admin/system-settings?isOpen=maintenance');
|
||||||
|
await page.getByRole('button', { name: 'Start maintenance mode' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('Temporarily Unavailable')).toBeVisible({ timeout: 10_000 });
|
||||||
|
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||||
|
await page.waitForURL('**/admin/system-settings*', { timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maintenance shows no options to users until they authenticate', async ({ page }) => {
|
||||||
|
const setCookie = await utils.enterMaintenance(admin.accessToken);
|
||||||
|
const cookie = setCookie
|
||||||
|
?.map((cookie) => cookie.split(';')[0].split('='))
|
||||||
|
?.find(([name]) => name === 'immich_maintenance_token');
|
||||||
|
|
||||||
|
expect(cookie).toBeTruthy();
|
||||||
|
|
||||||
|
await expect(async () => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForURL('**/maintenance?**', {
|
||||||
|
timeout: 1000,
|
||||||
|
});
|
||||||
|
}).toPass({ timeout: 10_000 });
|
||||||
|
|
||||||
|
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'End maintenance mode' })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.goto(`/maintenance?${new URLSearchParams({ token: cookie![1] })}`);
|
||||||
|
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'End maintenance mode' })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||||
|
await page.waitForURL('**/auth/login');
|
||||||
|
});
|
||||||
|
});
|
||||||
775
e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts
Normal file
775
e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts
Normal file
@@ -0,0 +1,775 @@
|
|||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import {
|
||||||
|
Changes,
|
||||||
|
createDefaultTimelineConfig,
|
||||||
|
generateTimelineData,
|
||||||
|
getAsset,
|
||||||
|
getMockAsset,
|
||||||
|
SeededRandom,
|
||||||
|
selectRandom,
|
||||||
|
selectRandomMultiple,
|
||||||
|
TimelineAssetConfig,
|
||||||
|
TimelineData,
|
||||||
|
} from 'src/generators/timeline';
|
||||||
|
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
|
||||||
|
import { pageRoutePromise, setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
|
||||||
|
import { utils } from 'src/utils';
|
||||||
|
import {
|
||||||
|
assetViewerUtils,
|
||||||
|
cancelAllPollers,
|
||||||
|
padYearMonth,
|
||||||
|
pageUtils,
|
||||||
|
poll,
|
||||||
|
thumbnailUtils,
|
||||||
|
timelineUtils,
|
||||||
|
} from 'src/web/specs/timeline/utils';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
test.describe('Timeline', () => {
|
||||||
|
let adminUserId: string;
|
||||||
|
let timelineRestData: TimelineData;
|
||||||
|
const assets: TimelineAssetConfig[] = [];
|
||||||
|
const yearMonths: string[] = [];
|
||||||
|
const testContext = new TimelineTestContext();
|
||||||
|
const changes: Changes = {
|
||||||
|
albumAdditions: [],
|
||||||
|
assetDeletions: [],
|
||||||
|
assetArchivals: [],
|
||||||
|
assetFavorites: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
test.fail(
|
||||||
|
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
|
||||||
|
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
|
||||||
|
);
|
||||||
|
utils.initSdk();
|
||||||
|
adminUserId = faker.string.uuid();
|
||||||
|
testContext.adminId = adminUserId;
|
||||||
|
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
||||||
|
for (const timeBucket of timelineRestData.buckets.values()) {
|
||||||
|
assets.push(...timeBucket);
|
||||||
|
}
|
||||||
|
for (const yearMonth of timelineRestData.buckets.keys()) {
|
||||||
|
const [year, month] = yearMonth.split('-');
|
||||||
|
yearMonths.push(`${year}-${Number(month)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async ({ context }) => {
|
||||||
|
await setupBaseMockApiRoutes(context, adminUserId);
|
||||||
|
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(() => {
|
||||||
|
cancelAllPollers();
|
||||||
|
testContext.slowBucket = false;
|
||||||
|
changes.albumAdditions = [];
|
||||||
|
changes.assetDeletions = [];
|
||||||
|
changes.assetArchivals = [];
|
||||||
|
changes.assetFavorites = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('/photos', () => {
|
||||||
|
test('Open /photos', async ({ page }) => {
|
||||||
|
await page.goto(`/photos`);
|
||||||
|
await page.waitForSelector('#asset-grid');
|
||||||
|
await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
|
||||||
|
});
|
||||||
|
test('Deep link to last photo', async ({ page }) => {
|
||||||
|
const lastAsset = assets.at(-1)!;
|
||||||
|
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
|
||||||
|
await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
|
||||||
|
await thumbnailUtils.expectInViewport(page, lastAsset.id);
|
||||||
|
});
|
||||||
|
const rng = new SeededRandom(529);
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
test('Deep link to random asset ' + i, async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
await pageUtils.deepLinkPhotosPage(page, asset.id);
|
||||||
|
await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
|
||||||
|
await thumbnailUtils.expectInViewport(page, asset.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test('Open /photos, open asset-viewer, browser back', async ({ page }) => {
|
||||||
|
const rng = new SeededRandom(22);
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
await pageUtils.deepLinkPhotosPage(page, asset.id);
|
||||||
|
const scrollTopBefore = await timelineUtils.getScrollTop(page);
|
||||||
|
await thumbnailUtils.clickAssetId(page, asset.id);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
await page.goBack();
|
||||||
|
await timelineUtils.locator(page).waitFor();
|
||||||
|
const scrollTopAfter = await timelineUtils.getScrollTop(page);
|
||||||
|
expect(scrollTopAfter).toBe(scrollTopBefore);
|
||||||
|
});
|
||||||
|
test('Open /photos, open asset-viewer, next photo, browser back, back', async ({ page }) => {
|
||||||
|
const rng = new SeededRandom(49);
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
const assetIndex = assets.indexOf(asset);
|
||||||
|
const nextAsset = assets[assetIndex + 1];
|
||||||
|
await pageUtils.deepLinkPhotosPage(page, asset.id);
|
||||||
|
const scrollTopBefore = await timelineUtils.getScrollTop(page);
|
||||||
|
await thumbnailUtils.clickAssetId(page, asset.id);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||||
|
await page.getByLabel('View next asset').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, nextAsset);
|
||||||
|
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${nextAsset.id}`);
|
||||||
|
await page.goBack();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
await page.goBack();
|
||||||
|
await page.waitForURL('**/photos?at=*');
|
||||||
|
const scrollTopAfter = await timelineUtils.getScrollTop(page);
|
||||||
|
expect(Math.abs(scrollTopAfter - scrollTopBefore)).toBeLessThan(5);
|
||||||
|
});
|
||||||
|
test('Open /photos, open asset-viewer, next photo 15x, backwardsArrow', async ({ page }) => {
|
||||||
|
await pageUtils.deepLinkPhotosPage(page, assets[0].id);
|
||||||
|
await thumbnailUtils.clickAssetId(page, assets[0].id);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[0]);
|
||||||
|
for (let i = 1; i <= 15; i++) {
|
||||||
|
await page.getByLabel('View next asset').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[i]);
|
||||||
|
}
|
||||||
|
await page.getByLabel('Go back').click();
|
||||||
|
await page.waitForURL('**/photos?at=*');
|
||||||
|
await thumbnailUtils.expectInViewport(page, assets[15].id);
|
||||||
|
await thumbnailUtils.expectBottomIsTimelineBottom(page, assets[15]!.id);
|
||||||
|
});
|
||||||
|
test('Open /photos, open asset-viewer, previous photo 15x, backwardsArrow', async ({ page }) => {
|
||||||
|
const lastAsset = assets.at(-1)!;
|
||||||
|
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
|
||||||
|
await thumbnailUtils.clickAssetId(page, lastAsset.id);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
|
||||||
|
for (let i = 1; i <= 15; i++) {
|
||||||
|
await page.getByLabel('View previous asset').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets.at(-1 - i)!);
|
||||||
|
}
|
||||||
|
await page.getByLabel('Go back').click();
|
||||||
|
await page.waitForURL('**/photos?at=*');
|
||||||
|
await thumbnailUtils.expectInViewport(page, assets.at(-1 - 15)!.id);
|
||||||
|
await thumbnailUtils.expectTopIsTimelineTop(page, assets.at(-1 - 15)!.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test.describe('keyboard', () => {
|
||||||
|
/**
|
||||||
|
* This text tests keyboard nativation, and also ensures that the scroll-to-asset behavior
|
||||||
|
* scrolls the minimum amount. That is, if you are navigating using right arrow (auto scrolling
|
||||||
|
* as necessary downwards), then the asset should always be at the lowest row of the grid.
|
||||||
|
*/
|
||||||
|
test('Next/previous asset - ArrowRight/ArrowLeft', async ({ page }) => {
|
||||||
|
await pageUtils.openPhotosPage(page);
|
||||||
|
await thumbnailUtils.withAssetId(page, assets[0].id).focus();
|
||||||
|
const rightKey = 'ArrowRight';
|
||||||
|
const leftKey = 'ArrowLeft';
|
||||||
|
for (let i = 1; i < 15; i++) {
|
||||||
|
await page.keyboard.press(rightKey);
|
||||||
|
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||||
|
}
|
||||||
|
for (let i = 15; i <= 20; i++) {
|
||||||
|
await page.keyboard.press(rightKey);
|
||||||
|
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||||
|
expect(await thumbnailUtils.expectBottomIsTimelineBottom(page, assets.at(i)!.id));
|
||||||
|
}
|
||||||
|
// now test previous asset
|
||||||
|
for (let i = 19; i >= 15; i--) {
|
||||||
|
await page.keyboard.press(leftKey);
|
||||||
|
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||||
|
}
|
||||||
|
for (let i = 14; i > 0; i--) {
|
||||||
|
await page.keyboard.press(leftKey);
|
||||||
|
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||||
|
expect(await thumbnailUtils.expectTopIsTimelineTop(page, assets.at(i)!.id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('Next/previous asset - Tab/Shift+Tab', async ({ page }) => {
|
||||||
|
await pageUtils.openPhotosPage(page);
|
||||||
|
await thumbnailUtils.withAssetId(page, assets[0].id).focus();
|
||||||
|
const rightKey = 'Tab';
|
||||||
|
const leftKey = 'Shift+Tab';
|
||||||
|
for (let i = 1; i < 15; i++) {
|
||||||
|
await page.keyboard.press(rightKey);
|
||||||
|
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||||
|
}
|
||||||
|
for (let i = 15; i <= 20; i++) {
|
||||||
|
await page.keyboard.press(rightKey);
|
||||||
|
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||||
|
}
|
||||||
|
// now test previous asset
|
||||||
|
for (let i = 19; i >= 15; i--) {
|
||||||
|
await page.keyboard.press(leftKey);
|
||||||
|
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||||
|
}
|
||||||
|
for (let i = 14; i > 0; i--) {
|
||||||
|
await page.keyboard.press(leftKey);
|
||||||
|
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('Next/previous day - d, Shift+D', async ({ page }) => {
|
||||||
|
await pageUtils.openPhotosPage(page);
|
||||||
|
let asset = assets[0];
|
||||||
|
await timelineUtils.locator(page).hover();
|
||||||
|
await page.keyboard.press('d');
|
||||||
|
await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
await page.keyboard.press('d');
|
||||||
|
const next = getMockAsset(asset, assets, 'next', 'day')!;
|
||||||
|
await assetViewerUtils.expectActiveAssetToBe(page, next.id);
|
||||||
|
asset = next;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
await page.keyboard.press('Shift+D');
|
||||||
|
const previous = getMockAsset(asset, assets, 'previous', 'day')!;
|
||||||
|
await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
|
||||||
|
asset = previous;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('Next/previous month - m, Shift+M', async ({ page }) => {
|
||||||
|
await pageUtils.openPhotosPage(page);
|
||||||
|
let asset = assets[0];
|
||||||
|
await timelineUtils.locator(page).hover();
|
||||||
|
await page.keyboard.press('m');
|
||||||
|
await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
await page.keyboard.press('m');
|
||||||
|
const next = getMockAsset(asset, assets, 'next', 'month')!;
|
||||||
|
await assetViewerUtils.expectActiveAssetToBe(page, next.id);
|
||||||
|
asset = next;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
await page.keyboard.press('Shift+M');
|
||||||
|
const previous = getMockAsset(asset, assets, 'previous', 'month')!;
|
||||||
|
await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
|
||||||
|
asset = previous;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('Next/previous year - y, Shift+Y', async ({ page }) => {
|
||||||
|
await pageUtils.openPhotosPage(page);
|
||||||
|
let asset = assets[0];
|
||||||
|
await timelineUtils.locator(page).hover();
|
||||||
|
await page.keyboard.press('y');
|
||||||
|
await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
await page.keyboard.press('y');
|
||||||
|
const next = getMockAsset(asset, assets, 'next', 'year')!;
|
||||||
|
await assetViewerUtils.expectActiveAssetToBe(page, next.id);
|
||||||
|
asset = next;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
await page.keyboard.press('Shift+Y');
|
||||||
|
const previous = getMockAsset(asset, assets, 'previous', 'year')!;
|
||||||
|
await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
|
||||||
|
asset = previous;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('Navigate to time - g', async ({ page }) => {
|
||||||
|
const rng = new SeededRandom(4782);
|
||||||
|
await pageUtils.openPhotosPage(page);
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
await pageUtils.goToAsset(page, asset.fileCreatedAt);
|
||||||
|
await thumbnailUtils.expectInViewport(page, asset.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test.describe('selection', () => {
|
||||||
|
test('Select day, unselect day', async ({ page }) => {
|
||||||
|
await pageUtils.openPhotosPage(page);
|
||||||
|
await pageUtils.selectDay(page, 'Wed, Dec 11, 2024');
|
||||||
|
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(4);
|
||||||
|
await pageUtils.selectDay(page, 'Wed, Dec 11, 2024');
|
||||||
|
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
test('Select asset, click asset to select', async ({ page }) => {
|
||||||
|
await pageUtils.openPhotosPage(page);
|
||||||
|
await thumbnailUtils.withAssetId(page, assets[1].id).hover();
|
||||||
|
await thumbnailUtils.selectButton(page, assets[1].id).click();
|
||||||
|
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(1);
|
||||||
|
// no need to hover, once selection is active
|
||||||
|
await thumbnailUtils.clickAssetId(page, assets[2].id);
|
||||||
|
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(2);
|
||||||
|
});
|
||||||
|
test('Select asset, click unselect asset', async ({ page }) => {
|
||||||
|
await pageUtils.openPhotosPage(page);
|
||||||
|
await thumbnailUtils.withAssetId(page, assets[1].id).hover();
|
||||||
|
await thumbnailUtils.selectButton(page, assets[1].id).click();
|
||||||
|
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(1);
|
||||||
|
await thumbnailUtils.clickAssetId(page, assets[1].id);
|
||||||
|
// the hover uses a checked button too, so just move mouse away
|
||||||
|
await page.mouse.move(0, 0);
|
||||||
|
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
test('Select asset, shift-hover candidates, shift-click end', async ({ page }) => {
|
||||||
|
await pageUtils.openPhotosPage(page);
|
||||||
|
const asset = assets[0];
|
||||||
|
await thumbnailUtils.withAssetId(page, asset.id).hover();
|
||||||
|
await thumbnailUtils.selectButton(page, asset.id).click();
|
||||||
|
await page.keyboard.down('Shift');
|
||||||
|
await thumbnailUtils.withAssetId(page, assets[2].id).hover();
|
||||||
|
await expect(
|
||||||
|
thumbnailUtils.locator(page).locator('.absolute.top-0.h-full.w-full.bg-immich-primary.opacity-40'),
|
||||||
|
).toHaveCount(3);
|
||||||
|
await thumbnailUtils.selectButton(page, assets[2].id).click();
|
||||||
|
await page.keyboard.up('Shift');
|
||||||
|
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(3);
|
||||||
|
});
|
||||||
|
test('Add multiple to selection - Select day, shift-click end', async ({ page }) => {
|
||||||
|
await pageUtils.openPhotosPage(page);
|
||||||
|
await thumbnailUtils.withAssetId(page, assets[0].id).hover();
|
||||||
|
await thumbnailUtils.selectButton(page, assets[0].id).click();
|
||||||
|
await thumbnailUtils.clickAssetId(page, assets[2].id);
|
||||||
|
await page.keyboard.down('Shift');
|
||||||
|
await thumbnailUtils.clickAssetId(page, assets[4].id);
|
||||||
|
await page.mouse.move(0, 0);
|
||||||
|
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test.describe('scroll', () => {
|
||||||
|
test('Open /photos, random click scrubber 20x', async ({ page }) => {
|
||||||
|
test.slow();
|
||||||
|
await pageUtils.openPhotosPage(page);
|
||||||
|
const rng = new SeededRandom(6637);
|
||||||
|
const selectedMonths = selectRandomMultiple(yearMonths, 20, rng);
|
||||||
|
for (const month of selectedMonths) {
|
||||||
|
await page.locator(`[data-segment-year-month="${month}"]`).click({ force: true });
|
||||||
|
const visibleMockAssetsYearMonths = await poll(page, async () => {
|
||||||
|
const assetIds = await thumbnailUtils.getAllInViewport(
|
||||||
|
page,
|
||||||
|
(assetId: string) => getYearMonth(assets, assetId) === month,
|
||||||
|
);
|
||||||
|
const visibleMockAssetsYearMonths: string[] = [];
|
||||||
|
for (const assetId of assetIds!) {
|
||||||
|
const yearMonth = getYearMonth(assets, assetId);
|
||||||
|
visibleMockAssetsYearMonths.push(yearMonth);
|
||||||
|
if (yearMonth === month) {
|
||||||
|
return [yearMonth];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (page.isClosed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(visibleMockAssetsYearMonths).toContain(month);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('Deep link to last photo, scroll up', async ({ page }) => {
|
||||||
|
const lastAsset = assets.at(-1)!;
|
||||||
|
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
|
||||||
|
|
||||||
|
await timelineUtils.locator(page).hover();
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
await page.mouse.wheel(0, -100);
|
||||||
|
await page.waitForTimeout(25);
|
||||||
|
}
|
||||||
|
|
||||||
|
await thumbnailUtils.expectInViewport(page, '14e5901f-fd7f-40c0-b186-4d7e7fc67968');
|
||||||
|
});
|
||||||
|
test('Deep link to first bucket, scroll down', async ({ page }) => {
|
||||||
|
const lastAsset = assets.at(0)!;
|
||||||
|
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
|
||||||
|
await timelineUtils.locator(page).hover();
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
await page.mouse.wheel(0, 100);
|
||||||
|
await page.waitForTimeout(25);
|
||||||
|
}
|
||||||
|
await thumbnailUtils.expectInViewport(page, 'b7983a13-4b4e-4950-a731-f2962d9a1555');
|
||||||
|
});
|
||||||
|
test('Deep link to last photo, drag scrubber to scroll up', async ({ page }) => {
|
||||||
|
const lastAsset = assets.at(-1)!;
|
||||||
|
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
|
||||||
|
const lastMonth = yearMonths.at(-1);
|
||||||
|
const firstScrubSegment = page.locator(`[data-segment-year-month="${yearMonths[0]}"]`);
|
||||||
|
const lastScrubSegment = page.locator(`[data-segment-year-month="${lastMonth}"]`);
|
||||||
|
const sourcebox = (await lastScrubSegment.boundingBox())!;
|
||||||
|
const targetBox = (await firstScrubSegment.boundingBox())!;
|
||||||
|
await firstScrubSegment.hover();
|
||||||
|
const currentY = sourcebox.y;
|
||||||
|
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, currentY);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, targetBox.y, { steps: 100 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await thumbnailUtils.expectInViewport(page, assets[0].id);
|
||||||
|
});
|
||||||
|
test('Deep link to first bucket, drag scrubber to scroll down', async ({ page }) => {
|
||||||
|
await pageUtils.deepLinkPhotosPage(page, assets[0].id);
|
||||||
|
const firstScrubSegment = page.locator(`[data-segment-year-month="${yearMonths[0]}"]`);
|
||||||
|
const sourcebox = (await firstScrubSegment.boundingBox())!;
|
||||||
|
await firstScrubSegment.hover();
|
||||||
|
const currentY = sourcebox.y;
|
||||||
|
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, currentY);
|
||||||
|
await page.mouse.down();
|
||||||
|
const height = page.viewportSize()?.height;
|
||||||
|
expect(height).toBeDefined();
|
||||||
|
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, height! - 10, {
|
||||||
|
steps: 100,
|
||||||
|
});
|
||||||
|
await page.mouse.up();
|
||||||
|
await thumbnailUtils.expectInViewport(page, assets.at(-1)!.id);
|
||||||
|
});
|
||||||
|
test('Buckets cancel on scroll', async ({ page }) => {
|
||||||
|
await pageUtils.openPhotosPage(page);
|
||||||
|
testContext.slowBucket = true;
|
||||||
|
const failedUris: string[] = [];
|
||||||
|
page.on('requestfailed', (request) => {
|
||||||
|
failedUris.push(request.url());
|
||||||
|
});
|
||||||
|
const offscreenSegment = page.locator(`[data-segment-year-month="${yearMonths[12]}"]`);
|
||||||
|
await offscreenSegment.click({ force: true });
|
||||||
|
const lastSegment = page.locator(`[data-segment-year-month="${yearMonths.at(-1)!}"]`);
|
||||||
|
await lastSegment.click({ force: true });
|
||||||
|
const uris = await poll(page, async () => (failedUris.length > 0 ? failedUris : null));
|
||||||
|
expect(uris).toEqual(expect.arrayContaining([expect.stringContaining(padYearMonth(yearMonths[12]!))]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test.describe('/albums', () => {
|
||||||
|
test('Open album', async ({ page }) => {
|
||||||
|
const album = timelineRestData.album;
|
||||||
|
await pageUtils.openAlbumPage(page, album.id);
|
||||||
|
await thumbnailUtils.expectInViewport(page, album.assetIds[0]);
|
||||||
|
});
|
||||||
|
test('Deep link to last photo', async ({ page }) => {
|
||||||
|
const album = timelineRestData.album;
|
||||||
|
const lastAsset = album.assetIds.at(-1);
|
||||||
|
await pageUtils.deepLinkAlbumPage(page, album.id, lastAsset!);
|
||||||
|
await thumbnailUtils.expectInViewport(page, album.assetIds.at(-1)!);
|
||||||
|
await thumbnailUtils.expectBottomIsTimelineBottom(page, album.assetIds.at(-1)!);
|
||||||
|
});
|
||||||
|
test('Add photos to album pre-selects existing', async ({ page }) => {
|
||||||
|
const album = timelineRestData.album;
|
||||||
|
await pageUtils.openAlbumPage(page, album.id);
|
||||||
|
await page.getByLabel('Add photos').click();
|
||||||
|
const asset = getAsset(timelineRestData, album.assetIds[0])!;
|
||||||
|
await pageUtils.goToAsset(page, asset.fileCreatedAt);
|
||||||
|
await thumbnailUtils.expectInViewport(page, asset.id);
|
||||||
|
await thumbnailUtils.expectSelectedReadonly(page, asset.id);
|
||||||
|
});
|
||||||
|
test('Add photos to album', async ({ page }) => {
|
||||||
|
const album = timelineRestData.album;
|
||||||
|
await pageUtils.openAlbumPage(page, album.id);
|
||||||
|
await page.locator('nav button[aria-label="Add photos"]').click();
|
||||||
|
const asset = getAsset(timelineRestData, album.assetIds[0])!;
|
||||||
|
await pageUtils.goToAsset(page, asset.fileCreatedAt);
|
||||||
|
await thumbnailUtils.expectInViewport(page, asset.id);
|
||||||
|
await thumbnailUtils.expectSelectedReadonly(page, asset.id);
|
||||||
|
await pageUtils.selectDay(page, 'Tue, Feb 27, 2024');
|
||||||
|
const put = pageRoutePromise(page, `**/api/albums/${album.id}/assets`, async (route, request) => {
|
||||||
|
const requestJson = request.postDataJSON();
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: requestJson.ids.map((id: string) => ({ id, success: true })),
|
||||||
|
});
|
||||||
|
changes.albumAdditions.push(...requestJson.ids);
|
||||||
|
});
|
||||||
|
await page.getByText('Done').click();
|
||||||
|
await expect(put).resolves.toEqual({
|
||||||
|
ids: [
|
||||||
|
'c077ea7b-cfa1-45e4-8554-f86c00ee5658',
|
||||||
|
'040fd762-dbbc-486d-a51a-2d84115e6229',
|
||||||
|
'86af0b5f-79d3-4f75-bab3-3b61f6c72b23',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const addedAsset = getAsset(timelineRestData, 'c077ea7b-cfa1-45e4-8554-f86c00ee5658')!;
|
||||||
|
await pageUtils.goToAsset(page, addedAsset.fileCreatedAt);
|
||||||
|
await thumbnailUtils.expectInViewport(page, 'c077ea7b-cfa1-45e4-8554-f86c00ee5658');
|
||||||
|
await thumbnailUtils.expectInViewport(page, '040fd762-dbbc-486d-a51a-2d84115e6229');
|
||||||
|
await thumbnailUtils.expectInViewport(page, '86af0b5f-79d3-4f75-bab3-3b61f6c72b23');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test.describe('/trash', () => {
|
||||||
|
test('open /photos, trash photo, open /trash, restore', async ({ page }) => {
|
||||||
|
await pageUtils.openPhotosPage(page);
|
||||||
|
const assetToTrash = assets[0];
|
||||||
|
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
|
||||||
|
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
|
||||||
|
await page.getByLabel('Menu').click();
|
||||||
|
const deleteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||||
|
const requestJson = request.postDataJSON();
|
||||||
|
changes.assetDeletions.push(...requestJson.ids);
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: requestJson.ids.map((id: string) => ({ id, success: true })),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.getByRole('menuitem').getByText('Delete').click();
|
||||||
|
await expect(deleteRequest).resolves.toEqual({
|
||||||
|
force: false,
|
||||||
|
ids: [assetToTrash.id],
|
||||||
|
});
|
||||||
|
await page.getByText('Trash', { exact: true }).click();
|
||||||
|
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
|
||||||
|
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
|
||||||
|
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
|
||||||
|
const restoreRequest = pageRoutePromise(page, '**/api/trash/restore/assets', async (route, request) => {
|
||||||
|
const requestJson = request.postDataJSON();
|
||||||
|
changes.assetDeletions = changes.assetDeletions.filter((id) => !requestJson.ids.includes(id));
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: { count: requestJson.ids.length },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.getByText('Restore', { exact: true }).click();
|
||||||
|
await expect(restoreRequest).resolves.toEqual({
|
||||||
|
ids: [assetToTrash.id],
|
||||||
|
});
|
||||||
|
await expect(thumbnailUtils.withAssetId(page, assetToTrash.id)).toHaveCount(0);
|
||||||
|
await page.getByText('Photos', { exact: true }).click();
|
||||||
|
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
|
||||||
|
});
|
||||||
|
test('open album, trash photo, open /trash, restore', async ({ page }) => {
|
||||||
|
const album = timelineRestData.album;
|
||||||
|
await pageUtils.openAlbumPage(page, album.id);
|
||||||
|
const assetToTrash = getAsset(timelineRestData, album.assetIds[0])!;
|
||||||
|
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
|
||||||
|
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
|
||||||
|
await page.getByLabel('Menu').click();
|
||||||
|
const deleteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||||
|
const requestJson = request.postDataJSON();
|
||||||
|
changes.assetDeletions.push(...requestJson.ids);
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: requestJson.ids.map((id: string) => ({ id, success: true })),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.getByRole('menuitem').getByText('Delete').click();
|
||||||
|
await expect(deleteRequest).resolves.toEqual({
|
||||||
|
force: false,
|
||||||
|
ids: [assetToTrash.id],
|
||||||
|
});
|
||||||
|
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
|
||||||
|
await page.getByText('Trash', { exact: true }).click();
|
||||||
|
await timelineUtils.waitForTimelineLoad(page);
|
||||||
|
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
|
||||||
|
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
|
||||||
|
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
|
||||||
|
const restoreRequest = pageRoutePromise(page, '**/api/trash/restore/assets', async (route, request) => {
|
||||||
|
const requestJson = request.postDataJSON();
|
||||||
|
changes.assetDeletions = changes.assetDeletions.filter((id) => !requestJson.ids.includes(id));
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: { count: requestJson.ids.length },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.getByText('Restore', { exact: true }).click();
|
||||||
|
await expect(restoreRequest).resolves.toEqual({
|
||||||
|
ids: [assetToTrash.id],
|
||||||
|
});
|
||||||
|
await expect(thumbnailUtils.withAssetId(page, assetToTrash.id)).toHaveCount(0);
|
||||||
|
await pageUtils.openAlbumPage(page, album.id);
|
||||||
|
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test.describe('/archive', () => {
|
||||||
|
test('open /photos, archive photo, open /archive, unarchive', async ({ page }) => {
|
||||||
|
await pageUtils.openPhotosPage(page);
|
||||||
|
const assetToArchive = assets[0];
|
||||||
|
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||||
|
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||||
|
await page.getByLabel('Menu').click();
|
||||||
|
const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||||
|
const requestJson = request.postDataJSON();
|
||||||
|
if (requestJson.visibility !== 'archive') {
|
||||||
|
return await route.continue();
|
||||||
|
}
|
||||||
|
await route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
});
|
||||||
|
changes.assetArchivals.push(...requestJson.ids);
|
||||||
|
});
|
||||||
|
await page.getByRole('menuitem').getByText('Archive').click();
|
||||||
|
await expect(archive).resolves.toEqual({
|
||||||
|
visibility: 'archive',
|
||||||
|
ids: [assetToArchive.id],
|
||||||
|
});
|
||||||
|
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
|
||||||
|
await page.getByRole('link').getByText('Archive').click();
|
||||||
|
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||||
|
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
|
||||||
|
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
|
||||||
|
const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
|
||||||
|
const requestJson = request.postDataJSON();
|
||||||
|
if (requestJson.visibility !== 'timeline') {
|
||||||
|
return await route.continue();
|
||||||
|
}
|
||||||
|
changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
|
||||||
|
await route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.getByLabel('Unarchive').click();
|
||||||
|
await expect(unarchiveRequest).resolves.toEqual({
|
||||||
|
visibility: 'timeline',
|
||||||
|
ids: [assetToArchive.id],
|
||||||
|
});
|
||||||
|
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
|
||||||
|
await page.getByText('Photos', { exact: true }).click();
|
||||||
|
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||||
|
});
|
||||||
|
test('open 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],
|
||||||
|
});
|
||||||
|
console.log('Skipping assertion - TODO - fix that archiving in album doesnt add icon');
|
||||||
|
// await thumbnail.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],
|
||||||
|
});
|
||||||
|
console.log('Skipping assertion - TODO - fix bug with not removing asset from timeline-manager after unarchive');
|
||||||
|
// await expect(thumbnail.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 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;
|
||||||
|
};
|
||||||
234
e2e/src/web/specs/timeline/utils.ts
Normal file
234
e2e/src/web/specs/timeline/utils.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
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(
|
||||||
|
'path[d="M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z"]',
|
||||||
|
),
|
||||||
|
).toHaveCount(1);
|
||||||
|
},
|
||||||
|
async expectThumbnailIsArchive(page: Page, assetId: string) {
|
||||||
|
await expect(
|
||||||
|
thumbnailUtils
|
||||||
|
.withAssetId(page, assetId)
|
||||||
|
.locator('path[d="M20 21H4V10H6V19H18V10H20V21M3 3H21V9H3V3M5 5V7H19V5M10.5 11V14H8L12 18L16 14H13.5V11"]'),
|
||||||
|
).toHaveCount(1);
|
||||||
|
},
|
||||||
|
async expectSelectedReadonly(page: Page, assetId: string) {
|
||||||
|
// todo - need a data attribute for selected
|
||||||
|
await expect(
|
||||||
|
page.locator(
|
||||||
|
`[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`,
|
||||||
|
),
|
||||||
|
).toBeVisible();
|
||||||
|
},
|
||||||
|
async expectTimelineHasOnScreenAssets(page: Page) {
|
||||||
|
const first = await thumbnailUtils.getFirstInViewport(page);
|
||||||
|
if (page.isClosed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(first).toBeTruthy();
|
||||||
|
},
|
||||||
|
async expectInViewport(page: Page, assetId: string) {
|
||||||
|
const box = await poll(page, () => thumbnailUtils.withAssetId(page, assetId).boundingBox());
|
||||||
|
if (page.isClosed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(box).toBeTruthy();
|
||||||
|
},
|
||||||
|
async expectBottomIsTimelineBottom(page: Page, assetId: string) {
|
||||||
|
const box = await thumbnailUtils.withAssetId(page, assetId).boundingBox();
|
||||||
|
const gridBox = await timelineUtils.locator(page).boundingBox();
|
||||||
|
if (page.isClosed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(box!.y + box!.height).toBeCloseTo(gridBox!.y + gridBox!.height, 0);
|
||||||
|
},
|
||||||
|
async expectTopIsTimelineTop(page: Page, assetId: string) {
|
||||||
|
const box = await thumbnailUtils.withAssetId(page, assetId).boundingBox();
|
||||||
|
const gridBox = await timelineUtils.locator(page).boundingBox();
|
||||||
|
if (page.isClosed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(box!.y).toBeCloseTo(gridBox!.y, 0);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const timelineUtils = {
|
||||||
|
locator(page: Page) {
|
||||||
|
return page.locator('#asset-grid');
|
||||||
|
},
|
||||||
|
async waitForTimelineLoad(page: Page) {
|
||||||
|
await expect(timelineUtils.locator(page)).toBeInViewport();
|
||||||
|
await expect.poll(() => thumbnailUtils.locator(page).count()).toBeGreaterThan(0);
|
||||||
|
},
|
||||||
|
async getScrollTop(page: Page) {
|
||||||
|
const queryTop = () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
return document.querySelector('#asset-grid').scrollTop;
|
||||||
|
});
|
||||||
|
await expect.poll(queryTop).toBeGreaterThan(0);
|
||||||
|
return await queryTop();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assetViewerUtils = {
|
||||||
|
locator(page: Page) {
|
||||||
|
return page.locator('#immich-asset-viewer');
|
||||||
|
},
|
||||||
|
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
||||||
|
await page
|
||||||
|
.locator(`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}"]`)
|
||||||
|
.or(page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}"]`))
|
||||||
|
.waitFor();
|
||||||
|
},
|
||||||
|
async expectActiveAssetToBe(page: Page, assetId: string) {
|
||||||
|
const activeElement = () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
return document.activeElement?.dataset?.asset;
|
||||||
|
});
|
||||||
|
await expect(poll(page, activeElement, (result) => result === assetId)).resolves.toBe(assetId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const pageUtils = {
|
||||||
|
async deepLinkPhotosPage(page: Page, assetId: string) {
|
||||||
|
await page.goto(`/photos?at=${assetId}`);
|
||||||
|
await timelineUtils.waitForTimelineLoad(page);
|
||||||
|
},
|
||||||
|
async openPhotosPage(page: Page) {
|
||||||
|
await page.goto(`/photos`);
|
||||||
|
await timelineUtils.waitForTimelineLoad(page);
|
||||||
|
},
|
||||||
|
async openAlbumPage(page: Page, albumId: string) {
|
||||||
|
await page.goto(`/albums/${albumId}`);
|
||||||
|
await timelineUtils.waitForTimelineLoad(page);
|
||||||
|
},
|
||||||
|
async deepLinkAlbumPage(page: Page, albumId: string, assetId: string) {
|
||||||
|
await page.goto(`/albums/${albumId}?at=${assetId}`);
|
||||||
|
await timelineUtils.waitForTimelineLoad(page);
|
||||||
|
},
|
||||||
|
async goToAsset(page: Page, assetDate: string) {
|
||||||
|
await timelineUtils.locator(page).hover();
|
||||||
|
const stringDate = DateTime.fromISO(assetDate).toFormat('MMddyyyy,hh:mm:ss.SSSa');
|
||||||
|
await page.keyboard.press('g');
|
||||||
|
await page.locator('#datetime').pressSequentially(stringDate);
|
||||||
|
await page.getByText('Confirm').click();
|
||||||
|
},
|
||||||
|
async selectDay(page: Page, day: string) {
|
||||||
|
await page.getByTitle(day).hover();
|
||||||
|
await page.locator('[data-group] .w-8').click();
|
||||||
|
},
|
||||||
|
async pauseTestDebug() {
|
||||||
|
console.log('NOTE: pausing test indefinately for debug');
|
||||||
|
await new Promise(() => void 0);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -52,14 +52,18 @@ test.describe('User Administration', () => {
|
|||||||
|
|
||||||
await page.goto(`/admin/users/${user.userId}`);
|
await page.goto(`/admin/users/${user.userId}`);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Edit user' }).click();
|
await page.getByRole('button', { name: 'Edit' }).click();
|
||||||
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
||||||
await page.getByText('Admin User').click();
|
await page.getByText('Admin User').click();
|
||||||
await expect(page.getByLabel('Admin User')).toBeChecked();
|
await expect(page.getByLabel('Admin User')).toBeChecked();
|
||||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||||
|
|
||||||
const updated = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
|
await expect
|
||||||
expect(updated.isAdmin).toBe(true);
|
.poll(async () => {
|
||||||
|
const userAdmin = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
|
||||||
|
return userAdmin.isAdmin;
|
||||||
|
})
|
||||||
|
.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('revoke admin access', async ({ context, page }) => {
|
test('revoke admin access', async ({ context, page }) => {
|
||||||
@@ -77,13 +81,17 @@ test.describe('User Administration', () => {
|
|||||||
|
|
||||||
await page.goto(`/admin/users/${user.userId}`);
|
await page.goto(`/admin/users/${user.userId}`);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Edit user' }).click();
|
await page.getByRole('button', { name: 'Edit' }).click();
|
||||||
await expect(page.getByLabel('Admin User')).toBeChecked();
|
await expect(page.getByLabel('Admin User')).toBeChecked();
|
||||||
await page.getByText('Admin User').click();
|
await page.getByText('Admin User').click();
|
||||||
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
||||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||||
|
|
||||||
const updated = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
|
await expect
|
||||||
expect(updated.isAdmin).toBe(false);
|
.poll(async () => {
|
||||||
|
const userAdmin = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
|
||||||
|
return userAdmin.isAdmin;
|
||||||
|
})
|
||||||
|
.toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Submodule e2e/test-assets updated: 37f60ea537...163c251744
48
i18n/en.json
48
i18n/en.json
@@ -17,7 +17,6 @@
|
|||||||
"add_birthday": "Add a birthday",
|
"add_birthday": "Add a birthday",
|
||||||
"add_endpoint": "Add endpoint",
|
"add_endpoint": "Add endpoint",
|
||||||
"add_exclusion_pattern": "Add exclusion pattern",
|
"add_exclusion_pattern": "Add exclusion pattern",
|
||||||
"add_import_path": "Add import path",
|
|
||||||
"add_location": "Add location",
|
"add_location": "Add location",
|
||||||
"add_more_users": "Add more users",
|
"add_more_users": "Add more users",
|
||||||
"add_partner": "Add partner",
|
"add_partner": "Add partner",
|
||||||
@@ -32,6 +31,7 @@
|
|||||||
"add_to_album_toggle": "Toggle selection for {album}",
|
"add_to_album_toggle": "Toggle selection for {album}",
|
||||||
"add_to_albums": "Add to albums",
|
"add_to_albums": "Add to albums",
|
||||||
"add_to_albums_count": "Add to albums ({count})",
|
"add_to_albums_count": "Add to albums ({count})",
|
||||||
|
"add_to_bottom_bar": "Add to",
|
||||||
"add_to_shared_album": "Add to shared album",
|
"add_to_shared_album": "Add to shared album",
|
||||||
"add_upload_to_stack": "Add upload to stack",
|
"add_upload_to_stack": "Add upload to stack",
|
||||||
"add_url": "Add URL",
|
"add_url": "Add URL",
|
||||||
@@ -112,13 +112,17 @@
|
|||||||
"jobs_failed": "{jobCount, plural, other {# failed}}",
|
"jobs_failed": "{jobCount, plural, other {# failed}}",
|
||||||
"library_created": "Created library: {library}",
|
"library_created": "Created library: {library}",
|
||||||
"library_deleted": "Library deleted",
|
"library_deleted": "Library deleted",
|
||||||
"library_import_path_description": "Specify a folder to import. This folder, including subfolders, will be scanned for images and videos.",
|
"library_details": "Library details",
|
||||||
|
"library_folder_description": "Specify a folder to import. This folder, including subfolders, will be scanned for images and videos.",
|
||||||
|
"library_remove_exclusion_pattern_prompt": "Are you sure you want to remove this exclusion pattern?",
|
||||||
|
"library_remove_folder_prompt": "Are you sure you want to remove this import folder?",
|
||||||
"library_scanning": "Periodic Scanning",
|
"library_scanning": "Periodic Scanning",
|
||||||
"library_scanning_description": "Configure periodic library scanning",
|
"library_scanning_description": "Configure periodic library scanning",
|
||||||
"library_scanning_enable_description": "Enable periodic library scanning",
|
"library_scanning_enable_description": "Enable periodic library scanning",
|
||||||
"library_settings": "External Library",
|
"library_settings": "External Library",
|
||||||
"library_settings_description": "Manage external library settings",
|
"library_settings_description": "Manage external library settings",
|
||||||
"library_tasks_description": "Scan external libraries for new and/or changed assets",
|
"library_tasks_description": "Scan external libraries for new and/or changed assets",
|
||||||
|
"library_updated": "Updated library",
|
||||||
"library_watching_enable_description": "Watch external libraries for file changes",
|
"library_watching_enable_description": "Watch external libraries for file changes",
|
||||||
"library_watching_settings": "Library watching [EXPERIMENTAL]",
|
"library_watching_settings": "Library watching [EXPERIMENTAL]",
|
||||||
"library_watching_settings_description": "Automatically watch for changed files",
|
"library_watching_settings_description": "Automatically watch for changed files",
|
||||||
@@ -173,6 +177,10 @@
|
|||||||
"machine_learning_smart_search_enabled": "Enable smart search",
|
"machine_learning_smart_search_enabled": "Enable smart search",
|
||||||
"machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.",
|
"machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.",
|
||||||
"machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online.",
|
"machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online.",
|
||||||
|
"maintenance_settings": "Maintenance",
|
||||||
|
"maintenance_settings_description": "Put Immich into maintenance mode.",
|
||||||
|
"maintenance_start": "Start maintenance mode",
|
||||||
|
"maintenance_start_error": "Failed to start maintenance mode.",
|
||||||
"manage_concurrency": "Manage Concurrency",
|
"manage_concurrency": "Manage Concurrency",
|
||||||
"manage_log_settings": "Manage log settings",
|
"manage_log_settings": "Manage log settings",
|
||||||
"map_dark_style": "Dark style",
|
"map_dark_style": "Dark style",
|
||||||
@@ -430,6 +438,7 @@
|
|||||||
"age_months": "Age {months, plural, one {# month} other {# months}}",
|
"age_months": "Age {months, plural, one {# month} other {# months}}",
|
||||||
"age_year_months": "Age 1 year, {months, plural, one {# month} other {# months}}",
|
"age_year_months": "Age 1 year, {months, plural, one {# month} other {# months}}",
|
||||||
"age_years": "{years, plural, other {Age #}}",
|
"age_years": "{years, plural, other {Age #}}",
|
||||||
|
"album": "Album",
|
||||||
"album_added": "Album added",
|
"album_added": "Album added",
|
||||||
"album_added_notification_setting_description": "Receive an email notification when you are added to a shared album",
|
"album_added_notification_setting_description": "Receive an email notification when you are added to a shared album",
|
||||||
"album_cover_updated": "Album cover updated",
|
"album_cover_updated": "Album cover updated",
|
||||||
@@ -475,6 +484,7 @@
|
|||||||
"allow_edits": "Allow edits",
|
"allow_edits": "Allow edits",
|
||||||
"allow_public_user_to_download": "Allow public user to download",
|
"allow_public_user_to_download": "Allow public user to download",
|
||||||
"allow_public_user_to_upload": "Allow public user to upload",
|
"allow_public_user_to_upload": "Allow public user to upload",
|
||||||
|
"allowed": "Allowed",
|
||||||
"alt_text_qr_code": "QR code image",
|
"alt_text_qr_code": "QR code image",
|
||||||
"anti_clockwise": "Anti-clockwise",
|
"anti_clockwise": "Anti-clockwise",
|
||||||
"api_key": "API Key",
|
"api_key": "API Key",
|
||||||
@@ -894,8 +904,6 @@
|
|||||||
"edit_description_prompt": "Please select a new description:",
|
"edit_description_prompt": "Please select a new description:",
|
||||||
"edit_exclusion_pattern": "Edit exclusion pattern",
|
"edit_exclusion_pattern": "Edit exclusion pattern",
|
||||||
"edit_faces": "Edit faces",
|
"edit_faces": "Edit faces",
|
||||||
"edit_import_path": "Edit import path",
|
|
||||||
"edit_import_paths": "Edit Import Paths",
|
|
||||||
"edit_key": "Edit key",
|
"edit_key": "Edit key",
|
||||||
"edit_link": "Edit link",
|
"edit_link": "Edit link",
|
||||||
"edit_location": "Edit location",
|
"edit_location": "Edit location",
|
||||||
@@ -967,8 +975,8 @@
|
|||||||
"failed_to_stack_assets": "Failed to stack assets",
|
"failed_to_stack_assets": "Failed to stack assets",
|
||||||
"failed_to_unstack_assets": "Failed to un-stack assets",
|
"failed_to_unstack_assets": "Failed to un-stack assets",
|
||||||
"failed_to_update_notification_status": "Failed to update notification status",
|
"failed_to_update_notification_status": "Failed to update notification status",
|
||||||
"import_path_already_exists": "This import path already exists.",
|
|
||||||
"incorrect_email_or_password": "Incorrect email or password",
|
"incorrect_email_or_password": "Incorrect email or password",
|
||||||
|
"library_folder_already_exists": "This import path already exists.",
|
||||||
"paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
|
"paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
|
||||||
"profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.",
|
"profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.",
|
||||||
"quota_higher_than_disk_size": "You set a quota higher than the disk size",
|
"quota_higher_than_disk_size": "You set a quota higher than the disk size",
|
||||||
@@ -977,7 +985,6 @@
|
|||||||
"unable_to_add_assets_to_shared_link": "Unable to add assets to shared link",
|
"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_comment": "Unable to add comment",
|
||||||
"unable_to_add_exclusion_pattern": "Unable to add exclusion pattern",
|
"unable_to_add_exclusion_pattern": "Unable to add exclusion pattern",
|
||||||
"unable_to_add_import_path": "Unable to add import path",
|
|
||||||
"unable_to_add_partners": "Unable to add partners",
|
"unable_to_add_partners": "Unable to add partners",
|
||||||
"unable_to_add_remove_archive": "Unable to {archived, select, true {remove asset from} other {add asset to}} archive",
|
"unable_to_add_remove_archive": "Unable to {archived, select, true {remove asset from} other {add asset to}} archive",
|
||||||
"unable_to_add_remove_favorites": "Unable to {favorite, select, true {add asset to} other {remove asset from}} favorites",
|
"unable_to_add_remove_favorites": "Unable to {favorite, select, true {add asset to} other {remove asset from}} favorites",
|
||||||
@@ -1000,12 +1007,10 @@
|
|||||||
"unable_to_delete_asset": "Unable to delete asset",
|
"unable_to_delete_asset": "Unable to delete asset",
|
||||||
"unable_to_delete_assets": "Error deleting assets",
|
"unable_to_delete_assets": "Error deleting assets",
|
||||||
"unable_to_delete_exclusion_pattern": "Unable to delete exclusion pattern",
|
"unable_to_delete_exclusion_pattern": "Unable to delete exclusion pattern",
|
||||||
"unable_to_delete_import_path": "Unable to delete import path",
|
|
||||||
"unable_to_delete_shared_link": "Unable to delete shared link",
|
"unable_to_delete_shared_link": "Unable to delete shared link",
|
||||||
"unable_to_delete_user": "Unable to delete user",
|
"unable_to_delete_user": "Unable to delete user",
|
||||||
"unable_to_download_files": "Unable to download files",
|
"unable_to_download_files": "Unable to download files",
|
||||||
"unable_to_edit_exclusion_pattern": "Unable to edit exclusion pattern",
|
"unable_to_edit_exclusion_pattern": "Unable to edit exclusion pattern",
|
||||||
"unable_to_edit_import_path": "Unable to edit import path",
|
|
||||||
"unable_to_empty_trash": "Unable to empty trash",
|
"unable_to_empty_trash": "Unable to empty trash",
|
||||||
"unable_to_enter_fullscreen": "Unable to enter fullscreen",
|
"unable_to_enter_fullscreen": "Unable to enter fullscreen",
|
||||||
"unable_to_exit_fullscreen": "Unable to exit fullscreen",
|
"unable_to_exit_fullscreen": "Unable to exit fullscreen",
|
||||||
@@ -1056,6 +1061,7 @@
|
|||||||
"unable_to_update_user": "Unable to update user",
|
"unable_to_update_user": "Unable to update user",
|
||||||
"unable_to_upload_file": "Unable to upload file"
|
"unable_to_upload_file": "Unable to upload file"
|
||||||
},
|
},
|
||||||
|
"exclusion_pattern": "Exclusion pattern",
|
||||||
"exif": "Exif",
|
"exif": "Exif",
|
||||||
"exif_bottom_sheet_description": "Add Description...",
|
"exif_bottom_sheet_description": "Add Description...",
|
||||||
"exif_bottom_sheet_description_error": "Error updating description",
|
"exif_bottom_sheet_description_error": "Error updating description",
|
||||||
@@ -1115,6 +1121,7 @@
|
|||||||
"folders_feature_description": "Browsing the folder view for the photos and videos on the file system",
|
"folders_feature_description": "Browsing the folder view for the photos and videos on the file system",
|
||||||
"forgot_pin_code_question": "Forgot your PIN?",
|
"forgot_pin_code_question": "Forgot your PIN?",
|
||||||
"forward": "Forward",
|
"forward": "Forward",
|
||||||
|
"full_path": "Full path: {path}",
|
||||||
"gcast_enabled": "Google Cast",
|
"gcast_enabled": "Google Cast",
|
||||||
"gcast_enabled_description": "This feature loads external resources from Google in order to work.",
|
"gcast_enabled_description": "This feature loads external resources from Google in order to work.",
|
||||||
"general": "General",
|
"general": "General",
|
||||||
@@ -1196,6 +1203,8 @@
|
|||||||
"import_path": "Import path",
|
"import_path": "Import path",
|
||||||
"in_albums": "In {count, plural, one {# album} other {# albums}}",
|
"in_albums": "In {count, plural, one {# album} other {# albums}}",
|
||||||
"in_archive": "In archive",
|
"in_archive": "In archive",
|
||||||
|
"in_year": "In {year}",
|
||||||
|
"in_year_selector": "In",
|
||||||
"include_archived": "Include archived",
|
"include_archived": "Include archived",
|
||||||
"include_shared_albums": "Include shared albums",
|
"include_shared_albums": "Include shared albums",
|
||||||
"include_shared_partner_assets": "Include shared partner assets",
|
"include_shared_partner_assets": "Include shared partner assets",
|
||||||
@@ -1232,6 +1241,7 @@
|
|||||||
"language_setting_description": "Select your preferred language",
|
"language_setting_description": "Select your preferred language",
|
||||||
"large_files": "Large Files",
|
"large_files": "Large Files",
|
||||||
"last": "Last",
|
"last": "Last",
|
||||||
|
"last_months": "{count, plural, one {Last month} other {Last # months}}",
|
||||||
"last_seen": "Last seen",
|
"last_seen": "Last seen",
|
||||||
"latest_version": "Latest Version",
|
"latest_version": "Latest Version",
|
||||||
"latitude": "Latitude",
|
"latitude": "Latitude",
|
||||||
@@ -1241,6 +1251,8 @@
|
|||||||
"let_others_respond": "Let others respond",
|
"let_others_respond": "Let others respond",
|
||||||
"level": "Level",
|
"level": "Level",
|
||||||
"library": "Library",
|
"library": "Library",
|
||||||
|
"library_add_folder": "Add folder",
|
||||||
|
"library_edit_folder": "Edit folder",
|
||||||
"library_options": "Library options",
|
"library_options": "Library options",
|
||||||
"library_page_device_albums": "Albums on Device",
|
"library_page_device_albums": "Albums on Device",
|
||||||
"library_page_new_album": "New album",
|
"library_page_new_album": "New album",
|
||||||
@@ -1312,8 +1324,17 @@
|
|||||||
"loop_videos_description": "Enable to automatically loop a video in the detail viewer.",
|
"loop_videos_description": "Enable to automatically loop a video in the detail viewer.",
|
||||||
"main_branch_warning": "You're using a development version; we strongly recommend using a release version!",
|
"main_branch_warning": "You're using a development version; we strongly recommend using a release version!",
|
||||||
"main_menu": "Main menu",
|
"main_menu": "Main menu",
|
||||||
|
"maintenance_description": "Immich has been put into <link>maintenance mode</link>.",
|
||||||
|
"maintenance_end": "End maintenance mode",
|
||||||
|
"maintenance_end_error": "Failed to end maintenance mode.",
|
||||||
|
"maintenance_logged_in_as": "Currently logged in as {user}",
|
||||||
|
"maintenance_title": "Temporarily Unavailable",
|
||||||
"make": "Make",
|
"make": "Make",
|
||||||
"manage_geolocation": "Manage location",
|
"manage_geolocation": "Manage location",
|
||||||
|
"manage_media_access_rationale": "This permission is required for proper handling of moving assets to the trash and restoring them from it.",
|
||||||
|
"manage_media_access_settings": "Open settings",
|
||||||
|
"manage_media_access_subtitle": "Allow the Immich app to manage and move media files.",
|
||||||
|
"manage_media_access_title": "Media Management Access",
|
||||||
"manage_shared_links": "Manage shared links",
|
"manage_shared_links": "Manage shared links",
|
||||||
"manage_sharing_with_partners": "Manage sharing with partners",
|
"manage_sharing_with_partners": "Manage sharing with partners",
|
||||||
"manage_the_app_settings": "Manage the app settings",
|
"manage_the_app_settings": "Manage the app settings",
|
||||||
@@ -1377,6 +1398,7 @@
|
|||||||
"more": "More",
|
"more": "More",
|
||||||
"move": "Move",
|
"move": "Move",
|
||||||
"move_off_locked_folder": "Move out of locked folder",
|
"move_off_locked_folder": "Move out of locked folder",
|
||||||
|
"move_to": "Move to",
|
||||||
"move_to_lock_folder_action_prompt": "{count} added to the locked folder",
|
"move_to_lock_folder_action_prompt": "{count} added to the locked folder",
|
||||||
"move_to_locked_folder": "Move to locked folder",
|
"move_to_locked_folder": "Move to locked folder",
|
||||||
"move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder",
|
"move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder",
|
||||||
@@ -1406,6 +1428,7 @@
|
|||||||
"new_pin_code": "New PIN code",
|
"new_pin_code": "New PIN code",
|
||||||
"new_pin_code_subtitle": "This is your first time accessing the locked folder. Create a PIN code to securely access this page",
|
"new_pin_code_subtitle": "This is your first time accessing the locked folder. Create a PIN code to securely access this page",
|
||||||
"new_timeline": "New Timeline",
|
"new_timeline": "New Timeline",
|
||||||
|
"new_update": "New update",
|
||||||
"new_user_created": "New user created",
|
"new_user_created": "New user created",
|
||||||
"new_version_available": "NEW VERSION AVAILABLE",
|
"new_version_available": "NEW VERSION AVAILABLE",
|
||||||
"newest_first": "Newest first",
|
"newest_first": "Newest first",
|
||||||
@@ -1421,12 +1444,14 @@
|
|||||||
"no_cast_devices_found": "No cast devices found",
|
"no_cast_devices_found": "No cast devices found",
|
||||||
"no_checksum_local": "No checksum available - cannot fetch local assets",
|
"no_checksum_local": "No checksum available - cannot fetch local assets",
|
||||||
"no_checksum_remote": "No checksum available - cannot fetch remote asset",
|
"no_checksum_remote": "No checksum available - cannot fetch remote asset",
|
||||||
|
"no_devices": "No authorized devices",
|
||||||
"no_duplicates_found": "No duplicates were found.",
|
"no_duplicates_found": "No duplicates were found.",
|
||||||
"no_exif_info_available": "No exif info available",
|
"no_exif_info_available": "No exif info available",
|
||||||
"no_explore_results_message": "Upload more photos to explore your collection.",
|
"no_explore_results_message": "Upload more photos to explore your collection.",
|
||||||
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
|
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
|
||||||
"no_libraries_message": "Create an external library to view your photos and videos",
|
"no_libraries_message": "Create an external library to view your photos and videos",
|
||||||
"no_local_assets_found": "No local assets found with this checksum",
|
"no_local_assets_found": "No local assets found with this checksum",
|
||||||
|
"no_location_set": "No location set",
|
||||||
"no_locked_photos_message": "Photos and videos in the locked folder are hidden and won't show up as you browse or search your library.",
|
"no_locked_photos_message": "Photos and videos in the locked folder are hidden and won't show up as you browse or search your library.",
|
||||||
"no_name": "No Name",
|
"no_name": "No Name",
|
||||||
"no_notifications": "No notifications",
|
"no_notifications": "No notifications",
|
||||||
@@ -1437,6 +1462,7 @@
|
|||||||
"no_results_description": "Try a synonym or more general keyword",
|
"no_results_description": "Try a synonym or more general keyword",
|
||||||
"no_shared_albums_message": "Create an album to share photos and videos with people in your network",
|
"no_shared_albums_message": "Create an album to share photos and videos with people in your network",
|
||||||
"no_uploads_in_progress": "No uploads in progress",
|
"no_uploads_in_progress": "No uploads in progress",
|
||||||
|
"not_allowed": "Not allowed",
|
||||||
"not_available": "N/A",
|
"not_available": "N/A",
|
||||||
"not_in_any_album": "Not in any album",
|
"not_in_any_album": "Not in any album",
|
||||||
"not_selected": "Not selected",
|
"not_selected": "Not selected",
|
||||||
@@ -1547,6 +1573,8 @@
|
|||||||
"photos_count": "{count, plural, one {{count, number} Photo} other {{count, number} Photos}}",
|
"photos_count": "{count, plural, one {{count, number} Photo} other {{count, number} Photos}}",
|
||||||
"photos_from_previous_years": "Photos from previous years",
|
"photos_from_previous_years": "Photos from previous years",
|
||||||
"pick_a_location": "Pick a location",
|
"pick_a_location": "Pick a location",
|
||||||
|
"pick_custom_range": "Custom range",
|
||||||
|
"pick_date_range": "Select a date range",
|
||||||
"pin_code_changed_successfully": "Successfully changed PIN code",
|
"pin_code_changed_successfully": "Successfully changed PIN code",
|
||||||
"pin_code_reset_successfully": "Successfully reset PIN code",
|
"pin_code_reset_successfully": "Successfully reset PIN code",
|
||||||
"pin_code_setup_successfully": "Successfully setup a PIN code",
|
"pin_code_setup_successfully": "Successfully setup a PIN code",
|
||||||
@@ -1814,6 +1842,8 @@
|
|||||||
"server_offline": "Server Offline",
|
"server_offline": "Server Offline",
|
||||||
"server_online": "Server Online",
|
"server_online": "Server Online",
|
||||||
"server_privacy": "Server Privacy",
|
"server_privacy": "Server Privacy",
|
||||||
|
"server_restarting_description": "This page will refresh momentarily.",
|
||||||
|
"server_restarting_title": "Server is restarting",
|
||||||
"server_stats": "Server Stats",
|
"server_stats": "Server Stats",
|
||||||
"server_update_available": "Server update is available",
|
"server_update_available": "Server update is available",
|
||||||
"server_version": "Server Version",
|
"server_version": "Server Version",
|
||||||
@@ -2027,6 +2057,7 @@
|
|||||||
"third_party_resources": "Third-Party Resources",
|
"third_party_resources": "Third-Party Resources",
|
||||||
"time": "Time",
|
"time": "Time",
|
||||||
"time_based_memories": "Time-based memories",
|
"time_based_memories": "Time-based memories",
|
||||||
|
"time_based_memories_duration": "Number of seconds to display each image.",
|
||||||
"timeline": "Timeline",
|
"timeline": "Timeline",
|
||||||
"timezone": "Timezone",
|
"timezone": "Timezone",
|
||||||
"to_archive": "Archive",
|
"to_archive": "Archive",
|
||||||
@@ -2167,6 +2198,7 @@
|
|||||||
"welcome": "Welcome",
|
"welcome": "Welcome",
|
||||||
"welcome_to_immich": "Welcome to Immich",
|
"welcome_to_immich": "Welcome to Immich",
|
||||||
"wifi_name": "Wi-Fi Name",
|
"wifi_name": "Wi-Fi Name",
|
||||||
|
"workflow": "Workflow",
|
||||||
"wrong_pin_code": "Wrong PIN code",
|
"wrong_pin_code": "Wrong PIN code",
|
||||||
"year": "Year",
|
"year": "Year",
|
||||||
"years_ago": "{years, plural, one {# year} other {# years}} ago",
|
"years_ago": "{years, plural, one {# year} other {# years}} ago",
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from rich.logging import RichHandler
|
|||||||
from uvicorn import Server
|
from uvicorn import Server
|
||||||
from uvicorn.workers import UvicornWorker
|
from uvicorn.workers import UvicornWorker
|
||||||
|
|
||||||
|
from .schemas import ModelPrecision
|
||||||
|
|
||||||
|
|
||||||
class ClipSettings(BaseModel):
|
class ClipSettings(BaseModel):
|
||||||
textual: str | None = None
|
textual: str | None = None
|
||||||
@@ -24,6 +26,11 @@ class FacialRecognitionSettings(BaseModel):
|
|||||||
detection: str | None = None
|
detection: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OcrSettings(BaseModel):
|
||||||
|
recognition: str | None = None
|
||||||
|
detection: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class PreloadModelData(BaseModel):
|
class PreloadModelData(BaseModel):
|
||||||
clip_fallback: str | None = os.getenv("MACHINE_LEARNING_PRELOAD__CLIP", None)
|
clip_fallback: str | None = os.getenv("MACHINE_LEARNING_PRELOAD__CLIP", None)
|
||||||
facial_recognition_fallback: str | None = os.getenv("MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION", None)
|
facial_recognition_fallback: str | None = os.getenv("MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION", None)
|
||||||
@@ -37,6 +44,7 @@ class PreloadModelData(BaseModel):
|
|||||||
del os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION"]
|
del os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION"]
|
||||||
clip: ClipSettings = ClipSettings()
|
clip: ClipSettings = ClipSettings()
|
||||||
facial_recognition: FacialRecognitionSettings = FacialRecognitionSettings()
|
facial_recognition: FacialRecognitionSettings = FacialRecognitionSettings()
|
||||||
|
ocr: OcrSettings = OcrSettings()
|
||||||
|
|
||||||
|
|
||||||
class MaxBatchSize(BaseModel):
|
class MaxBatchSize(BaseModel):
|
||||||
@@ -70,6 +78,7 @@ class Settings(BaseSettings):
|
|||||||
rknn_threads: int = 1
|
rknn_threads: int = 1
|
||||||
preload: PreloadModelData | None = None
|
preload: PreloadModelData | None = None
|
||||||
max_batch_size: MaxBatchSize | None = None
|
max_batch_size: MaxBatchSize | None = None
|
||||||
|
openvino_precision: ModelPrecision = ModelPrecision.FP32
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device_id(self) -> str:
|
def device_id(self) -> str:
|
||||||
|
|||||||
@@ -103,6 +103,20 @@ async def preload_models(preload: PreloadModelData) -> None:
|
|||||||
ModelTask.FACIAL_RECOGNITION,
|
ModelTask.FACIAL_RECOGNITION,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if preload.ocr.detection is not None:
|
||||||
|
await load_models(
|
||||||
|
preload.ocr.detection,
|
||||||
|
ModelType.DETECTION,
|
||||||
|
ModelTask.OCR,
|
||||||
|
)
|
||||||
|
|
||||||
|
if preload.ocr.recognition is not None:
|
||||||
|
await load_models(
|
||||||
|
preload.ocr.recognition,
|
||||||
|
ModelType.RECOGNITION,
|
||||||
|
ModelTask.OCR,
|
||||||
|
)
|
||||||
|
|
||||||
if preload.clip_fallback is not None:
|
if preload.clip_fallback is not None:
|
||||||
log.warning(
|
log.warning(
|
||||||
"Deprecated env variable: 'MACHINE_LEARNING_PRELOAD__CLIP'. "
|
"Deprecated env variable: 'MACHINE_LEARNING_PRELOAD__CLIP'. "
|
||||||
|
|||||||
@@ -78,6 +78,14 @@ _INSIGHTFACE_MODELS = {
|
|||||||
_PADDLE_MODELS = {
|
_PADDLE_MODELS = {
|
||||||
"PP-OCRv5_server",
|
"PP-OCRv5_server",
|
||||||
"PP-OCRv5_mobile",
|
"PP-OCRv5_mobile",
|
||||||
|
"CH__PP-OCRv5_server",
|
||||||
|
"CH__PP-OCRv5_mobile",
|
||||||
|
"EL__PP-OCRv5_mobile",
|
||||||
|
"EN__PP-OCRv5_mobile",
|
||||||
|
"ESLAV__PP-OCRv5_mobile",
|
||||||
|
"KOREAN__PP-OCRv5_mobile",
|
||||||
|
"LATIN__PP-OCRv5_mobile",
|
||||||
|
"TH__PP-OCRv5_mobile",
|
||||||
}
|
}
|
||||||
|
|
||||||
SUPPORTED_PROVIDERS = [
|
SUPPORTED_PROVIDERS = [
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from numpy.typing import NDArray
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from rapidocr.ch_ppocr_det import TextDetector as RapidTextDetector
|
from rapidocr.ch_ppocr_det.utils import DBPostProcess
|
||||||
from rapidocr.inference_engine.base import FileInfo, InferSession
|
from rapidocr.inference_engine.base import FileInfo, InferSession
|
||||||
from rapidocr.utils import DownloadFile, DownloadFileInput
|
from rapidocr.utils.download_file import DownloadFile, DownloadFileInput
|
||||||
from rapidocr.utils.typings import EngineType, LangDet, OCRVersion, TaskType
|
from rapidocr.utils.typings import EngineType, LangDet, OCRVersion, TaskType
|
||||||
from rapidocr.utils.typings import ModelType as RapidModelType
|
from rapidocr.utils.typings import ModelType as RapidModelType
|
||||||
|
|
||||||
from immich_ml.config import log
|
from immich_ml.config import log
|
||||||
from immich_ml.models.base import InferenceModel
|
from immich_ml.models.base import InferenceModel
|
||||||
from immich_ml.models.transforms import decode_cv2
|
|
||||||
from immich_ml.schemas import ModelFormat, ModelSession, ModelTask, ModelType
|
from immich_ml.schemas import ModelFormat, ModelSession, ModelTask, ModelType
|
||||||
from immich_ml.sessions.ort import OrtSession
|
from immich_ml.sessions.ort import OrtSession
|
||||||
|
|
||||||
from .schemas import OcrOptions, TextDetectionOutput
|
from .schemas import TextDetectionOutput
|
||||||
|
|
||||||
|
|
||||||
class TextDetector(InferenceModel):
|
class TextDetector(InferenceModel):
|
||||||
@@ -22,15 +23,22 @@ class TextDetector(InferenceModel):
|
|||||||
identity = (ModelType.DETECTION, ModelTask.OCR)
|
identity = (ModelType.DETECTION, ModelTask.OCR)
|
||||||
|
|
||||||
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
|
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
|
||||||
super().__init__(model_name, **model_kwargs, model_format=ModelFormat.ONNX)
|
super().__init__(model_name.split("__")[-1], **model_kwargs, model_format=ModelFormat.ONNX)
|
||||||
self.max_resolution = 736
|
self.max_resolution = 736
|
||||||
self.min_score = 0.5
|
self.mean = np.array([0.5, 0.5, 0.5], dtype=np.float32)
|
||||||
self.score_mode = "fast"
|
self.std_inv = np.float32(1.0) / (np.array([0.5, 0.5, 0.5], dtype=np.float32) * 255.0)
|
||||||
self._empty: TextDetectionOutput = {
|
self._empty: TextDetectionOutput = {
|
||||||
"image": np.empty(0, dtype=np.float32),
|
|
||||||
"boxes": np.empty(0, dtype=np.float32),
|
"boxes": np.empty(0, dtype=np.float32),
|
||||||
"scores": np.empty(0, dtype=np.float32),
|
"scores": np.empty(0, dtype=np.float32),
|
||||||
}
|
}
|
||||||
|
self.postprocess = DBPostProcess(
|
||||||
|
thresh=0.3,
|
||||||
|
box_thresh=model_kwargs.get("minScore", 0.5),
|
||||||
|
max_candidates=1000,
|
||||||
|
unclip_ratio=1.6,
|
||||||
|
use_dilation=True,
|
||||||
|
score_mode="fast",
|
||||||
|
)
|
||||||
|
|
||||||
def _download(self) -> None:
|
def _download(self) -> None:
|
||||||
model_info = InferSession.get_model_url(
|
model_info = InferSession.get_model_url(
|
||||||
@@ -52,35 +60,65 @@ class TextDetector(InferenceModel):
|
|||||||
|
|
||||||
def _load(self) -> ModelSession:
|
def _load(self) -> ModelSession:
|
||||||
# TODO: support other runtime sessions
|
# TODO: support other runtime sessions
|
||||||
session = OrtSession(self.model_path)
|
return OrtSession(self.model_path)
|
||||||
self.model = RapidTextDetector(
|
|
||||||
OcrOptions(
|
|
||||||
session=session.session,
|
|
||||||
limit_side_len=self.max_resolution,
|
|
||||||
limit_type="min",
|
|
||||||
box_thresh=self.min_score,
|
|
||||||
score_mode=self.score_mode,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return session
|
|
||||||
|
|
||||||
def _predict(self, inputs: bytes | Image.Image) -> TextDetectionOutput:
|
# partly adapted from RapidOCR
|
||||||
results = self.model(decode_cv2(inputs))
|
def _predict(self, inputs: Image.Image) -> TextDetectionOutput:
|
||||||
if results.boxes is None or results.scores is None or results.img is None:
|
w, h = inputs.size
|
||||||
|
if w < 32 or h < 32:
|
||||||
|
return self._empty
|
||||||
|
out = self.session.run(None, {"x": self._transform(inputs)})[0]
|
||||||
|
boxes, scores = self.postprocess(out, (h, w))
|
||||||
|
if len(boxes) == 0:
|
||||||
return self._empty
|
return self._empty
|
||||||
return {
|
return {
|
||||||
"image": results.img,
|
"boxes": self.sorted_boxes(boxes),
|
||||||
"boxes": np.array(results.boxes, dtype=np.float32),
|
"scores": np.array(scores, dtype=np.float32),
|
||||||
"scores": np.array(results.scores, dtype=np.float32),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# adapted from RapidOCR
|
||||||
|
def _transform(self, img: Image.Image) -> NDArray[np.float32]:
|
||||||
|
if img.height < img.width:
|
||||||
|
ratio = float(self.max_resolution) / img.height
|
||||||
|
else:
|
||||||
|
ratio = float(self.max_resolution) / img.width
|
||||||
|
|
||||||
|
resize_h = int(img.height * ratio)
|
||||||
|
resize_w = int(img.width * ratio)
|
||||||
|
|
||||||
|
resize_h = int(round(resize_h / 32) * 32)
|
||||||
|
resize_w = int(round(resize_w / 32) * 32)
|
||||||
|
resized_img = img.resize((int(resize_w), int(resize_h)), resample=Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
img_np: NDArray[np.float32] = cv2.cvtColor(np.array(resized_img, dtype=np.float32), cv2.COLOR_RGB2BGR) # type: ignore
|
||||||
|
img_np -= self.mean
|
||||||
|
img_np *= self.std_inv
|
||||||
|
img_np = np.transpose(img_np, (2, 0, 1))
|
||||||
|
return np.expand_dims(img_np, axis=0)
|
||||||
|
|
||||||
|
def sorted_boxes(self, dt_boxes: NDArray[np.float32]) -> NDArray[np.float32]:
|
||||||
|
if len(dt_boxes) == 0:
|
||||||
|
return dt_boxes
|
||||||
|
|
||||||
|
# Sort by y, then identify lines, then sort by (line, x)
|
||||||
|
y_order = np.argsort(dt_boxes[:, 0, 1], kind="stable")
|
||||||
|
sorted_y = dt_boxes[y_order, 0, 1]
|
||||||
|
|
||||||
|
line_ids = np.empty(len(dt_boxes), dtype=np.int32)
|
||||||
|
line_ids[0] = 0
|
||||||
|
np.cumsum(np.abs(np.diff(sorted_y)) >= 10, out=line_ids[1:])
|
||||||
|
|
||||||
|
# Create composite sort key for final ordering
|
||||||
|
# Shift line_ids by large factor, add x for tie-breaking
|
||||||
|
sort_key = line_ids[y_order] * 1e6 + dt_boxes[y_order, 0, 0]
|
||||||
|
final_order = np.argsort(sort_key, kind="stable")
|
||||||
|
sorted_boxes: NDArray[np.float32] = dt_boxes[y_order[final_order]]
|
||||||
|
return sorted_boxes
|
||||||
|
|
||||||
def configure(self, **kwargs: Any) -> None:
|
def configure(self, **kwargs: Any) -> None:
|
||||||
if (max_resolution := kwargs.get("maxResolution")) is not None:
|
if (max_resolution := kwargs.get("maxResolution")) is not None:
|
||||||
self.max_resolution = max_resolution
|
self.max_resolution = max_resolution
|
||||||
self.model.limit_side_len = max_resolution
|
|
||||||
if (min_score := kwargs.get("minScore")) is not None:
|
if (min_score := kwargs.get("minScore")) is not None:
|
||||||
self.min_score = min_score
|
self.postprocess.box_thresh = min_score
|
||||||
self.model.postprocess_op.box_thresh = min_score
|
|
||||||
if (score_mode := kwargs.get("scoreMode")) is not None:
|
if (score_mode := kwargs.get("scoreMode")) is not None:
|
||||||
self.score_mode = score_mode
|
self.postprocess.score_mode = score_mode
|
||||||
self.model.postprocess_op.score_mode = score_mode
|
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
from PIL.Image import Image
|
from PIL import Image
|
||||||
from rapidocr.ch_ppocr_rec import TextRecInput
|
from rapidocr.ch_ppocr_rec import TextRecInput
|
||||||
from rapidocr.ch_ppocr_rec import TextRecognizer as RapidTextRecognizer
|
from rapidocr.ch_ppocr_rec import TextRecognizer as RapidTextRecognizer
|
||||||
from rapidocr.inference_engine.base import FileInfo, InferSession
|
from rapidocr.inference_engine.base import FileInfo, InferSession
|
||||||
from rapidocr.utils import DownloadFile, DownloadFileInput
|
from rapidocr.utils.download_file import DownloadFile, DownloadFileInput
|
||||||
from rapidocr.utils.typings import EngineType, LangRec, OCRVersion, TaskType
|
from rapidocr.utils.typings import EngineType, LangRec, OCRVersion, TaskType
|
||||||
from rapidocr.utils.typings import ModelType as RapidModelType
|
from rapidocr.utils.typings import ModelType as RapidModelType
|
||||||
from rapidocr.utils.vis_res import VisRes
|
from rapidocr.utils.vis_res import VisRes
|
||||||
|
|
||||||
from immich_ml.config import log, settings
|
from immich_ml.config import log, settings
|
||||||
from immich_ml.models.base import InferenceModel
|
from immich_ml.models.base import InferenceModel
|
||||||
|
from immich_ml.models.transforms import pil_to_cv2
|
||||||
from immich_ml.schemas import ModelFormat, ModelSession, ModelTask, ModelType
|
from immich_ml.schemas import ModelFormat, ModelSession, ModelTask, ModelType
|
||||||
from immich_ml.sessions.ort import OrtSession
|
from immich_ml.sessions.ort import OrtSession
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ class TextRecognizer(InferenceModel):
|
|||||||
identity = (ModelType.RECOGNITION, ModelTask.OCR)
|
identity = (ModelType.RECOGNITION, ModelTask.OCR)
|
||||||
|
|
||||||
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
|
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
|
||||||
|
self.language = LangRec[model_name.split("__")[0]] if "__" in model_name else LangRec.CH
|
||||||
self.min_score = model_kwargs.get("minScore", 0.9)
|
self.min_score = model_kwargs.get("minScore", 0.9)
|
||||||
self._empty: TextRecognitionOutput = {
|
self._empty: TextRecognitionOutput = {
|
||||||
"box": np.empty(0, dtype=np.float32),
|
"box": np.empty(0, dtype=np.float32),
|
||||||
@@ -41,7 +42,7 @@ class TextRecognizer(InferenceModel):
|
|||||||
engine_type=EngineType.ONNXRUNTIME,
|
engine_type=EngineType.ONNXRUNTIME,
|
||||||
ocr_version=OCRVersion.PPOCRV5,
|
ocr_version=OCRVersion.PPOCRV5,
|
||||||
task_type=TaskType.REC,
|
task_type=TaskType.REC,
|
||||||
lang_type=LangRec.CH,
|
lang_type=self.language,
|
||||||
model_type=RapidModelType.MOBILE if "mobile" in self.model_name else RapidModelType.SERVER,
|
model_type=RapidModelType.MOBILE if "mobile" in self.model_name else RapidModelType.SERVER,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -61,21 +62,21 @@ class TextRecognizer(InferenceModel):
|
|||||||
session=session.session,
|
session=session.session,
|
||||||
rec_batch_num=settings.max_batch_size.text_recognition if settings.max_batch_size is not None else 6,
|
rec_batch_num=settings.max_batch_size.text_recognition if settings.max_batch_size is not None else 6,
|
||||||
rec_img_shape=(3, 48, 320),
|
rec_img_shape=(3, 48, 320),
|
||||||
|
lang_type=self.language,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return session
|
return session
|
||||||
|
|
||||||
def _predict(self, _: Image, texts: TextDetectionOutput) -> TextRecognitionOutput:
|
def _predict(self, img: Image.Image, texts: TextDetectionOutput) -> TextRecognitionOutput:
|
||||||
boxes, img, box_scores = texts["boxes"], texts["image"], texts["scores"]
|
boxes, box_scores = texts["boxes"], texts["scores"]
|
||||||
if boxes.shape[0] == 0:
|
if boxes.shape[0] == 0:
|
||||||
return self._empty
|
return self._empty
|
||||||
rec = self.model(TextRecInput(img=self.get_crop_img_list(img, boxes)))
|
rec = self.model(TextRecInput(img=self.get_crop_img_list(img, boxes)))
|
||||||
if rec.txts is None:
|
if rec.txts is None:
|
||||||
return self._empty
|
return self._empty
|
||||||
|
|
||||||
height, width = img.shape[0:2]
|
boxes[:, :, 0] /= img.width
|
||||||
boxes[:, :, 0] /= width
|
boxes[:, :, 1] /= img.height
|
||||||
boxes[:, :, 1] /= height
|
|
||||||
|
|
||||||
text_scores = np.array(rec.scores)
|
text_scores = np.array(rec.scores)
|
||||||
valid_text_score_idx = text_scores > self.min_score
|
valid_text_score_idx = text_scores > self.min_score
|
||||||
@@ -87,7 +88,7 @@ class TextRecognizer(InferenceModel):
|
|||||||
"textScore": text_scores[valid_text_score_idx],
|
"textScore": text_scores[valid_text_score_idx],
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_crop_img_list(self, img: NDArray[np.float32], boxes: NDArray[np.float32]) -> list[NDArray[np.float32]]:
|
def get_crop_img_list(self, img: Image.Image, boxes: NDArray[np.float32]) -> list[NDArray[np.uint8]]:
|
||||||
img_crop_width = np.maximum(
|
img_crop_width = np.maximum(
|
||||||
np.linalg.norm(boxes[:, 1] - boxes[:, 0], axis=1), np.linalg.norm(boxes[:, 2] - boxes[:, 3], axis=1)
|
np.linalg.norm(boxes[:, 1] - boxes[:, 0], axis=1), np.linalg.norm(boxes[:, 2] - boxes[:, 3], axis=1)
|
||||||
).astype(np.int32)
|
).astype(np.int32)
|
||||||
@@ -98,22 +99,55 @@ class TextRecognizer(InferenceModel):
|
|||||||
pts_std[:, 1:3, 0] = img_crop_width[:, None]
|
pts_std[:, 1:3, 0] = img_crop_width[:, None]
|
||||||
pts_std[:, 2:4, 1] = img_crop_height[:, None]
|
pts_std[:, 2:4, 1] = img_crop_height[:, None]
|
||||||
|
|
||||||
img_crop_sizes = np.stack([img_crop_width, img_crop_height], axis=1).tolist()
|
img_crop_sizes = np.stack([img_crop_width, img_crop_height], axis=1)
|
||||||
imgs: list[NDArray[np.float32]] = []
|
all_coeffs = self._get_perspective_transform(pts_std, boxes)
|
||||||
for box, pts_std, dst_size in zip(list(boxes), list(pts_std), img_crop_sizes):
|
imgs: list[NDArray[np.uint8]] = []
|
||||||
M = cv2.getPerspectiveTransform(box, pts_std)
|
for coeffs, dst_size in zip(all_coeffs, img_crop_sizes):
|
||||||
dst_img: NDArray[np.float32] = cv2.warpPerspective(
|
dst_img = img.transform(
|
||||||
img,
|
size=tuple(dst_size),
|
||||||
M,
|
method=Image.Transform.PERSPECTIVE,
|
||||||
dst_size,
|
data=tuple(coeffs),
|
||||||
borderMode=cv2.BORDER_REPLICATE,
|
resample=Image.Resampling.BICUBIC,
|
||||||
flags=cv2.INTER_CUBIC,
|
)
|
||||||
) # type: ignore
|
|
||||||
dst_height, dst_width = dst_img.shape[0:2]
|
dst_width, dst_height = dst_img.size
|
||||||
if dst_height * 1.0 / dst_width >= 1.5:
|
if dst_height * 1.0 / dst_width >= 1.5:
|
||||||
dst_img = np.rot90(dst_img)
|
dst_img = dst_img.rotate(90, expand=True)
|
||||||
imgs.append(dst_img)
|
imgs.append(pil_to_cv2(dst_img))
|
||||||
|
|
||||||
return imgs
|
return imgs
|
||||||
|
|
||||||
|
def _get_perspective_transform(self, src: NDArray[np.float32], dst: NDArray[np.float32]) -> NDArray[np.float32]:
|
||||||
|
N = src.shape[0]
|
||||||
|
x, y = src[:, :, 0], src[:, :, 1]
|
||||||
|
u, v = dst[:, :, 0], dst[:, :, 1]
|
||||||
|
A = np.zeros((N, 8, 9), dtype=np.float32)
|
||||||
|
|
||||||
|
# Fill even rows (0, 2, 4, 6): [x, y, 1, 0, 0, 0, -u*x, -u*y, -u]
|
||||||
|
A[:, ::2, 0] = x
|
||||||
|
A[:, ::2, 1] = y
|
||||||
|
A[:, ::2, 2] = 1
|
||||||
|
A[:, ::2, 6] = -u * x
|
||||||
|
A[:, ::2, 7] = -u * y
|
||||||
|
A[:, ::2, 8] = -u
|
||||||
|
|
||||||
|
# Fill odd rows (1, 3, 5, 7): [0, 0, 0, x, y, 1, -v*x, -v*y, -v]
|
||||||
|
A[:, 1::2, 3] = x
|
||||||
|
A[:, 1::2, 4] = y
|
||||||
|
A[:, 1::2, 5] = 1
|
||||||
|
A[:, 1::2, 6] = -v * x
|
||||||
|
A[:, 1::2, 7] = -v * y
|
||||||
|
A[:, 1::2, 8] = -v
|
||||||
|
|
||||||
|
# Solve using SVD for all matrices at once
|
||||||
|
_, _, Vt = np.linalg.svd(A)
|
||||||
|
H = Vt[:, -1, :].reshape(N, 3, 3)
|
||||||
|
H = H / H[:, 2:3, 2:3]
|
||||||
|
|
||||||
|
# Extract the 8 coefficients for each transformation
|
||||||
|
return np.column_stack(
|
||||||
|
[H[:, 0, 0], H[:, 0, 1], H[:, 0, 2], H[:, 1, 0], H[:, 1, 1], H[:, 1, 2], H[:, 2, 0], H[:, 2, 1]]
|
||||||
|
) # pyright: ignore[reportReturnType]
|
||||||
|
|
||||||
def configure(self, **kwargs: Any) -> None:
|
def configure(self, **kwargs: Any) -> None:
|
||||||
self.min_score = kwargs.get("minScore", self.min_score)
|
self.min_score = kwargs.get("minScore", self.min_score)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from typing_extensions import TypedDict
|
|||||||
|
|
||||||
|
|
||||||
class TextDetectionOutput(TypedDict):
|
class TextDetectionOutput(TypedDict):
|
||||||
image: npt.NDArray[np.float32]
|
|
||||||
boxes: npt.NDArray[np.float32]
|
boxes: npt.NDArray[np.float32]
|
||||||
scores: npt.NDArray[np.float32]
|
scores: npt.NDArray[np.float32]
|
||||||
|
|
||||||
@@ -21,8 +20,8 @@ class TextRecognitionOutput(TypedDict):
|
|||||||
|
|
||||||
# RapidOCR expects `engine_type`, `lang_type`, and `font_path` to be attributes
|
# RapidOCR expects `engine_type`, `lang_type`, and `font_path` to be attributes
|
||||||
class OcrOptions(dict[str, Any]):
|
class OcrOptions(dict[str, Any]):
|
||||||
def __init__(self, **options: Any) -> None:
|
def __init__(self, lang_type: LangRec | None = None, **options: Any) -> None:
|
||||||
super().__init__(**options)
|
super().__init__(**options)
|
||||||
self.engine_type = EngineType.ONNXRUNTIME
|
self.engine_type = EngineType.ONNXRUNTIME
|
||||||
self.lang_type = LangRec.CH
|
self.lang_type = lang_type
|
||||||
self.font_path = None
|
self.font_path = None
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ class ModelSource(StrEnum):
|
|||||||
PADDLE = "paddle"
|
PADDLE = "paddle"
|
||||||
|
|
||||||
|
|
||||||
|
class ModelPrecision(StrEnum):
|
||||||
|
FP16 = "FP16"
|
||||||
|
FP32 = "FP32"
|
||||||
|
|
||||||
|
|
||||||
ModelIdentity = tuple[ModelType, ModelTask]
|
ModelIdentity = tuple[ModelType, ModelTask]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -93,10 +93,12 @@ class OrtSession:
|
|||||||
case "CUDAExecutionProvider" | "ROCMExecutionProvider":
|
case "CUDAExecutionProvider" | "ROCMExecutionProvider":
|
||||||
options = {"arena_extend_strategy": "kSameAsRequested", "device_id": settings.device_id}
|
options = {"arena_extend_strategy": "kSameAsRequested", "device_id": settings.device_id}
|
||||||
case "OpenVINOExecutionProvider":
|
case "OpenVINOExecutionProvider":
|
||||||
|
openvino_dir = self.model_path.parent / "openvino"
|
||||||
|
device = f"GPU.{settings.device_id}"
|
||||||
options = {
|
options = {
|
||||||
"device_type": f"GPU.{settings.device_id}",
|
"device_type": device,
|
||||||
"precision": "FP32",
|
"precision": settings.openvino_precision.value,
|
||||||
"cache_dir": (self.model_path.parent / "openvino").as_posix(),
|
"cache_dir": openvino_dir.as_posix(),
|
||||||
}
|
}
|
||||||
case "CoreMLExecutionProvider":
|
case "CoreMLExecutionProvider":
|
||||||
options = {
|
options = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "immich-ml"
|
name = "immich-ml"
|
||||||
version = "2.2.1"
|
version = "2.2.3"
|
||||||
description = ""
|
description = ""
|
||||||
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
||||||
requires-python = ">=3.10,<4.0"
|
requires-python = ">=3.10,<4.0"
|
||||||
@@ -22,7 +22,6 @@ dependencies = [
|
|||||||
"rich>=13.4.2",
|
"rich>=13.4.2",
|
||||||
"tokenizers>=0.15.0,<1.0",
|
"tokenizers>=0.15.0,<1.0",
|
||||||
"uvicorn[standard]>=0.22.0,<1.0",
|
"uvicorn[standard]>=0.22.0,<1.0",
|
||||||
"setuptools>=78.1.0",
|
|
||||||
"rapidocr>=3.1.0",
|
"rapidocr>=3.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from immich_ml.models.clip.textual import MClipTextualEncoder, OpenClipTextualEn
|
|||||||
from immich_ml.models.clip.visual import OpenClipVisualEncoder
|
from immich_ml.models.clip.visual import OpenClipVisualEncoder
|
||||||
from immich_ml.models.facial_recognition.detection import FaceDetector
|
from immich_ml.models.facial_recognition.detection import FaceDetector
|
||||||
from immich_ml.models.facial_recognition.recognition import FaceRecognizer
|
from immich_ml.models.facial_recognition.recognition import FaceRecognizer
|
||||||
from immich_ml.schemas import ModelFormat, ModelTask, ModelType
|
from immich_ml.schemas import ModelFormat, ModelPrecision, ModelTask, ModelType
|
||||||
from immich_ml.sessions.ann import AnnSession
|
from immich_ml.sessions.ann import AnnSession
|
||||||
from immich_ml.sessions.ort import OrtSession
|
from immich_ml.sessions.ort import OrtSession
|
||||||
from immich_ml.sessions.rknn import RknnSession, run_inference
|
from immich_ml.sessions.rknn import RknnSession, run_inference
|
||||||
@@ -240,11 +240,16 @@ class TestOrtSession:
|
|||||||
|
|
||||||
@pytest.mark.ov_device_ids(["GPU.0", "CPU"])
|
@pytest.mark.ov_device_ids(["GPU.0", "CPU"])
|
||||||
def test_sets_default_provider_options(self, ov_device_ids: list[str]) -> None:
|
def test_sets_default_provider_options(self, ov_device_ids: list[str]) -> None:
|
||||||
model_path = "/cache/ViT-B-32__openai/model.onnx"
|
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
|
||||||
|
|
||||||
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider", "CPUExecutionProvider"])
|
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider", "CPUExecutionProvider"])
|
||||||
|
|
||||||
assert session.provider_options == [
|
assert session.provider_options == [
|
||||||
{"device_type": "GPU.0", "precision": "FP32", "cache_dir": "/cache/ViT-B-32__openai/openvino"},
|
{
|
||||||
|
"device_type": "GPU.0",
|
||||||
|
"precision": "FP32",
|
||||||
|
"cache_dir": "/cache/ViT-B-32__openai/textual/openvino",
|
||||||
|
},
|
||||||
{"arena_extend_strategy": "kSameAsRequested"},
|
{"arena_extend_strategy": "kSameAsRequested"},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -262,6 +267,21 @@ class TestOrtSession:
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def test_sets_openvino_to_fp16_if_enabled(self, mocker: MockerFixture) -> None:
|
||||||
|
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
|
||||||
|
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
||||||
|
mocker.patch.object(settings, "openvino_precision", ModelPrecision.FP16)
|
||||||
|
|
||||||
|
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
|
||||||
|
|
||||||
|
assert session.provider_options == [
|
||||||
|
{
|
||||||
|
"device_type": "GPU.1",
|
||||||
|
"precision": "FP16",
|
||||||
|
"cache_dir": "/cache/ViT-B-32__openai/textual/openvino",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
def test_sets_provider_options_for_cuda(self) -> None:
|
def test_sets_provider_options_for_cuda(self) -> None:
|
||||||
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
||||||
|
|
||||||
@@ -417,7 +437,7 @@ class TestRknnSession:
|
|||||||
session.run(None, input_feed)
|
session.run(None, input_feed)
|
||||||
|
|
||||||
rknn_session.return_value.put.assert_called_once_with([input1, input2])
|
rknn_session.return_value.put.assert_called_once_with([input1, input2])
|
||||||
np_spy.call_count == 2
|
assert np_spy.call_count == 2
|
||||||
np_spy.assert_has_calls([mock.call(input1), mock.call(input2)])
|
np_spy.assert_has_calls([mock.call(input1), mock.call(input2)])
|
||||||
|
|
||||||
|
|
||||||
@@ -925,11 +945,34 @@ class TestCache:
|
|||||||
any_order=True,
|
any_order=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def test_preloads_ocr_models(self, monkeypatch: MonkeyPatch, mock_get_model: mock.Mock) -> None:
|
||||||
|
os.environ["MACHINE_LEARNING_PRELOAD__OCR__DETECTION"] = "PP-OCRv5_mobile"
|
||||||
|
os.environ["MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION"] = "PP-OCRv5_mobile"
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
assert settings.preload is not None
|
||||||
|
assert settings.preload.ocr.detection == "PP-OCRv5_mobile"
|
||||||
|
assert settings.preload.ocr.recognition == "PP-OCRv5_mobile"
|
||||||
|
|
||||||
|
model_cache = ModelCache()
|
||||||
|
monkeypatch.setattr("immich_ml.main.model_cache", model_cache)
|
||||||
|
|
||||||
|
await preload_models(settings.preload)
|
||||||
|
mock_get_model.assert_has_calls(
|
||||||
|
[
|
||||||
|
mock.call("PP-OCRv5_mobile", ModelType.DETECTION, ModelTask.OCR),
|
||||||
|
mock.call("PP-OCRv5_mobile", ModelType.RECOGNITION, ModelTask.OCR),
|
||||||
|
],
|
||||||
|
any_order=True,
|
||||||
|
)
|
||||||
|
|
||||||
async def test_preloads_all_models(self, monkeypatch: MonkeyPatch, mock_get_model: mock.Mock) -> None:
|
async def test_preloads_all_models(self, monkeypatch: MonkeyPatch, mock_get_model: mock.Mock) -> None:
|
||||||
os.environ["MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL"] = "ViT-B-32__openai"
|
os.environ["MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL"] = "ViT-B-32__openai"
|
||||||
os.environ["MACHINE_LEARNING_PRELOAD__CLIP__VISUAL"] = "ViT-B-32__openai"
|
os.environ["MACHINE_LEARNING_PRELOAD__CLIP__VISUAL"] = "ViT-B-32__openai"
|
||||||
os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION"] = "buffalo_s"
|
os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION"] = "buffalo_s"
|
||||||
os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION"] = "buffalo_s"
|
os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION"] = "buffalo_s"
|
||||||
|
os.environ["MACHINE_LEARNING_PRELOAD__OCR__DETECTION"] = "PP-OCRv5_mobile"
|
||||||
|
os.environ["MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION"] = "PP-OCRv5_mobile"
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
assert settings.preload is not None
|
assert settings.preload is not None
|
||||||
@@ -937,6 +980,8 @@ class TestCache:
|
|||||||
assert settings.preload.clip.textual == "ViT-B-32__openai"
|
assert settings.preload.clip.textual == "ViT-B-32__openai"
|
||||||
assert settings.preload.facial_recognition.recognition == "buffalo_s"
|
assert settings.preload.facial_recognition.recognition == "buffalo_s"
|
||||||
assert settings.preload.facial_recognition.detection == "buffalo_s"
|
assert settings.preload.facial_recognition.detection == "buffalo_s"
|
||||||
|
assert settings.preload.ocr.detection == "PP-OCRv5_mobile"
|
||||||
|
assert settings.preload.ocr.recognition == "PP-OCRv5_mobile"
|
||||||
|
|
||||||
model_cache = ModelCache()
|
model_cache = ModelCache()
|
||||||
monkeypatch.setattr("immich_ml.main.model_cache", model_cache)
|
monkeypatch.setattr("immich_ml.main.model_cache", model_cache)
|
||||||
@@ -948,6 +993,8 @@ class TestCache:
|
|||||||
mock.call("ViT-B-32__openai", ModelType.VISUAL, ModelTask.SEARCH),
|
mock.call("ViT-B-32__openai", ModelType.VISUAL, ModelTask.SEARCH),
|
||||||
mock.call("buffalo_s", ModelType.DETECTION, ModelTask.FACIAL_RECOGNITION),
|
mock.call("buffalo_s", ModelType.DETECTION, ModelTask.FACIAL_RECOGNITION),
|
||||||
mock.call("buffalo_s", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION),
|
mock.call("buffalo_s", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION),
|
||||||
|
mock.call("PP-OCRv5_mobile", ModelType.DETECTION, ModelTask.OCR),
|
||||||
|
mock.call("PP-OCRv5_mobile", ModelType.RECOGNITION, ModelTask.OCR),
|
||||||
],
|
],
|
||||||
any_order=True,
|
any_order=True,
|
||||||
)
|
)
|
||||||
|
|||||||
2
machine-learning/uv.lock
generated
2
machine-learning/uv.lock
generated
@@ -1100,7 +1100,6 @@ dependencies = [
|
|||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
{ name = "rapidocr" },
|
{ name = "rapidocr" },
|
||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "setuptools" },
|
|
||||||
{ name = "tokenizers" },
|
{ name = "tokenizers" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
@@ -1188,7 +1187,6 @@ requires-dist = [
|
|||||||
{ name = "rapidocr", specifier = ">=3.1.0" },
|
{ name = "rapidocr", specifier = ">=3.1.0" },
|
||||||
{ name = "rich", specifier = ">=13.4.2" },
|
{ name = "rich", specifier = ">=13.4.2" },
|
||||||
{ name = "rknn-toolkit-lite2", marker = "extra == 'rknn'", specifier = ">=2.3.0,<3" },
|
{ name = "rknn-toolkit-lite2", marker = "extra == 'rknn'", specifier = ">=2.3.0,<3" },
|
||||||
{ name = "setuptools", specifier = ">=78.1.0" },
|
|
||||||
{ name = "tokenizers", specifier = ">=0.15.0,<1.0" },
|
{ name = "tokenizers", specifier = ">=0.15.0,<1.0" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.22.0,<1.0" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.22.0,<1.0" },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
#
|
#
|
||||||
# Pump one or both of the server/mobile versions in appropriate files
|
# Pump one or both of the server/mobile versions in appropriate files
|
||||||
#
|
#
|
||||||
# usage: './scripts/pump-version.sh -s <major|minor|patch> <-m>
|
# usage: './scripts/pump-version.sh -s <major|minor|patch> <-m> <true|false>
|
||||||
#
|
#
|
||||||
# examples:
|
# examples:
|
||||||
# ./scripts/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50
|
# ./scripts/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50
|
||||||
# ./scripts/pump-version.sh -s minor -m # 1.0.0+50 => 1.1.0+51
|
# ./scripts/pump-version.sh -s minor -m true # 1.0.0+50 => 1.1.0+51
|
||||||
# ./scripts/pump-version.sh -m # 1.0.0+50 => 1.0.0+51
|
# ./scripts/pump-version.sh -m true # 1.0.0+50 => 1.0.0+51
|
||||||
#
|
#
|
||||||
|
|
||||||
SERVER_PUMP="false"
|
SERVER_PUMP="false"
|
||||||
@@ -88,7 +88,6 @@ if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
sed -i "s/\"android\.injected\.version\.name\" => \"$CURRENT_SERVER\",/\"android\.injected\.version\.name\" => \"$NEXT_SERVER\",/" mobile/android/fastlane/Fastfile
|
sed -i "s/\"android\.injected\.version\.name\" => \"$CURRENT_SERVER\",/\"android\.injected\.version\.name\" => \"$NEXT_SERVER\",/" mobile/android/fastlane/Fastfile
|
||||||
sed -i "s/version_number: \"$CURRENT_SERVER\"$/version_number: \"$NEXT_SERVER\"/" mobile/ios/fastlane/Fastfile
|
|
||||||
sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.injected\.version\.code\" => $NEXT_MOBILE,/" mobile/android/fastlane/Fastfile
|
sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.injected\.version\.code\" => $NEXT_MOBILE,/" mobile/android/fastlane/Fastfile
|
||||||
sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml
|
sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml
|
||||||
|
|
||||||
|
|||||||
515
mise.toml
515
mise.toml
@@ -1,7 +1,9 @@
|
|||||||
|
experimental_monorepo_root = true
|
||||||
|
|
||||||
[tools]
|
[tools]
|
||||||
node = "24.11.0"
|
node = "24.11.0"
|
||||||
flutter = "3.35.7"
|
flutter = "3.35.7"
|
||||||
pnpm = "10.19.0"
|
pnpm = "10.20.0"
|
||||||
terragrunt = "0.91.2"
|
terragrunt = "0.91.2"
|
||||||
opentofu = "1.10.6"
|
opentofu = "1.10.6"
|
||||||
|
|
||||||
@@ -14,514 +16,21 @@ postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm"
|
|||||||
experimental = true
|
experimental = true
|
||||||
pin = true
|
pin = true
|
||||||
|
|
||||||
# .github
|
# SDK tasks
|
||||||
[tasks."github:install"]
|
|
||||||
run = "pnpm install --filter github --frozen-lockfile"
|
|
||||||
|
|
||||||
[tasks."github:format"]
|
|
||||||
env._.path = "./.github/node_modules/.bin"
|
|
||||||
dir = ".github"
|
|
||||||
run = "prettier --check ."
|
|
||||||
|
|
||||||
[tasks."github:format-fix"]
|
|
||||||
env._.path = "./.github/node_modules/.bin"
|
|
||||||
dir = ".github"
|
|
||||||
run = "prettier --write ."
|
|
||||||
|
|
||||||
# @immich/cli
|
|
||||||
[tasks."cli:install"]
|
|
||||||
run = "pnpm install --filter @immich/cli --frozen-lockfile"
|
|
||||||
|
|
||||||
[tasks."cli:build"]
|
|
||||||
env._.path = "./cli/node_modules/.bin"
|
|
||||||
dir = "cli"
|
|
||||||
run = "vite build"
|
|
||||||
|
|
||||||
[tasks."cli:test"]
|
|
||||||
env._.path = "./cli/node_modules/.bin"
|
|
||||||
dir = "cli"
|
|
||||||
run = "vite"
|
|
||||||
|
|
||||||
[tasks."cli:lint"]
|
|
||||||
env._.path = "./cli/node_modules/.bin"
|
|
||||||
dir = "cli"
|
|
||||||
run = "eslint \"src/**/*.ts\" --max-warnings 0"
|
|
||||||
|
|
||||||
[tasks."cli:lint-fix"]
|
|
||||||
run = "mise run cli:lint --fix"
|
|
||||||
|
|
||||||
[tasks."cli:format"]
|
|
||||||
env._.path = "./cli/node_modules/.bin"
|
|
||||||
dir = "cli"
|
|
||||||
run = "prettier --check ."
|
|
||||||
|
|
||||||
[tasks."cli:format-fix"]
|
|
||||||
env._.path = "./cli/node_modules/.bin"
|
|
||||||
dir = "cli"
|
|
||||||
run = "prettier --write ."
|
|
||||||
|
|
||||||
[tasks."cli:check"]
|
|
||||||
env._.path = "./cli/node_modules/.bin"
|
|
||||||
dir = "cli"
|
|
||||||
run = "tsc --noEmit"
|
|
||||||
|
|
||||||
# @immich/sdk
|
|
||||||
[tasks."sdk:install"]
|
[tasks."sdk:install"]
|
||||||
|
dir = "open-api/typescript-sdk"
|
||||||
run = "pnpm install --filter @immich/sdk --frozen-lockfile"
|
run = "pnpm install --filter @immich/sdk --frozen-lockfile"
|
||||||
|
|
||||||
[tasks."sdk:build"]
|
[tasks."sdk:build"]
|
||||||
env._.path = "./open-api/typescript-sdk/node_modules/.bin"
|
dir = "open-api/typescript-sdk"
|
||||||
dir = "./open-api/typescript-sdk"
|
env._.path = "./node_modules/.bin"
|
||||||
run = "tsc"
|
run = "tsc"
|
||||||
|
|
||||||
# docs
|
# i18n tasks
|
||||||
[tasks."docs:install"]
|
|
||||||
run = "pnpm install --filter documentation --frozen-lockfile"
|
|
||||||
|
|
||||||
[tasks."docs:start"]
|
|
||||||
env._.path = "./docs/node_modules/.bin"
|
|
||||||
dir = "docs"
|
|
||||||
run = "docusaurus --port 3005"
|
|
||||||
|
|
||||||
[tasks."docs:build"]
|
|
||||||
env._.path = "./docs/node_modules/.bin"
|
|
||||||
dir = "docs"
|
|
||||||
run = [
|
|
||||||
"jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
|
|
||||||
"docusaurus build",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
[tasks."docs:preview"]
|
|
||||||
env._.path = "./docs/node_modules/.bin"
|
|
||||||
dir = "docs"
|
|
||||||
run = "docusaurus serve"
|
|
||||||
|
|
||||||
|
|
||||||
[tasks."docs:format"]
|
|
||||||
env._.path = "./docs/node_modules/.bin"
|
|
||||||
dir = "docs"
|
|
||||||
run = "prettier --check ."
|
|
||||||
|
|
||||||
[tasks."docs:format-fix"]
|
|
||||||
env._.path = "./docs/node_modules/.bin"
|
|
||||||
dir = "docs"
|
|
||||||
run = "prettier --write ."
|
|
||||||
|
|
||||||
|
|
||||||
# e2e
|
|
||||||
[tasks."e2e:install"]
|
|
||||||
run = "pnpm install --filter immich-e2e --frozen-lockfile"
|
|
||||||
|
|
||||||
[tasks."e2e:test"]
|
|
||||||
env._.path = "./e2e/node_modules/.bin"
|
|
||||||
dir = "e2e"
|
|
||||||
run = "vitest --run"
|
|
||||||
|
|
||||||
[tasks."e2e:test-web"]
|
|
||||||
env._.path = "./e2e/node_modules/.bin"
|
|
||||||
dir = "e2e"
|
|
||||||
run = "playwright test"
|
|
||||||
|
|
||||||
[tasks."e2e:format"]
|
|
||||||
env._.path = "./e2e/node_modules/.bin"
|
|
||||||
dir = "e2e"
|
|
||||||
run = "prettier --check ."
|
|
||||||
|
|
||||||
[tasks."e2e:format-fix"]
|
|
||||||
env._.path = "./e2e/node_modules/.bin"
|
|
||||||
dir = "e2e"
|
|
||||||
run = "prettier --write ."
|
|
||||||
|
|
||||||
[tasks."e2e:lint"]
|
|
||||||
env._.path = "./e2e/node_modules/.bin"
|
|
||||||
dir = "e2e"
|
|
||||||
run = "eslint \"src/**/*.ts\" --max-warnings 0"
|
|
||||||
|
|
||||||
[tasks."e2e:lint-fix"]
|
|
||||||
run = "mise run e2e:lint --fix"
|
|
||||||
|
|
||||||
[tasks."e2e:check"]
|
|
||||||
env._.path = "./e2e/node_modules/.bin"
|
|
||||||
dir = "e2e"
|
|
||||||
run = "tsc --noEmit"
|
|
||||||
|
|
||||||
# i18n
|
|
||||||
[tasks."i18n:format"]
|
[tasks."i18n:format"]
|
||||||
run = "mise run i18n:format-fix"
|
dir = "i18n"
|
||||||
|
run = { task = ":i18n:format-fix" }
|
||||||
|
|
||||||
[tasks."i18n:format-fix"]
|
[tasks."i18n:format-fix"]
|
||||||
run = "pnpm dlx sort-json ./i18n/*.json"
|
dir = "i18n"
|
||||||
|
run = "pnpm dlx sort-json *.json"
|
||||||
|
|
||||||
# server
|
|
||||||
[tasks."server:install"]
|
|
||||||
run = "pnpm install --filter immich --frozen-lockfile"
|
|
||||||
|
|
||||||
[tasks."server:build"]
|
|
||||||
env._.path = "./server/node_modules/.bin"
|
|
||||||
dir = "server"
|
|
||||||
run = "nest build"
|
|
||||||
|
|
||||||
[tasks."server:test"]
|
|
||||||
env._.path = "./server/node_modules/.bin"
|
|
||||||
dir = "server"
|
|
||||||
run = "vitest --config test/vitest.config.mjs"
|
|
||||||
|
|
||||||
[tasks."server:test-medium"]
|
|
||||||
env._.path = "./server/node_modules/.bin"
|
|
||||||
dir = "server"
|
|
||||||
run = "vitest --config test/vitest.config.medium.mjs"
|
|
||||||
|
|
||||||
[tasks."server:format"]
|
|
||||||
env._.path = "./server/node_modules/.bin"
|
|
||||||
dir = "server"
|
|
||||||
run = "prettier --check ."
|
|
||||||
|
|
||||||
[tasks."server:format-fix"]
|
|
||||||
env._.path = "./server/node_modules/.bin"
|
|
||||||
dir = "server"
|
|
||||||
run = "prettier --write ."
|
|
||||||
|
|
||||||
[tasks."server:lint"]
|
|
||||||
env._.path = "./server/node_modules/.bin"
|
|
||||||
dir = "server"
|
|
||||||
run = "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0"
|
|
||||||
|
|
||||||
[tasks."server:lint-fix"]
|
|
||||||
run = "mise run server:lint --fix"
|
|
||||||
|
|
||||||
[tasks."server:check"]
|
|
||||||
env._.path = "./server/node_modules/.bin"
|
|
||||||
dir = "server"
|
|
||||||
run = "tsc --noEmit"
|
|
||||||
|
|
||||||
[tasks."server:sql"]
|
|
||||||
dir = "server"
|
|
||||||
run = "node ./dist/bin/sync-open-api.js"
|
|
||||||
|
|
||||||
[tasks."server:open-api"]
|
|
||||||
dir = "server"
|
|
||||||
run = "node ./dist/bin/sync-open-api.js"
|
|
||||||
|
|
||||||
[tasks."server:migrations"]
|
|
||||||
dir = "server"
|
|
||||||
run = "node ./dist/bin/migrations.js"
|
|
||||||
description = "Run database migration commands (create, generate, run, debug, or query)"
|
|
||||||
|
|
||||||
[tasks."server:schema-drop"]
|
|
||||||
run = "mise run server:migrations query 'DROP schema public cascade; CREATE schema public;'"
|
|
||||||
|
|
||||||
[tasks."server:schema-reset"]
|
|
||||||
run = "mise run server:schema-drop && mise run server:migrations run"
|
|
||||||
|
|
||||||
[tasks."server:email-dev"]
|
|
||||||
env._.path = "./server/node_modules/.bin"
|
|
||||||
dir = "server"
|
|
||||||
run = "email dev -p 3050 --dir src/emails"
|
|
||||||
|
|
||||||
[tasks."server:checklist"]
|
|
||||||
run = [
|
|
||||||
"mise run server:install",
|
|
||||||
"mise run server:format",
|
|
||||||
"mise run server:lint",
|
|
||||||
"mise run server:check",
|
|
||||||
"mise run server:test-medium --run",
|
|
||||||
"mise run server:test --run",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# web
|
|
||||||
[tasks."web:install"]
|
|
||||||
run = "pnpm install --filter immich-web --frozen-lockfile"
|
|
||||||
|
|
||||||
[tasks."web:svelte-kit-sync"]
|
|
||||||
env._.path = "./web/node_modules/.bin"
|
|
||||||
dir = "web"
|
|
||||||
run = "svelte-kit sync"
|
|
||||||
|
|
||||||
[tasks."web:build"]
|
|
||||||
env._.path = "./web/node_modules/.bin"
|
|
||||||
dir = "web"
|
|
||||||
run = "vite build"
|
|
||||||
|
|
||||||
[tasks."web:build-stats"]
|
|
||||||
env.BUILD_STATS = "true"
|
|
||||||
env._.path = "./web/node_modules/.bin"
|
|
||||||
dir = "web"
|
|
||||||
run = "vite build"
|
|
||||||
|
|
||||||
[tasks."web:preview"]
|
|
||||||
env._.path = "./web/node_modules/.bin"
|
|
||||||
dir = "web"
|
|
||||||
run = "vite preview"
|
|
||||||
|
|
||||||
[tasks."web:start"]
|
|
||||||
env._.path = "web/node_modules/.bin"
|
|
||||||
dir = "web"
|
|
||||||
run = "vite dev --host 0.0.0.0 --port 3000"
|
|
||||||
|
|
||||||
[tasks."web:test"]
|
|
||||||
depends = "web:svelte-kit-sync"
|
|
||||||
env._.path = "web/node_modules/.bin"
|
|
||||||
dir = "web"
|
|
||||||
run = "vitest"
|
|
||||||
|
|
||||||
[tasks."web:format"]
|
|
||||||
env._.path = "web/node_modules/.bin"
|
|
||||||
dir = "web"
|
|
||||||
run = "prettier --check ."
|
|
||||||
|
|
||||||
[tasks."web:format-fix"]
|
|
||||||
env._.path = "web/node_modules/.bin"
|
|
||||||
dir = "web"
|
|
||||||
run = "prettier --write ."
|
|
||||||
|
|
||||||
[tasks."web:lint"]
|
|
||||||
env._.path = "web/node_modules/.bin"
|
|
||||||
dir = "web"
|
|
||||||
run = "eslint . --max-warnings 0 --concurrency 4"
|
|
||||||
|
|
||||||
[tasks."web:lint-fix"]
|
|
||||||
run = "mise run web:lint --fix"
|
|
||||||
|
|
||||||
[tasks."web:check"]
|
|
||||||
depends = "web:svelte-kit-sync"
|
|
||||||
env._.path = "web/node_modules/.bin"
|
|
||||||
dir = "web"
|
|
||||||
run = "tsc --noEmit"
|
|
||||||
|
|
||||||
[tasks."web:check-svelte"]
|
|
||||||
depends = "web:svelte-kit-sync"
|
|
||||||
env._.path = "web/node_modules/.bin"
|
|
||||||
dir = "web"
|
|
||||||
run = "svelte-check --no-tsconfig --fail-on-warnings"
|
|
||||||
|
|
||||||
[tasks."web:checklist"]
|
|
||||||
run = [
|
|
||||||
"mise run web:install",
|
|
||||||
"mise run web:format",
|
|
||||||
"mise run web:check",
|
|
||||||
"mise run web:test --run",
|
|
||||||
"mise run web:lint",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# mobile
|
|
||||||
[tasks."mobile:codegen:dart"]
|
|
||||||
alias = "mobile:codegen"
|
|
||||||
description = "Execute build_runner to auto-generate dart code"
|
|
||||||
dir = "mobile"
|
|
||||||
sources = [
|
|
||||||
"pubspec.yaml",
|
|
||||||
"build.yaml",
|
|
||||||
"lib/**/*.dart",
|
|
||||||
"infrastructure/**/*.drift",
|
|
||||||
]
|
|
||||||
outputs = { auto = true }
|
|
||||||
run = "dart run build_runner build --delete-conflicting-outputs"
|
|
||||||
|
|
||||||
[tasks."mobile:codegen:pigeon"]
|
|
||||||
alias = "mobile:pigeon"
|
|
||||||
description = "Generate pigeon platform code"
|
|
||||||
dir = "mobile"
|
|
||||||
depends = [
|
|
||||||
"mobile:pigeon:native-sync",
|
|
||||||
"mobile:pigeon:thumbnail",
|
|
||||||
"mobile:pigeon:background-worker",
|
|
||||||
"mobile:pigeon:background-worker-lock",
|
|
||||||
"mobile:pigeon:connectivity",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tasks."mobile:codegen:translation"]
|
|
||||||
alias = "mobile:translation"
|
|
||||||
description = "Generate translations from i18n JSONs"
|
|
||||||
dir = "mobile"
|
|
||||||
run = [
|
|
||||||
{ task = "i18n:format-fix" },
|
|
||||||
{ tasks = [
|
|
||||||
"mobile:i18n:loader",
|
|
||||||
"mobile:i18n:keys",
|
|
||||||
] },
|
|
||||||
]
|
|
||||||
|
|
||||||
[tasks."mobile:codegen:app-icon"]
|
|
||||||
description = "Generate app icons"
|
|
||||||
dir = "mobile"
|
|
||||||
run = "flutter pub run flutter_launcher_icons:main"
|
|
||||||
|
|
||||||
[tasks."mobile:codegen:splash"]
|
|
||||||
description = "Generate splash screen"
|
|
||||||
dir = "mobile"
|
|
||||||
run = "flutter pub run flutter_native_splash:create"
|
|
||||||
|
|
||||||
[tasks."mobile:test"]
|
|
||||||
description = "Run mobile tests"
|
|
||||||
dir = "mobile"
|
|
||||||
run = "flutter test"
|
|
||||||
|
|
||||||
[tasks."mobile:lint"]
|
|
||||||
description = "Analyze Dart code"
|
|
||||||
dir = "mobile"
|
|
||||||
depends = ["mobile:analyze:dart", "mobile:analyze:dcm"]
|
|
||||||
|
|
||||||
[tasks."mobile:lint-fix"]
|
|
||||||
description = "Auto-fix Dart code"
|
|
||||||
dir = "mobile"
|
|
||||||
depends = ["mobile:analyze:fix:dart", "mobile:analyze:fix:dcm"]
|
|
||||||
|
|
||||||
[tasks."mobile:format"]
|
|
||||||
description = "Format Dart code"
|
|
||||||
dir = "mobile"
|
|
||||||
run = "dart format --set-exit-if-changed $(find lib -name '*.dart' -not \\( -name '*.g.dart' -o -name '*.drift.dart' -o -name '*.gr.dart' \\))"
|
|
||||||
|
|
||||||
[tasks."mobile:build:android"]
|
|
||||||
description = "Build Android release"
|
|
||||||
dir = "mobile"
|
|
||||||
run = "flutter build appbundle"
|
|
||||||
|
|
||||||
[tasks."mobile:drift:migration"]
|
|
||||||
alias = "mobile:migration"
|
|
||||||
description = "Generate database migrations"
|
|
||||||
dir = "mobile"
|
|
||||||
run = "dart run drift_dev make-migrations"
|
|
||||||
|
|
||||||
|
|
||||||
# mobile internal tasks
|
|
||||||
[tasks."mobile:pigeon:native-sync"]
|
|
||||||
description = "Generate native sync API pigeon code"
|
|
||||||
dir = "mobile"
|
|
||||||
hide = true
|
|
||||||
sources = ["pigeon/native_sync_api.dart"]
|
|
||||||
outputs = [
|
|
||||||
"lib/platform/native_sync_api.g.dart",
|
|
||||||
"ios/Runner/Sync/Messages.g.swift",
|
|
||||||
"android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt",
|
|
||||||
]
|
|
||||||
run = [
|
|
||||||
"dart run pigeon --input pigeon/native_sync_api.dart",
|
|
||||||
"dart format lib/platform/native_sync_api.g.dart",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tasks."mobile:pigeon:thumbnail"]
|
|
||||||
description = "Generate thumbnail API pigeon code"
|
|
||||||
dir = "mobile"
|
|
||||||
hide = true
|
|
||||||
sources = ["pigeon/thumbnail_api.dart"]
|
|
||||||
outputs = [
|
|
||||||
"lib/platform/thumbnail_api.g.dart",
|
|
||||||
"ios/Runner/Images/Thumbnails.g.swift",
|
|
||||||
"android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt",
|
|
||||||
]
|
|
||||||
run = [
|
|
||||||
"dart run pigeon --input pigeon/thumbnail_api.dart",
|
|
||||||
"dart format lib/platform/thumbnail_api.g.dart",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tasks."mobile:pigeon:background-worker"]
|
|
||||||
description = "Generate background worker API pigeon code"
|
|
||||||
dir = "mobile"
|
|
||||||
hide = true
|
|
||||||
sources = ["pigeon/background_worker_api.dart"]
|
|
||||||
outputs = [
|
|
||||||
"lib/platform/background_worker_api.g.dart",
|
|
||||||
"ios/Runner/Background/BackgroundWorker.g.swift",
|
|
||||||
"android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt",
|
|
||||||
]
|
|
||||||
run = [
|
|
||||||
"dart run pigeon --input pigeon/background_worker_api.dart",
|
|
||||||
"dart format lib/platform/background_worker_api.g.dart",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tasks."mobile:pigeon:background-worker-lock"]
|
|
||||||
description = "Generate background worker lock API pigeon code"
|
|
||||||
dir = "mobile"
|
|
||||||
hide = true
|
|
||||||
sources = ["pigeon/background_worker_lock_api.dart"]
|
|
||||||
outputs = [
|
|
||||||
"lib/platform/background_worker_lock_api.g.dart",
|
|
||||||
"android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerLock.g.kt",
|
|
||||||
]
|
|
||||||
run = [
|
|
||||||
"dart run pigeon --input pigeon/background_worker_lock_api.dart",
|
|
||||||
"dart format lib/platform/background_worker_lock_api.g.dart",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tasks."mobile:pigeon:connectivity"]
|
|
||||||
description = "Generate connectivity API pigeon code"
|
|
||||||
dir = "mobile"
|
|
||||||
hide = true
|
|
||||||
sources = ["pigeon/connectivity_api.dart"]
|
|
||||||
outputs = [
|
|
||||||
"lib/platform/connectivity_api.g.dart",
|
|
||||||
"ios/Runner/Connectivity/Connectivity.g.swift",
|
|
||||||
"android/app/src/main/kotlin/app/alextran/immich/connectivity/Connectivity.g.kt",
|
|
||||||
]
|
|
||||||
run = [
|
|
||||||
"dart run pigeon --input pigeon/connectivity_api.dart",
|
|
||||||
"dart format lib/platform/connectivity_api.g.dart",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tasks."mobile:i18n:loader"]
|
|
||||||
description = "Generate i18n loader"
|
|
||||||
dir = "mobile"
|
|
||||||
hide = true
|
|
||||||
sources = ["i18n/"]
|
|
||||||
outputs = "lib/generated/codegen_loader.g.dart"
|
|
||||||
run = [
|
|
||||||
"dart run easy_localization:generate -S ../i18n",
|
|
||||||
"dart format lib/generated/codegen_loader.g.dart",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tasks."mobile:i18n:keys"]
|
|
||||||
description = "Generate i18n keys"
|
|
||||||
dir = "mobile"
|
|
||||||
hide = true
|
|
||||||
sources = ["i18n/en.json"]
|
|
||||||
outputs = "lib/generated/intl_keys.g.dart"
|
|
||||||
run = [
|
|
||||||
"dart run bin/generate_keys.dart",
|
|
||||||
"dart format lib/generated/intl_keys.g.dart",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tasks."mobile:analyze:dart"]
|
|
||||||
description = "Run Dart analysis"
|
|
||||||
dir = "mobile"
|
|
||||||
hide = true
|
|
||||||
run = "dart analyze --fatal-infos"
|
|
||||||
|
|
||||||
[tasks."mobile:analyze:dcm"]
|
|
||||||
description = "Run Dart Code Metrics"
|
|
||||||
dir = "mobile"
|
|
||||||
hide = true
|
|
||||||
run = "dcm analyze lib --fatal-style --fatal-warnings"
|
|
||||||
|
|
||||||
[tasks."mobile:analyze:fix:dart"]
|
|
||||||
description = "Auto-fix Dart analysis"
|
|
||||||
dir = "mobile"
|
|
||||||
hide = true
|
|
||||||
run = "dart fix --apply"
|
|
||||||
|
|
||||||
[tasks."mobile:analyze:fix:dcm"]
|
|
||||||
description = "Auto-fix Dart Code Metrics"
|
|
||||||
dir = "mobile"
|
|
||||||
hide = true
|
|
||||||
run = "dcm fix lib"
|
|
||||||
|
|
||||||
# docs deployment
|
|
||||||
[tasks."tg:fmt"]
|
|
||||||
run = "terragrunt hclfmt"
|
|
||||||
description = "Format terragrunt files"
|
|
||||||
|
|
||||||
[tasks.tf]
|
|
||||||
run = "terragrunt run --all"
|
|
||||||
description = "Wrapper for terragrunt run-all"
|
|
||||||
dir = "{{cwd}}"
|
|
||||||
|
|
||||||
[tasks."tf:fmt"]
|
|
||||||
run = "tofu fmt -recursive tf/"
|
|
||||||
description = "Format terraform files"
|
|
||||||
|
|
||||||
[tasks."tf:init"]
|
|
||||||
run = "mise run tf init -- -reconfigure"
|
|
||||||
dir = "{{cwd}}"
|
|
||||||
|
|||||||
@@ -155,14 +155,22 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
|||||||
"restoreFromTrash" -> {
|
"restoreFromTrash" -> {
|
||||||
val fileName = call.argument<String>("fileName")
|
val fileName = call.argument<String>("fileName")
|
||||||
val type = call.argument<Int>("type")
|
val type = call.argument<Int>("type")
|
||||||
|
val mediaId = call.argument<String>("mediaId")
|
||||||
if (fileName != null && type != null) {
|
if (fileName != null && type != null) {
|
||||||
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
|
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
|
||||||
restoreFromTrash(fileName, type, result)
|
restoreFromTrash(fileName, type, result)
|
||||||
} else {
|
} else {
|
||||||
result.error("PERMISSION_DENIED", "Media permission required", null)
|
result.error("PERMISSION_DENIED", "Media permission required", null)
|
||||||
}
|
}
|
||||||
|
} else
|
||||||
|
if (mediaId != null && type != null) {
|
||||||
|
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
|
||||||
|
restoreFromTrashById(mediaId, type, result)
|
||||||
} else {
|
} else {
|
||||||
result.error("INVALID_NAME", "The file name is not specified.", null)
|
result.error("PERMISSION_DENIED", "Media permission required", null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.error("INVALID_PARAMS", "Required params are not specified.", null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +183,17 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"hasManageMediaPermission" -> {
|
||||||
|
if (hasManageMediaPermission()) {
|
||||||
|
Log.i("Manage storage permission", "Permission already granted")
|
||||||
|
result.success(true)
|
||||||
|
} else {
|
||||||
|
result.success(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"manageMediaPermission" -> requestManageMediaPermission(result)
|
||||||
|
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,6 +242,28 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
|||||||
uri.let { toggleTrash(listOf(it), false, result) }
|
uri.let { toggleTrash(listOf(it), false, result) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
|
private fun restoreFromTrashById(mediaId: String, type: Int, result: Result) {
|
||||||
|
val id = mediaId.toLongOrNull()
|
||||||
|
if (id == null) {
|
||||||
|
result.error("INVALID_ID", "The file id is not a valid number: $mediaId", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isInTrash(id)) {
|
||||||
|
result.error("TrashNotFound", "Item with id=$id not found in trash", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val uri = ContentUris.withAppendedId(contentUriForType(type), id)
|
||||||
|
|
||||||
|
try {
|
||||||
|
Log.i(TAG, "restoreFromTrashById: uri=$uri (type=$type,id=$id)")
|
||||||
|
restoreUris(listOf(uri), result)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "restoreFromTrashById failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.R)
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
private fun toggleTrash(contentUris: List<Uri>, isTrashed: Boolean, result: Result) {
|
private fun toggleTrash(contentUris: List<Uri>, isTrashed: Boolean, result: Result) {
|
||||||
val activity = activityBinding?.activity
|
val activity = activityBinding?.activity
|
||||||
@@ -264,14 +305,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
|||||||
contentResolver.query(queryUri, projection, queryArgs, null)?.use { cursor ->
|
contentResolver.query(queryUri, projection, queryArgs, null)?.use { cursor ->
|
||||||
if (cursor.moveToFirst()) {
|
if (cursor.moveToFirst()) {
|
||||||
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID))
|
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID))
|
||||||
// same order as AssetType from dart
|
return ContentUris.withAppendedId(contentUriForType(type), id)
|
||||||
val contentUri = when (type) {
|
|
||||||
1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
|
||||||
2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
|
||||||
3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
|
||||||
else -> queryUri
|
|
||||||
}
|
|
||||||
return ContentUris.withAppendedId(contentUri, id)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -315,6 +349,40 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
|
private fun isInTrash(id: Long): Boolean {
|
||||||
|
val contentResolver = context?.contentResolver ?: return false
|
||||||
|
val filesUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||||
|
val args = Bundle().apply {
|
||||||
|
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Files.FileColumns._ID}=?")
|
||||||
|
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(id.toString()))
|
||||||
|
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
|
||||||
|
putInt(ContentResolver.QUERY_ARG_LIMIT, 1)
|
||||||
|
}
|
||||||
|
return contentResolver.query(filesUri, arrayOf(MediaStore.Files.FileColumns._ID), args, null)
|
||||||
|
?.use { it.moveToFirst() } == true
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
|
private fun restoreUris(uris: List<Uri>, result: Result) {
|
||||||
|
if (uris.isEmpty()) {
|
||||||
|
result.error("TrashError", "No URIs to restore", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Log.i(TAG, "restoreUris: count=${uris.size}, first=${uris.first()}")
|
||||||
|
toggleTrash(uris, false, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
|
private fun contentUriForType(type: Int): Uri =
|
||||||
|
when (type) {
|
||||||
|
// same order as AssetType from dart
|
||||||
|
1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||||
|
2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||||
|
3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||||
|
else -> MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val TAG = "BackgroundServicePlugin"
|
private const val TAG = "BackgroundServicePlugin"
|
||||||
|
|||||||
@@ -305,6 +305,7 @@ interface NativeSyncApi {
|
|||||||
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
|
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
|
||||||
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
||||||
fun cancelHashing()
|
fun cancelHashing()
|
||||||
|
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The codec used by NativeSyncApi. */
|
/** The codec used by NativeSyncApi. */
|
||||||
@@ -483,6 +484,21 @@ interface NativeSyncApi {
|
|||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$separatedMessageChannelSuffix", codec, taskQueue)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { _, reply ->
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
listOf(api.getTrashedAssets())
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
MessagesPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,4 +21,9 @@ class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), Na
|
|||||||
override fun getMediaChanges(): SyncDelta {
|
override fun getMediaChanges(): SyncDelta {
|
||||||
throw IllegalStateException("Method not supported on this Android version.")
|
throw IllegalStateException("Method not supported on this Android version.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getTrashedAssets(): Map<String, List<PlatformAsset>> {
|
||||||
|
//Method not supported on this Android version.
|
||||||
|
return emptyMap()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package app.alextran.immich.sync
|
package app.alextran.immich.sync
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.annotation.RequiresExtension
|
import androidx.annotation.RequiresExtension
|
||||||
@@ -86,4 +88,29 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
|
|||||||
// Unmounted volumes are handled in dart when the album is removed
|
// Unmounted volumes are handled in dart when the album is removed
|
||||||
return SyncDelta(hasChanges, changed, deleted, assetAlbums)
|
return SyncDelta(hasChanges, changed, deleted, assetAlbums)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getTrashedAssets(): Map<String, List<PlatformAsset>> {
|
||||||
|
|
||||||
|
val result = LinkedHashMap<String, MutableList<PlatformAsset>>()
|
||||||
|
val volumes = MediaStore.getExternalVolumeNames(ctx)
|
||||||
|
|
||||||
|
for (volume in volumes) {
|
||||||
|
|
||||||
|
val queryArgs = Bundle().apply {
|
||||||
|
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, MEDIA_SELECTION)
|
||||||
|
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, MEDIA_SELECTION_ARGS)
|
||||||
|
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
|
||||||
|
}
|
||||||
|
|
||||||
|
getCursor(volume, queryArgs).use { cursor ->
|
||||||
|
getAssets(cursor).forEach { res ->
|
||||||
|
if (res is AssetResult.ValidAsset) {
|
||||||
|
result.getOrPut(res.albumId) { mutableListOf() }.add(res.asset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.mapValues { it.value.toList() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import android.annotation.SuppressLint
|
|||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import androidx.core.database.getStringOrNull
|
import androidx.core.database.getStringOrNull
|
||||||
@@ -81,6 +83,16 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
sortOrder,
|
sortOrder,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
protected fun getCursor(
|
||||||
|
volume: String,
|
||||||
|
queryArgs: Bundle
|
||||||
|
): Cursor? = ctx.contentResolver.query(
|
||||||
|
MediaStore.Files.getContentUri(volume),
|
||||||
|
ASSET_PROJECTION,
|
||||||
|
queryArgs,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
protected fun getAssets(cursor: Cursor?): Sequence<AssetResult> {
|
protected fun getAssets(cursor: Cursor?): Sequence<AssetResult> {
|
||||||
return sequence {
|
return sequence {
|
||||||
cursor?.use { c ->
|
cursor?.use { c ->
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ platform :android do
|
|||||||
task: 'bundle',
|
task: 'bundle',
|
||||||
build_type: 'Release',
|
build_type: 'Release',
|
||||||
properties: {
|
properties: {
|
||||||
"android.injected.version.code" => 3024,
|
"android.injected.version.code" => 3026,
|
||||||
"android.injected.version.name" => "2.2.1",
|
"android.injected.version.name" => "2.2.3",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||||
|
|||||||
1
mobile/drift_schemas/main/drift_schema_v13.json
generated
Normal file
1
mobile/drift_schemas/main/drift_schema_v13.json
generated
Normal file
File diff suppressed because one or more lines are too long
@@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 77;
|
objectVersion = 54;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@@ -32,6 +32,9 @@
|
|||||||
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; };
|
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; };
|
||||||
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */; };
|
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */; };
|
||||||
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */; };
|
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */; };
|
||||||
|
FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; };
|
||||||
|
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; };
|
||||||
|
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FC2EC1725A0045228E /* StructuredFieldValues */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -133,11 +136,15 @@
|
|||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
B231F52D2E93A44A00BC45D1 /* Core */ = {
|
B231F52D2E93A44A00BC45D1 /* Core */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
);
|
||||||
path = Core;
|
path = Core;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
|
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
);
|
||||||
path = Sync;
|
path = Sync;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -149,6 +156,13 @@
|
|||||||
path = WidgetExtension;
|
path = WidgetExtension;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
FEE084F22EC172080045228E /* Schemas */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
);
|
||||||
|
path = Schemas;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -156,6 +170,9 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
FEE084F82EC172460045228E /* SQLiteData in Frameworks */,
|
||||||
|
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */,
|
||||||
|
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */,
|
||||||
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */,
|
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -250,6 +267,7 @@
|
|||||||
97C146F01CF9000F007C117D /* Runner */ = {
|
97C146F01CF9000F007C117D /* Runner */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
FEE084F22EC172080045228E /* Schemas */,
|
||||||
B231F52D2E93A44A00BC45D1 /* Core */,
|
B231F52D2E93A44A00BC45D1 /* Core */,
|
||||||
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
||||||
B21E34A62E5AF9760031FDB9 /* Background */,
|
B21E34A62E5AF9760031FDB9 /* Background */,
|
||||||
@@ -337,6 +355,7 @@
|
|||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
B231F52D2E93A44A00BC45D1 /* Core */,
|
B231F52D2E93A44A00BC45D1 /* Core */,
|
||||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||||
|
FEE084F22EC172080045228E /* Schemas */,
|
||||||
);
|
);
|
||||||
name = Runner;
|
name = Runner;
|
||||||
productName = Runner;
|
productName = Runner;
|
||||||
@@ -415,6 +434,10 @@
|
|||||||
Base,
|
Base,
|
||||||
);
|
);
|
||||||
mainGroup = 97C146E51CF9000F007C117D;
|
mainGroup = 97C146E51CF9000F007C117D;
|
||||||
|
packageReferences = (
|
||||||
|
FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */,
|
||||||
|
FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */,
|
||||||
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -526,14 +549,10 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "[CP] Copy Pods Resources";
|
name = "[CP] Copy Pods Resources";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||||
@@ -562,14 +581,10 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||||
@@ -718,7 +733,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 231;
|
CURRENT_PROJECT_VERSION = 233;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
@@ -862,7 +877,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 231;
|
CURRENT_PROJECT_VERSION = 233;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
@@ -892,7 +907,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 231;
|
CURRENT_PROJECT_VERSION = 233;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
@@ -926,7 +941,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 231;
|
CURRENT_PROJECT_VERSION = 233;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
@@ -969,7 +984,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 231;
|
CURRENT_PROJECT_VERSION = 233;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
@@ -1009,7 +1024,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 231;
|
CURRENT_PROJECT_VERSION = 233;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
@@ -1048,7 +1063,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 231;
|
CURRENT_PROJECT_VERSION = 233;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -1092,7 +1107,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 231;
|
CURRENT_PROJECT_VERSION = 233;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -1133,7 +1148,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 231;
|
CURRENT_PROJECT_VERSION = 233;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -1205,6 +1220,43 @@
|
|||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
|
FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/pointfreeco/sqlite-data";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 1.3.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/apple/swift-http-structured-headers.git";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 1.5.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
FEE084F72EC172460045228E /* SQLiteData */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */;
|
||||||
|
productName = SQLiteData;
|
||||||
|
};
|
||||||
|
FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */;
|
||||||
|
productName = RawStructuredFieldValues;
|
||||||
|
};
|
||||||
|
FEE084FC2EC1725A0045228E /* StructuredFieldValues */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */;
|
||||||
|
productName = StructuredFieldValues;
|
||||||
|
};
|
||||||
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
{
|
||||||
|
"originHash" : "9be33bfaa68721646604aefff3cabbdaf9a193da192aae024c265065671f6c49",
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "combine-schedulers",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/combine-schedulers",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "fd16d76fd8b9a976d88bfb6cacc05ca8d19c91b6",
|
||||||
|
"version" : "1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "grdb.swift",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/groue/GRDB.swift",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "18497b68fdbb3a09528d260a0a0e1e7e61c8c53d",
|
||||||
|
"version" : "7.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "opencombine",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/OpenCombine/OpenCombine.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "8576f0d579b27020beccbccc3ea6844f3ddfc2c2",
|
||||||
|
"version" : "0.14.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "sqlite-data",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/sqlite-data",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "b66b894b9a5710f1072c8eb6448a7edfc2d743d9",
|
||||||
|
"version" : "1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-case-paths",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-case-paths",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "6989976265be3f8d2b5802c722f9ba168e227c71",
|
||||||
|
"version" : "1.7.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-clocks",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-clocks",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e",
|
||||||
|
"version" : "1.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-collections",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-collections",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e",
|
||||||
|
"version" : "1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-concurrency-extras",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
|
||||||
|
"version" : "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-custom-dump",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-custom-dump",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
|
||||||
|
"version" : "1.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-dependencies",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-dependencies",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc",
|
||||||
|
"version" : "1.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-http-structured-headers",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-http-structured-headers.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a9f3c352f4d46afd155e00b3c6e85decae6bcbeb",
|
||||||
|
"version" : "1.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-identified-collections",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-identified-collections",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597",
|
||||||
|
"version" : "1.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-perception",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-perception",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "4f47ebafed5f0b0172cf5c661454fa8e28fb2ac4",
|
||||||
|
"version" : "2.0.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-sharing",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-sharing",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818",
|
||||||
|
"version" : "2.7.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-snapshot-testing",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-snapshot-testing",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a8b7c5e0ed33d8ab8887d1654d9b59f2cbad529b",
|
||||||
|
"version" : "1.18.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-structured-queries",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-structured-queries",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "9c84335373bae5f5c9f7b5f0adf3ae10f2cab5b9",
|
||||||
|
"version" : "0.25.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-syntax",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/swiftlang/swift-syntax",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "4799286537280063c85a32f09884cfbca301b1a1",
|
||||||
|
"version" : "602.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-tagged",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-tagged",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "3907a9438f5b57d317001dc99f3f11b46882272b",
|
||||||
|
"version" : "0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "xctest-dynamic-overlay",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "4c27acf5394b645b70d8ba19dc249c0472d5f618",
|
||||||
|
"version" : "1.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
{
|
||||||
|
"originHash" : "9be33bfaa68721646604aefff3cabbdaf9a193da192aae024c265065671f6c49",
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "combine-schedulers",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/combine-schedulers",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "5928286acce13def418ec36d05a001a9641086f2",
|
||||||
|
"version" : "1.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "grdb.swift",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/groue/GRDB.swift",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "18497b68fdbb3a09528d260a0a0e1e7e61c8c53d",
|
||||||
|
"version" : "7.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "sqlite-data",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/sqlite-data",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "b66b894b9a5710f1072c8eb6448a7edfc2d743d9",
|
||||||
|
"version" : "1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-case-paths",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-case-paths",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "6989976265be3f8d2b5802c722f9ba168e227c71",
|
||||||
|
"version" : "1.7.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-clocks",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-clocks",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e",
|
||||||
|
"version" : "1.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-collections",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-collections",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e",
|
||||||
|
"version" : "1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-concurrency-extras",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
|
||||||
|
"version" : "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-custom-dump",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-custom-dump",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
|
||||||
|
"version" : "1.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-dependencies",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-dependencies",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc",
|
||||||
|
"version" : "1.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-http-structured-headers",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-http-structured-headers.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a9f3c352f4d46afd155e00b3c6e85decae6bcbeb",
|
||||||
|
"version" : "1.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-identified-collections",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-identified-collections",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597",
|
||||||
|
"version" : "1.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-perception",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-perception",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "4f47ebafed5f0b0172cf5c661454fa8e28fb2ac4",
|
||||||
|
"version" : "2.0.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-sharing",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-sharing",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818",
|
||||||
|
"version" : "2.7.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-snapshot-testing",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-snapshot-testing",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a8b7c5e0ed33d8ab8887d1654d9b59f2cbad529b",
|
||||||
|
"version" : "1.18.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-structured-queries",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-structured-queries",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "1447ea20550f6f02c4b48cc80931c3ed40a9c756",
|
||||||
|
"version" : "0.25.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-syntax",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/swiftlang/swift-syntax",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "4799286537280063c85a32f09884cfbca301b1a1",
|
||||||
|
"version" : "602.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-tagged",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-tagged",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "3907a9438f5b57d317001dc99f3f11b46882272b",
|
||||||
|
"version" : "0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "xctest-dynamic-overlay",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "4c27acf5394b645b70d8ba19dc249c0472d5f618",
|
||||||
|
"version" : "1.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 3
|
||||||
|
}
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2.1.0</string>
|
<string>2.2.1</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>231</string>
|
<string>233</string>
|
||||||
<key>FLTEnableImpeller</key>
|
<key>FLTEnableImpeller</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
|||||||
177
mobile/ios/Runner/Schemas/Constants.swift
Normal file
177
mobile/ios/Runner/Schemas/Constants.swift
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import SQLiteData
|
||||||
|
|
||||||
|
struct Endpoint: Codable {
|
||||||
|
let url: URL
|
||||||
|
let status: Status
|
||||||
|
|
||||||
|
enum Status: String, Codable {
|
||||||
|
case loading, valid, error, unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StoreKey: Int, CaseIterable, QueryBindable {
|
||||||
|
// MARK: - Int
|
||||||
|
case _version = 0
|
||||||
|
static let version = Typed<Int>(rawValue: ._version)
|
||||||
|
case _deviceIdHash = 3
|
||||||
|
static let deviceIdHash = Typed<Int>(rawValue: ._deviceIdHash)
|
||||||
|
case _backupTriggerDelay = 8
|
||||||
|
static let backupTriggerDelay = Typed<Int>(rawValue: ._backupTriggerDelay)
|
||||||
|
case _tilesPerRow = 103
|
||||||
|
static let tilesPerRow = Typed<Int>(rawValue: ._tilesPerRow)
|
||||||
|
case _groupAssetsBy = 105
|
||||||
|
static let groupAssetsBy = Typed<Int>(rawValue: ._groupAssetsBy)
|
||||||
|
case _uploadErrorNotificationGracePeriod = 106
|
||||||
|
static let uploadErrorNotificationGracePeriod = Typed<Int>(rawValue: ._uploadErrorNotificationGracePeriod)
|
||||||
|
case _thumbnailCacheSize = 110
|
||||||
|
static let thumbnailCacheSize = Typed<Int>(rawValue: ._thumbnailCacheSize)
|
||||||
|
case _imageCacheSize = 111
|
||||||
|
static let imageCacheSize = Typed<Int>(rawValue: ._imageCacheSize)
|
||||||
|
case _albumThumbnailCacheSize = 112
|
||||||
|
static let albumThumbnailCacheSize = Typed<Int>(rawValue: ._albumThumbnailCacheSize)
|
||||||
|
case _selectedAlbumSortOrder = 113
|
||||||
|
static let selectedAlbumSortOrder = Typed<Int>(rawValue: ._selectedAlbumSortOrder)
|
||||||
|
case _logLevel = 115
|
||||||
|
static let logLevel = Typed<Int>(rawValue: ._logLevel)
|
||||||
|
case _mapRelativeDate = 119
|
||||||
|
static let mapRelativeDate = Typed<Int>(rawValue: ._mapRelativeDate)
|
||||||
|
case _mapThemeMode = 124
|
||||||
|
static let mapThemeMode = Typed<Int>(rawValue: ._mapThemeMode)
|
||||||
|
|
||||||
|
// MARK: - String
|
||||||
|
case _assetETag = 1
|
||||||
|
static let assetETag = Typed<String>(rawValue: ._assetETag)
|
||||||
|
case _currentUser = 2
|
||||||
|
static let currentUser = Typed<String>(rawValue: ._currentUser)
|
||||||
|
case _deviceId = 4
|
||||||
|
static let deviceId = Typed<String>(rawValue: ._deviceId)
|
||||||
|
case _accessToken = 11
|
||||||
|
static let accessToken = Typed<String>(rawValue: ._accessToken)
|
||||||
|
case _serverEndpoint = 12
|
||||||
|
static let serverEndpoint = Typed<String>(rawValue: ._serverEndpoint)
|
||||||
|
case _sslClientCertData = 15
|
||||||
|
static let sslClientCertData = Typed<String>(rawValue: ._sslClientCertData)
|
||||||
|
case _sslClientPasswd = 16
|
||||||
|
static let sslClientPasswd = Typed<String>(rawValue: ._sslClientPasswd)
|
||||||
|
case _themeMode = 102
|
||||||
|
static let themeMode = Typed<String>(rawValue: ._themeMode)
|
||||||
|
case _customHeaders = 127
|
||||||
|
static let customHeaders = Typed<[String: String]>(rawValue: ._customHeaders)
|
||||||
|
case _primaryColor = 128
|
||||||
|
static let primaryColor = Typed<String>(rawValue: ._primaryColor)
|
||||||
|
case _preferredWifiName = 133
|
||||||
|
static let preferredWifiName = Typed<String>(rawValue: ._preferredWifiName)
|
||||||
|
|
||||||
|
// MARK: - Endpoint
|
||||||
|
case _externalEndpointList = 135
|
||||||
|
static let externalEndpointList = Typed<[Endpoint]>(rawValue: ._externalEndpointList)
|
||||||
|
|
||||||
|
// MARK: - URL
|
||||||
|
case _localEndpoint = 134
|
||||||
|
static let localEndpoint = Typed<URL>(rawValue: ._localEndpoint)
|
||||||
|
case _serverUrl = 10
|
||||||
|
static let serverUrl = Typed<URL>(rawValue: ._serverUrl)
|
||||||
|
|
||||||
|
// MARK: - Date
|
||||||
|
case _backupFailedSince = 5
|
||||||
|
static let backupFailedSince = Typed<Date>(rawValue: ._backupFailedSince)
|
||||||
|
|
||||||
|
// MARK: - Bool
|
||||||
|
case _backupRequireWifi = 6
|
||||||
|
static let backupRequireWifi = Typed<Bool>(rawValue: ._backupRequireWifi)
|
||||||
|
case _backupRequireCharging = 7
|
||||||
|
static let backupRequireCharging = Typed<Bool>(rawValue: ._backupRequireCharging)
|
||||||
|
case _autoBackup = 13
|
||||||
|
static let autoBackup = Typed<Bool>(rawValue: ._autoBackup)
|
||||||
|
case _backgroundBackup = 14
|
||||||
|
static let backgroundBackup = Typed<Bool>(rawValue: ._backgroundBackup)
|
||||||
|
case _loadPreview = 100
|
||||||
|
static let loadPreview = Typed<Bool>(rawValue: ._loadPreview)
|
||||||
|
case _loadOriginal = 101
|
||||||
|
static let loadOriginal = Typed<Bool>(rawValue: ._loadOriginal)
|
||||||
|
case _dynamicLayout = 104
|
||||||
|
static let dynamicLayout = Typed<Bool>(rawValue: ._dynamicLayout)
|
||||||
|
case _backgroundBackupTotalProgress = 107
|
||||||
|
static let backgroundBackupTotalProgress = Typed<Bool>(rawValue: ._backgroundBackupTotalProgress)
|
||||||
|
case _backgroundBackupSingleProgress = 108
|
||||||
|
static let backgroundBackupSingleProgress = Typed<Bool>(rawValue: ._backgroundBackupSingleProgress)
|
||||||
|
case _storageIndicator = 109
|
||||||
|
static let storageIndicator = Typed<Bool>(rawValue: ._storageIndicator)
|
||||||
|
case _advancedTroubleshooting = 114
|
||||||
|
static let advancedTroubleshooting = Typed<Bool>(rawValue: ._advancedTroubleshooting)
|
||||||
|
case _preferRemoteImage = 116
|
||||||
|
static let preferRemoteImage = Typed<Bool>(rawValue: ._preferRemoteImage)
|
||||||
|
case _loopVideo = 117
|
||||||
|
static let loopVideo = Typed<Bool>(rawValue: ._loopVideo)
|
||||||
|
case _mapShowFavoriteOnly = 118
|
||||||
|
static let mapShowFavoriteOnly = Typed<Bool>(rawValue: ._mapShowFavoriteOnly)
|
||||||
|
case _selfSignedCert = 120
|
||||||
|
static let selfSignedCert = Typed<Bool>(rawValue: ._selfSignedCert)
|
||||||
|
case _mapIncludeArchived = 121
|
||||||
|
static let mapIncludeArchived = Typed<Bool>(rawValue: ._mapIncludeArchived)
|
||||||
|
case _ignoreIcloudAssets = 122
|
||||||
|
static let ignoreIcloudAssets = Typed<Bool>(rawValue: ._ignoreIcloudAssets)
|
||||||
|
case _selectedAlbumSortReverse = 123
|
||||||
|
static let selectedAlbumSortReverse = Typed<Bool>(rawValue: ._selectedAlbumSortReverse)
|
||||||
|
case _mapwithPartners = 125
|
||||||
|
static let mapwithPartners = Typed<Bool>(rawValue: ._mapwithPartners)
|
||||||
|
case _enableHapticFeedback = 126
|
||||||
|
static let enableHapticFeedback = Typed<Bool>(rawValue: ._enableHapticFeedback)
|
||||||
|
case _dynamicTheme = 129
|
||||||
|
static let dynamicTheme = Typed<Bool>(rawValue: ._dynamicTheme)
|
||||||
|
case _colorfulInterface = 130
|
||||||
|
static let colorfulInterface = Typed<Bool>(rawValue: ._colorfulInterface)
|
||||||
|
case _syncAlbums = 131
|
||||||
|
static let syncAlbums = Typed<Bool>(rawValue: ._syncAlbums)
|
||||||
|
case _autoEndpointSwitching = 132
|
||||||
|
static let autoEndpointSwitching = Typed<Bool>(rawValue: ._autoEndpointSwitching)
|
||||||
|
case _loadOriginalVideo = 136
|
||||||
|
static let loadOriginalVideo = Typed<Bool>(rawValue: ._loadOriginalVideo)
|
||||||
|
case _manageLocalMediaAndroid = 137
|
||||||
|
static let manageLocalMediaAndroid = Typed<Bool>(rawValue: ._manageLocalMediaAndroid)
|
||||||
|
case _readonlyModeEnabled = 138
|
||||||
|
static let readonlyModeEnabled = Typed<Bool>(rawValue: ._readonlyModeEnabled)
|
||||||
|
case _autoPlayVideo = 139
|
||||||
|
static let autoPlayVideo = Typed<Bool>(rawValue: ._autoPlayVideo)
|
||||||
|
case _photoManagerCustomFilter = 1000
|
||||||
|
static let photoManagerCustomFilter = Typed<Bool>(rawValue: ._photoManagerCustomFilter)
|
||||||
|
case _betaPromptShown = 1001
|
||||||
|
static let betaPromptShown = Typed<Bool>(rawValue: ._betaPromptShown)
|
||||||
|
case _betaTimeline = 1002
|
||||||
|
static let betaTimeline = Typed<Bool>(rawValue: ._betaTimeline)
|
||||||
|
case _enableBackup = 1003
|
||||||
|
static let enableBackup = Typed<Bool>(rawValue: ._enableBackup)
|
||||||
|
case _useWifiForUploadVideos = 1004
|
||||||
|
static let useWifiForUploadVideos = Typed<Bool>(rawValue: ._useWifiForUploadVideos)
|
||||||
|
case _useWifiForUploadPhotos = 1005
|
||||||
|
static let useWifiForUploadPhotos = Typed<Bool>(rawValue: ._useWifiForUploadPhotos)
|
||||||
|
case _needBetaMigration = 1006
|
||||||
|
static let needBetaMigration = Typed<Bool>(rawValue: ._needBetaMigration)
|
||||||
|
case _shouldResetSync = 1007
|
||||||
|
static let shouldResetSync = Typed<Bool>(rawValue: ._shouldResetSync)
|
||||||
|
|
||||||
|
struct Typed<T>: RawRepresentable {
|
||||||
|
let rawValue: StoreKey
|
||||||
|
|
||||||
|
@_transparent
|
||||||
|
init(rawValue value: StoreKey) {
|
||||||
|
self.rawValue = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BackupSelection: Int, QueryBindable {
|
||||||
|
case selected, none, excluded
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AvatarColor: Int, QueryBindable {
|
||||||
|
case primary, pink, red, yellow, blue, green, purple, orange, gray, amber
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AlbumUserRole: Int, QueryBindable {
|
||||||
|
case editor, viewer
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MemoryType: Int, QueryBindable {
|
||||||
|
case onThisDay
|
||||||
|
}
|
||||||
146
mobile/ios/Runner/Schemas/Store.swift
Normal file
146
mobile/ios/Runner/Schemas/Store.swift
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import SQLiteData
|
||||||
|
|
||||||
|
enum StoreError: Error {
|
||||||
|
case invalidJSON(String)
|
||||||
|
case invalidURL(String)
|
||||||
|
case encodingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol StoreConvertible {
|
||||||
|
associatedtype StorageType
|
||||||
|
static func fromValue(_ value: StorageType) throws(StoreError) -> Self
|
||||||
|
static func toValue(_ value: Self) throws(StoreError) -> StorageType
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Int: StoreConvertible {
|
||||||
|
static func fromValue(_ value: Int) -> Int { value }
|
||||||
|
static func toValue(_ value: Int) -> Int { value }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Bool: StoreConvertible {
|
||||||
|
static func fromValue(_ value: Int) -> Bool { value == 1 }
|
||||||
|
static func toValue(_ value: Bool) -> Int { value ? 1 : 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Date: StoreConvertible {
|
||||||
|
static func fromValue(_ value: Int) -> Date { Date(timeIntervalSince1970: TimeInterval(value) / 1000) }
|
||||||
|
static func toValue(_ value: Date) -> Int { Int(value.timeIntervalSince1970 * 1000) }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension String: StoreConvertible {
|
||||||
|
static func fromValue(_ value: String) -> String { value }
|
||||||
|
static func toValue(_ value: String) -> String { value }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension URL: StoreConvertible {
|
||||||
|
static func fromValue(_ value: String) throws(StoreError) -> URL {
|
||||||
|
guard let url = URL(string: value) else {
|
||||||
|
throw StoreError.invalidURL(value)
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
static func toValue(_ value: URL) -> String { value.absoluteString }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension StoreConvertible where Self: Codable, StorageType == String {
|
||||||
|
static var jsonDecoder: JSONDecoder { JSONDecoder() }
|
||||||
|
static var jsonEncoder: JSONEncoder { JSONEncoder() }
|
||||||
|
|
||||||
|
static func fromValue(_ value: String) throws(StoreError) -> Self {
|
||||||
|
do {
|
||||||
|
return try jsonDecoder.decode(Self.self, from: Data(value.utf8))
|
||||||
|
} catch {
|
||||||
|
throw StoreError.invalidJSON(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func toValue(_ value: Self) throws(StoreError) -> String {
|
||||||
|
let encoded: Data
|
||||||
|
do {
|
||||||
|
encoded = try jsonEncoder.encode(value)
|
||||||
|
} catch {
|
||||||
|
throw StoreError.encodingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let string = String(data: encoded, encoding: .utf8) else {
|
||||||
|
throw StoreError.encodingFailed
|
||||||
|
}
|
||||||
|
return string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Array: StoreConvertible where Element: Codable {
|
||||||
|
typealias StorageType = String
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Dictionary: StoreConvertible where Key == String, Value: Codable {
|
||||||
|
typealias StorageType = String
|
||||||
|
}
|
||||||
|
|
||||||
|
class StoreRepository {
|
||||||
|
private let db: DatabasePool
|
||||||
|
|
||||||
|
init(db: DatabasePool) {
|
||||||
|
self.db = db
|
||||||
|
}
|
||||||
|
|
||||||
|
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) throws -> T? where T.StorageType == Int {
|
||||||
|
let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) }
|
||||||
|
if let value = try db.read({ conn in try query.fetchOne(conn) }) ?? nil {
|
||||||
|
return try T.fromValue(value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) throws -> T? where T.StorageType == String {
|
||||||
|
let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) }
|
||||||
|
if let value = try db.read({ conn in try query.fetchOne(conn) }) ?? nil {
|
||||||
|
return try T.fromValue(value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) async throws -> T? where T.StorageType == Int {
|
||||||
|
let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) }
|
||||||
|
if let value = try await db.read({ conn in try query.fetchOne(conn) }) ?? nil {
|
||||||
|
return try T.fromValue(value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) async throws -> T? where T.StorageType == String {
|
||||||
|
let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) }
|
||||||
|
if let value = try await db.read({ conn in try query.fetchOne(conn) }) ?? nil {
|
||||||
|
return try T.fromValue(value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) throws where T.StorageType == Int {
|
||||||
|
let value = try T.toValue(value)
|
||||||
|
try db.write { conn in
|
||||||
|
try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: value) }.execute(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) throws where T.StorageType == String {
|
||||||
|
let value = try T.toValue(value)
|
||||||
|
try db.write { conn in
|
||||||
|
try Store.upsert { Store(id: key.rawValue, stringValue: value, intValue: nil) }.execute(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) async throws where T.StorageType == Int {
|
||||||
|
let value = try T.toValue(value)
|
||||||
|
try await db.write { conn in
|
||||||
|
try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: value) }.execute(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) async throws where T.StorageType == String {
|
||||||
|
let value = try T.toValue(value)
|
||||||
|
try await db.write { conn in
|
||||||
|
try Store.upsert { Store(id: key.rawValue, stringValue: value, intValue: nil) }.execute(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
237
mobile/ios/Runner/Schemas/Tables.swift
Normal file
237
mobile/ios/Runner/Schemas/Tables.swift
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import GRDB
|
||||||
|
import SQLiteData
|
||||||
|
|
||||||
|
@Table("asset_face_entity")
|
||||||
|
struct AssetFace {
|
||||||
|
let id: String
|
||||||
|
let assetId: String
|
||||||
|
let personId: String?
|
||||||
|
let imageWidth: Int
|
||||||
|
let imageHeight: Int
|
||||||
|
let boundingBoxX1: Int
|
||||||
|
let boundingBoxY1: Int
|
||||||
|
let boundingBoxX2: Int
|
||||||
|
let boundingBoxY2: Int
|
||||||
|
let sourceType: String
|
||||||
|
}
|
||||||
|
|
||||||
|
@Table("auth_user_entity")
|
||||||
|
struct AuthUser {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let email: String
|
||||||
|
let isAdmin: Bool
|
||||||
|
let hasProfileImage: Bool
|
||||||
|
let profileChangedAt: Date
|
||||||
|
let avatarColor: AvatarColor
|
||||||
|
let quotaSizeInBytes: Int
|
||||||
|
let quotaUsageInBytes: Int
|
||||||
|
let pinCode: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
@Table("local_album_entity")
|
||||||
|
struct LocalAlbum {
|
||||||
|
let id: String
|
||||||
|
let backupSelection: BackupSelection
|
||||||
|
let linkedRemoteAlbumId: String?
|
||||||
|
let marker_: Bool?
|
||||||
|
let name: String
|
||||||
|
let isIosSharedAlbum: Bool
|
||||||
|
let updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
@Table("local_album_asset_entity")
|
||||||
|
struct LocalAlbumAsset {
|
||||||
|
let id: ID
|
||||||
|
let marker_: String?
|
||||||
|
|
||||||
|
@Selection
|
||||||
|
struct ID {
|
||||||
|
let assetId: String
|
||||||
|
let albumId: String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Table("local_asset_entity")
|
||||||
|
struct LocalAsset {
|
||||||
|
let id: String
|
||||||
|
let checksum: String?
|
||||||
|
let createdAt: Date
|
||||||
|
let durationInSeconds: Int?
|
||||||
|
let height: Int?
|
||||||
|
let isFavorite: Bool
|
||||||
|
let name: String
|
||||||
|
let orientation: String
|
||||||
|
let type: Int
|
||||||
|
let updatedAt: Date
|
||||||
|
let width: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
@Table("memory_asset_entity")
|
||||||
|
struct MemoryAsset {
|
||||||
|
let id: ID
|
||||||
|
|
||||||
|
@Selection
|
||||||
|
struct ID {
|
||||||
|
let assetId: String
|
||||||
|
let albumId: String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Table("memory_entity")
|
||||||
|
struct Memory {
|
||||||
|
let id: String
|
||||||
|
let createdAt: Date
|
||||||
|
let updatedAt: Date
|
||||||
|
let deletedAt: Date?
|
||||||
|
let ownerId: String
|
||||||
|
let type: MemoryType
|
||||||
|
let data: String
|
||||||
|
let isSaved: Bool
|
||||||
|
let memoryAt: Date
|
||||||
|
let seenAt: Date?
|
||||||
|
let showAt: Date?
|
||||||
|
let hideAt: Date?
|
||||||
|
}
|
||||||
|
|
||||||
|
@Table("partner_entity")
|
||||||
|
struct Partner {
|
||||||
|
let id: ID
|
||||||
|
let inTimeline: Bool
|
||||||
|
|
||||||
|
@Selection
|
||||||
|
struct ID {
|
||||||
|
let sharedById: String
|
||||||
|
let sharedWithId: String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Table("person_entity")
|
||||||
|
struct Person {
|
||||||
|
let id: String
|
||||||
|
let createdAt: Date
|
||||||
|
let updatedAt: Date
|
||||||
|
let ownerId: String
|
||||||
|
let name: String
|
||||||
|
let faceAssetId: String?
|
||||||
|
let isFavorite: Bool
|
||||||
|
let isHidden: Bool
|
||||||
|
let color: String?
|
||||||
|
let birthDate: Date?
|
||||||
|
}
|
||||||
|
|
||||||
|
@Table("remote_album_entity")
|
||||||
|
struct RemoteAlbum {
|
||||||
|
let id: String
|
||||||
|
let createdAt: Date
|
||||||
|
let description: String?
|
||||||
|
let isActivityEnabled: Bool
|
||||||
|
let name: String
|
||||||
|
let order: Int
|
||||||
|
let ownerId: String
|
||||||
|
let thumbnailAssetId: String?
|
||||||
|
let updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
@Table("remote_album_asset_entity")
|
||||||
|
struct RemoteAlbumAsset {
|
||||||
|
let id: ID
|
||||||
|
|
||||||
|
@Selection
|
||||||
|
struct ID {
|
||||||
|
let assetId: String
|
||||||
|
let albumId: String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Table("remote_album_user_entity")
|
||||||
|
struct RemoteAlbumUser {
|
||||||
|
let id: ID
|
||||||
|
let role: AlbumUserRole
|
||||||
|
|
||||||
|
@Selection
|
||||||
|
struct ID {
|
||||||
|
let albumId: String
|
||||||
|
let userId: String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Table("remote_asset_entity")
|
||||||
|
struct RemoteAsset {
|
||||||
|
let id: String
|
||||||
|
let checksum: String?
|
||||||
|
let deletedAt: Date?
|
||||||
|
let isFavorite: Int
|
||||||
|
let libraryId: String?
|
||||||
|
let livePhotoVideoId: String?
|
||||||
|
let localDateTime: Date?
|
||||||
|
let orientation: String
|
||||||
|
let ownerId: String
|
||||||
|
let stackId: String?
|
||||||
|
let visibility: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
@Table("remote_exif_entity")
|
||||||
|
struct RemoteExif {
|
||||||
|
@Column(primaryKey: true)
|
||||||
|
let assetId: String
|
||||||
|
let city: String?
|
||||||
|
let state: String?
|
||||||
|
let country: String?
|
||||||
|
let dateTimeOriginal: Date?
|
||||||
|
let description: String?
|
||||||
|
let height: Int?
|
||||||
|
let width: Int?
|
||||||
|
let exposureTime: String?
|
||||||
|
let fNumber: Double?
|
||||||
|
let fileSize: Int?
|
||||||
|
let focalLength: Double?
|
||||||
|
let latitude: Double?
|
||||||
|
let longitude: Double?
|
||||||
|
let iso: Int?
|
||||||
|
let make: String?
|
||||||
|
let model: String?
|
||||||
|
let lens: String?
|
||||||
|
let orientation: String?
|
||||||
|
let timeZone: String?
|
||||||
|
let rating: Int?
|
||||||
|
let projectionType: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
@Table("stack_entity")
|
||||||
|
struct Stack {
|
||||||
|
let id: String
|
||||||
|
let createdAt: Date
|
||||||
|
let updatedAt: Date
|
||||||
|
let ownerId: String
|
||||||
|
let primaryAssetId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
@Table("store_entity")
|
||||||
|
struct Store {
|
||||||
|
let id: StoreKey
|
||||||
|
let stringValue: String?
|
||||||
|
let intValue: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
@Table("user_entity")
|
||||||
|
struct User {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let email: String
|
||||||
|
let hasProfileImage: Bool
|
||||||
|
let profileChangedAt: Date
|
||||||
|
let avatarColor: AvatarColor
|
||||||
|
}
|
||||||
|
|
||||||
|
@Table("user_metadata_entity")
|
||||||
|
struct UserMetadata {
|
||||||
|
let id: ID
|
||||||
|
let value: Data
|
||||||
|
|
||||||
|
@Selection
|
||||||
|
struct ID {
|
||||||
|
let userId: String
|
||||||
|
let key: Date
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -364,6 +364,7 @@ protocol NativeSyncApi {
|
|||||||
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
|
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
|
||||||
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
||||||
func cancelHashing() throws
|
func cancelHashing() throws
|
||||||
|
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||||
@@ -532,5 +533,20 @@ class NativeSyncApiSetup {
|
|||||||
} else {
|
} else {
|
||||||
cancelHashingChannel.setMessageHandler(nil)
|
cancelHashingChannel.setMessageHandler(nil)
|
||||||
}
|
}
|
||||||
|
let getTrashedAssetsChannel = taskQueue == nil
|
||||||
|
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||||
|
if let api = api {
|
||||||
|
getTrashedAssetsChannel.setMessageHandler { _, reply in
|
||||||
|
do {
|
||||||
|
let result = try api.getTrashedAssets()
|
||||||
|
reply(wrapResult(result))
|
||||||
|
} catch {
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
getTrashedAssetsChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -378,6 +378,10 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getTrashedAssets() throws -> [String: [PlatformAsset]] {
|
||||||
|
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
|
||||||
|
}
|
||||||
|
|
||||||
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
|
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
|
||||||
// Ensure to actually getting all assets for the Recents album
|
// Ensure to actually getting all assets for the Recents album
|
||||||
if (album.assetCollectionSubtype == .smartAlbumUserLibrary) {
|
if (album.assetCollectionSubtype == .smartAlbumUserLibrary) {
|
||||||
|
|||||||
@@ -32,6 +32,17 @@ platform :ios do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Helper method to get version from pubspec.yaml
|
||||||
|
def get_version_from_pubspec
|
||||||
|
require 'yaml'
|
||||||
|
|
||||||
|
pubspec_path = File.join(Dir.pwd, "../..", "pubspec.yaml")
|
||||||
|
pubspec = YAML.load_file(pubspec_path)
|
||||||
|
|
||||||
|
version_string = pubspec['version']
|
||||||
|
version_string ? version_string.split('+').first : nil
|
||||||
|
end
|
||||||
|
|
||||||
# Helper method to configure code signing for all targets
|
# Helper method to configure code signing for all targets
|
||||||
def configure_code_signing(bundle_id_suffix: "")
|
def configure_code_signing(bundle_id_suffix: "")
|
||||||
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
|
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
|
||||||
@@ -101,7 +112,7 @@ platform :ios do
|
|||||||
workspace: "Runner.xcworkspace",
|
workspace: "Runner.xcworkspace",
|
||||||
configuration: configuration,
|
configuration: configuration,
|
||||||
export_method: "app-store",
|
export_method: "app-store",
|
||||||
xcargs: "CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
|
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
|
||||||
export_options: {
|
export_options: {
|
||||||
provisioningProfiles: {
|
provisioningProfiles: {
|
||||||
"#{app_identifier}" => "#{app_identifier} AppStore",
|
"#{app_identifier}" => "#{app_identifier} AppStore",
|
||||||
@@ -158,7 +169,8 @@ platform :ios do
|
|||||||
# Build and upload with version number
|
# Build and upload with version number
|
||||||
build_and_upload(
|
build_and_upload(
|
||||||
api_key: api_key,
|
api_key: api_key,
|
||||||
version_number: "2.1.0"
|
version_number: get_version_from_pubspec,
|
||||||
|
distribute_external: false,
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -168,8 +180,9 @@ platform :ios do
|
|||||||
path: "./Runner.xcodeproj",
|
path: "./Runner.xcodeproj",
|
||||||
targets: ["Runner", "ShareExtension", "WidgetExtension"]
|
targets: ["Runner", "ShareExtension", "WidgetExtension"]
|
||||||
)
|
)
|
||||||
|
|
||||||
increment_version_number(
|
increment_version_number(
|
||||||
version_number: "2.2.1"
|
version_number: get_version_from_pubspec
|
||||||
)
|
)
|
||||||
increment_build_number(
|
increment_build_number(
|
||||||
build_number: latest_testflight_build_number + 1,
|
build_number: latest_testflight_build_number + 1,
|
||||||
@@ -182,7 +195,7 @@ platform :ios do
|
|||||||
configuration: "Release",
|
configuration: "Release",
|
||||||
export_method: "app-store",
|
export_method: "app-store",
|
||||||
skip_package_ipa: false,
|
skip_package_ipa: false,
|
||||||
xcargs: "-allowProvisioningUpdates",
|
xcargs: "-skipMacroValidation -allowProvisioningUpdates",
|
||||||
export_options: {
|
export_options: {
|
||||||
method: "app-store",
|
method: "app-store",
|
||||||
signingStyle: "automatic",
|
signingStyle: "automatic",
|
||||||
@@ -197,4 +210,37 @@ platform :ios do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
desc "iOS Build Only (no TestFlight upload)"
|
||||||
|
lane :gha_build_only do
|
||||||
|
# Use the same build process as production, just skip the upload
|
||||||
|
# This ensures PR builds validate the same way as production builds
|
||||||
|
|
||||||
|
# Install provisioning profiles (use development profiles for PR builds)
|
||||||
|
install_provisioning_profile(path: "profile_dev.mobileprovision")
|
||||||
|
install_provisioning_profile(path: "profile_dev_share.mobileprovision")
|
||||||
|
install_provisioning_profile(path: "profile_dev_widget.mobileprovision")
|
||||||
|
|
||||||
|
# Configure code signing for dev bundle IDs
|
||||||
|
configure_code_signing(bundle_id_suffix: "development")
|
||||||
|
|
||||||
|
# Build the app (same as gha_testflight_dev but without upload)
|
||||||
|
build_app(
|
||||||
|
scheme: "Runner",
|
||||||
|
workspace: "Runner.xcworkspace",
|
||||||
|
configuration: "Release",
|
||||||
|
export_method: "app-store",
|
||||||
|
skip_package_ipa: true,
|
||||||
|
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
|
||||||
|
export_options: {
|
||||||
|
provisioningProfiles: {
|
||||||
|
"#{BASE_BUNDLE_ID}.development" => "#{BASE_BUNDLE_ID}.development AppStore",
|
||||||
|
"#{BASE_BUNDLE_ID}.development.ShareExtension" => "#{BASE_BUNDLE_ID}.development.ShareExtension AppStore",
|
||||||
|
"#{BASE_BUNDLE_ID}.development.Widget" => "#{BASE_BUNDLE_ID}.development.Widget AppStore"
|
||||||
|
},
|
||||||
|
signingStyle: "manual",
|
||||||
|
signingCertificate: CODE_SIGN_IDENTITY
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -15,18 +15,18 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
|
|||||||
|
|
||||||
## iOS
|
## iOS
|
||||||
|
|
||||||
### ios release_dev
|
### ios gha_testflight_dev
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
[bundle exec] fastlane ios release_dev
|
[bundle exec] fastlane ios gha_testflight_dev
|
||||||
```
|
```
|
||||||
|
|
||||||
iOS Development Build to TestFlight (requires separate bundle ID)
|
iOS Development Build to TestFlight (requires separate bundle ID)
|
||||||
|
|
||||||
### ios release_ci
|
### ios gha_release_prod
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
[bundle exec] fastlane ios release_ci
|
[bundle exec] fastlane ios gha_release_prod
|
||||||
```
|
```
|
||||||
|
|
||||||
iOS Release to TestFlight
|
iOS Release to TestFlight
|
||||||
|
|||||||
@@ -58,3 +58,6 @@ const int kPhotoTabIndex = 0;
|
|||||||
const int kSearchTabIndex = 1;
|
const int kSearchTabIndex = 1;
|
||||||
const int kAlbumTabIndex = 2;
|
const int kAlbumTabIndex = 2;
|
||||||
const int kLibraryTabIndex = 3;
|
const int kLibraryTabIndex = 3;
|
||||||
|
|
||||||
|
// Workaround for SQLite's variable limit (SQLITE_MAX_VARIABLE_NUMBER = 32766)
|
||||||
|
const int kDriftMaxChunk = 32000;
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ class ExifInfo {
|
|||||||
final int? fileSize;
|
final int? fileSize;
|
||||||
final String? description;
|
final String? description;
|
||||||
final bool isFlipped;
|
final bool isFlipped;
|
||||||
final double? width;
|
|
||||||
final double? height;
|
|
||||||
final String? orientation;
|
final String? orientation;
|
||||||
final String? timeZone;
|
final String? timeZone;
|
||||||
final DateTime? dateTimeOriginal;
|
final DateTime? dateTimeOriginal;
|
||||||
@@ -46,8 +44,6 @@ class ExifInfo {
|
|||||||
this.fileSize,
|
this.fileSize,
|
||||||
this.description,
|
this.description,
|
||||||
this.orientation,
|
this.orientation,
|
||||||
this.width,
|
|
||||||
this.height,
|
|
||||||
this.timeZone,
|
this.timeZone,
|
||||||
this.dateTimeOriginal,
|
this.dateTimeOriginal,
|
||||||
this.isFlipped = false,
|
this.isFlipped = false,
|
||||||
@@ -72,8 +68,6 @@ class ExifInfo {
|
|||||||
return other.fileSize == fileSize &&
|
return other.fileSize == fileSize &&
|
||||||
other.description == description &&
|
other.description == description &&
|
||||||
other.isFlipped == isFlipped &&
|
other.isFlipped == isFlipped &&
|
||||||
other.width == width &&
|
|
||||||
other.height == height &&
|
|
||||||
other.orientation == orientation &&
|
other.orientation == orientation &&
|
||||||
other.timeZone == timeZone &&
|
other.timeZone == timeZone &&
|
||||||
other.dateTimeOriginal == dateTimeOriginal &&
|
other.dateTimeOriginal == dateTimeOriginal &&
|
||||||
@@ -98,8 +92,6 @@ class ExifInfo {
|
|||||||
description.hashCode ^
|
description.hashCode ^
|
||||||
orientation.hashCode ^
|
orientation.hashCode ^
|
||||||
isFlipped.hashCode ^
|
isFlipped.hashCode ^
|
||||||
width.hashCode ^
|
|
||||||
height.hashCode ^
|
|
||||||
timeZone.hashCode ^
|
timeZone.hashCode ^
|
||||||
dateTimeOriginal.hashCode ^
|
dateTimeOriginal.hashCode ^
|
||||||
latitude.hashCode ^
|
latitude.hashCode ^
|
||||||
@@ -123,8 +115,6 @@ class ExifInfo {
|
|||||||
fileSize: ${fileSize ?? 'NA'},
|
fileSize: ${fileSize ?? 'NA'},
|
||||||
description: ${description ?? 'NA'},
|
description: ${description ?? 'NA'},
|
||||||
orientation: ${orientation ?? 'NA'},
|
orientation: ${orientation ?? 'NA'},
|
||||||
width: ${width ?? 'NA'},
|
|
||||||
height: ${height ?? 'NA'},
|
|
||||||
isFlipped: $isFlipped,
|
isFlipped: $isFlipped,
|
||||||
timeZone: ${timeZone ?? 'NA'},
|
timeZone: ${timeZone ?? 'NA'},
|
||||||
dateTimeOriginal: ${dateTimeOriginal ?? 'NA'},
|
dateTimeOriginal: ${dateTimeOriginal ?? 'NA'},
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ class AssetService {
|
|||||||
if (asset.hasRemote) {
|
if (asset.hasRemote) {
|
||||||
final exif = await getExif(asset);
|
final exif = await getExif(asset);
|
||||||
isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
|
isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
|
||||||
width = exif?.width ?? asset.width?.toDouble();
|
width = asset.width?.toDouble();
|
||||||
height = exif?.height ?? asset.height?.toDouble();
|
height = asset.height?.toDouble();
|
||||||
} else if (asset is LocalAsset) {
|
} else if (asset is LocalAsset) {
|
||||||
isFlipped = CurrentPlatform.isAndroid && (asset.orientation == 90 || asset.orientation == 270);
|
isFlipped = CurrentPlatform.isAndroid && (asset.orientation == 90 || asset.orientation == 270);
|
||||||
width = asset.width?.toDouble();
|
width = asset.width?.toDouble();
|
||||||
|
|||||||
@@ -177,6 +177,12 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _cleanup() async {
|
Future<void> _cleanup() async {
|
||||||
|
await runZonedGuarded(_handleCleanup, (error, stack) {
|
||||||
|
dPrint(() => "Error during background worker cleanup: $error, $stack");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleCleanup() async {
|
||||||
// If ref is null, it means the service was never initialized properly
|
// If ref is null, it means the service was never initialized properly
|
||||||
if (_isCleanedUp || _ref == null) {
|
if (_isCleanedUp || _ref == null) {
|
||||||
return;
|
return;
|
||||||
@@ -186,11 +192,16 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
|||||||
_isCleanedUp = true;
|
_isCleanedUp = true;
|
||||||
final backgroundSyncManager = _ref?.read(backgroundSyncProvider);
|
final backgroundSyncManager = _ref?.read(backgroundSyncProvider);
|
||||||
final nativeSyncApi = _ref?.read(nativeSyncApiProvider);
|
final nativeSyncApi = _ref?.read(nativeSyncApiProvider);
|
||||||
|
|
||||||
|
await _drift.close();
|
||||||
|
await _driftLogger.close();
|
||||||
|
|
||||||
_ref?.dispose();
|
_ref?.dispose();
|
||||||
_ref = null;
|
_ref = null;
|
||||||
|
|
||||||
_cancellationToken.cancel();
|
_cancellationToken.cancel();
|
||||||
_logger.info("Cleaning up background worker");
|
_logger.info("Cleaning up background worker");
|
||||||
|
|
||||||
final cleanupFutures = [
|
final cleanupFutures = [
|
||||||
nativeSyncApi?.cancelHashing(),
|
nativeSyncApi?.cancelHashing(),
|
||||||
workerManagerPatch.dispose().catchError((_) async {
|
workerManagerPatch.dispose().catchError((_) async {
|
||||||
@@ -199,8 +210,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
|||||||
}),
|
}),
|
||||||
LogService.I.dispose(),
|
LogService.I.dispose(),
|
||||||
Store.dispose(),
|
Store.dispose(),
|
||||||
_drift.close(),
|
|
||||||
_driftLogger.close(),
|
|
||||||
backgroundSyncManager?.cancel(),
|
backgroundSyncManager?.cancel(),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -239,7 +249,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
|||||||
final networkCapabilities = await _ref?.read(connectivityApiProvider).getCapabilities() ?? [];
|
final networkCapabilities = await _ref?.read(connectivityApiProvider).getCapabilities() ?? [];
|
||||||
return _ref
|
return _ref
|
||||||
?.read(uploadServiceProvider)
|
?.read(uploadServiceProvider)
|
||||||
.startBackupWithHttpClient(currentUser.id, networkCapabilities.hasWifi, _cancellationToken);
|
.startBackupWithHttpClient(currentUser.id, networkCapabilities.isUnmetered, _cancellationToken);
|
||||||
},
|
},
|
||||||
(error, stack) {
|
(error, stack) {
|
||||||
dPrint(() => "Error in backup zone $error, $stack");
|
dPrint(() => "Error in backup zone $error, $stack");
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user