Compare commits

..

31 Commits

Author SHA1 Message Date
Daniel Dietzler 3ad04d8d6b feat: kysely 0.29 2026-05-11 13:47:52 +02:00
Timon 271f1cb868 feat(web): Add metadata overlay to slideshow (#24627)
* feat: add slideshow metadata overlay and settings

* Introduced a new SlideshowMetadataOverlay component to display image information during slideshows.
* Updated slideshow settings modal to include options for showing the metadata overlay and selecting its display mode (Description Only or Full).
* Added corresponding translations and store management for the new overlay features.

* remove noisy log

* constant opacity

* 2nd pass

* more

* use text components

* lint
2026-05-11 11:49:12 +02:00
Mert 8c8dc9d32f chore(ml)!: remove deprecated envs (#28326)
remove deprecated envs
2026-05-09 22:40:05 +00:00
Alex fd18e55f7c chore: token extraction for build mobile (#28320)
Co-authored-by: bo0tzz <git@bo0tzz.me>
2026-05-09 15:08:07 +00:00
shenlong faab9e620d refactor: medium repository context helpers (#28311)
* refactor: medium repository context helpers

* test: add regress test for 26723

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-09 08:19:31 -05:00
Matthew Momjian 5ba3efafd8 fix(deployment): remove unneeded volume (#28307)
remove unneeded volume
2026-05-09 09:07:54 -04:00
Alex 8b3c9bf9c3 feat(ci): publish PR Android APK to comment (#28283)
* feat(ci): publish PR Android APK to R2 with installable links

Adds a universal debug APK to PR builds and uploads it to a public
R2 bucket alongside the existing GitHub Actions artifact. Posts a
sticky PR comment with tap-to-install links and a QR code so testers
can install directly on their device without unzipping artifacts.

Required setup:
- Secrets: R2_APK_ACCESS_KEY_ID, R2_APK_SECRET_ACCESS_KEY,
  R2_APK_ACCOUNT_ID, R2_APK_BUCKET
- Optional repo variable: APK_PUBLIC_HOST (defaults to apk.immich.app)
- R2 bucket configured with a public custom domain matching APK_PUBLIC_HOST

* chore(ci): drop R2 upload, link directly to GitHub artifact

Surfaces the existing release-apk-signed artifact in a sticky PR
comment with a QR code. Avoids new infra and secrets — the trade-off
is GitHub login and a zip wrapper instead of tap-to-install.

* feat(ci): build PR APK as release and publish to GitHub Release

PR builds now produce a release APK signed with the release keystore.
The universal APK is published as a GitHub Release asset under tag
'pr-<num>' (prerelease), giving testers a direct, unzipped, tap-to-
install URL plus a QR code in the PR comment. The release-apk-signed
artifact is unchanged.

* chore(ci): drop GitHub Release, publish universal APK as own artifact

Reverts the prerelease publish. Uploads the universal release APK as
a separate single-file artifact so its download URL gives a zip
containing only that APK — no extra files to dig through. The QR in
the PR comment points at this universal-only artifact.

* chore(ci): build only universal APK for PR, drop split artifact

PR builds skip the arm64-only split — release-apk-signed now contains
just the universal app-release.apk, so the download zip is a single
file. Removes the redundant separate universal artifact and points
the PR comment QR at the main artifact URL.

* feat(mobile): suffix PR APK applicationId so it installs alongside production

Each PR build now becomes app.alextran.immich.pr<num> via PR_NUMBER env
read in build.gradle, so testers can install multiple PR builds and the
Play Store version on the same device without uninstalling. Also tags
the version with -pr<num> for visibility.

* feat(ci): allow PR APK build to run on forks

Forks can now run the Android build job. Steps that need repo secrets
(create-workflow-token, Create Keystore) are skipped when the PR is
from a fork, the checkout falls back to GITHUB_TOKEN, and build.gradle
falls back to debug signing if the release keystore isn't materialised.
The PR comment still requires write access, so it's gated to non-fork
PRs — fork APKs are reachable from the workflow run's artifact tab.
2026-05-09 07:46:40 -05:00
Yaros 41f285aa3e feat(mobile): increased tap area on video player overlay (#27269)
* fix(mobile): improved tap area on video player

* fix: back button padding

* chore: use sizedbox.square & button padding

* chore: fixed padding
2026-05-09 10:47:41 +07:00
Sandro fdac6c8bc4 fix(docs): missing colon in config file doc (#28313)
Fix missing colon
2026-05-09 09:44:41 +07:00
Thorsten Winkler d7f05d2510 fix(mobile): Deduplicate assets in person view timeline (#26723)
fix(mobile): deduplicate assets in person view timeline

Previously, assets with multiple face records for the same person (e.g.,
manual Digikam imports and Immich ML detections) appeared multiple times
in the person timeline. This was caused by an inner join on the
assetFaceEntity without proper deduplication.

This commit refactors the timeline queries to use a subquery approach
instead of joins and grouping. This ensures:

- _getPersonBucketAssets: Only unique assets are fetched, even if
  multiple face records exist for a single asset.
- _watchPersonBucket: Asset counts in timeline headers are accurate
  and represent unique assets.
- Performance: Database overhead is reduced by avoiding complex joins
  and explicit groupBy operations on large result sets.

Signed-off-by: thowdev <12428285+thowdev@users.noreply.github.com>
2026-05-09 01:53:18 +00:00
stfn 3100bd5eed fix(mobile): avoid duplicate assets in album view (#28152)
fix(mobile): avoid duplicate assets in remote album timeline

Co-authored-by: Stefan Friedli <stefan@stefanfriedli.ch>
2026-05-09 08:24:54 +07:00
Jason Rasmussen 8a024e2b50 chore: faster web linting (#28303) 2026-05-08 16:55:14 -04:00
Jason Rasmussen 25a6a38b30 chore: use mise (#28298) 2026-05-08 15:21:33 -04:00
Santo Shakil 7c6750941e fix(mobile): mounted check before setState in album sync action (#28300)
_manualSyncAlbums fires a setState 1s after sync via Future.delayed
with no mounted check. if the widget is gone by then, setState throws
null check and the global error logger logs it severe.
2026-05-08 18:55:03 +00:00
renovate[bot] 832ed4d015 chore(deps): update dependency exiftool-vendored to v35.19.0 [security] (#28261)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-05-08 12:24:31 +02:00
Santo Shakil 238895cad9 fix(mobile): restore notification plugin init (#28284)
#27666 removed LocalNotificationService with the legacy stack, which
was the only place calling FlutterLocalNotificationsPlugin().initialize().
without it, ios never prompts for the notification perm on fresh
installs so background_downloader notifications get dropped silently.

restores the init in the same spot the deleted call used to live.
2026-05-08 10:45:52 +07:00
sakshamchawla e2ec04e86c feat: hide hidden person from memories (#20877)
* hide hidden person from memories

* clean up

* fix united test

* clean up

* moved sql to inline, rebased

* clean up

* clean up again

* chore: sync sql

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-05-07 19:54:26 +00:00
renovate[bot] 6050526360 fix(deps): update dependency connectivity_plus to v7 (#22921)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-07 15:22:26 -04:00
renovate[bot] bfd76570c5 chore(deps): update dependency python-multipart to v0.0.27 [security] (#28286)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-07 15:22:03 -04:00
renovate[bot] 37e6a49652 fix(deps): update dependency nestjs-otel to v8 (#27863)
* fix(deps): update dependency nestjs-otel to v8

* fix: apiMetrics

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-05-07 19:14:15 +00:00
Mert 36caeb34ec chore(ml)!: require numpy 2.4 (#28158)
require numpy 2.4
2026-05-07 19:07:39 +00:00
renovate[bot] 87713c7f2f chore(deps): update dependency flutter to v3.41.9 (#28235)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-07 15:00:37 -04:00
Daniel Dietzler 2039c129f2 refactor: settings accordion reactivity (#28281) 2026-05-07 19:00:23 +00:00
Timon 52b00b0bad chore(ml): add mise checklist command (#28267)
* chore(ml): add mise checklist command

* don't depend tests on installing a cpu flavor
2026-05-07 12:28:24 -04:00
shenlong 21af184045 refactor: move cleanup config to metadata table (#28225)
* refactor: app metadata

* refactor to per row store

* cleanup

* more test

* review changes

* more refactor

* refactor

* migrate primary color

* migrate dynamic theme

* migrate colorfulInterface

* cleanup providers

* migrate cleanup

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-07 11:27:06 -05:00
Timon 1fcc2b704b feat(server)!: add isOwned filter to albums API (#28213)
* feat(server)!: add owned filter to albums API

BREAKING CHANGE: GET /albums with no parameters now returns all accessible albums (owned + shared-with-me) instead of only owned albums.

* document tri-state matrix

* web impl

* collapse to single method and handover branching to sql

* dedupe

* verify that owned, shared, and notShared counts are mapped independently from their respective queries

* refactor(server): add select:['id'] overload to albumRepository.getAll

Avoid fetching full album rows (with albumUsers/sharedLinks subqueries) in map.service where only album IDs are needed.

* focus relevant test filters

* fmt

* Revert "verify that owned, shared, and notShared counts are mapped independently from their respective queries"

This reverts commit 47aab458192c766de4662aada5a6841b091d2a80.

* sync sql

* Revert "document tri-state matrix"

This reverts commit a5b2355d0c.

* address review comments

* inline shared condition and return as ternary

* sync sql

* use [...albums].sort

Array.toSorted() is not supported in Chrome 109

* use isShared and isOwned nomenclature

* fix e2e tests

* add params to sql query
2026-05-07 12:13:07 -04:00
Timon 7de73dc176 fix(server): hide isFavorite from partner asset sync stream (#28035)
* fix(server): hide isFavorite from partner asset sync stream

* use new column entry instead

* sync sql

* add migration

* use sql.val

* sync sql
2026-05-07 12:00:54 -04:00
TheBestX11 fe2bf0c6dd fix(mobile): correct filter default and UI desync in similar photos search (#27516)
* fix(mobile): view similar defaults to images only

* fix(mobile): reset filter chips when pre-filter is applied

---------

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
2026-05-07 15:22:35 +00:00
shenlong d4a97f2d25 refactor: move theme config to metadata table (#28224)
* refactor: app metadata

* refactor to per row store

* cleanup

* more test

* review changes

* more refactor

* refactor

* migrate primary color

* migrate dynamic theme

* migrate colorfulInterface

* cleanup providers

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-07 15:12:14 +00:00
shenlong bd58db4fcc fix: periodically execute pragma optimize (#28241)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-07 09:54:35 -05:00
Luis Nachtigall 7f43c6a3a3 fix(mobile): prevent asset loading issues when changing page or when closing memories (#27596) 2026-05-07 09:13:22 -04:00
116 changed files with 1913 additions and 1474 deletions
+25 -4
View File
@@ -73,8 +73,8 @@ jobs:
needs: pre-job needs: pre-job
permissions: permissions:
contents: read contents: read
# Skip when PR from a fork pull-requests: write
if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && fromJSON(needs.pre-job.outputs.should_run).mobile == true }} if: ${{ github.actor != 'dependabot[bot]' && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
runs-on: mich runs-on: mich
steps: steps:
@@ -86,11 +86,12 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: ${{ inputs.ref || github.sha }} ref: ${{ inputs.ref }}
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Create the Keystore - name: Create the Keystore
if: ${{ !github.event.pull_request.head.repo.fork }}
env: env:
KEY_JKS: ${{ secrets.KEY_JKS }} KEY_JKS: ${{ secrets.KEY_JKS }}
working-directory: ./mobile working-directory: ./mobile
@@ -144,20 +145,40 @@ jobs:
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 }}
IS_MAIN: ${{ github.ref == 'refs/heads/main' }} IS_MAIN: ${{ github.ref == 'refs/heads/main' }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: | run: |
if [[ $IS_MAIN == 'true' ]]; then if [[ $IS_MAIN == 'true' ]]; then
flutter build apk --release flutter build apk --release
flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64,android-x64 flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64,android-x64
else else
flutter build apk --debug --split-per-abi --target-platform android-arm64 flutter build apk --release
fi fi
- name: Publish Android Artifact - name: Publish Android Artifact
id: upload-apk
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: release-apk-signed name: release-apk-signed
path: mobile/build/app/outputs/flutter-apk/*.apk path: mobile/build/app/outputs/flutter-apk/*.apk
- name: Comment APK download link on PR
if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork }}
uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
APK_URL: ${{ steps.upload-apk.outputs.artifact-url }}
with:
github-token: ${{ steps.token.outputs.token }}
message-id: 'mobile-android-apk'
message: |
📱 **Android release APK (universal)** — `${{ env.HEAD_SHA }}`
Download: ${{ env.APK_URL }}
<img src="https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=${{ env.APK_URL }}" alt="QR code" />
Installs as a separate app (applicationId `app.alextran.immich.pr${{ github.event.pull_request.number }}`), so it coexists with the Play Store version and any other PR builds.
- name: Save Gradle Cache - name: Save Gradle Cache
id: cache-gradle-save id: cache-gradle-save
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+7 -17
View File
@@ -36,30 +36,20 @@ jobs:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }} client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup Mise
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: './cli/.nvmrc' github_token: ${{ steps.token.outputs.token }}
registry-url: 'https://registry.npmjs.org'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Setup typescript-sdk - name: Publish
run: pnpm install && pnpm run build
working-directory: ./open-api/typescript-sdk
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm publish --provenance --no-git-checks
if: ${{ github.event_name == 'release' }} if: ${{ github.event_name == 'release' }}
run: mise run ci-publish
docker: docker:
name: Docker name: Docker
+3 -9
View File
@@ -64,17 +64,11 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
fetch-depth: 0
- name: Setup pnpm - name: Setup Mise
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: './docs/.nvmrc' github_token: ${{ steps.token.outputs.token }}
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run install - name: Run install
run: pnpm install run: pnpm install
+7 -13
View File
@@ -14,29 +14,23 @@ jobs:
contents: write contents: write
pull-requests: write pull-requests: write
steps: steps:
- name: Generate a token - id: token
id: generate-token uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with: with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }} client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: 'Checkout' - name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: ${{ github.event.pull_request.head.ref }} ref: ${{ github.event.pull_request.head.ref }}
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true persist-credentials: true
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup Mise
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a # v6.0.4 uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: './server/.nvmrc' github_token: ${{ steps.token.outputs.token }}
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Fix formatting - name: Fix formatting
run: pnpm --recursive install && pnpm run --recursive --if-present --parallel format:fix run: pnpm --recursive install && pnpm run --recursive --if-present --parallel format:fix
+10 -15
View File
@@ -48,33 +48,28 @@ jobs:
version: ${{ steps.output.outputs.version }} version: ${{ steps.output.outputs.version }}
permissions: {} # No job-level permissions are needed because it uses the app-token permissions: {} # No job-level permissions are needed because it uses the app-token
steps: steps:
- name: Generate a token - id: token
id: generate-token uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with: with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }} client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout - name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
token: ${{ steps.generate-token.outputs.token }} token: ${{ steps.token.outputs.token }}
persist-credentials: true persist-credentials: true
ref: main ref: main
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
with:
github_token: ${{ steps.token.outputs.token }}
# TODO move to mise
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
- name: Setup pnpm
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a # v6.0.4
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Bump version - name: Bump version
env: env:
SERVER_BUMP: ${{ inputs.serverBump }} SERVER_BUMP: ${{ inputs.serverBump }}
+8 -10
View File
@@ -24,24 +24,22 @@ jobs:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }} client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup Mise
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: './open-api/typescript-sdk/.nvmrc' github_token: ${{ steps.token.outputs.token }}
registry-url: 'https://registry.npmjs.org'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install deps - name: Install deps
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Build - name: Build
run: pnpm build run: pnpm build
- name: Publish - name: Publish
run: pnpm publish --provenance --no-git-checks run: pnpm publish --provenance --no-git-checks
+127 -160
View File
@@ -78,28 +78,14 @@ jobs:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup Mise
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: './server/.nvmrc' github_token: ${{ steps.token.outputs.token }}
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml' - name: Run ci-unit
- name: Run package manager install run: mise run ci-unit
run: pnpm install
- name: Run linter
run: pnpm lint
if: ${{ !cancelled() }}
- name: Run formatter
run: pnpm format
if: ${{ !cancelled() }}
- name: Run tsc
run: pnpm check
if: ${{ !cancelled() }}
- name: Run small tests & coverage
run: pnpm test
if: ${{ !cancelled() }}
cli-unit-tests: cli-unit-tests:
name: Unit Test CLI name: Unit Test CLI
needs: pre-job needs: pre-job
@@ -122,31 +108,15 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 - name: Setup Mise
- name: Setup Node uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: './cli/.nvmrc' github_token: ${{ steps.token.outputs.token }}
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml' - name: Run ci-unit
- name: Setup typescript-sdk run: mise run ci-unit
run: pnpm install && pnpm run build
working-directory: ./open-api/typescript-sdk
- name: Install deps
run: pnpm install
- name: Run linter
run: pnpm lint
if: ${{ !cancelled() }}
- name: Run formatter
run: pnpm format
if: ${{ !cancelled() }}
- name: Run tsc
run: pnpm check
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
run: pnpm test
if: ${{ !cancelled() }}
cli-unit-tests-win: cli-unit-tests-win:
name: Unit Test CLI (Windows) name: Unit Test CLI (Windows)
needs: pre-job needs: pre-job
@@ -169,26 +139,33 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: './cli/.nvmrc' node-version-file: '.nvmrc'
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Setup typescript-sdk - name: Setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk working-directory: ./open-api/typescript-sdk
- name: Install deps - name: Install deps
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
# Skip linter & formatter in Windows test. # Skip linter & formatter in Windows test.
- name: Run tsc - name: Run tsc
run: pnpm check run: pnpm check
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run unit tests & coverage - name: Run unit tests & coverage
run: pnpm test run: pnpm test
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
web-lint: web-lint:
name: Lint Web name: Lint Web
needs: pre-job needs: pre-job
@@ -211,28 +188,22 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 - name: Setup Mise
- name: Setup Node uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: './web/.nvmrc' github_token: ${{ steps.token.outputs.token }}
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run setup typescript-sdk - name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build run: mise run //:sdk:install && mise run //:sdk:build
working-directory: ./open-api/typescript-sdk
- name: Run pnpm install - name: Run pnpm install
run: pnpm rebuild && pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Run linter - name: Run linter
run: pnpm lint run: pnpm lint
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run formatter
run: pnpm format
if: ${{ !cancelled() }}
- name: Run svelte checks
run: pnpm check:svelte
if: ${{ !cancelled() }}
web-unit-tests: web-unit-tests:
name: Test Web name: Test Web
needs: pre-job needs: pre-job
@@ -255,25 +226,15 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 - name: Setup Mise
- name: Setup Node uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: './web/.nvmrc' github_token: ${{ steps.token.outputs.token }}
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml' - name: Run ci-unit
- name: Run setup typescript-sdk run: mise run ci-unit
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
- name: Run npm install
run: pnpm install --frozen-lockfile
- name: Run tsc
run: pnpm check:typescript
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
run: pnpm test
if: ${{ !cancelled() }}
i18n-tests: i18n-tests:
name: Test i18n name: Test i18n
needs: pre-job needs: pre-job
@@ -293,24 +254,25 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 - name: Setup Mise
- name: Setup Node uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: './web/.nvmrc' github_token: ${{ steps.token.outputs.token }}
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install dependencies - name: Install dependencies
run: pnpm --filter=immich-i18n install --frozen-lockfile run: pnpm --filter=immich-i18n install --frozen-lockfile
- name: Format - name: Format
run: pnpm --filter=immich-i18n format:fix run: pnpm --filter=immich-i18n format:fix
- name: Find file changes - name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-files id: verify-changed-files
with: with:
files: | files: |
i18n/** i18n/**
- name: Verify files have not changed - name: Verify files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true' if: steps.verify-changed-files.outputs.files_changed == 'true'
env: env:
@@ -319,6 +281,7 @@ jobs:
echo "ERROR: i18n files not up to date!" echo "ERROR: i18n files not up to date!"
echo "Changed files: ${CHANGED_FILES}" echo "Changed files: ${CHANGED_FILES}"
exit 1 exit 1
e2e-tests-lint: e2e-tests-lint:
name: End-to-End Lint name: End-to-End Lint
needs: pre-job needs: pre-job
@@ -341,30 +304,16 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 - name: Setup Mise
- name: Setup Node uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: './e2e/.nvmrc' github_token: ${{ steps.token.outputs.token }}
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml' - name: Run ci-unit
- name: Run setup typescript-sdk run: mise run ci-unit
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
if: ${{ !cancelled() }}
- name: Run linter
run: pnpm lint
if: ${{ !cancelled() }}
- name: Run formatter
run: pnpm format
if: ${{ !cancelled() }}
- name: Run tsc
run: pnpm check
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
server-medium-tests: server-medium-tests:
name: Medium Tests (Server) name: Medium Tests (Server)
needs: pre-job needs: pre-job
@@ -388,23 +337,16 @@ jobs:
persist-credentials: false persist-credentials: false
submodules: 'recursive' submodules: 'recursive'
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0 uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
- name: Run pnpm install
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile - name: Run ci-medium
- name: Run medium tests run: mise run ci-medium
run: pnpm test:medium
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
e2e-tests-server-cli: e2e-tests-server-cli:
name: End-to-End Tests (Server & CLI) name: End-to-End Tests (Server & CLI)
needs: pre-job needs: pre-job
@@ -431,52 +373,63 @@ jobs:
persist-credentials: false persist-credentials: false
submodules: 'recursive' submodules: 'recursive'
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: './e2e/.nvmrc' node-version-file: '.nvmrc'
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run setup typescript-sdk
- name: Setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
- name: Run setup web - name: Run setup web
run: pnpm install --frozen-lockfile && pnpm exec svelte-kit sync run: pnpm install --frozen-lockfile && pnpm exec svelte-kit sync
working-directory: ./web working-directory: ./web
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run setup cli - name: Run setup cli
run: pnpm install --frozen-lockfile && pnpm build run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./cli working-directory: ./cli
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Start Docker Compose - name: Start Docker Compose
run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300 run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run e2e tests (api & cli) - name: Run e2e tests (api & cli)
env: env:
VITEST_DISABLE_DOCKER_SETUP: true VITEST_DISABLE_DOCKER_SETUP: true
run: pnpm test run: pnpm test
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run e2e tests (maintenance) - name: Run e2e tests (maintenance)
env: env:
VITEST_DISABLE_DOCKER_SETUP: true VITEST_DISABLE_DOCKER_SETUP: true
run: pnpm test:maintenance run: pnpm test:maintenance
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Capture Docker logs - name: Capture Docker logs
if: always() if: always()
run: docker compose logs --no-color > docker-compose-logs.txt run: docker compose logs --no-color > docker-compose-logs.txt
working-directory: ./e2e working-directory: ./e2e
- name: Archive Docker logs - name: Archive Docker logs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always() if: always()
with: with:
name: e2e-server-docker-logs-${{ matrix.runner }} name: e2e-server-docker-logs-${{ matrix.runner }}
path: e2e/docker-compose-logs.txt path: e2e/docker-compose-logs.txt
e2e-tests-web: e2e-tests-web:
name: End-to-End Tests (Web) name: End-to-End Tests (Web)
needs: pre-job needs: pre-job
@@ -503,70 +456,85 @@ jobs:
persist-credentials: false persist-credentials: false
submodules: 'recursive' submodules: 'recursive'
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: './e2e/.nvmrc' node-version-file: '.nvmrc'
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run setup typescript-sdk - name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Install Playwright Browsers - name: Install Playwright Browsers
run: pnpm exec playwright install chromium --only-shell run: pnpm exec playwright install chromium --only-shell
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Docker build - name: Docker build
run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300 run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run e2e tests (web) - name: Run e2e tests (web)
env: env:
PLAYWRIGHT_DISABLE_WEBSERVER: true PLAYWRIGHT_DISABLE_WEBSERVER: true
run: pnpm test:web run: pnpm test:web
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Archive e2e test (web) results - name: Archive e2e test (web) results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: success() || failure() if: success() || failure()
with: with:
name: e2e-web-test-results-${{ matrix.runner }} name: e2e-web-test-results-${{ matrix.runner }}
path: e2e/playwright-report/ path: e2e/playwright-report/
- name: Run ui tests (web) - name: Run ui tests (web)
env: env:
PLAYWRIGHT_DISABLE_WEBSERVER: true PLAYWRIGHT_DISABLE_WEBSERVER: true
run: pnpm test:web:ui run: pnpm test:web:ui
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Archive ui test (web) results - name: Archive ui test (web) results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: success() || failure() if: success() || failure()
with: with:
name: e2e-ui-test-results-${{ matrix.runner }} name: e2e-ui-test-results-${{ matrix.runner }}
path: e2e/playwright-report/ path: e2e/playwright-report/
- name: Run maintenance tests - name: Run maintenance tests
env: env:
PLAYWRIGHT_DISABLE_WEBSERVER: true PLAYWRIGHT_DISABLE_WEBSERVER: true
run: pnpm test:web:maintenance run: pnpm test:web:maintenance
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Archive maintenance tests (web) results - name: Archive maintenance tests (web) results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: success() || failure() if: success() || failure()
with: with:
name: e2e-maintenance-isolated-test-results-${{ matrix.runner }} name: e2e-maintenance-isolated-test-results-${{ matrix.runner }}
path: e2e/playwright-report/ path: e2e/playwright-report/
- name: Capture Docker logs - name: Capture Docker logs
if: always() if: always()
run: docker compose logs --no-color > docker-compose-logs.txt run: docker compose logs --no-color > docker-compose-logs.txt
working-directory: ./e2e working-directory: ./e2e
- name: Archive Docker logs - name: Archive Docker logs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always() if: always()
with: with:
name: e2e-web-docker-logs-${{ matrix.runner }} name: e2e-web-docker-logs-${{ matrix.runner }}
path: e2e/docker-compose-logs.txt path: e2e/docker-compose-logs.txt
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]
@@ -613,9 +581,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
env:
MISE_CEILING_PATHS: ${{ github.workspace }}
MISE_TRUSTED_CONFIG_PATHS: ${{ github.workspace }}/machine-learning/mise.toml
defaults: defaults:
run: run:
working-directory: ./machine-learning working-directory: ./machine-learning
@@ -635,18 +600,10 @@ jobs:
uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0 uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
with: with:
github_token: ${{ steps.token.outputs.token }} github_token: ${{ steps.token.outputs.token }}
working_directory: ./machine-learning
- name: Install dependencies - name: Run ci-unit
run: mise run install --extra cpu run: mise run ci-unit
- name: Lint code
run: mise run lint --output-format=github
- name: Format code
run: mise run format
- name: Run type checking
run: mise run check
- name: Run tests and coverage
run: mise run test
github-files-formatting: github-files-formatting:
name: .github Files Formatting name: .github Files Formatting
needs: pre-job needs: pre-job
@@ -669,19 +626,19 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 - name: Setup Mise
- name: Setup Node uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: './.github/.nvmrc' github_token: ${{ steps.token.outputs.token }}
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run pnpm install - name: Run pnpm install
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Run formatter - name: Run formatter
run: pnpm format run: pnpm format
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
shellcheck: shellcheck:
name: ShellCheck name: ShellCheck
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -720,21 +677,22 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 - name: Setup Mise
- name: Setup Node uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: './server/.nvmrc' github_token: ${{ steps.token.outputs.token }}
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install server dependencies - name: Install server dependencies
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile
- name: Build the app - name: Build the app
run: pnpm --filter immich build run: pnpm --filter immich build
- name: Run API generation - name: Run API generation
run: ./bin/generate-open-api.sh run: ./bin/generate-open-api.sh
working-directory: open-api working-directory: open-api
- name: Find file changes - name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-files id: verify-changed-files
@@ -743,6 +701,7 @@ jobs:
mobile/openapi mobile/openapi
open-api/typescript-sdk open-api/typescript-sdk
open-api/immich-openapi-specs.json open-api/immich-openapi-specs.json
- name: Verify files have not changed - name: Verify files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true' if: steps.verify-changed-files.outputs.files_changed == 'true'
env: env:
@@ -751,6 +710,7 @@ jobs:
echo "ERROR: Generated files not up to date!" echo "ERROR: Generated files not up to date!"
echo "Changed files: ${CHANGED_FILES}" echo "Changed files: ${CHANGED_FILES}"
exit 1 exit 1
sql-schema-up-to-date: sql-schema-up-to-date:
name: SQL Schema Checks name: SQL Schema Checks
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -782,31 +742,35 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 - name: Setup Mise
- name: Setup Node uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: './server/.nvmrc' github_token: ${{ steps.token.outputs.token }}
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install server dependencies - name: Install server dependencies
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
- name: Build the app - name: Build the app
run: pnpm build run: pnpm build
- name: Run existing migrations - name: Run existing migrations
run: pnpm migrations:run run: pnpm migrations:run
- name: Test npm run schema:reset command works - name: Test npm run schema:reset command works
run: pnpm schema:reset run: pnpm schema:reset
- name: Generate new migrations - name: Generate new migrations
continue-on-error: true continue-on-error: true
run: pnpm migrations:generate src/TestMigration run: pnpm migrations:generate src/TestMigration
- name: Find file changes - name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-files id: verify-changed-files
with: with:
files: | files: |
server/src server/src
- name: Verify migration files have not changed - name: Verify migration files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true' if: steps.verify-changed-files.outputs.files_changed == 'true'
env: env:
@@ -816,16 +780,19 @@ jobs:
echo "Changed files: ${CHANGED_FILES}" echo "Changed files: ${CHANGED_FILES}"
cat ./src/*-TestMigration.ts cat ./src/*-TestMigration.ts
exit 1 exit 1
- name: Run SQL generation - name: Run SQL generation
run: pnpm sync:sql run: pnpm sync:sql
env: env:
DB_URL: postgres://postgres:postgres@localhost:5432/immich DB_URL: postgres://postgres:postgres@localhost:5432/immich
- name: Find file changes - name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-sql-files id: verify-changed-sql-files
with: with:
files: | files: |
server/src/queries server/src/queries
- name: Verify SQL files have not changed - name: Verify SQL files have not changed
if: steps.verify-changed-sql-files.outputs.files_changed == 'true' if: steps.verify-changed-sql-files.outputs.files_changed == 'true'
env: env:
View File
-1
View File
@@ -1 +0,0 @@
24.15.0
+24 -1
View File
@@ -7,7 +7,7 @@ run = "vite build"
[tasks.test] [tasks.test]
env._.path = "./node_modules/.bin" env._.path = "./node_modules/.bin"
run = "vite" run = "vitest"
[tasks.lint] [tasks.lint]
env._.path = "./node_modules/.bin" env._.path = "./node_modules/.bin"
@@ -27,3 +27,26 @@ run = "prettier --write ."
[tasks.check] [tasks.check]
env._.path = "./node_modules/.bin" env._.path = "./node_modules/.bin"
run = "tsc --noEmit" run = "tsc --noEmit"
[tasks.ci-publish]
depends = ["//:sdk:install", "//:sdk:build"]
run = [
{ task = ":install" },
{ task = ":build" },
"pnpm publish --provenance --no-git-checks",
]
[tasks.ci-unit]
depends = ["//:sdk:install", "//:sdk:build"]
run = [
{ task = ":install" },
{ task = ":format" },
{ task = ":lint" },
{ task = ":check" },
{ task = ":test --run" },
]
[tasks.checklist]
run = [
{ task = ":ci-unit" },
]
-3
View File
@@ -66,8 +66,5 @@
"fastq": "^1.17.1", "fastq": "^1.17.1",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"micromatch": "^4.0.8" "micromatch": "^4.0.8"
},
"volta": {
"node": "24.15.0"
} }
} }
-3
View File
@@ -95,6 +95,3 @@ services:
restart: always restart: always
healthcheck: healthcheck:
disable: false disable: false
volumes:
model-cache:
-1
View File
@@ -1 +0,0 @@
24.15.0
+1 -1
View File
@@ -264,4 +264,4 @@ volumes:
- ./configuration.yml:${IMMICH_CONFIG_FILE} - ./configuration.yml:${IMMICH_CONFIG_FILE}
``` ```
:: :::
-3
View File
@@ -56,8 +56,5 @@
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
},
"volta": {
"node": "24.15.0"
} }
} }
+2 -1
View File
@@ -11,5 +11,6 @@
"@types/oidc-provider": "^9.0.0", "@types/oidc-provider": "^9.0.0",
"oidc-provider": "^9.0.0", "oidc-provider": "^9.0.0",
"tsx": "^4.20.6" "tsx": "^4.20.6"
} },
"packageManager": "pnpm@10.33.1"
} }
-1
View File
@@ -1 +0,0 @@
24.15.0
+15
View File
@@ -27,3 +27,18 @@ run = { task = "lint --fix" }
[tasks.check] [tasks.check]
env._.path = "./node_modules/.bin" env._.path = "./node_modules/.bin"
run = "tsc --noEmit" run = "tsc --noEmit"
[tasks.ci-setup]
depends = ["//:sdk:install", "//:sdk:build", "//cli:install", "//cli:build"]
run = { task = ":install" }
[tasks.ci-unit]
depends = ["//:sdk:install", "//:sdk:build"]
run = [
{ task = ":install" },
{ task = ":format" },
{ task = ":lint" },
{ task = ":check" },
]
-3
View File
@@ -56,8 +56,5 @@
"utimes": "^5.2.1", "utimes": "^5.2.1",
"vite-tsconfig-paths": "^6.1.1", "vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.0.0" "vitest": "^4.0.0"
},
"volta": {
"node": "24.15.0"
} }
} }
+74 -10
View File
@@ -146,7 +146,7 @@ describe('/albums', () => {
it('should not return shared albums with a deleted owner', async () => { it('should not return shared albums with a deleted owner', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/albums?shared=true') .get('/albums?isShared=true')
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
@@ -188,7 +188,7 @@ describe('/albums', () => {
it('should return the album collection including owned and shared', async () => { it('should return the album collection including owned and shared', async () => {
const { status, body } = await request(app).get('/albums').set('Authorization', `Bearer ${user1.accessToken}`); const { status, body } = await request(app).get('/albums').set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(4); expect(body).toHaveLength(5);
expect(body).toEqual( expect(body).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
@@ -219,13 +219,20 @@ describe('/albums', () => {
]), ]),
shared: false, shared: false,
}), }),
expect.objectContaining({
albumName: user2SharedUser,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user2.userId }) },
]),
shared: true,
}),
]), ]),
); );
}); });
it('should return the album collection filtered by shared', async () => { it('should return the album collection filtered by isShared', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/albums?shared=true') .get('/albums?isShared=true')
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(4); expect(body).toHaveLength(4);
@@ -263,9 +270,9 @@ describe('/albums', () => {
); );
}); });
it('should return the album collection filtered by NOT shared', async () => { it('should return the album collection filtered by NOT isShared', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/albums?shared=false') .get('/albums?isShared=false')
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(1); expect(body).toHaveLength(1);
@@ -282,6 +289,63 @@ describe('/albums', () => {
); );
}); });
it('should return only owned albums when filtered by isOwned=true', async () => {
const { status, body } = await request(app)
.get('/albums?isOwned=true')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(4);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ albumName: user1SharedEditorUser }),
expect.objectContaining({ albumName: user1SharedViewerUser }),
expect.objectContaining({ albumName: user1SharedLink }),
expect.objectContaining({ albumName: user1NotShared }),
]),
);
});
it('should return only shared-with-me albums when filtered by isOwned=false', async () => {
const { status, body } = await request(app)
.get('/albums?isOwned=false')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({
albumName: user2SharedUser,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user2.userId }) },
]),
}),
]),
);
});
it('should return owned shared-out albums when filtered by isOwned=true&ishared=true', async () => {
const { status, body } = await request(app)
.get('/albums?isOwned=true&isShared=true')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(3);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ albumName: user1SharedEditorUser }),
expect.objectContaining({ albumName: user1SharedViewerUser }),
expect.objectContaining({ albumName: user1SharedLink }),
]),
);
});
it('should return empty list when filtered by isOwned=false&isShared=false', async () => {
const { status, body } = await request(app)
.get('/albums?isOwned=false&isShared=false')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(0);
});
it('should return the album collection filtered by assetId', async () => { it('should return the album collection filtered by assetId', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get(`/albums?assetId=${user1Asset2.id}`) .get(`/albums?assetId=${user1Asset2.id}`)
@@ -290,17 +354,17 @@ describe('/albums', () => {
expect(body).toHaveLength(2); expect(body).toHaveLength(2);
}); });
it('should return the album collection filtered by assetId and ignores shared=true', async () => { it('should return the album collection filtered by assetId and ignores isShared=true', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get(`/albums?shared=true&assetId=${user1Asset1.id}`) .get(`/albums?isShared=true&assetId=${user1Asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(5); expect(body).toHaveLength(5);
}); });
it('should return the album collection filtered by assetId and ignores shared=false', async () => { it('should return the album collection filtered by assetId and ignores isShared=false', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get(`/albums?shared=false&assetId=${user1Asset1.id}`) .get(`/albums?isShared=false&assetId=${user1Asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(5); expect(body).toHaveLength(5);
+2 -1
View File
@@ -240,7 +240,8 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
}); });
}); });
await context.route('**/api/albums*', async (route, request) => { await context.route('**/api/albums*', async (route, request) => {
if (request.url().endsWith('albums?shared=true') || request.url().endsWith('albums')) { const url = request.url();
if (url.endsWith('albums?isShared=true') || url.endsWith('albums?isOwned=true') || url.endsWith('albums')) {
return route.fulfill({ return route.fulfill({
status: 200, status: 200,
contentType: 'application/json', contentType: 'application/json',
+4
View File
@@ -2192,6 +2192,7 @@
"show_schema": "Show schema", "show_schema": "Show schema",
"show_search_options": "Show search options", "show_search_options": "Show search options",
"show_shared_links": "Show shared links", "show_shared_links": "Show shared links",
"show_slideshow_metadata_overlay": "Show image info overlay",
"show_slideshow_transition": "Show slideshow transition", "show_slideshow_transition": "Show slideshow transition",
"show_supporter_badge": "Supporter badge", "show_supporter_badge": "Supporter badge",
"show_supporter_badge_description": "Show a supporter badge", "show_supporter_badge_description": "Show a supporter badge",
@@ -2207,6 +2208,9 @@
"skip_to_folders": "Skip to folders", "skip_to_folders": "Skip to folders",
"skip_to_tags": "Skip to tags", "skip_to_tags": "Skip to tags",
"slideshow": "Slideshow", "slideshow": "Slideshow",
"slideshow_metadata_overlay_mode": "Overlay content",
"slideshow_metadata_overlay_mode_description_only": "Description only",
"slideshow_metadata_overlay_mode_full": "Full",
"slideshow_repeat": "Repeat slideshow", "slideshow_repeat": "Repeat slideshow",
"slideshow_repeat_description": "Loop back to beginning when slideshow ends", "slideshow_repeat_description": "Loop back to beginning when slideshow ends",
"slideshow_settings": "Slideshow settings", "slideshow_settings": "Slideshow settings",
-13
View File
@@ -32,25 +32,12 @@ class OcrSettings(BaseModel):
class PreloadModelData(BaseModel): class PreloadModelData(BaseModel):
clip_fallback: str | None = os.getenv("MACHINE_LEARNING_PRELOAD__CLIP", None)
facial_recognition_fallback: str | None = os.getenv("MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION", None)
if clip_fallback is not None:
os.environ["MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL"] = clip_fallback
os.environ["MACHINE_LEARNING_PRELOAD__CLIP__VISUAL"] = clip_fallback
del os.environ["MACHINE_LEARNING_PRELOAD__CLIP"]
if facial_recognition_fallback is not None:
os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION"] = facial_recognition_fallback
os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION"] = facial_recognition_fallback
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() ocr: OcrSettings = OcrSettings()
class MaxBatchSize(BaseModel): class MaxBatchSize(BaseModel):
ocr_fallback: str | None = os.getenv("MACHINE_LEARNING_MAX_BATCH_SIZE__TEXT_RECOGNITION", None)
if ocr_fallback is not None:
os.environ["MACHINE_LEARNING_MAX_BATCH_SIZE__OCR"] = ocr_fallback
facial_recognition: int | None = None facial_recognition: int | None = None
ocr: int | None = None ocr: int | None = None
-14
View File
@@ -117,20 +117,6 @@ async def preload_models(preload: PreloadModelData) -> None:
ModelTask.OCR, ModelTask.OCR,
) )
if preload.clip_fallback is not None:
log.warning(
"Deprecated env variable: 'MACHINE_LEARNING_PRELOAD__CLIP'. "
"Use 'MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL' and "
"'MACHINE_LEARNING_PRELOAD__CLIP__VISUAL' instead."
)
if preload.facial_recognition_fallback is not None:
log.warning(
"Deprecated env variable: 'MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION'. "
"Use 'MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION' and "
"'MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION' instead."
)
def update_state() -> Iterator[None]: def update_state() -> Iterator[None]:
global active_requests, last_called global active_requests, last_called
+18
View File
@@ -16,3 +16,21 @@ run = "uv run ruff format immich_ml"
[tasks.check] [tasks.check]
run = "uv run mypy --strict immich_ml/" run = "uv run mypy --strict immich_ml/"
[tasks.ci-unit]
run = [
{ task = ":install --extra cpu" },
{ task = ":format" },
{ task = ":lint --output-format=github" },
{ task = ":check" },
{ task = ":test" },
]
[tasks.checklist]
run = [
{ task = ":install" },
{ task = ":format" },
{ task = ":lint" },
{ task = ":check" },
{ task = ":test" },
]
+1 -1
View File
@@ -11,7 +11,7 @@ dependencies = [
"gunicorn>=21.1.0", "gunicorn>=21.1.0",
"huggingface-hub>=1.0,<2.0", "huggingface-hub>=1.0,<2.0",
"insightface>=0.7.3,<1.0", "insightface>=0.7.3,<1.0",
"numpy<2.4.0", "numpy>=2.4.0,<3.0",
"opencv-python-headless>=4.7.0.72,<5.0", "opencv-python-headless>=4.7.0.72,<5.0",
"orjson>=3.9.5", "orjson>=3.9.5",
"pillow>=12.2,<13", "pillow>=12.2,<13",
+77 -79
View File
@@ -1005,7 +1005,7 @@ requires-dist = [
{ name = "gunicorn", specifier = ">=21.1.0" }, { name = "gunicorn", specifier = ">=21.1.0" },
{ name = "huggingface-hub", specifier = ">=1.0,<2.0" }, { name = "huggingface-hub", specifier = ">=1.0,<2.0" },
{ name = "insightface", specifier = ">=0.7.3,<1.0" }, { name = "insightface", specifier = ">=0.7.3,<1.0" },
{ name = "numpy", specifier = "<2.4.0" }, { name = "numpy", specifier = ">=2.4.0,<3.0" },
{ name = "onnxruntime", marker = "extra == 'armnn'", specifier = ">=1.23.2,<2" }, { name = "onnxruntime", marker = "extra == 'armnn'", specifier = ">=1.23.2,<2" },
{ name = "onnxruntime", marker = "extra == 'cpu'", specifier = ">=1.23.2,<2" }, { name = "onnxruntime", marker = "extra == 'cpu'", specifier = ">=1.23.2,<2" },
{ name = "onnxruntime", marker = "extra == 'rknn'", specifier = ">=1.23.2,<2" }, { name = "onnxruntime", marker = "extra == 'rknn'", specifier = ">=1.23.2,<2" },
@@ -1558,83 +1558,81 @@ wheels = [
[[package]] [[package]]
name = "numpy" name = "numpy"
version = "2.3.5" version = "2.4.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/76/65/21b3bc86aac7b8f2862db1e808f1ea22b028e30a225a34a5ede9bf8678f2/numpy-2.3.5.tar.gz", hash = "sha256:784db1dcdab56bf0517743e746dfb0f885fc68d948aba86eeec2cba234bdf1c0", size = 20584950, upload-time = "2025-11-16T22:52:42.067Z" } sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/43/77/84dd1d2e34d7e2792a236ba180b5e8fcc1e3e414e761ce0253f63d7f572e/numpy-2.3.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:de5672f4a7b200c15a4127042170a694d4df43c992948f5e1af57f0174beed10", size = 17034641, upload-time = "2025-11-16T22:49:19.336Z" }, { url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" },
{ url = "https://files.pythonhosted.org/packages/2a/ea/25e26fa5837106cde46ae7d0b667e20f69cbbc0efd64cba8221411ab26ae/numpy-2.3.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:acfd89508504a19ed06ef963ad544ec6664518c863436306153e13e94605c218", size = 12528324, upload-time = "2025-11-16T22:49:22.582Z" }, { url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" },
{ url = "https://files.pythonhosted.org/packages/4d/1a/e85f0eea4cf03d6a0228f5c0256b53f2df4bc794706e7df019fc622e47f1/numpy-2.3.5-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ffe22d2b05504f786c867c8395de703937f934272eb67586817b46188b4ded6d", size = 5356872, upload-time = "2025-11-16T22:49:25.408Z" }, { url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" },
{ url = "https://files.pythonhosted.org/packages/5c/bb/35ef04afd567f4c989c2060cde39211e4ac5357155c1833bcd1166055c61/numpy-2.3.5-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:872a5cf366aec6bb1147336480fef14c9164b154aeb6542327de4970282cd2f5", size = 6893148, upload-time = "2025-11-16T22:49:27.549Z" }, { url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" },
{ url = "https://files.pythonhosted.org/packages/f2/2b/05bbeb06e2dff5eab512dfc678b1cc5ee94d8ac5956a0885c64b6b26252b/numpy-2.3.5-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3095bdb8dd297e5920b010e96134ed91d852d81d490e787beca7e35ae1d89cf7", size = 14557282, upload-time = "2025-11-16T22:49:30.964Z" }, { url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" },
{ url = "https://files.pythonhosted.org/packages/65/fb/2b23769462b34398d9326081fad5655198fcf18966fcb1f1e49db44fbf31/numpy-2.3.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8cba086a43d54ca804ce711b2a940b16e452807acebe7852ff327f1ecd49b0d4", size = 16897903, upload-time = "2025-11-16T22:49:34.191Z" }, { url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" },
{ url = "https://files.pythonhosted.org/packages/ac/14/085f4cf05fc3f1e8aa95e85404e984ffca9b2275a5dc2b1aae18a67538b8/numpy-2.3.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6cf9b429b21df6b99f4dee7a1218b8b7ffbbe7df8764dc0bd60ce8a0708fed1e", size = 16341672, upload-time = "2025-11-16T22:49:37.2Z" }, { url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" },
{ url = "https://files.pythonhosted.org/packages/6f/3b/1f73994904142b2aa290449b3bb99772477b5fd94d787093e4f24f5af763/numpy-2.3.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:396084a36abdb603546b119d96528c2f6263921c50df3c8fd7cb28873a237748", size = 18838896, upload-time = "2025-11-16T22:49:39.727Z" }, { url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" },
{ url = "https://files.pythonhosted.org/packages/cd/b9/cf6649b2124f288309ffc353070792caf42ad69047dcc60da85ee85fea58/numpy-2.3.5-cp311-cp311-win32.whl", hash = "sha256:b0c7088a73aef3d687c4deef8452a3ac7c1be4e29ed8bf3b366c8111128ac60c", size = 6563608, upload-time = "2025-11-16T22:49:42.079Z" }, { url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" },
{ url = "https://files.pythonhosted.org/packages/aa/44/9fe81ae1dcc29c531843852e2874080dc441338574ccc4306b39e2ff6e59/numpy-2.3.5-cp311-cp311-win_amd64.whl", hash = "sha256:a414504bef8945eae5f2d7cb7be2d4af77c5d1cb5e20b296c2c25b61dff2900c", size = 13078442, upload-time = "2025-11-16T22:49:43.99Z" }, { url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" },
{ url = "https://files.pythonhosted.org/packages/6d/a7/f99a41553d2da82a20a2f22e93c94f928e4490bb447c9ff3c4ff230581d3/numpy-2.3.5-cp311-cp311-win_arm64.whl", hash = "sha256:0cd00b7b36e35398fa2d16af7b907b65304ef8bb4817a550e06e5012929830fa", size = 10458555, upload-time = "2025-11-16T22:49:47.092Z" }, { url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" },
{ url = "https://files.pythonhosted.org/packages/44/37/e669fe6cbb2b96c62f6bbedc6a81c0f3b7362f6a59230b23caa673a85721/numpy-2.3.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:74ae7b798248fe62021dbf3c914245ad45d1a6b0cb4a29ecb4b31d0bfbc4cc3e", size = 16733873, upload-time = "2025-11-16T22:49:49.84Z" }, { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" },
{ url = "https://files.pythonhosted.org/packages/c5/65/df0db6c097892c9380851ab9e44b52d4f7ba576b833996e0080181c0c439/numpy-2.3.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee3888d9ff7c14604052b2ca5535a30216aa0a58e948cdd3eeb8d3415f638769", size = 12259838, upload-time = "2025-11-16T22:49:52.863Z" }, { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" },
{ url = "https://files.pythonhosted.org/packages/5b/e1/1ee06e70eb2136797abe847d386e7c0e830b67ad1d43f364dd04fa50d338/numpy-2.3.5-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:612a95a17655e213502f60cfb9bf9408efdc9eb1d5f50535cc6eb365d11b42b5", size = 5088378, upload-time = "2025-11-16T22:49:55.055Z" }, { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" },
{ url = "https://files.pythonhosted.org/packages/6d/9c/1ca85fb86708724275103b81ec4cf1ac1d08f465368acfc8da7ab545bdae/numpy-2.3.5-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3101e5177d114a593d79dd79658650fe28b5a0d8abeb8ce6f437c0e6df5be1a4", size = 6628559, upload-time = "2025-11-16T22:49:57.371Z" }, { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" },
{ url = "https://files.pythonhosted.org/packages/74/78/fcd41e5a0ce4f3f7b003da85825acddae6d7ecb60cf25194741b036ca7d6/numpy-2.3.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b973c57ff8e184109db042c842423ff4f60446239bd585a5131cc47f06f789d", size = 14250702, upload-time = "2025-11-16T22:49:59.632Z" }, { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" },
{ url = "https://files.pythonhosted.org/packages/b6/23/2a1b231b8ff672b4c450dac27164a8b2ca7d9b7144f9c02d2396518352eb/numpy-2.3.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d8163f43acde9a73c2a33605353a4f1bc4798745a8b1d73183b28e5b435ae28", size = 16606086, upload-time = "2025-11-16T22:50:02.127Z" }, { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" },
{ url = "https://files.pythonhosted.org/packages/a0/c5/5ad26fbfbe2012e190cc7d5003e4d874b88bb18861d0829edc140a713021/numpy-2.3.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:51c1e14eb1e154ebd80e860722f9e6ed6ec89714ad2db2d3aa33c31d7c12179b", size = 16025985, upload-time = "2025-11-16T22:50:04.536Z" }, { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" },
{ url = "https://files.pythonhosted.org/packages/d2/fa/dd48e225c46c819288148d9d060b047fd2a6fb1eb37eae25112ee4cb4453/numpy-2.3.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b46b4ec24f7293f23adcd2d146960559aaf8020213de8ad1909dba6c013bf89c", size = 18542976, upload-time = "2025-11-16T22:50:07.557Z" }, { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" },
{ url = "https://files.pythonhosted.org/packages/05/79/ccbd23a75862d95af03d28b5c6901a1b7da4803181513d52f3b86ed9446e/numpy-2.3.5-cp312-cp312-win32.whl", hash = "sha256:3997b5b3c9a771e157f9aae01dd579ee35ad7109be18db0e85dbdbe1de06e952", size = 6285274, upload-time = "2025-11-16T22:50:10.746Z" }, { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" },
{ url = "https://files.pythonhosted.org/packages/2d/57/8aeaf160312f7f489dea47ab61e430b5cb051f59a98ae68b7133ce8fa06a/numpy-2.3.5-cp312-cp312-win_amd64.whl", hash = "sha256:86945f2ee6d10cdfd67bcb4069c1662dd711f7e2a4343db5cecec06b87cf31aa", size = 12782922, upload-time = "2025-11-16T22:50:12.811Z" }, { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" },
{ url = "https://files.pythonhosted.org/packages/78/a6/aae5cc2ca78c45e64b9ef22f089141d661516856cf7c8a54ba434576900d/numpy-2.3.5-cp312-cp312-win_arm64.whl", hash = "sha256:f28620fe26bee16243be2b7b874da327312240a7cdc38b769a697578d2100013", size = 10194667, upload-time = "2025-11-16T22:50:16.16Z" }, { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" },
{ url = "https://files.pythonhosted.org/packages/db/69/9cde09f36da4b5a505341180a3f2e6fadc352fd4d2b7096ce9778db83f1a/numpy-2.3.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d0f23b44f57077c1ede8c5f26b30f706498b4862d3ff0a7298b8411dd2f043ff", size = 16728251, upload-time = "2025-11-16T22:50:19.013Z" }, { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" },
{ url = "https://files.pythonhosted.org/packages/79/fb/f505c95ceddd7027347b067689db71ca80bd5ecc926f913f1a23e65cf09b/numpy-2.3.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa5bc7c5d59d831d9773d1170acac7893ce3a5e130540605770ade83280e7188", size = 12254652, upload-time = "2025-11-16T22:50:21.487Z" }, { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" },
{ url = "https://files.pythonhosted.org/packages/78/da/8c7738060ca9c31b30e9301ee0cf6c5ffdbf889d9593285a1cead337f9a5/numpy-2.3.5-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ccc933afd4d20aad3c00bcef049cb40049f7f196e0397f1109dba6fed63267b0", size = 5083172, upload-time = "2025-11-16T22:50:24.562Z" }, { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" },
{ url = "https://files.pythonhosted.org/packages/a4/b4/ee5bb2537fb9430fd2ef30a616c3672b991a4129bb1c7dcc42aa0abbe5d7/numpy-2.3.5-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afaffc4393205524af9dfa400fa250143a6c3bc646c08c9f5e25a9f4b4d6a903", size = 6622990, upload-time = "2025-11-16T22:50:26.47Z" }, { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" },
{ url = "https://files.pythonhosted.org/packages/95/03/dc0723a013c7d7c19de5ef29e932c3081df1c14ba582b8b86b5de9db7f0f/numpy-2.3.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c75442b2209b8470d6d5d8b1c25714270686f14c749028d2199c54e29f20b4d", size = 14248902, upload-time = "2025-11-16T22:50:28.861Z" }, { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" },
{ url = "https://files.pythonhosted.org/packages/f5/10/ca162f45a102738958dcec8023062dad0cbc17d1ab99d68c4e4a6c45fb2b/numpy-2.3.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e06aa0af8c0f05104d56450d6093ee639e15f24ecf62d417329d06e522e017", size = 16597430, upload-time = "2025-11-16T22:50:31.56Z" }, { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" },
{ url = "https://files.pythonhosted.org/packages/2a/51/c1e29be863588db58175175f057286900b4b3327a1351e706d5e0f8dd679/numpy-2.3.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ed89927b86296067b4f81f108a2271d8926467a8868e554eaf370fc27fa3ccaf", size = 16024551, upload-time = "2025-11-16T22:50:34.242Z" }, { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" },
{ url = "https://files.pythonhosted.org/packages/83/68/8236589d4dbb87253d28259d04d9b814ec0ecce7cb1c7fed29729f4c3a78/numpy-2.3.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51c55fe3451421f3a6ef9a9c1439e82101c57a2c9eab9feb196a62b1a10b58ce", size = 18533275, upload-time = "2025-11-16T22:50:37.651Z" }, { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" },
{ url = "https://files.pythonhosted.org/packages/40/56/2932d75b6f13465239e3b7b7e511be27f1b8161ca2510854f0b6e521c395/numpy-2.3.5-cp313-cp313-win32.whl", hash = "sha256:1978155dd49972084bd6ef388d66ab70f0c323ddee6f693d539376498720fb7e", size = 6277637, upload-time = "2025-11-16T22:50:40.11Z" }, { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" },
{ url = "https://files.pythonhosted.org/packages/0c/88/e2eaa6cffb115b85ed7c7c87775cb8bcf0816816bc98ca8dbfa2ee33fe6e/numpy-2.3.5-cp313-cp313-win_amd64.whl", hash = "sha256:00dc4e846108a382c5869e77c6ed514394bdeb3403461d25a829711041217d5b", size = 12779090, upload-time = "2025-11-16T22:50:42.503Z" }, { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" },
{ url = "https://files.pythonhosted.org/packages/8f/88/3f41e13a44ebd4034ee17baa384acac29ba6a4fcc2aca95f6f08ca0447d1/numpy-2.3.5-cp313-cp313-win_arm64.whl", hash = "sha256:0472f11f6ec23a74a906a00b48a4dcf3849209696dff7c189714511268d103ae", size = 10194710, upload-time = "2025-11-16T22:50:44.971Z" }, { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" },
{ url = "https://files.pythonhosted.org/packages/13/cb/71744144e13389d577f867f745b7df2d8489463654a918eea2eeb166dfc9/numpy-2.3.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:414802f3b97f3c1eef41e530aaba3b3c1620649871d8cb38c6eaff034c2e16bd", size = 16827292, upload-time = "2025-11-16T22:50:47.715Z" }, { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" },
{ url = "https://files.pythonhosted.org/packages/71/80/ba9dc6f2a4398e7f42b708a7fdc841bb638d353be255655498edbf9a15a8/numpy-2.3.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5ee6609ac3604fa7780e30a03e5e241a7956f8e2fcfe547d51e3afa5247ac47f", size = 12378897, upload-time = "2025-11-16T22:50:51.327Z" }, { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" },
{ url = "https://files.pythonhosted.org/packages/2e/6d/db2151b9f64264bcceccd51741aa39b50150de9b602d98ecfe7e0c4bff39/numpy-2.3.5-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:86d835afea1eaa143012a2d7a3f45a3adce2d7adc8b4961f0b362214d800846a", size = 5207391, upload-time = "2025-11-16T22:50:54.542Z" }, { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" },
{ url = "https://files.pythonhosted.org/packages/80/ae/429bacace5ccad48a14c4ae5332f6aa8ab9f69524193511d60ccdfdc65fa/numpy-2.3.5-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:30bc11310e8153ca664b14c5f1b73e94bd0503681fcf136a163de856f3a50139", size = 6721275, upload-time = "2025-11-16T22:50:56.794Z" }, { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" },
{ url = "https://files.pythonhosted.org/packages/74/5b/1919abf32d8722646a38cd527bc3771eb229a32724ee6ba340ead9b92249/numpy-2.3.5-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1062fde1dcf469571705945b0f221b73928f34a20c904ffb45db101907c3454e", size = 14306855, upload-time = "2025-11-16T22:50:59.208Z" }, { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" },
{ url = "https://files.pythonhosted.org/packages/a5/87/6831980559434973bebc30cd9c1f21e541a0f2b0c280d43d3afd909b66d0/numpy-2.3.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce581db493ea1a96c0556360ede6607496e8bf9b3a8efa66e06477267bc831e9", size = 16657359, upload-time = "2025-11-16T22:51:01.991Z" }, { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" },
{ url = "https://files.pythonhosted.org/packages/dd/91/c797f544491ee99fd00495f12ebb7802c440c1915811d72ac5b4479a3356/numpy-2.3.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:cc8920d2ec5fa99875b670bb86ddeb21e295cb07aa331810d9e486e0b969d946", size = 16093374, upload-time = "2025-11-16T22:51:05.291Z" }, { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" },
{ url = "https://files.pythonhosted.org/packages/74/a6/54da03253afcbe7a72785ec4da9c69fb7a17710141ff9ac5fcb2e32dbe64/numpy-2.3.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9ee2197ef8c4f0dfe405d835f3b6a14f5fee7782b5de51ba06fb65fc9b36e9f1", size = 18594587, upload-time = "2025-11-16T22:51:08.585Z" }, { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" },
{ url = "https://files.pythonhosted.org/packages/80/e9/aff53abbdd41b0ecca94285f325aff42357c6b5abc482a3fcb4994290b18/numpy-2.3.5-cp313-cp313t-win32.whl", hash = "sha256:70b37199913c1bd300ff6e2693316c6f869c7ee16378faf10e4f5e3275b299c3", size = 6405940, upload-time = "2025-11-16T22:51:11.541Z" }, { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" },
{ url = "https://files.pythonhosted.org/packages/d5/81/50613fec9d4de5480de18d4f8ef59ad7e344d497edbef3cfd80f24f98461/numpy-2.3.5-cp313-cp313t-win_amd64.whl", hash = "sha256:b501b5fa195cc9e24fe102f21ec0a44dffc231d2af79950b451e0d99cea02234", size = 12920341, upload-time = "2025-11-16T22:51:14.312Z" }, { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" },
{ url = "https://files.pythonhosted.org/packages/bb/ab/08fd63b9a74303947f34f0bd7c5903b9c5532c2d287bead5bdf4c556c486/numpy-2.3.5-cp313-cp313t-win_arm64.whl", hash = "sha256:a80afd79f45f3c4a7d341f13acbe058d1ca8ac017c165d3fa0d3de6bc1a079d7", size = 10262507, upload-time = "2025-11-16T22:51:16.846Z" }, { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" },
{ url = "https://files.pythonhosted.org/packages/ba/97/1a914559c19e32d6b2e233cf9a6a114e67c856d35b1d6babca571a3e880f/numpy-2.3.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:bf06bc2af43fa8d32d30fae16ad965663e966b1a3202ed407b84c989c3221e82", size = 16735706, upload-time = "2025-11-16T22:51:19.558Z" }, { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" },
{ url = "https://files.pythonhosted.org/packages/57/d4/51233b1c1b13ecd796311216ae417796b88b0616cfd8a33ae4536330748a/numpy-2.3.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:052e8c42e0c49d2575621c158934920524f6c5da05a1d3b9bab5d8e259e045f0", size = 12264507, upload-time = "2025-11-16T22:51:22.492Z" }, { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" },
{ url = "https://files.pythonhosted.org/packages/45/98/2fe46c5c2675b8306d0b4a3ec3494273e93e1226a490f766e84298576956/numpy-2.3.5-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:1ed1ec893cff7040a02c8aa1c8611b94d395590d553f6b53629a4461dc7f7b63", size = 5093049, upload-time = "2025-11-16T22:51:25.171Z" }, { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" },
{ url = "https://files.pythonhosted.org/packages/ce/0e/0698378989bb0ac5f1660c81c78ab1fe5476c1a521ca9ee9d0710ce54099/numpy-2.3.5-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2dcd0808a421a482a080f89859a18beb0b3d1e905b81e617a188bd80422d62e9", size = 6626603, upload-time = "2025-11-16T22:51:27Z" }, { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" },
{ url = "https://files.pythonhosted.org/packages/5e/a6/9ca0eecc489640615642a6cbc0ca9e10df70df38c4d43f5a928ff18d8827/numpy-2.3.5-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727fd05b57df37dc0bcf1a27767a3d9a78cbbc92822445f32cc3436ba797337b", size = 14262696, upload-time = "2025-11-16T22:51:29.402Z" }, { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" },
{ url = "https://files.pythonhosted.org/packages/c8/f6/07ec185b90ec9d7217a00eeeed7383b73d7e709dae2a9a021b051542a708/numpy-2.3.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fffe29a1ef00883599d1dc2c51aa2e5d80afe49523c261a74933df395c15c520", size = 16597350, upload-time = "2025-11-16T22:51:32.167Z" }, { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" },
{ url = "https://files.pythonhosted.org/packages/75/37/164071d1dde6a1a84c9b8e5b414fa127981bad47adf3a6b7e23917e52190/numpy-2.3.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f7f0e05112916223d3f438f293abf0727e1181b5983f413dfa2fefc4098245c", size = 16040190, upload-time = "2025-11-16T22:51:35.403Z" }, { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" },
{ url = "https://files.pythonhosted.org/packages/08/3c/f18b82a406b04859eb026d204e4e1773eb41c5be58410f41ffa511d114ae/numpy-2.3.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2e2eb32ddb9ccb817d620ac1d8dae7c3f641c1e5f55f531a33e8ab97960a75b8", size = 18536749, upload-time = "2025-11-16T22:51:39.698Z" }, { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" },
{ url = "https://files.pythonhosted.org/packages/40/79/f82f572bf44cf0023a2fe8588768e23e1592585020d638999f15158609e1/numpy-2.3.5-cp314-cp314-win32.whl", hash = "sha256:66f85ce62c70b843bab1fb14a05d5737741e74e28c7b8b5a064de10142fad248", size = 6335432, upload-time = "2025-11-16T22:51:42.476Z" }, { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" },
{ url = "https://files.pythonhosted.org/packages/a3/2e/235b4d96619931192c91660805e5e49242389742a7a82c27665021db690c/numpy-2.3.5-cp314-cp314-win_amd64.whl", hash = "sha256:e6a0bc88393d65807d751a614207b7129a310ca4fe76a74e5c7da5fa5671417e", size = 12919388, upload-time = "2025-11-16T22:51:45.275Z" }, { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" },
{ url = "https://files.pythonhosted.org/packages/07/2b/29fd75ce45d22a39c61aad74f3d718e7ab67ccf839ca8b60866054eb15f8/numpy-2.3.5-cp314-cp314-win_arm64.whl", hash = "sha256:aeffcab3d4b43712bb7a60b65f6044d444e75e563ff6180af8f98dd4b905dfd2", size = 10476651, upload-time = "2025-11-16T22:51:47.749Z" }, { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" },
{ url = "https://files.pythonhosted.org/packages/17/e1/f6a721234ebd4d87084cfa68d081bcba2f5cfe1974f7de4e0e8b9b2a2ba1/numpy-2.3.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17531366a2e3a9e30762c000f2c43a9aaa05728712e25c11ce1dbe700c53ad41", size = 16834503, upload-time = "2025-11-16T22:51:50.443Z" }, { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" },
{ url = "https://files.pythonhosted.org/packages/5c/1c/baf7ffdc3af9c356e1c135e57ab7cf8d247931b9554f55c467efe2c69eff/numpy-2.3.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d21644de1b609825ede2f48be98dfde4656aefc713654eeee280e37cadc4e0ad", size = 12381612, upload-time = "2025-11-16T22:51:53.609Z" }, { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" },
{ url = "https://files.pythonhosted.org/packages/74/91/f7f0295151407ddc9ba34e699013c32c3c91944f9b35fcf9281163dc1468/numpy-2.3.5-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c804e3a5aba5460c73955c955bdbd5c08c354954e9270a2c1565f62e866bdc39", size = 5210042, upload-time = "2025-11-16T22:51:56.213Z" }, { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" },
{ url = "https://files.pythonhosted.org/packages/2e/3b/78aebf345104ec50dd50a4d06ddeb46a9ff5261c33bcc58b1c4f12f85ec2/numpy-2.3.5-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:cc0a57f895b96ec78969c34f682c602bf8da1a0270b09bc65673df2e7638ec20", size = 6724502, upload-time = "2025-11-16T22:51:58.584Z" }, { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" },
{ url = "https://files.pythonhosted.org/packages/02/c6/7c34b528740512e57ef1b7c8337ab0b4f0bddf34c723b8996c675bc2bc91/numpy-2.3.5-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:900218e456384ea676e24ea6a0417f030a3b07306d29d7ad843957b40a9d8d52", size = 14308962, upload-time = "2025-11-16T22:52:01.698Z" }, { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" },
{ url = "https://files.pythonhosted.org/packages/80/35/09d433c5262bc32d725bafc619e095b6a6651caf94027a03da624146f655/numpy-2.3.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09a1bea522b25109bf8e6f3027bd810f7c1085c64a0c7ce050c1676ad0ba010b", size = 16655054, upload-time = "2025-11-16T22:52:04.267Z" }, { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" },
{ url = "https://files.pythonhosted.org/packages/7a/ab/6a7b259703c09a88804fa2430b43d6457b692378f6b74b356155283566ac/numpy-2.3.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04822c00b5fd0323c8166d66c701dc31b7fbd252c100acd708c48f763968d6a3", size = 16091613, upload-time = "2025-11-16T22:52:08.651Z" }, { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" },
{ url = "https://files.pythonhosted.org/packages/c2/88/330da2071e8771e60d1038166ff9d73f29da37b01ec3eb43cb1427464e10/numpy-2.3.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d6889ec4ec662a1a37eb4b4fb26b6100841804dac55bd9df579e326cdc146227", size = 18591147, upload-time = "2025-11-16T22:52:11.453Z" }, { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" },
{ url = "https://files.pythonhosted.org/packages/51/41/851c4b4082402d9ea860c3626db5d5df47164a712cb23b54be028b184c1c/numpy-2.3.5-cp314-cp314t-win32.whl", hash = "sha256:93eebbcf1aafdf7e2ddd44c2923e2672e1010bddc014138b229e49725b4d6be5", size = 6479806, upload-time = "2025-11-16T22:52:14.641Z" }, { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" },
{ url = "https://files.pythonhosted.org/packages/90/30/d48bde1dfd93332fa557cff1972fbc039e055a52021fbef4c2c4b1eefd17/numpy-2.3.5-cp314-cp314t-win_amd64.whl", hash = "sha256:c8a9958e88b65c3b27e22ca2a076311636850b612d6bbfb76e8d156aacde2aaf", size = 13105760, upload-time = "2025-11-16T22:52:17.975Z" }, { url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" },
{ url = "https://files.pythonhosted.org/packages/2d/fd/4b5eb0b3e888d86aee4d198c23acec7d214baaf17ea93c1adec94c9518b9/numpy-2.3.5-cp314-cp314t-win_arm64.whl", hash = "sha256:6203fdf9f3dc5bdaed7319ad8698e685c7a3be10819f41d32a0723e611733b42", size = 10545459, upload-time = "2025-11-16T22:52:20.55Z" }, { url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" },
{ url = "https://files.pythonhosted.org/packages/c6/65/f9dea8e109371ade9c782b4e4756a82edf9d3366bca495d84d79859a0b79/numpy-2.3.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f0963b55cdd70fad460fa4c1341f12f976bb26cb66021a5580329bd498988310", size = 16910689, upload-time = "2025-11-16T22:52:23.247Z" }, { url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" },
{ url = "https://files.pythonhosted.org/packages/00/4f/edb00032a8fb92ec0a679d3830368355da91a69cab6f3e9c21b64d0bb986/numpy-2.3.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f4255143f5160d0de972d28c8f9665d882b5f61309d8362fdd3e103cf7bf010c", size = 12457053, upload-time = "2025-11-16T22:52:26.367Z" }, { url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" },
{ url = "https://files.pythonhosted.org/packages/16/a4/e8a53b5abd500a63836a29ebe145fc1ab1f2eefe1cfe59276020373ae0aa/numpy-2.3.5-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:a4b9159734b326535f4dd01d947f919c6eefd2d9827466a696c44ced82dfbc18", size = 5285635, upload-time = "2025-11-16T22:52:29.266Z" }, { url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" },
{ url = "https://files.pythonhosted.org/packages/a3/2f/37eeb9014d9c8b3e9c55bc599c68263ca44fdbc12a93e45a21d1d56df737/numpy-2.3.5-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2feae0d2c91d46e59fcd62784a3a83b3fb677fead592ce51b5a6fbb4f95965ff", size = 6801770, upload-time = "2025-11-16T22:52:31.421Z" }, { url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" },
{ url = "https://files.pythonhosted.org/packages/7d/e4/68d2f474df2cb671b2b6c2986a02e520671295647dad82484cde80ca427b/numpy-2.3.5-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffac52f28a7849ad7576293c0cb7b9f08304e8f7d738a8cb8a90ec4c55a998eb", size = 14391768, upload-time = "2025-11-16T22:52:33.593Z" }, { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" },
{ url = "https://files.pythonhosted.org/packages/b8/50/94ccd8a2b141cb50651fddd4f6a48874acb3c91c8f0842b08a6afc4b0b21/numpy-2.3.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63c0e9e7eea69588479ebf4a8a270d5ac22763cc5854e9a7eae952a3908103f7", size = 16729263, upload-time = "2025-11-16T22:52:36.369Z" },
{ url = "https://files.pythonhosted.org/packages/2d/ee/346fa473e666fe14c52fcdd19ec2424157290a032d4c41f98127bfb31ac7/numpy-2.3.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f16417ec91f12f814b10bafe79ef77e70113a2f5f7018640e7425ff979253425", size = 12967213, upload-time = "2025-11-16T22:52:39.38Z" },
] ]
[[package]] [[package]]
@@ -2314,11 +2312,11 @@ wheels = [
[[package]] [[package]]
name = "python-multipart" name = "python-multipart"
version = "0.0.26" version = "0.0.27"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" } sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" }, { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" },
] ]
[[package]] [[package]]
+1 -1
View File
@@ -16,7 +16,7 @@ config_roots = [
[tools] [tools]
node = "24.15.0" node = "24.15.0"
flutter = "3.41.7" flutter = "3.41.9"
pnpm = "10.33.1" pnpm = "10.33.1"
terragrunt = "1.0.3" terragrunt = "1.0.3"
opentofu = "1.11.6" opentofu = "1.11.6"
+8 -1
View File
@@ -64,8 +64,15 @@ android {
} }
release { release {
signingConfig signingConfigs.release def hasKeystore = file("../key.jks").exists() && file("../key.jks").length() > 0
signingConfig hasKeystore ? signingConfigs.release : signingConfigs.debug
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
def prNumber = System.getenv("PR_NUMBER")
if (prNumber) {
applicationIdSuffix ".pr${prNumber}"
versionNameSuffix "-pr${prNumber}"
}
} }
} }
namespace 'app.alextran.immich' namespace 'app.alextran.immich'
-3
View File
@@ -2,9 +2,6 @@ import 'package:flutter/material.dart';
enum ImmichColorPreset { indigo, deepPurple, pink, red, orange, yellow, lime, green, cyan, slateGray } enum ImmichColorPreset { indigo, deepPurple, pink, red, orange, yellow, lime, green, cyan, slateGray }
const ImmichColorPreset defaultColorPreset = ImmichColorPreset.indigo;
const String defaultColorPresetName = "indigo";
const Color immichBrandColorLight = Color(0xFF4150AF); const Color immichBrandColorLight = Color(0xFF4150AF);
const Color immichBrandColorDark = Color(0xFFACCBFA); const Color immichBrandColorDark = Color(0xFFACCBFA);
const Color whiteOpacity75 = Color.fromRGBO(255, 255, 255, 0.75); const Color whiteOpacity75 = Color.fromRGBO(255, 255, 255, 0.75);
@@ -1,18 +1,22 @@
import 'package:immich_mobile/domain/models/config/cleanup_config.dart';
import 'package:immich_mobile/domain/models/config/theme_config.dart'; import 'package:immich_mobile/domain/models/config/theme_config.dart';
class AppConfig { class AppConfig {
final ThemeConfig theme; final ThemeConfig theme;
final CleanupConfig cleanup;
const AppConfig({this.theme = const ThemeConfig()}); const AppConfig({this.theme = const .new(), this.cleanup = const .new()});
AppConfig copyWith({ThemeConfig? theme}) => .new(theme: theme ?? this.theme); AppConfig copyWith({ThemeConfig? theme, CleanupConfig? cleanup}) =>
.new(theme: theme ?? this.theme, cleanup: cleanup ?? this.cleanup);
@override @override
bool operator ==(Object other) => identical(this, other) || (other is AppConfig && other.theme == theme); bool operator ==(Object other) =>
identical(this, other) || (other is AppConfig && other.theme == theme && other.cleanup == cleanup);
@override @override
int get hashCode => theme.hashCode; int get hashCode => Object.hash(theme, cleanup);
@override @override
String toString() => 'AppConfig(theme: $theme)'; String toString() => 'AppConfig(theme: $theme, cleanup: $cleanup)';
} }
@@ -0,0 +1,48 @@
import 'package:immich_mobile/constants/enums.dart';
class CleanupConfig {
final bool keepFavorites;
final AssetKeepType keepMediaType;
final List<String> keepAlbumIds;
final int cutoffDaysAgo;
final bool defaultsInitialized;
const CleanupConfig({
this.keepFavorites = true,
this.keepMediaType = AssetKeepType.none,
this.keepAlbumIds = const [],
this.cutoffDaysAgo = -1,
this.defaultsInitialized = false,
});
CleanupConfig copyWith({
bool? keepFavorites,
AssetKeepType? keepMediaType,
List<String>? keepAlbumIds,
int? cutoffDaysAgo,
bool? defaultsInitialized,
}) => .new(
keepFavorites: keepFavorites ?? this.keepFavorites,
keepMediaType: keepMediaType ?? this.keepMediaType,
keepAlbumIds: keepAlbumIds ?? this.keepAlbumIds,
cutoffDaysAgo: cutoffDaysAgo ?? this.cutoffDaysAgo,
defaultsInitialized: defaultsInitialized ?? this.defaultsInitialized,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is CleanupConfig &&
other.keepFavorites == keepFavorites &&
other.keepMediaType == keepMediaType &&
other.keepAlbumIds == keepAlbumIds &&
other.cutoffDaysAgo == cutoffDaysAgo &&
other.defaultsInitialized == defaultsInitialized);
@override
int get hashCode => Object.hash(keepFavorites, keepMediaType, keepAlbumIds, cutoffDaysAgo, defaultsInitialized);
@override
String toString() =>
'CleanupConfig(keepFavorites: $keepFavorites, keepMediaType: $keepMediaType, keepAlbumIds: $keepAlbumIds, cutoffDaysAgo: $cutoffDaysAgo, defaultsInitialized: $defaultsInitialized)';
}
@@ -1,18 +1,44 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/colors.dart';
class ThemeConfig { class ThemeConfig {
final ThemeMode mode; final ThemeMode mode;
final ImmichColorPreset primaryColor;
final bool dynamicTheme;
final bool colorfulInterface;
const ThemeConfig({this.mode = .system}); const ThemeConfig({
this.mode = .system,
this.primaryColor = .indigo,
this.dynamicTheme = false,
this.colorfulInterface = true,
});
ThemeConfig copyWith({ThemeMode? mode}) => .new(mode: mode ?? this.mode); ThemeConfig copyWith({
ThemeMode? mode,
ImmichColorPreset? primaryColor,
bool? dynamicTheme,
bool? colorfulInterface,
}) => .new(
mode: mode ?? this.mode,
primaryColor: primaryColor ?? this.primaryColor,
dynamicTheme: dynamicTheme ?? this.dynamicTheme,
colorfulInterface: colorfulInterface ?? this.colorfulInterface,
);
@override @override
bool operator ==(Object other) => identical(this, other) || (other is ThemeConfig && other.mode == mode); bool operator ==(Object other) =>
identical(this, other) ||
(other is ThemeConfig &&
other.mode == mode &&
other.primaryColor == primaryColor &&
other.dynamicTheme == dynamicTheme &&
other.colorfulInterface == colorfulInterface);
@override @override
int get hashCode => mode.hashCode; int get hashCode => Object.hash(mode, primaryColor, dynamicTheme, colorfulInterface);
@override @override
String toString() => 'ThemeConfig(mode: $mode)'; String toString() =>
'ThemeConfig(mode: $mode, primaryColor: $primaryColor, dynamicTheme: $dynamicTheme, colorfulInterface: $colorfulInterface)';
} }
+50 -1
View File
@@ -1,5 +1,9 @@
import 'dart:convert';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart'; import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/config/system_config.dart'; import 'package:immich_mobile/domain/models/config/system_config.dart';
import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/models/log.model.dart';
@@ -13,8 +17,26 @@ enum MetadataDomain<T extends Object> {
} }
enum MetadataKey<T extends Object> { enum MetadataKey<T extends Object> {
// Theme
themePrimaryColor<ImmichColorPreset>(.appConfig, 'theme.primaryColor', .indigo, _EnumCodec(ImmichColorPreset.values)),
themeMode<ThemeMode>(.appConfig, 'theme.mode', .system, _EnumCodec(ThemeMode.values)), themeMode<ThemeMode>(.appConfig, 'theme.mode', .system, _EnumCodec(ThemeMode.values)),
logLevel<LogLevel>(.systemConfig, 'log.level', .info, _EnumCodec(LogLevel.values)); themeDynamic<bool>(.appConfig, 'theme.dynamic', false),
themeColorfulInterface<bool>(.appConfig, 'theme.colorfulInterface', true),
// Log
logLevel<LogLevel>(.systemConfig, 'log.level', .info, _EnumCodec(LogLevel.values)),
// Cleanup
cleanupKeepFavorites<bool>(.appConfig, 'cleanup.keepFavorites', true),
cleanupKeepMediaType<AssetKeepType>(
.appConfig,
'cleanup.keepMediaType',
AssetKeepType.none,
_EnumCodec(AssetKeepType.values),
),
cleanupKeepAlbumIds<List<String>>(.appConfig, 'cleanup.keepAlbumIds', [], _ListCodec(_PrimitiveCodec.string)),
cleanupCutoffDaysAgo<int>(.appConfig, 'cleanup.cutoffDaysAgo', -1),
cleanupDefaultsInitialized<bool>(.appConfig, 'cleanup.defaultsInitialized', false);
final MetadataDomain domain; final MetadataDomain domain;
final String name; final String name;
@@ -81,6 +103,33 @@ final class _DateTimeCodec extends _MetadataCodec<DateTime> {
DateTime? decode(String raw) => DateTime.tryParse(raw); DateTime? decode(String raw) => DateTime.tryParse(raw);
} }
final class _ListCodec<T extends Object> extends _MetadataCodec<List<T>> {
final _MetadataCodec<T> _elementCodec;
const _ListCodec(this._elementCodec);
@override
String encode(List<T> value) => jsonEncode(value.map(_elementCodec.encode).toList());
@override
List<T>? decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! List) return null;
final result = <T>[];
for (final item in decoded) {
if (item is! String) return null;
final element = _elementCodec.decode(item);
if (element == null) return null;
result.add(element);
}
return result;
} on FormatException {
return null;
}
}
}
final class _PrimitiveCodec<T extends Object> extends _MetadataCodec<T> { final class _PrimitiveCodec<T extends Object> extends _MetadataCodec<T> {
final T? Function(String) _parse; final T? Function(String) _parse;
+8 -11
View File
@@ -48,11 +48,6 @@ enum StoreKey<T> {
enableHapticFeedback<bool>._(126), enableHapticFeedback<bool>._(126),
customHeaders<String>._(127), customHeaders<String>._(127),
// theme settings
primaryColor<String>._(128),
dynamicTheme<bool>._(129),
colorfulInterface<bool>._(130),
syncAlbums<bool>._(131), syncAlbums<bool>._(131),
// Auto endpoint switching // Auto endpoint switching
@@ -86,16 +81,18 @@ enum StoreKey<T> {
shouldResetSync<bool>._(1007), shouldResetSync<bool>._(1007),
// Free up space // Free up space
cleanupKeepFavorites<bool>._(1008),
cleanupKeepMediaType<int>._(1009),
cleanupKeepAlbumIds<String>._(1010),
cleanupCutoffDaysAgo<int>._(1011),
cleanupDefaultsInitialized<bool>._(1012),
syncMigrationStatus<String>._(1013), syncMigrationStatus<String>._(1013),
// Legacy keys that have been migrated to the new metadata store // Legacy keys that have been migrated to the new metadata store
legacyPrimaryColor<String>._(128),
legacyDynamicTheme<bool>._(129),
legacyColorfulInterface<bool>._(130),
legacyThemeMode<String>._(102), legacyThemeMode<String>._(102),
legacyCleanupKeepFavorites<bool>._(1008),
legacyCleanupKeepMediaType<int>._(1009),
legacyCleanupKeepAlbumIds<String>._(1010),
legacyCleanupCutoffDaysAgo<int>._(1011),
legacyCleanupDefaultsInitialized<bool>._(1012),
legacyLogLevel<int>._(115); legacyLogLevel<int>._(115);
const StoreKey._(this.id); const StoreKey._(this.id);
@@ -176,15 +176,8 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
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 = null;
_cancellationToken.complete();
_logger.info("Cleaning up background worker"); _logger.info("Cleaning up background worker");
_cancellationToken.complete();
final cleanupFutures = [ final cleanupFutures = [
nativeSyncApi?.cancelHashing(), nativeSyncApi?.cancelHashing(),
workerManagerPatch.dispose().catchError((_) async { workerManagerPatch.dispose().catchError((_) async {
@@ -195,10 +188,15 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
Store.dispose(), Store.dispose(),
backgroundSyncManager?.cancel(), backgroundSyncManager?.cancel(),
_drift.optimize(allTables: true),
]; ];
await Future.wait(cleanupFutures.nonNulls); await Future.wait(cleanupFutures.nonNulls);
_logger.info("Background worker resources cleaned up"); await _drift.close();
await _driftLogger.close();
_ref?.dispose();
_ref = null;
} catch (error, stack) { } catch (error, stack) {
dPrint(() => 'Failed to cleanup background worker: $error with stack: $stack'); dPrint(() => 'Failed to cleanup background worker: $error with stack: $stack');
} }
@@ -30,6 +30,7 @@ import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart';
import 'package:logging/logging.dart';
@DriftDatabase( @DriftDatabase(
tables: [ tables: [
@@ -85,6 +86,17 @@ class Drift extends $Drift {
}); });
} }
Future<void> optimize({bool allTables = false}) async {
try {
if (allTables) {
await customStatement('PRAGMA optimize=0x10002');
}
await customStatement('PRAGMA optimize');
} catch (error) {
Logger('Drift').fine('Failed to optimize database', error);
}
}
@override @override
int get schemaVersion => 25; int get schemaVersion => 25;
@@ -265,6 +277,7 @@ class Drift extends $Drift {
} }
await customStatement('PRAGMA foreign_keys = ON;'); await customStatement('PRAGMA foreign_keys = ON;');
await optimize();
}, },
beforeOpen: (details) async { beforeOpen: (details) async {
await customStatement('PRAGMA foreign_keys = ON'); await customStatement('PRAGMA foreign_keys = ON');
@@ -47,7 +47,7 @@ class MetadataRepository extends DriftDatabaseRepository {
T _read<T extends Object>(MetadataKey<T> key) => (_cache[key] as T?) ?? key.defaultValue; T _read<T extends Object>(MetadataKey<T> key) => (_cache[key] as T?) ?? key.defaultValue;
Future<void> write<T extends Object>(MetadataKey<T> key, T value) async { Future<void> write<T extends Object, U extends T>(MetadataKey<T> key, U value) async {
if (_read(key) == value) return; if (_read(key) == value) return;
await _db await _db
@@ -63,9 +63,9 @@ class MetadataRepository extends DriftDatabaseRepository {
_updateCache(key, key.defaultValue); _updateCache(key, key.defaultValue);
} }
Stream<AppConfig> watchAppConfig() => _watchDomain(MetadataDomain.appConfig).distinct(); Stream<AppConfig> watchAppConfig() => _watchDomain(.appConfig).distinct();
Stream<SystemConfig> watchSystemConfig() => _watchDomain(MetadataDomain.systemConfig).distinct(); Stream<SystemConfig> watchSystemConfig() => _watchDomain(.systemConfig).distinct();
Stream<T> _watchDomain<T extends Object>(MetadataDomain<T> domain) { Stream<T> _watchDomain<T extends Object>(MetadataDomain<T> domain) {
final query = _db.select(_db.metadataEntity)..where((t) => t.key.like('${domain.prefix}.%')); final query = _db.select(_db.metadataEntity)..where((t) => t.key.like('${domain.prefix}.%'));
@@ -100,7 +100,21 @@ extension<T extends Object> on MetadataDomain<T> {
void rebuild(MetadataRepository repo) { void rebuild(MetadataRepository repo) {
switch (this) { switch (this) {
case .appConfig: case .appConfig:
repo._appConfig = .new(theme: .new(mode: repo._read(.themeMode))); repo._appConfig = .new(
theme: .new(
mode: repo._read(.themeMode),
primaryColor: repo._read(.themePrimaryColor),
dynamicTheme: repo._read(.themeDynamic),
colorfulInterface: repo._read(.themeColorfulInterface),
),
cleanup: .new(
keepFavorites: repo._read(.cleanupKeepFavorites),
keepMediaType: repo._read(.cleanupKeepMediaType),
keepAlbumIds: repo._read(.cleanupKeepAlbumIds),
cutoffDaysAgo: repo._read(.cleanupCutoffDaysAgo),
defaultsInitialized: repo._read(.cleanupDefaultsInitialized),
),
);
case .systemConfig: case .systemConfig:
repo._systemConfig = .new(logLevel: repo._read(.logLevel)); repo._systemConfig = .new(logLevel: repo._read(.logLevel));
} }
@@ -244,17 +244,21 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
final isAscending = albumData.order == AlbumAssetOrder.asc; final isAscending = albumData.order == AlbumAssetOrder.asc;
final query = _db.remoteAssetEntity.select().addColumns([_db.localAssetEntity.id]).join([ // Correlated subquery picks the first matching local asset by checksum,
// avoiding fan-out when the same photo exists in multiple device albums (#23273).
final localId = subqueryExpression<String>(
_db.localAssetEntity.selectOnly()
..addColumns([_db.localAssetEntity.id])
..where(_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum))
..limit(1),
);
final query = _db.remoteAssetEntity.select().addColumns([localId]).join([
innerJoin( innerJoin(
_db.remoteAlbumAssetEntity, _db.remoteAlbumAssetEntity,
_db.remoteAlbumAssetEntity.assetId.equalsExp(_db.remoteAssetEntity.id), _db.remoteAlbumAssetEntity.assetId.equalsExp(_db.remoteAssetEntity.id),
useColumns: false, useColumns: false,
), ),
leftOuterJoin(
_db.localAssetEntity,
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
useColumns: false,
),
])..where(_db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAlbumAssetEntity.albumId.equals(albumId)); ])..where(_db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAlbumAssetEntity.albumId.equals(albumId));
if (isAscending) { if (isAscending) {
@@ -265,9 +269,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
query.limit(count, offset: offset); query.limit(count, offset: offset);
return query return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto(localId: row.read(localId))).get();
.map((row) => row.readTable(_db.remoteAssetEntity).toDto(localId: row.read(_db.localAssetEntity.id)))
.get();
} }
TimelineQuery fromAssets(List<BaseAsset> assets, TimelineOrigin origin) => ( TimelineQuery fromAssets(List<BaseAsset> assets, TimelineOrigin origin) => (
@@ -422,23 +424,22 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
} }
Stream<List<Bucket>> _watchPersonBucket(String userId, String personId, {GroupAssetsBy groupBy = GroupAssetsBy.day}) { Stream<List<Bucket>> _watchPersonBucket(String userId, String personId, {GroupAssetsBy groupBy = GroupAssetsBy.day}) {
final idQuery = _db.assetFaceEntity.selectOnly()
..addColumns([_db.assetFaceEntity.assetId])
..where(
_db.assetFaceEntity.personId.equals(personId) &
_db.assetFaceEntity.isVisible.equals(true) &
_db.assetFaceEntity.deletedAt.isNull(),
);
if (groupBy == GroupAssetsBy.none) { if (groupBy == GroupAssetsBy.none) {
final query = _db.remoteAssetEntity.selectOnly() final query = _db.remoteAssetEntity.selectOnly()
..addColumns([_db.remoteAssetEntity.id.count()]) ..addColumns([_db.remoteAssetEntity.id.count()])
..join([
innerJoin(
_db.assetFaceEntity,
_db.assetFaceEntity.assetId.equalsExp(_db.remoteAssetEntity.id),
useColumns: false,
),
])
..where( ..where(
_db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAssetEntity.id.isInQuery(idQuery) &
_db.remoteAssetEntity.deletedAt.isNull() &
_db.remoteAssetEntity.ownerId.equals(userId) & _db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) & _db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline),
_db.assetFaceEntity.personId.equals(personId) &
_db.assetFaceEntity.isVisible.equals(true) &
_db.assetFaceEntity.deletedAt.isNull(),
); );
return query.map((row) { return query.map((row) {
@@ -452,20 +453,11 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
final query = _db.remoteAssetEntity.selectOnly() final query = _db.remoteAssetEntity.selectOnly()
..addColumns([assetCountExp, dateExp]) ..addColumns([assetCountExp, dateExp])
..join([
innerJoin(
_db.assetFaceEntity,
_db.assetFaceEntity.assetId.equalsExp(_db.remoteAssetEntity.id),
useColumns: false,
),
])
..where( ..where(
_db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAssetEntity.id.isInQuery(idQuery) &
_db.remoteAssetEntity.ownerId.equals(userId) & _db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) & _db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
_db.assetFaceEntity.personId.equals(personId) & _db.remoteAssetEntity.deletedAt.isNull(),
_db.assetFaceEntity.isVisible.equals(true) &
_db.assetFaceEntity.deletedAt.isNull(),
) )
..groupBy([dateExp]) ..groupBy([dateExp])
..orderBy([OrderingTerm.desc(dateExp)]); ..orderBy([OrderingTerm.desc(dateExp)]);
@@ -483,26 +475,26 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
required int offset, required int offset,
required int count, required int count,
}) { }) {
final query = final idQuery = _db.assetFaceEntity.selectOnly()
_db.remoteAssetEntity.select().join([ ..addColumns([_db.assetFaceEntity.assetId])
innerJoin( ..where(
_db.assetFaceEntity, _db.assetFaceEntity.personId.equals(personId) &
_db.assetFaceEntity.assetId.equalsExp(_db.remoteAssetEntity.id), _db.assetFaceEntity.isVisible.equals(true) &
useColumns: false, _db.assetFaceEntity.deletedAt.isNull(),
), );
])
..where(
_db.remoteAssetEntity.deletedAt.isNull() &
_db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
_db.assetFaceEntity.personId.equals(personId) &
_db.assetFaceEntity.isVisible.equals(true) &
_db.assetFaceEntity.deletedAt.isNull(),
)
..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
..limit(count, offset: offset);
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get(); final query = _db.remoteAssetEntity.select()
..where(
(row) =>
row.id.isInQuery(idQuery) &
row.deletedAt.isNull() &
row.ownerId.equals(userId) &
row.visibility.equalsValue(AssetVisibility.timeline),
)
..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
..limit(count, offset: offset);
return query.map((row) => row.toDto()).get();
} }
TimelineQuery map(List<String> userIds, TimelineMapOptions options, GroupAssetsBy groupBy) => ( TimelineQuery map(List<String> userIds, TimelineMapOptions options, GroupAssetsBy groupBy) => (
+10 -1
View File
@@ -10,6 +10,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/constants/locales.dart';
@@ -24,6 +25,7 @@ import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/locale_provider.dart'; import 'package:immich_mobile/providers/locale_provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart';
@@ -162,6 +164,13 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
} }
} }
SystemChrome.setSystemUIOverlayStyle(overlayStyle); SystemChrome.setSystemUIOverlayStyle(overlayStyle);
await FlutterLocalNotificationsPlugin().initialize(
const InitializationSettings(
android: AndroidInitializationSettings('@drawable/notification_icon'),
iOS: DarwinInitializationSettings(),
),
);
} }
Future<DeepLink> _deepLinkBuilder(PlatformDeepLink deepLink) async { Future<DeepLink> _deepLinkBuilder(PlatformDeepLink deepLink) async {
@@ -241,7 +250,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
localizationsDelegates: context.localizationDelegates, localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales, supportedLocales: context.supportedLocales,
locale: context.locale, locale: context.locale,
themeMode: ref.watch(immichThemeModeProvider), themeMode: ref.watch(appConfigProvider.select((config) => config.theme.mode)),
darkTheme: getThemeData(colorScheme: immichTheme.dark, locale: context.locale), darkTheme: getThemeData(colorScheme: immichTheme.dark, locale: context.locale),
theme: getThemeData(colorScheme: immichTheme.light, locale: context.locale), theme: getThemeData(colorScheme: immichTheme.light, locale: context.locale),
builder: (context, child) => ImmichTranslationProvider( builder: (context, child) => ImmichTranslationProvider(
@@ -6,8 +6,8 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart';
@@ -35,7 +35,7 @@ class BootstrapErrorWidget extends StatelessWidget {
@override @override
Widget build(BuildContext _) { Widget build(BuildContext _) {
final immichTheme = defaultColorPreset.themeOfPreset; final immichTheme = MetadataKey.themePrimaryColor.defaultValue.themeOfPreset;
return EasyLocalization( return EasyLocalization(
supportedLocales: locales.values.toList(), supportedLocales: locales.values.toList(),
@@ -160,12 +160,25 @@ class DriftMemoryPage extends HookConsumerWidget {
currentAssetPage.value = otherIndex; currentAssetPage.value = otherIndex;
updateProgressText(); updateProgressText();
final activeMemory = currentMemory.value;
// Wait for page change animation to finish // Wait for page change animation to finish
await Future.delayed(const Duration(milliseconds: 400)); await Future.delayed(const Duration(milliseconds: 400));
// check if memory is still the same and if context is still mounted
if (currentMemory.value != activeMemory || !context.mounted) {
return;
}
// And then precache the next asset // And then precache the next asset
await precacheAsset(otherIndex + 1); await precacheAsset(otherIndex + 1);
final asset = currentMemory.value.assets[otherIndex]; // check again as precache involves async operations
if (currentMemory.value != activeMemory || !context.mounted) {
return;
}
final asset = activeMemory.assets[otherIndex];
currentAsset.value = asset; currentAsset.value = asset;
ref.read(assetViewerProvider.notifier).setAsset(asset); ref.read(assetViewerProvider.notifier).setAsset(asset);
} }
@@ -106,10 +106,17 @@ class DriftSearchPage extends HookConsumerWidget {
Future.microtask(() { Future.microtask(() {
textSearchController.clear(); textSearchController.clear();
peopleCurrentFilterWidget.value = null;
dateRangeCurrentFilterWidget.value = null;
cameraCurrentFilterWidget.value = null;
tagCurrentFilterWidget.value = null;
mediaTypeCurrentFilterWidget.value = null;
ratingCurrentFilterWidget.value = null;
displayOptionCurrentFilterWidget.value = null;
locationCurrentFilterWidget.value = preFilter.location.city != null
? Text(preFilter.location.city!, style: context.textTheme.labelLarge)
: null;
search(preFilter); search(preFilter);
if (preFilter.location.city != null) {
locationCurrentFilterWidget.value = Text(preFilter.location.city!, style: context.textTheme.labelLarge);
}
}); });
return null; return null;
@@ -37,7 +37,7 @@ class SimilarPhotosActionButton extends ConsumerWidget {
date: SearchDateFilter(), date: SearchDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(), rating: SearchRatingFilter(),
mediaType: AssetType.image, mediaType: AssetType.other,
), ),
); );
@@ -49,9 +49,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
bool _showingDetails = false; bool _showingDetails = false;
bool _isZoomed = false; bool _isZoomed = false;
// Frozen during dismiss drag + settle to prevent widget tree swap mid-animation.
bool _frozenMotionPlaying = false;
bool _dismissSettling = false;
final _scrollController = SnapScrollController(); final _scrollController = SnapScrollController();
double _snapOffset = 0.0; double _snapOffset = 0.0;
@@ -139,9 +136,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
> 0 => _DragIntent.dismiss, > 0 => _DragIntent.dismiss,
_ => _DragIntent.none, _ => _DragIntent.none,
}; };
if (_dragIntent == _DragIntent.dismiss) {
_frozenMotionPlaying = ref.read(isPlayingMotionVideoProvider);
}
} }
switch (_dragIntent) { switch (_dragIntent) {
@@ -179,18 +173,12 @@ class _AssetPageState extends ConsumerState<AssetPage> {
context.maybePop(); context.maybePop();
return; return;
} }
_viewController?.animateMultiple(
position: _initialPhotoViewState.position,
scale: _viewController?.initialScale ?? _initialPhotoViewState.scale,
rotation: _initialPhotoViewState.rotation,
);
_viewer.setOpacity(1.0); _viewer.setOpacity(1.0);
_dismissSettling = true;
_viewController
?.animateMultiple(
position: _initialPhotoViewState.position,
scale: _viewController?.initialScale ?? _initialPhotoViewState.scale,
rotation: _initialPhotoViewState.rotation,
)
.whenComplete(() {
if (!mounted) return;
setState(() => _dismissSettling = false);
});
} }
} }
@@ -368,10 +356,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
final currentHeroTag = ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag)); final currentHeroTag = ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag));
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); _showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex)); final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
final liveMotionPlaying = ref.watch(isPlayingMotionVideoProvider); final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
final isPlayingMotionVideo = (_dragIntent == _DragIntent.dismiss || _dismissSettling)
? _frozenMotionPlaying
: liveMotionPlaying;
final asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index); final asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
if (asset == null) { if (asset == null) {
@@ -65,26 +65,37 @@ class ViewerBottomBar extends ConsumerWidget {
labelLarge: context.themeData.textTheme.labelLarge?.copyWith(color: Colors.white), labelLarge: context.themeData.textTheme.labelLarge?.copyWith(color: Colors.white),
), ),
), ),
child: Container( child: Stack(
decoration: const BoxDecoration( children: [
gradient: LinearGradient( const Positioned.fill(
begin: Alignment.bottomCenter, child: IgnorePointer(
end: Alignment.topCenter, child: DecoratedBox(
colors: [Colors.black45, Colors.black12, Colors.transparent], decoration: BoxDecoration(
stops: [0.0, 0.7, 1.0], gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black45, Colors.black12, Colors.transparent],
stops: [0.0, 0.7, 1.0],
),
),
),
),
), ),
), SafeArea(
child: SafeArea( top: false,
top: false, child: Padding(
child: Column( padding: const EdgeInsets.only(top: 16),
mainAxisSize: MainAxisSize.min, child: Column(
children: [ mainAxisSize: MainAxisSize.min,
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag), children: [
if (!isReadonlyModeEnabled) if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), if (!isReadonlyModeEnabled)
], Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
],
),
),
), ),
), ],
), ),
), ),
); );
@@ -75,29 +75,41 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
child: AnimatedOpacity( child: AnimatedOpacity(
opacity: opacity, opacity: opacity,
duration: Durations.short2, duration: Durations.short2,
child: DecoratedBox( child: Stack(
decoration: BoxDecoration( children: [
gradient: showingDetails Positioned.fill(
? null child: IgnorePointer(
: const LinearGradient( child: DecoratedBox(
begin: Alignment.topCenter, decoration: BoxDecoration(
end: Alignment.bottomCenter, gradient: showingDetails
colors: [Colors.black45, Colors.black12, Colors.transparent], ? null
stops: [0.0, 0.7, 1.0], : const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black45, Colors.black12, Colors.transparent],
stops: [0.0, 0.7, 1.0],
),
), ),
), ),
child: AppBar( ),
backgroundColor: Colors.transparent, ),
leading: const _AppBarBackButton(), SafeArea(
iconTheme: const IconThemeData(size: 22, color: Colors.white), bottom: false,
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white), child: SizedBox.square(
shape: const Border(), child: Theme(
actions: showingDetails || isReadonlyModeEnabled data: context.themeData.copyWith(iconTheme: const IconThemeData(size: 22, color: Colors.white)),
? null child: Row(
: isInLockedView children: [
? lockedViewActions const _AppBarBackButton(),
: actions, const Spacer(),
), if (!showingDetails && !isReadonlyModeEnabled)
if (isInLockedView) ...lockedViewActions else ...actions,
],
),
),
),
),
],
), ),
), ),
); );
@@ -113,20 +125,17 @@ class _AppBarBackButton extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails)); final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
return Padding( return ElevatedButton(
padding: const EdgeInsets.only(left: 12.0), style: ElevatedButton.styleFrom(
child: ElevatedButton( backgroundColor: showingDetails ? context.colorScheme.surface : Colors.transparent,
style: ElevatedButton.styleFrom( shape: const CircleBorder(),
backgroundColor: showingDetails ? context.colorScheme.surface : Colors.transparent, iconSize: 22,
shape: const CircleBorder(), iconColor: showingDetails ? context.colorScheme.onSurface : Colors.white,
iconSize: 22, padding: const EdgeInsets.all(10.0),
iconColor: showingDetails ? context.colorScheme.onSurface : Colors.white, elevation: showingDetails ? 4 : 0,
padding: EdgeInsets.zero,
elevation: showingDetails ? 4 : 0,
),
onPressed: context.maybePop,
child: const Icon(Icons.arrow_back_rounded),
), ),
onPressed: context.maybePop,
child: const Icon(Icons.arrow_back_rounded),
); );
} }
} }
+16 -18
View File
@@ -1,9 +1,9 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/cleanup.service.dart'; import 'package:immich_mobile/services/cleanup.service.dart';
class CleanupState { class CleanupState {
@@ -54,27 +54,25 @@ final cleanupProvider = StateNotifierProvider<CleanupNotifier, CleanupState>((re
return CleanupNotifier( return CleanupNotifier(
ref.watch(cleanupServiceProvider), ref.watch(cleanupServiceProvider),
ref.watch(currentUserProvider)?.id, ref.watch(currentUserProvider)?.id,
ref.watch(appSettingsServiceProvider), ref.watch(metadataProvider),
); );
}); });
class CleanupNotifier extends StateNotifier<CleanupState> { class CleanupNotifier extends StateNotifier<CleanupState> {
final CleanupService _cleanupService; final CleanupService _cleanupService;
final String? _userId; final String? _userId;
final AppSettingsService _appSettingsService; final MetadataRepository _metadataRepository;
CleanupNotifier(this._cleanupService, this._userId, this._appSettingsService) : super(const CleanupState()) { CleanupNotifier(this._cleanupService, this._userId, this._metadataRepository) : super(const CleanupState()) {
_loadPersistedSettings(); _loadPersistedSettings();
} }
void _loadPersistedSettings() { void _loadPersistedSettings() {
final keepFavorites = _appSettingsService.getSetting(AppSettingsEnum.cleanupKeepFavorites); final cleanup = _metadataRepository.appConfig.cleanup;
final keepMediaTypeIndex = _appSettingsService.getSetting(AppSettingsEnum.cleanupKeepMediaType); final keepFavorites = cleanup.keepFavorites;
final keepAlbumIdsString = _appSettingsService.getSetting(AppSettingsEnum.cleanupKeepAlbumIds); final keepMediaType = cleanup.keepMediaType;
final cutoffDaysAgo = _appSettingsService.getSetting(AppSettingsEnum.cleanupCutoffDaysAgo); final keepAlbumIds = cleanup.keepAlbumIds.toSet();
final cutoffDaysAgo = cleanup.cutoffDaysAgo;
final keepMediaType = AssetKeepType.values[keepMediaTypeIndex.clamp(0, AssetKeepType.values.length - 1)];
final keepAlbumIds = keepAlbumIdsString.isEmpty ? <String>{} : keepAlbumIdsString.split(',').toSet();
final selectedDate = cutoffDaysAgo >= 0 ? DateTime.now().subtract(Duration(days: cutoffDaysAgo)) : null; final selectedDate = cutoffDaysAgo >= 0 ? DateTime.now().subtract(Duration(days: cutoffDaysAgo)) : null;
state = state.copyWith( state = state.copyWith(
@@ -89,18 +87,18 @@ class CleanupNotifier extends StateNotifier<CleanupState> {
state = state.copyWith(selectedDate: date, assetsToDelete: []); state = state.copyWith(selectedDate: date, assetsToDelete: []);
if (date != null) { if (date != null) {
final daysAgo = DateTime.now().difference(date).inDays; final daysAgo = DateTime.now().difference(date).inDays;
_appSettingsService.setSetting(AppSettingsEnum.cleanupCutoffDaysAgo, daysAgo); _metadataRepository.write(.cleanupCutoffDaysAgo, daysAgo);
} }
} }
void setKeepMediaType(AssetKeepType keepMediaType) { void setKeepMediaType(AssetKeepType keepMediaType) {
state = state.copyWith(keepMediaType: keepMediaType, assetsToDelete: []); state = state.copyWith(keepMediaType: keepMediaType, assetsToDelete: []);
_appSettingsService.setSetting(AppSettingsEnum.cleanupKeepMediaType, keepMediaType.index); _metadataRepository.write(.cleanupKeepMediaType, keepMediaType);
} }
void setKeepFavorites(bool keepFavorites) { void setKeepFavorites(bool keepFavorites) {
state = state.copyWith(keepFavorites: keepFavorites, assetsToDelete: []); state = state.copyWith(keepFavorites: keepFavorites, assetsToDelete: []);
_appSettingsService.setSetting(AppSettingsEnum.cleanupKeepFavorites, keepFavorites); _metadataRepository.write(.cleanupKeepFavorites, keepFavorites);
} }
void toggleKeepAlbum(String albumId) { void toggleKeepAlbum(String albumId) {
@@ -120,7 +118,7 @@ class CleanupNotifier extends StateNotifier<CleanupState> {
} }
void _persistExcludedAlbumIds(Set<String> albumIds) { void _persistExcludedAlbumIds(Set<String> albumIds) {
_appSettingsService.setSetting(AppSettingsEnum.cleanupKeepAlbumIds, albumIds.join(',')); _metadataRepository.write(.cleanupKeepAlbumIds, albumIds.toList());
} }
void cleanupStaleAlbumIds(Set<String> existingAlbumIds) { void cleanupStaleAlbumIds(Set<String> existingAlbumIds) {
@@ -133,7 +131,7 @@ class CleanupNotifier extends StateNotifier<CleanupState> {
} }
void applyDefaultAlbumSelections(List<(String id, String name)> albums) { void applyDefaultAlbumSelections(List<(String id, String name)> albums) {
final isInitialized = _appSettingsService.getSetting(AppSettingsEnum.cleanupDefaultsInitialized); final isInitialized = _metadataRepository.appConfig.cleanup.defaultsInitialized;
if (isInitialized) return; if (isInitialized) return;
final toKeep = _cleanupService.getDefaultKeepAlbumIds(albums); final toKeep = _cleanupService.getDefaultKeepAlbumIds(albums);
@@ -144,7 +142,7 @@ class CleanupNotifier extends StateNotifier<CleanupState> {
_persistExcludedAlbumIds(keepAlbumIds); _persistExcludedAlbumIds(keepAlbumIds);
} }
_appSettingsService.setSetting(AppSettingsEnum.cleanupDefaultsInitialized, true); _metadataRepository.write(.cleanupDefaultsInitialized, true);
} }
Future<void> scanAssets() async { Future<void> scanAssets() async {
@@ -3,7 +3,7 @@ import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/config/system_config.dart'; import 'package:immich_mobile/domain/models/config/system_config.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
final metadataProvider = Provider<MetadataRepository>((_) => MetadataRepository.instance); final metadataProvider = Provider.autoDispose<MetadataRepository>((_) => MetadataRepository.instance);
final appConfigProvider = Provider.autoDispose<AppConfig>((ref) { final appConfigProvider = Provider.autoDispose<AppConfig>((ref) {
final repo = ref.watch(metadataProvider); final repo = ref.watch(metadataProvider);
+7 -36
View File
@@ -1,46 +1,17 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/theme/color_scheme.dart'; import 'package:immich_mobile/theme/color_scheme.dart';
import 'package:immich_mobile/theme/dynamic_theme.dart'; import 'package:immich_mobile/theme/dynamic_theme.dart';
import 'package:immich_mobile/theme/theme_data.dart'; import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/utils/debug_print.dart';
final immichThemeModeProvider = StateProvider<ThemeMode>((ref) => ref.watch(appConfigProvider).theme.mode);
final immichThemePresetProvider = StateProvider<ImmichColorPreset>((ref) {
final appSettingsProvider = ref.watch(appSettingsServiceProvider);
final primaryColorPreset = appSettingsProvider.getSetting(AppSettingsEnum.primaryColor);
dPrint(() => "Current theme preset $primaryColorPreset");
try {
return ImmichColorPreset.values.firstWhere((e) => e.name == primaryColorPreset);
} catch (e) {
dPrint(() => "Theme preset $primaryColorPreset not found. Applying default preset.");
appSettingsProvider.setSetting(AppSettingsEnum.primaryColor, defaultColorPresetName);
return defaultColorPreset;
}
});
final dynamicThemeSettingProvider = StateProvider<bool>((ref) {
return ref.watch(appSettingsServiceProvider).getSetting(AppSettingsEnum.dynamicTheme);
});
final colorfulInterfaceSettingProvider = StateProvider<bool>((ref) {
return ref.watch(appSettingsServiceProvider).getSetting(AppSettingsEnum.colorfulInterface);
});
// Provider for current selected theme // Provider for current selected theme
final immichThemeProvider = StateProvider<ImmichTheme>((ref) { final immichThemeProvider = StateProvider<ImmichTheme>((ref) {
final primaryColorPreset = ref.read(immichThemePresetProvider); final themeConfig = ref.watch(appConfigProvider.select((config) => config.theme));
final useSystemColor = ref.watch(dynamicThemeSettingProvider);
final useColorfulInterface = ref.watch(colorfulInterfaceSettingProvider);
final ImmichTheme? dynamicTheme = DynamicTheme.theme;
final currentTheme = (useSystemColor && dynamicTheme != null) ? dynamicTheme : primaryColorPreset.themeOfPreset;
return useColorfulInterface ? currentTheme : decolorizeSurfaces(theme: currentTheme); final ImmichTheme? dynamicTheme = DynamicTheme.theme;
final currentTheme = (themeConfig.dynamicTheme && dynamicTheme != null)
? dynamicTheme
: themeConfig.primaryColor.themeOfPreset;
return themeConfig.colorfulInterface ? currentTheme : decolorizeSurfaces(theme: currentTheme);
}); });
+1 -10
View File
@@ -1,13 +1,9 @@
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
enum AppSettingsEnum<T> { enum AppSettingsEnum<T> {
loadPreview<bool>(StoreKey.loadPreview, "loadPreview", true), loadPreview<bool>(StoreKey.loadPreview, "loadPreview", true),
loadOriginal<bool>(StoreKey.loadOriginal, "loadOriginal", false), loadOriginal<bool>(StoreKey.loadOriginal, "loadOriginal", false),
primaryColor<String>(StoreKey.primaryColor, "primaryColor", defaultColorPresetName),
dynamicTheme<bool>(StoreKey.dynamicTheme, "dynamicTheme", false),
colorfulInterface<bool>(StoreKey.colorfulInterface, "colorfulInterface", true),
tilesPerRow<int>(StoreKey.tilesPerRow, "tilesPerRow", 4), tilesPerRow<int>(StoreKey.tilesPerRow, "tilesPerRow", 4),
dynamicLayout<bool>(StoreKey.dynamicLayout, "dynamicLayout", false), dynamicLayout<bool>(StoreKey.dynamicLayout, "dynamicLayout", false),
groupAssetsBy<int>(StoreKey.groupAssetsBy, "groupBy", 0), groupAssetsBy<int>(StoreKey.groupAssetsBy, "groupBy", 0),
@@ -53,12 +49,7 @@ enum AppSettingsEnum<T> {
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false), readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false),
albumGridView<bool>(StoreKey.albumGridView, "albumGridView", false), albumGridView<bool>(StoreKey.albumGridView, "albumGridView", false),
backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false), backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false),
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30), backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30);
cleanupKeepFavorites<bool>(StoreKey.cleanupKeepFavorites, null, true),
cleanupKeepMediaType<int>(StoreKey.cleanupKeepMediaType, null, 0),
cleanupKeepAlbumIds<String>(StoreKey.cleanupKeepAlbumIds, null, ""),
cleanupCutoffDaysAgo<int>(StoreKey.cleanupCutoffDaysAgo, null, -1),
cleanupDefaultsInitialized<bool>(StoreKey.cleanupDefaultsInitialized, null, false);
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
+97 -27
View File
@@ -2,13 +2,14 @@ import 'dart:async';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
@@ -40,37 +41,106 @@ Future<void> _migrateTo25() async {
} }
Future<void> _migrateTo26(Drift drift) async { Future<void> _migrateTo26(Drift drift) async {
final repo = MetadataRepository.instance; final migrator = _StoreMigrator(drift);
final migrated = <int>[]; await migrator.migrateEnumName(StoreKey.legacyThemeMode, MetadataKey.themeMode, ThemeMode.values);
await migrator.migrateEnumIndex(StoreKey.legacyLogLevel, MetadataKey.logLevel, LogLevel.values);
await migrator.migrateEnumName(StoreKey.legacyPrimaryColor, MetadataKey.themePrimaryColor, ImmichColorPreset.values);
await migrator.migrateBool(StoreKey.legacyDynamicTheme, MetadataKey.themeDynamic);
await migrator.migrateBool(StoreKey.legacyColorfulInterface, MetadataKey.themeColorfulInterface);
final cleanupKeepAlbumIds = await migrator.readLegacyStoreString(StoreKey.legacyCleanupKeepAlbumIds.id);
if (cleanupKeepAlbumIds != null) {
final ids = cleanupKeepAlbumIds.split(',').where((id) => id.isNotEmpty).toList();
await drift.metadataEntity.insertOnConflictUpdate(
MetadataEntityCompanion.insert(
key: MetadataKey.cleanupKeepAlbumIds.key,
value: MetadataKey.cleanupKeepAlbumIds.encode(ids),
updatedAt: Value(DateTime.now()),
),
);
await migrator.deleteLegacyStoreRows([StoreKey.legacyCleanupKeepAlbumIds.id]);
}
await migrator.migrateBool(StoreKey.legacyCleanupKeepFavorites, MetadataKey.cleanupKeepFavorites);
await migrator.migrateEnumIndex(
StoreKey.legacyCleanupKeepMediaType,
MetadataKey.cleanupKeepMediaType,
AssetKeepType.values,
);
await migrator.migrateInt(StoreKey.legacyCleanupCutoffDaysAgo, MetadataKey.cleanupCutoffDaysAgo);
await migrator.migrateBool(StoreKey.legacyCleanupDefaultsInitialized, MetadataKey.cleanupDefaultsInitialized);
await migrator.complete();
}
final themeMode = await _readLegacyStoreString(drift, StoreKey.legacyThemeMode.id); class _StoreMigrator {
if (themeMode != null) { final Drift _db;
final mode = ThemeMode.values.firstWhere((m) => m.name == themeMode, orElse: () => ThemeMode.system); final Map<MetadataKey<Object>, Object> _cache = {};
await repo.write(MetadataKey.themeMode, mode); final List<int> _migratedStoreIds = [];
migrated.add(StoreKey.legacyThemeMode.id);
_StoreMigrator(this._db);
Future<void> migrateEnumIndex<T extends Enum>(StoreKey<int> legacyKey, MetadataKey<T> newKey, List<T> values) async {
final index = await readLegacyStoreInt(legacyKey.id);
if (index == null) return;
final enumValue = values.elementAtOrNull(index) ?? newKey.defaultValue;
_cache[newKey] = enumValue;
_migratedStoreIds.add(legacyKey.id);
} }
final logLevelIndex = await _readLegacyStoreInt(drift, StoreKey.legacyLogLevel.id); Future<void> migrateEnumName<T extends Enum>(
if (logLevelIndex != null) { StoreKey<String> legacyKey,
final logLevel = LogLevel.values.elementAtOrNull(logLevelIndex) ?? LogLevel.info; MetadataKey<T> newKey,
await LogService.I.setLogLevel(logLevel); List<T> values,
migrated.add(StoreKey.legacyLogLevel.id); ) async {
final name = await readLegacyStoreString(legacyKey.id);
if (name == null) return;
final enumValue = values.firstWhere((e) => e.name == name, orElse: () => newKey.defaultValue);
_cache[newKey] = enumValue;
_migratedStoreIds.add(legacyKey.id);
} }
await _deleteLegacyStoreRows(drift, migrated); Future<void> migrateBool(StoreKey<bool> legacyKey, MetadataKey<bool> newKey) async {
} final intValue = await readLegacyStoreInt(legacyKey.id);
if (intValue == null) return;
Future<String?> _readLegacyStoreString(Drift drift, int id) async { final boolValue = intValue != 0;
final row = await (drift.storeEntity.select()..where((t) => t.id.equals(id))).getSingleOrNull(); _cache[newKey] = boolValue;
return row?.stringValue; _migratedStoreIds.add(legacyKey.id);
} }
Future<int?> _readLegacyStoreInt(Drift drift, int id) async { Future<void> migrateInt(StoreKey<int> legacyKey, MetadataKey<int> newKey) async {
final row = await (drift.storeEntity.select()..where((t) => t.id.equals(id))).getSingleOrNull(); final intValue = await readLegacyStoreInt(legacyKey.id);
return row?.intValue; if (intValue == null) return;
}
Future<void> _deleteLegacyStoreRows(Drift drift, List<int> ids) async { _cache[newKey] = intValue;
if (ids.isEmpty) return; _migratedStoreIds.add(legacyKey.id);
await (drift.storeEntity.delete()..where((t) => t.id.isIn(ids))).go(); }
Future<void> complete() async {
await _db.batch((batch) {
for (final entry in _cache.entries) {
batch.insert(
_db.metadataEntity,
MetadataEntityCompanion(key: Value(entry.key.key), value: Value(entry.key.encode(entry.value))),
mode: InsertMode.insertOrReplace,
);
}
});
await deleteLegacyStoreRows(_migratedStoreIds);
}
Future<String?> readLegacyStoreString(int id) async {
final row = await (_db.storeEntity.select()..where((t) => t.id.equals(id))).getSingleOrNull();
return row?.stringValue;
}
Future<int?> readLegacyStoreInt(int id) async {
final row = await (_db.storeEntity.select()..where((t) => t.id.equals(id))).getSingleOrNull();
return row?.intValue;
}
Future<void> deleteLegacyStoreRows(List<int> ids) async {
if (ids.isEmpty) return;
await (_db.storeEntity.delete()..where((t) => t.id.isIn(ids))).go();
}
} }
@@ -119,13 +119,15 @@ class _VideoControlsState extends ConsumerState<VideoControls> {
onPressed: () => _toggle(isCasting), onPressed: () => _toggle(isCasting),
), ),
const Spacer(), const Spacer(),
Text( IgnorePointer(
"${position.format()} / ${duration.format()}", child: Text(
style: const TextStyle( "${position.format()} / ${duration.format()}",
color: Colors.white, style: const TextStyle(
fontWeight: FontWeight.w500, color: Colors.white,
fontFeatures: [FontFeature.tabularFigures()], fontWeight: FontWeight.w500,
shadows: VideoControls._controlShadows, fontFeatures: [FontFeature.tabularFigures()],
shadows: VideoControls._controlShadows,
),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -38,13 +38,12 @@ abstract class PhotoViewControllerBase<T extends PhotoViewControllerValue> {
/// Closes streams and removes eventual listeners. /// Closes streams and removes eventual listeners.
void dispose(); void dispose();
void positionAnimationBuilder(Future<void> Function(Offset)? value); void positionAnimationBuilder(void Function(Offset)? value);
void scaleAnimationBuilder(Future<void> Function(double)? value); void scaleAnimationBuilder(void Function(double)? value);
void rotationAnimationBuilder(Future<void> Function(double)? value); void rotationAnimationBuilder(void Function(double)? value);
/// Animates multiple fields of the state. The returned future completes /// Animates multiple fields of the state
/// when all underlying animations have settled. void animateMultiple({Offset? position, double? scale, double? rotation});
Future<void> animateMultiple({Offset? position, double? scale, double? rotation});
/// Add a listener that will ignore updates made internally /// Add a listener that will ignore updates made internally
/// ///
@@ -149,9 +148,9 @@ class PhotoViewController implements PhotoViewControllerBase<PhotoViewController
@override @override
ScaleBoundaries? scaleBoundaries; ScaleBoundaries? scaleBoundaries;
late Future<void> Function(Offset)? _animatePosition; late void Function(Offset)? _animatePosition;
late Future<void> Function(double)? _animateScale; late void Function(double)? _animateScale;
late Future<void> Function(double)? _animateRotation; late void Function(double)? _animateRotation;
@override @override
Stream<PhotoViewControllerValue> get outputStateStream => _outputCtrl.stream; Stream<PhotoViewControllerValue> get outputStateStream => _outputCtrl.stream;
@@ -160,17 +159,17 @@ class PhotoViewController implements PhotoViewControllerBase<PhotoViewController
late PhotoViewControllerValue prevValue; late PhotoViewControllerValue prevValue;
@override @override
void positionAnimationBuilder(Future<void> Function(Offset)? value) { void positionAnimationBuilder(void Function(Offset)? value) {
_animatePosition = value; _animatePosition = value;
} }
@override @override
void scaleAnimationBuilder(Future<void> Function(double)? value) { void scaleAnimationBuilder(void Function(double)? value) {
_animateScale = value; _animateScale = value;
} }
@override @override
void rotationAnimationBuilder(Future<void> Function(double)? value) { void rotationAnimationBuilder(void Function(double)? value) {
_animateRotation = value; _animateRotation = value;
} }
@@ -194,18 +193,18 @@ class PhotoViewController implements PhotoViewControllerBase<PhotoViewController
} }
@override @override
Future<void> animateMultiple({Offset? position, double? scale, double? rotation}) { void animateMultiple({Offset? position, double? scale, double? rotation}) {
final futures = <Future<void>>[];
if (position != null && _animatePosition != null) { if (position != null && _animatePosition != null) {
futures.add(_animatePosition!(position)); _animatePosition!(position);
} }
if (scale != null && _animateScale != null) { if (scale != null && _animateScale != null) {
futures.add(_animateScale!(scale)); _animateScale!(scale);
} }
if (rotation != null && _animateRotation != null) { if (rotation != null && _animateRotation != null) {
futures.add(_animateRotation!(rotation)); _animateRotation!(rotation);
} }
return Future.wait(futures);
} }
@override @override
@@ -237,31 +237,34 @@ class PhotoViewCoreState extends State<PhotoViewCore>
nextScaleState(); nextScaleState();
} }
Future<void> animateScale(double from, double to) { void animateScale(double from, double to) {
if (!mounted) { if (!mounted) {
return Future.value(); return;
} }
_scaleAnimation = Tween<double>(begin: from, end: to).animate(_scaleAnimationController); _scaleAnimation = Tween<double>(begin: from, end: to).animate(_scaleAnimationController);
_scaleAnimationController.value = 0.0; _scaleAnimationController
return _scaleAnimationController.fling(velocity: 0.4); ..value = 0.0
..fling(velocity: 0.4);
} }
Future<void> animatePosition(Offset from, Offset to) { void animatePosition(Offset from, Offset to) {
if (!mounted) { if (!mounted) {
return Future.value(); return;
} }
_positionAnimation = Tween<Offset>(begin: from, end: to).animate(_positionAnimationController); _positionAnimation = Tween<Offset>(begin: from, end: to).animate(_positionAnimationController);
_positionAnimationController.value = 0.0; _positionAnimationController
return _positionAnimationController.fling(velocity: 0.4); ..value = 0.0
..fling(velocity: 0.4);
} }
Future<void> animateRotation(double from, double to) { void animateRotation(double from, double to) {
if (!mounted) { if (!mounted) {
return Future.value(); return;
} }
_rotationAnimation = Tween<double>(begin: from, end: to).animate(_rotationAnimationController); _rotationAnimation = Tween<double>(begin: from, end: to).animate(_rotationAnimationController);
_rotationAnimationController.value = 0.0; _rotationAnimationController
return _rotationAnimationController.fling(velocity: 0.4); ..value = 0.0
..fling(velocity: 0.4);
} }
void onAnimationStatus(AnimationStatus status) { void onAnimationStatus(AnimationStatus status) {
@@ -277,19 +280,18 @@ class PhotoViewCoreState extends State<PhotoViewCore>
} }
} }
Future<void> _animateControllerPosition(Offset position) { void _animateControllerPosition(Offset position) {
return animatePosition(controller.position, position); animatePosition(controller.position, position);
} }
Future<void> _animateControllerScale(double scale) { void _animateControllerScale(double scale) {
if (controller.scale != null) { if (controller.scale != null) {
return animateScale(controller.scale!, scale); animateScale(controller.scale!, scale);
} }
return Future.value();
} }
Future<void> _animateControllerRotation(double rotation) { void _animateControllerRotation(double rotation) {
return animateRotation(controller.rotation, rotation); animateRotation(controller.rotation, rotation);
} }
@override @override
@@ -74,6 +74,7 @@ class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton>
} catch (_) { } catch (_) {
} finally { } finally {
Future.delayed(const Duration(seconds: 1), () { Future.delayed(const Duration(seconds: 1), () {
if (!mounted) return;
setState(() { setState(() {
isAlbumSyncInProgress = false; isAlbumSyncInProgress = false;
}); });
@@ -1,15 +1,13 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/colors.dart'; import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/theme.provider.dart'; import 'package:immich_mobile/providers/theme.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/theme/color_scheme.dart'; import 'package:immich_mobile/theme/color_scheme.dart';
import 'package:immich_mobile/theme/dynamic_theme.dart'; import 'package:immich_mobile/theme/dynamic_theme.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
class PrimaryColorSetting extends HookConsumerWidget { class PrimaryColorSetting extends HookConsumerWidget {
const PrimaryColorSetting({super.key}); const PrimaryColorSetting({super.key});
@@ -17,18 +15,10 @@ class PrimaryColorSetting extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final themeProvider = ref.read(immichThemeProvider); final themeProvider = ref.read(immichThemeProvider);
final themeConfig = ref.watch(appConfigProvider.select((config) => config.theme));
final primaryColorSetting = useAppSettingsState(AppSettingsEnum.primaryColor);
final systemPrimaryColorSetting = useAppSettingsState(AppSettingsEnum.dynamicTheme);
final currentPreset = useValueNotifier(ref.read(immichThemePresetProvider));
const tileSize = 55.0; const tileSize = 55.0;
useValueChanged(
primaryColorSetting.value,
(_, __) => currentPreset.value = ImmichColorPreset.values.firstWhere((e) => e.name == primaryColorSetting.value),
);
void popBottomSheet() { void popBottomSheet() {
Future.delayed(const Duration(milliseconds: 200), () { Future.delayed(const Duration(milliseconds: 200), () {
Navigator.pop(context); Navigator.pop(context);
@@ -36,23 +26,18 @@ class PrimaryColorSetting extends HookConsumerWidget {
} }
onUseSystemColorChange(bool newValue) { onUseSystemColorChange(bool newValue) {
systemPrimaryColorSetting.value = newValue; ref.read(metadataProvider).write(.themeDynamic, newValue);
ref.watch(dynamicThemeSettingProvider.notifier).state = newValue;
ref.invalidate(immichThemeProvider);
popBottomSheet(); popBottomSheet();
} }
onPrimaryColorChange(ImmichColorPreset colorPreset) { onPrimaryColorChange(ImmichColorPreset colorPreset) {
primaryColorSetting.value = colorPreset.name; ref.read(metadataProvider).write(.themePrimaryColor, colorPreset);
ref.watch(immichThemePresetProvider.notifier).state = colorPreset;
ref.invalidate(immichThemeProvider);
//turn off system color setting //turn off system color setting
if (systemPrimaryColorSetting.value) { if (themeConfig.dynamicTheme) {
onUseSystemColorChange(false); ref.read(metadataProvider).write(.themeDynamic, false);
} else {
popBottomSheet();
} }
popBottomSheet();
} }
buildPrimaryColorTile({ buildPrimaryColorTile({
@@ -122,7 +107,7 @@ class PrimaryColorSetting extends HookConsumerWidget {
'theme_setting_system_primary_color_title'.tr(), 'theme_setting_system_primary_color_title'.tr(),
style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500, height: 1.5), style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500, height: 1.5),
), ),
value: systemPrimaryColorSetting.value, value: themeConfig.dynamicTheme,
onChanged: onUseSystemColorChange, onChanged: onUseSystemColorChange,
), ),
), ),
@@ -140,7 +125,7 @@ class PrimaryColorSetting extends HookConsumerWidget {
topColor: theme.light.primary, topColor: theme.light.primary,
bottomColor: theme.dark.primary, bottomColor: theme.dark.primary,
tileSize: tileSize, tileSize: tileSize,
showSelector: currentPreset.value == preset && !systemPrimaryColorSetting.value, showSelector: themeConfig.primaryColor == preset && !themeConfig.dynamicTheme,
), ),
); );
}).toList(), }).toList(),
@@ -1,13 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/theme.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/settings/preference_settings/primary_color_setting.dart'; import 'package:immich_mobile/widgets/settings/preference_settings/primary_color_setting.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
@@ -17,52 +13,38 @@ class ThemeSetting extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final currentTheme = useState(ref.read(immichThemeModeProvider)); final currentTheme = useState(ref.read(appConfigProvider.select((config) => config.theme.mode)));
final isDarkTheme = useValueNotifier(currentTheme.value == ThemeMode.dark); final isDarkTheme = useValueNotifier(currentTheme.value == ThemeMode.dark);
final isSystemTheme = useValueNotifier(currentTheme.value == ThemeMode.system); final isSystemTheme = useValueNotifier(currentTheme.value == ThemeMode.system);
final colorfulInterface = useValueNotifier(
final applyThemeToBackgroundSetting = useAppSettingsState(AppSettingsEnum.colorfulInterface); ref.watch(appConfigProvider.select((config) => config.theme.colorfulInterface)),
final applyThemeToBackgroundProvider = useValueNotifier(ref.read(colorfulInterfaceSettingProvider));
useValueChanged(
applyThemeToBackgroundSetting.value,
(_, __) => applyThemeToBackgroundProvider.value = applyThemeToBackgroundSetting.value,
); );
void onThemeChange(bool isDark) { void onThemeChange(bool isDark) {
if (isDark) { currentTheme.value = isDark ? ThemeMode.dark : ThemeMode.light;
ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.dark; ref.read(metadataProvider).write(.themeMode, currentTheme.value);
currentTheme.value = ThemeMode.dark;
} else {
ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.light;
currentTheme.value = ThemeMode.light;
}
ref.read(metadataProvider).write(MetadataKey.themeMode, currentTheme.value);
} }
void onSystemThemeChange(bool isSystem) { void onSystemThemeChange(bool isSystem) {
if (isSystem) { if (isSystem) {
currentTheme.value = ThemeMode.system; currentTheme.value = ThemeMode.system;
isSystemTheme.value = true; isSystemTheme.value = true;
ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.system;
} else { } else {
final currentSystemBrightness = context.platformBrightness; final currentSystemBrightness = context.platformBrightness;
isSystemTheme.value = false; isSystemTheme.value = false;
isDarkTheme.value = currentSystemBrightness == Brightness.dark; isDarkTheme.value = currentSystemBrightness == Brightness.dark;
if (currentSystemBrightness == Brightness.light) { if (currentSystemBrightness == Brightness.light) {
currentTheme.value = ThemeMode.light; currentTheme.value = ThemeMode.light;
ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.light;
} else if (currentSystemBrightness == Brightness.dark) { } else if (currentSystemBrightness == Brightness.dark) {
currentTheme.value = ThemeMode.dark; currentTheme.value = ThemeMode.dark;
ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.dark;
} }
} }
ref.read(metadataProvider).write(MetadataKey.themeMode, currentTheme.value); ref.read(metadataProvider).write(.themeMode, currentTheme.value);
} }
void onSurfaceColorSettingChange(bool useColorfulInterface) { void onSurfaceColorSettingChange(bool useColorfulInterface) {
applyThemeToBackgroundSetting.value = useColorfulInterface; ref.read(metadataProvider).write(.themeColorfulInterface, useColorfulInterface);
ref.watch(colorfulInterfaceSettingProvider.notifier).state = useColorfulInterface; colorfulInterface.value = useColorfulInterface;
} }
return Column( return Column(
@@ -85,7 +67,7 @@ class ThemeSetting extends HookConsumerWidget {
), ),
const PrimaryColorSetting(), const PrimaryColorSetting(),
SettingsSwitchListTile( SettingsSwitchListTile(
valueNotifier: applyThemeToBackgroundProvider, valueNotifier: colorfulInterface,
title: "theme_setting_colorful_interface_title".t(context: context), title: "theme_setting_colorful_interface_title".t(context: context),
subtitle: 'theme_setting_colorful_interface_subtitle'.t(context: context), subtitle: 'theme_setting_colorful_interface_subtitle'.t(context: context),
onChanged: onSurfaceColorSettingChange, onChanged: onSurfaceColorSettingChange,
+20 -11
View File
@@ -506,11 +506,14 @@ class AlbumsApi {
/// Parameters: /// Parameters:
/// ///
/// * [String] assetId: /// * [String] assetId:
/// Filter albums containing this asset ID (ignores shared parameter) /// Filter albums containing this asset ID (ignores other parameters)
/// ///
/// * [bool] shared: /// * [bool] isOwned:
/// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums /// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter
Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, bool? shared, }) async { ///
/// * [bool] isShared:
/// Filter by shared status: true = only shared, false = not shared, undefined = no filter
Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, bool? isOwned, bool? isShared, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/albums'; final apiPath = r'/albums';
@@ -524,8 +527,11 @@ class AlbumsApi {
if (assetId != null) { if (assetId != null) {
queryParams.addAll(_queryParams('', 'assetId', assetId)); queryParams.addAll(_queryParams('', 'assetId', assetId));
} }
if (shared != null) { if (isOwned != null) {
queryParams.addAll(_queryParams('', 'shared', shared)); queryParams.addAll(_queryParams('', 'isOwned', isOwned));
}
if (isShared != null) {
queryParams.addAll(_queryParams('', 'isShared', isShared));
} }
const contentTypes = <String>[]; const contentTypes = <String>[];
@@ -549,12 +555,15 @@ class AlbumsApi {
/// Parameters: /// Parameters:
/// ///
/// * [String] assetId: /// * [String] assetId:
/// Filter albums containing this asset ID (ignores shared parameter) /// Filter albums containing this asset ID (ignores other parameters)
/// ///
/// * [bool] shared: /// * [bool] isOwned:
/// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums /// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter
Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, bool? shared, }) async { ///
final response = await getAllAlbumsWithHttpInfo( assetId: assetId, shared: shared, ); /// * [bool] isShared:
/// Filter by shared status: true = only shared, false = not shared, undefined = no filter
Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, bool? isOwned, bool? isShared, }) async {
final response = await getAllAlbumsWithHttpInfo( assetId: assetId, isOwned: isOwned, isShared: isShared, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
+2 -2
View File
@@ -253,10 +253,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: connectivity_plus name: connectivity_plus
sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec sha256: "62ffa266d9a23b79fb3fcbc206afc00bb979417ba57b1324c546b5aab95ba057"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.5" version: "7.1.1"
connectivity_plus_platform_interface: connectivity_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
+1 -1
View File
@@ -14,7 +14,7 @@ dependencies:
background_downloader: ^9.5.4 background_downloader: ^9.5.4
cast: ^2.1.0 cast: ^2.1.0
collection: ^1.19.1 collection: ^1.19.1
connectivity_plus: ^6.1.5 connectivity_plus: ^7.0.0
crop_image: ^1.0.17 crop_image: ^1.0.17
crypto: ^3.0.7 crypto: ^3.0.7
device_info_plus: ^12.4.0 device_info_plus: ^12.4.0
@@ -40,7 +40,7 @@ void main() {
when(() => mockLogRepo.truncate(limit: any(named: 'limit'))).thenAnswer((_) async => {}); when(() => mockLogRepo.truncate(limit: any(named: 'limit'))).thenAnswer((_) async => {});
when(() => mockMetadataRepository.systemConfig).thenReturn(const SystemConfig(logLevel: LogLevel.fine)); when(() => mockMetadataRepository.systemConfig).thenReturn(const SystemConfig(logLevel: LogLevel.fine));
when(() => mockMetadataRepository.write<LogLevel>(MetadataKey.logLevel, any())).thenAnswer((_) async {}); when(() => mockMetadataRepository.write<LogLevel, LogLevel>(MetadataKey.logLevel, any())).thenAnswer((_) async {});
when(() => mockLogRepo.getAll()).thenAnswer((_) async => []); when(() => mockLogRepo.getAll()).thenAnswer((_) async => []);
when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true); when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true);
when(() => mockLogRepo.insertAll(any())).thenAnswer((_) async => true); when(() => mockLogRepo.insertAll(any())).thenAnswer((_) async => true);
@@ -71,7 +71,7 @@ void main() {
test('Updates the log level via metadata repository', () { test('Updates the log level via metadata repository', () {
final captured = verify( final captured = verify(
() => mockMetadataRepository.write<LogLevel>(MetadataKey.logLevel, captureAny()), () => mockMetadataRepository.write<LogLevel, LogLevel>(MetadataKey.logLevel, captureAny()),
).captured.firstOrNull; ).captured.firstOrNull;
expect(captured, LogLevel.shout); expect(captured, LogLevel.shout);
}); });
@@ -14,7 +14,7 @@ import '../../fixtures/user.stub.dart';
const _kTestAccessToken = "#TestToken"; const _kTestAccessToken = "#TestToken";
final _kTestBackupFailed = DateTime(2025, 2, 20, 11, 45); final _kTestBackupFailed = DateTime(2025, 2, 20, 11, 45);
const _kTestVersion = 10; const _kTestVersion = 10;
const _kTestColorfulInterface = false; const _kTestBackupRequireWifi = false;
final _kTestUser = UserStub.admin; final _kTestUser = UserStub.admin;
Future<void> _populateStore(Drift db) async { Future<void> _populateStore(Drift db) async {
@@ -22,8 +22,8 @@ Future<void> _populateStore(Drift db) async {
batch.insert( batch.insert(
db.storeEntity, db.storeEntity,
StoreEntityCompanion( StoreEntityCompanion(
id: Value(StoreKey.colorfulInterface.id), id: Value(StoreKey.backupRequireWifi.id),
intValue: const Value(_kTestColorfulInterface ? 1 : 0), intValue: const Value(_kTestBackupRequireWifi ? 1 : 0),
stringValue: const Value(null), stringValue: const Value(null),
), ),
); );
@@ -93,11 +93,11 @@ void main() {
}); });
test('converts bool', () async { test('converts bool', () async {
bool? colorfulInterface = await sut.tryGet(StoreKey.colorfulInterface); bool? backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
expect(colorfulInterface, isNull); expect(backupRequireWifi, isNull);
await sut.upsert(StoreKey.colorfulInterface, _kTestColorfulInterface); await sut.upsert(StoreKey.backupRequireWifi, _kTestBackupRequireWifi);
colorfulInterface = await sut.tryGet(StoreKey.colorfulInterface); backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
expect(colorfulInterface, _kTestColorfulInterface); expect(backupRequireWifi, _kTestBackupRequireWifi);
}); });
test('converts user', () async { test('converts user', () async {
@@ -115,11 +115,11 @@ void main() {
}); });
test('delete()', () async { test('delete()', () async {
bool? isColorful = await sut.tryGet(StoreKey.colorfulInterface); bool? backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
expect(isColorful, isFalse); expect(backupRequireWifi, isFalse);
await sut.delete(StoreKey.colorfulInterface); await sut.delete(StoreKey.backupRequireWifi);
isColorful = await sut.tryGet(StoreKey.colorfulInterface); backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi);
expect(isColorful, isNull); expect(backupRequireWifi, isNull);
}); });
test('deleteAll()', () async { test('deleteAll()', () async {
@@ -165,14 +165,14 @@ void main() {
[ [
const StoreDto<Object>(StoreKey.version, _kTestVersion), const StoreDto<Object>(StoreKey.version, _kTestVersion),
StoreDto<Object>(StoreKey.backupFailedSince, _kTestBackupFailed), StoreDto<Object>(StoreKey.backupFailedSince, _kTestBackupFailed),
const StoreDto<Object>(StoreKey.backupRequireWifi, _kTestBackupRequireWifi),
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken), const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
const StoreDto<Object>(StoreKey.colorfulInterface, _kTestColorfulInterface),
], ],
[ [
const StoreDto<Object>(StoreKey.version, _kTestVersion + 10), const StoreDto<Object>(StoreKey.version, _kTestVersion + 10),
StoreDto<Object>(StoreKey.backupFailedSince, _kTestBackupFailed), StoreDto<Object>(StoreKey.backupFailedSince, _kTestBackupFailed),
const StoreDto<Object>(StoreKey.backupRequireWifi, _kTestBackupRequireWifi),
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken), const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
const StoreDto<Object>(StoreKey.colorfulInterface, _kTestColorfulInterface),
], ],
]), ]),
), ),
@@ -33,7 +33,7 @@ void main() {
test('returns single album when only one album exists', () async { test('returns single album when only one album exists', () async {
final album = await ctx.newRemoteAlbum(ownerId: userId); final album = await ctx.newRemoteAlbum(ownerId: userId);
final asset = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 1)); final asset = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 1));
await ctx.insertRemoteAlbumAsset(albumId: album.id, assetId: asset.id); await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: asset.id);
final result = await sut.getSortedAlbumIds([album.id], aggregation: AssetDateAggregation.start); final result = await sut.getSortedAlbumIds([album.id], aggregation: AssetDateAggregation.start);
expect(result, [album.id]); expect(result, [album.id]);
@@ -44,22 +44,22 @@ void main() {
final album1 = await ctx.newRemoteAlbum(ownerId: userId); final album1 = await ctx.newRemoteAlbum(ownerId: userId);
final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 10)); final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 10));
final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 20)); final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 20));
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id); await ctx.newRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id);
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset2.id); await ctx.newRemoteAlbumAsset(albumId: album1.id, assetId: asset2.id);
// Album 2: Assets from Jan 5 to Jan 15 (start: Jan 5) // Album 2: Assets from Jan 5 to Jan 15 (start: Jan 5)
final album2 = await ctx.newRemoteAlbum(ownerId: userId); final album2 = await ctx.newRemoteAlbum(ownerId: userId);
final asset3 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 5)); final asset3 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 5));
final asset4 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 15)); final asset4 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 15));
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset3.id); await ctx.newRemoteAlbumAsset(albumId: album2.id, assetId: asset3.id);
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset4.id); await ctx.newRemoteAlbumAsset(albumId: album2.id, assetId: asset4.id);
// Album 3: Assets from Jan 25 to Jan 30 (start: Jan 25) // Album 3: Assets from Jan 25 to Jan 30 (start: Jan 25)
final album3 = await ctx.newRemoteAlbum(ownerId: userId); final album3 = await ctx.newRemoteAlbum(ownerId: userId);
final asset5 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 25)); final asset5 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 25));
final asset6 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 30)); final asset6 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 30));
await ctx.insertRemoteAlbumAsset(albumId: album3.id, assetId: asset5.id); await ctx.newRemoteAlbumAsset(albumId: album3.id, assetId: asset5.id);
await ctx.insertRemoteAlbumAsset(albumId: album3.id, assetId: asset6.id); await ctx.newRemoteAlbumAsset(albumId: album3.id, assetId: asset6.id);
final result = await sut.getSortedAlbumIds([ final result = await sut.getSortedAlbumIds([
album1.id, album1.id,
@@ -76,22 +76,22 @@ void main() {
final album1 = await ctx.newRemoteAlbum(ownerId: userId); final album1 = await ctx.newRemoteAlbum(ownerId: userId);
final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 10)); final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 10));
final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 20)); final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 20));
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id); await ctx.newRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id);
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset2.id); await ctx.newRemoteAlbumAsset(albumId: album1.id, assetId: asset2.id);
// Album 2: Assets from Jan 5 to Jan 15 (end: Jan 15) // Album 2: Assets from Jan 5 to Jan 15 (end: Jan 15)
final album2 = await ctx.newRemoteAlbum(ownerId: userId); final album2 = await ctx.newRemoteAlbum(ownerId: userId);
final asset3 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 5)); final asset3 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 5));
final asset4 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 15)); final asset4 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 15));
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset3.id); await ctx.newRemoteAlbumAsset(albumId: album2.id, assetId: asset3.id);
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset4.id); await ctx.newRemoteAlbumAsset(albumId: album2.id, assetId: asset4.id);
// Album 3: Assets from Jan 25 to Jan 30 (end: Jan 30) // Album 3: Assets from Jan 25 to Jan 30 (end: Jan 30)
final album3 = await ctx.newRemoteAlbum(ownerId: userId); final album3 = await ctx.newRemoteAlbum(ownerId: userId);
final asset5 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 25)); final asset5 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 25));
final asset6 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 30)); final asset6 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 30));
await ctx.insertRemoteAlbumAsset(albumId: album3.id, assetId: asset5.id); await ctx.newRemoteAlbumAsset(albumId: album3.id, assetId: asset5.id);
await ctx.insertRemoteAlbumAsset(albumId: album3.id, assetId: asset6.id); await ctx.newRemoteAlbumAsset(albumId: album3.id, assetId: asset6.id);
final result = await sut.getSortedAlbumIds([ final result = await sut.getSortedAlbumIds([
album1.id, album1.id,
@@ -106,11 +106,11 @@ void main() {
test('handles albums with single asset', () async { test('handles albums with single asset', () async {
final album1 = await ctx.newRemoteAlbum(ownerId: userId); final album1 = await ctx.newRemoteAlbum(ownerId: userId);
final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 15)); final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 15));
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id); await ctx.newRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id);
final album2 = await ctx.newRemoteAlbum(ownerId: userId); final album2 = await ctx.newRemoteAlbum(ownerId: userId);
final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 10)); final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 10));
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset2.id); await ctx.newRemoteAlbumAsset(albumId: album2.id, assetId: asset2.id);
final result = await sut.getSortedAlbumIds([album1.id, album2.id], aggregation: AssetDateAggregation.start); final result = await sut.getSortedAlbumIds([album1.id, album2.id], aggregation: AssetDateAggregation.start);
@@ -121,15 +121,15 @@ void main() {
// Create 3 albums // Create 3 albums
final album1 = await ctx.newRemoteAlbum(ownerId: userId); final album1 = await ctx.newRemoteAlbum(ownerId: userId);
final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 10)); final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 10));
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id); await ctx.newRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id);
final album2 = await ctx.newRemoteAlbum(ownerId: userId); final album2 = await ctx.newRemoteAlbum(ownerId: userId);
final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 5)); final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 5));
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset2.id); await ctx.newRemoteAlbumAsset(albumId: album2.id, assetId: asset2.id);
final album3 = await ctx.newRemoteAlbum(ownerId: userId); final album3 = await ctx.newRemoteAlbum(ownerId: userId);
final asset3 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 15)); final asset3 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 15));
await ctx.insertRemoteAlbumAsset(albumId: album3.id, assetId: asset3.id); await ctx.newRemoteAlbumAsset(albumId: album3.id, assetId: asset3.id);
// Only request album1 and album3 // Only request album1 and album3
final result = await sut.getSortedAlbumIds([album1.id, album3.id], aggregation: AssetDateAggregation.start); final result = await sut.getSortedAlbumIds([album1.id, album3.id], aggregation: AssetDateAggregation.start);
@@ -143,11 +143,11 @@ void main() {
final album1 = await ctx.newRemoteAlbum(ownerId: userId); final album1 = await ctx.newRemoteAlbum(ownerId: userId);
final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: sameDate); final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: sameDate);
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id); await ctx.newRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id);
final album2 = await ctx.newRemoteAlbum(ownerId: userId); final album2 = await ctx.newRemoteAlbum(ownerId: userId);
final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: sameDate); final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: sameDate);
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset2.id); await ctx.newRemoteAlbumAsset(albumId: album2.id, assetId: asset2.id);
final result = await sut.getSortedAlbumIds([album1.id, album2.id], aggregation: AssetDateAggregation.start); final result = await sut.getSortedAlbumIds([album1.id, album2.id], aggregation: AssetDateAggregation.start);
@@ -159,15 +159,15 @@ void main() {
test('handles albums across different years', () async { test('handles albums across different years', () async {
final album1 = await ctx.newRemoteAlbum(ownerId: userId); final album1 = await ctx.newRemoteAlbum(ownerId: userId);
final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2023, 12, 25)); final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2023, 12, 25));
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id); await ctx.newRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id);
final album2 = await ctx.newRemoteAlbum(ownerId: userId); final album2 = await ctx.newRemoteAlbum(ownerId: userId);
final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 5)); final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 5));
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset2.id); await ctx.newRemoteAlbumAsset(albumId: album2.id, assetId: asset2.id);
final album3 = await ctx.newRemoteAlbum(ownerId: userId); final album3 = await ctx.newRemoteAlbum(ownerId: userId);
final asset3 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2025, 1, 1)); final asset3 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2025, 1, 1));
await ctx.insertRemoteAlbumAsset(albumId: album3.id, assetId: asset3.id); await ctx.newRemoteAlbumAsset(albumId: album3.id, assetId: asset3.id);
final result = await sut.getSortedAlbumIds([ final result = await sut.getSortedAlbumIds([
album1.id, album1.id,
@@ -186,15 +186,15 @@ void main() {
final asset3 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 15)); final asset3 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 15));
final asset4 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 20)); final asset4 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 20));
final asset5 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 25)); final asset5 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 25));
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id); await ctx.newRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id);
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset2.id); await ctx.newRemoteAlbumAsset(albumId: album1.id, assetId: asset2.id);
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset3.id); await ctx.newRemoteAlbumAsset(albumId: album1.id, assetId: asset3.id);
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset4.id); await ctx.newRemoteAlbumAsset(albumId: album1.id, assetId: asset4.id);
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset5.id); await ctx.newRemoteAlbumAsset(albumId: album1.id, assetId: asset5.id);
final album2 = await ctx.newRemoteAlbum(ownerId: userId); final album2 = await ctx.newRemoteAlbum(ownerId: userId);
final asset6 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 1)); final asset6 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 1));
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset6.id); await ctx.newRemoteAlbumAsset(albumId: album2.id, assetId: asset6.id);
final resultStart = await sut.getSortedAlbumIds([album1.id, album2.id], aggregation: AssetDateAggregation.start); final resultStart = await sut.getSortedAlbumIds([album1.id, album2.id], aggregation: AssetDateAggregation.start);
@@ -0,0 +1,73 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:intl/date_symbol_data_local.dart';
import '../repository_context.dart';
void main() {
late MediumRepositoryContext ctx;
late DriftTimelineRepository sut;
setUpAll(() async {
await initializeDateFormatting();
});
setUp(() {
ctx = MediumRepositoryContext();
sut = DriftTimelineRepository(ctx.db);
});
tearDown(() async {
await ctx.dispose();
});
group('remoteAlbum assets', () {
test('no duplicate assets when identical checksum appears in multiple local asset rows', () async {
// Regression check for #23273: a LEFT OUTER JOIN on checksum would fan out and create duplicates
// happens when same photo exists in multiple albums on device
final user = await ctx.newUser();
final checksum = 'yolo';
final album = await ctx.newRemoteAlbum(ownerId: user.id);
final remoteAsset = await ctx.newRemoteAsset(ownerId: user.id, checksum: checksum);
await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: remoteAsset.id);
final localAsset1 = await ctx.newLocalAsset(checksum: checksum);
final localAsset2 = await ctx.newLocalAsset(checksum: checksum);
final query = sut.remoteAlbum(album.id, .day);
final buckets = await query.bucketSource().first;
expect(buckets, hasLength(1));
expect(buckets.single.assetCount, 1);
final assets = await query.assetSource(0, 10);
expect(assets, hasLength(1));
expect((assets.first as RemoteAsset).id, remoteAsset.id);
expect([localAsset1.id, localAsset2.id], contains((assets.first as RemoteAsset).localId));
});
});
group('person assets', () {
test('does not duplicate an asset that has multiple face records for the same person', () async {
// Regression check for #26723: an INNER JOIN between remote_asset_entity and asset_face_entity
// fanned out one asset into N rows when N face records pointed at the same (asset, person) pair
final user = await ctx.newUser();
final asset = await ctx.newRemoteAsset(ownerId: user.id);
final person = await ctx.newPerson(ownerId: user.id);
await ctx.newFace(assetId: asset.id, personId: person.id);
await ctx.newFace(assetId: asset.id, personId: person.id);
final query = sut.person(user.id, person.id, .day);
final buckets = await query.bucketSource().first;
expect(buckets, hasLength(1));
expect(buckets.single.assetCount, 1);
final assets = await query.assetSource(0, 10);
expect(assets, hasLength(1));
expect((assets.first as RemoteAsset).id, asset.id);
});
});
}
+118 -80
View File
@@ -1,14 +1,14 @@
import 'dart:math';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart';
@@ -23,7 +23,6 @@ import '../utils.dart';
class MediumRepositoryContext { class MediumRepositoryContext {
final Drift db; final Drift db;
final Random _random = Random();
MediumRepositoryContext() : db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); MediumRepositoryContext() : db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
@@ -33,7 +32,7 @@ class MediumRepositoryContext {
static Value<T> _resolveUndefined<T>(T? plain, Option<T>? option, T fallback) { static Value<T> _resolveUndefined<T>(T? plain, Option<T>? option, T fallback) {
if (plain != null) { if (plain != null) {
return Value(plain); return .new(plain);
} }
return _resolveOption(option, fallback); return _resolveOption(option, fallback);
@@ -44,7 +43,7 @@ class MediumRepositoryContext {
return option.fold(Value.new, Value.absent); return option.fold(Value.new, Value.absent);
} }
return Value(fallback); return .new(fallback);
} }
Future<UserEntityData> newUser({ Future<UserEntityData> newUser({
@@ -54,17 +53,17 @@ class MediumRepositoryContext {
DateTime? profileChangedAt, DateTime? profileChangedAt,
bool? hasProfileImage, bool? hasProfileImage,
}) async { }) async {
id = TestUtils.uuid(id); id ??= TestUtils.uuid();
return await db return await db
.into(db.userEntity) .into(db.userEntity)
.insertReturning( .insertReturning(
UserEntityCompanion( UserEntityCompanion(
id: Value(id), id: .new(id),
email: Value(email ?? '$id@test.com'), email: .new(email ?? '$id@test.com'),
name: Value(email ?? 'user_$id'), name: .new(email ?? 'user_$id'),
avatarColor: Value(avatarColor ?? AvatarColor.values[_random.nextInt(AvatarColor.values.length)]), avatarColor: .new(avatarColor ?? TestUtils.randElement(AvatarColor.values)),
profileChangedAt: Value(TestUtils.date(profileChangedAt)), profileChangedAt: .new(TestUtils.date(profileChangedAt)),
hasProfileImage: Value(hasProfileImage ?? false), hasProfileImage: .new(hasProfileImage ?? false),
), ),
); );
} }
@@ -88,31 +87,31 @@ class MediumRepositoryContext {
String? thumbHash, String? thumbHash,
String? libraryId, String? libraryId,
}) async { }) async {
id = TestUtils.uuid(id); id ??= TestUtils.uuid();
createdAt = TestUtils.date(createdAt); createdAt ??= TestUtils.date();
return db return db
.into(db.remoteAssetEntity) .into(db.remoteAssetEntity)
.insertReturning( .insertReturning(
RemoteAssetEntityCompanion( RemoteAssetEntityCompanion(
id: Value(id), id: .new(id),
name: Value('remote_$id.jpg'), name: .new('remote_$id.jpg'),
checksum: Value(TestUtils.uuid(checksum)), checksum: .new(TestUtils.uuid(checksum)),
type: Value(type ?? AssetType.image), type: .new(type ?? .image),
createdAt: Value(createdAt), createdAt: .new(createdAt),
updatedAt: Value(TestUtils.date(updatedAt)), updatedAt: .new(TestUtils.date(updatedAt)),
ownerId: Value(TestUtils.uuid(ownerId)), ownerId: .new(TestUtils.uuid(ownerId)),
visibility: Value(visibility ?? AssetVisibility.timeline), visibility: .new(visibility ?? .timeline),
deletedAt: Value(deletedAt), deletedAt: .new(deletedAt),
durationMs: Value(durationMs ?? 0), durationMs: .new(durationMs ?? 0),
width: Value(width ?? _random.nextInt(1000)), width: .new(width ?? TestUtils.randInt(1000)),
height: Value(height ?? _random.nextInt(1000)), height: .new(height ?? TestUtils.randInt(1000)),
isFavorite: Value(isFavorite ?? false), isFavorite: .new(isFavorite ?? false),
isEdited: Value(isEdited ?? false), isEdited: .new(isEdited ?? false),
livePhotoVideoId: Value(livePhotoVideoId), livePhotoVideoId: .new(livePhotoVideoId),
stackId: Value(stackId), stackId: .new(stackId),
localDateTime: Value(createdAt.toLocal()), localDateTime: .new(createdAt.toLocal()),
thumbHash: Value(TestUtils.uuid(thumbHash)), thumbHash: .new(TestUtils.uuid(thumbHash)),
libraryId: Value(TestUtils.uuid(libraryId)), libraryId: .new(TestUtils.uuid(libraryId)),
), ),
); );
} }
@@ -130,12 +129,12 @@ class MediumRepositoryContext {
.into(db.remoteAssetCloudIdEntity) .into(db.remoteAssetCloudIdEntity)
.insertReturning( .insertReturning(
RemoteAssetCloudIdEntityCompanion( RemoteAssetCloudIdEntityCompanion(
assetId: Value(TestUtils.uuid(id)), assetId: .new(TestUtils.uuid(id)),
cloudId: Value(TestUtils.uuid(cloudId)), cloudId: .new(TestUtils.uuid(cloudId)),
createdAt: Value(TestUtils.date(createdAt)), createdAt: .new(TestUtils.date(createdAt)),
adjustmentTime: _resolveUndefined(adjustmentTime, adjustmentTimeOption, DateTime.now()), adjustmentTime: _resolveUndefined(adjustmentTime, adjustmentTimeOption, DateTime.now()),
latitude: _resolveOption(latitude, _random.nextDouble() * 180 - 90), latitude: _resolveOption(latitude, TestUtils.randDouble(-90, 90)),
longitude: _resolveOption(longitude, _random.nextDouble() * 360 - 180), longitude: _resolveOption(longitude, TestUtils.randDouble(-180, 180)),
), ),
); );
} }
@@ -151,40 +150,81 @@ class MediumRepositoryContext {
AlbumAssetOrder? order, AlbumAssetOrder? order,
String? thumbnailAssetId, String? thumbnailAssetId,
}) async { }) async {
id = TestUtils.uuid(id); id ??= TestUtils.uuid();
final album = await db final album = await db
.into(db.remoteAlbumEntity) .into(db.remoteAlbumEntity)
.insertReturning( .insertReturning(
RemoteAlbumEntityCompanion( RemoteAlbumEntityCompanion(
id: Value(id), id: .new(id),
name: Value(name ?? 'remote_album_$id'), name: .new(name ?? 'remote_album_$id'),
createdAt: Value(TestUtils.date(createdAt)), createdAt: .new(TestUtils.date(createdAt)),
updatedAt: Value(TestUtils.date(updatedAt)), updatedAt: .new(TestUtils.date(updatedAt)),
description: Value(description ?? 'Description for album $id'), description: .new(description ?? 'Description for album $id'),
isActivityEnabled: Value(isActivityEnabled ?? false), isActivityEnabled: .new(isActivityEnabled ?? false),
order: Value(order ?? AlbumAssetOrder.asc), order: .new(order ?? .asc),
thumbnailAssetId: Value(thumbnailAssetId), thumbnailAssetId: .new(thumbnailAssetId),
), ),
); );
await db await db
.into(db.remoteAlbumUserEntity) .into(db.remoteAlbumUserEntity)
.insert( .insert(
RemoteAlbumUserEntityCompanion.insert( RemoteAlbumUserEntityCompanion(
albumId: id, albumId: .new(id),
userId: ownerId ?? const Uuid().v4(), userId: .new(TestUtils.uuid(ownerId)),
role: AlbumUserRole.owner, role: const .new(.owner),
), ),
); );
return album; return album;
} }
Future<void> insertRemoteAlbumAsset({required String albumId, required String assetId}) { Future<void> newRemoteAlbumAsset({required String albumId, required String assetId}) {
return db return db
.into(db.remoteAlbumAssetEntity) .into(db.remoteAlbumAssetEntity)
.insert(RemoteAlbumAssetEntityCompanion.insert(albumId: albumId, assetId: assetId)); .insert(RemoteAlbumAssetEntityCompanion(albumId: .new(albumId), assetId: .new(assetId)));
}
Future<PersonEntityData> newPerson({String? id, String? ownerId, String? name, bool? isFavorite, bool? isHidden}) {
id ??= TestUtils.uuid();
return db
.into(db.personEntity)
.insertReturning(
PersonEntityCompanion(
id: .new(id),
ownerId: .new(TestUtils.uuid(ownerId)),
name: .new(name ?? 'person_$id'),
isFavorite: .new(isFavorite ?? false),
isHidden: .new(isHidden ?? false),
),
);
}
Future<AssetFaceEntityData> newFace({String? assetId, String? personId, int? imageWidth, int? imageHeight}) {
imageWidth ??= TestUtils.randInt(999) + 1;
imageHeight ??= TestUtils.randInt(999) + 1;
final x1 = TestUtils.randInt(imageWidth - 1);
final y1 = TestUtils.randInt(imageHeight - 1);
final x2 = x1 + 1 + TestUtils.randInt(imageWidth - x1 - 1);
final y2 = y1 + 1 + TestUtils.randInt(imageHeight - y1 - 1);
return db
.into(db.assetFaceEntity)
.insertReturning(
AssetFaceEntityCompanion(
id: .new(TestUtils.uuid()),
assetId: .new(TestUtils.uuid(assetId)),
personId: .new(TestUtils.uuid(personId)),
imageWidth: .new(imageWidth),
imageHeight: .new(imageHeight),
boundingBoxX1: .new(x1),
boundingBoxY1: .new(y1),
boundingBoxX2: .new(x2),
boundingBoxY2: .new(y2),
sourceType: const .new('machine-learning'),
),
);
} }
Future<LocalAssetEntityData> newLocalAsset({ Future<LocalAssetEntityData> newLocalAsset({
@@ -206,26 +246,26 @@ class MediumRepositoryContext {
int? orientation, int? orientation,
DateTime? updatedAt, DateTime? updatedAt,
}) async { }) async {
id = TestUtils.uuid(id); id ??= TestUtils.uuid();
return db return db
.into(db.localAssetEntity) .into(db.localAssetEntity)
.insertReturning( .insertReturning(
LocalAssetEntityCompanion( LocalAssetEntityCompanion(
id: Value(id), id: .new(id),
name: Value(name ?? 'local_$id.jpg'), name: .new(name ?? 'local_$id.jpg'),
height: Value(height ?? _random.nextInt(1000)), height: .new(height ?? TestUtils.randInt(1000)),
width: Value(width ?? _random.nextInt(1000)), width: .new(width ?? TestUtils.randInt(1000)),
durationMs: Value(durationMs ?? 0), durationMs: .new(durationMs ?? 0),
orientation: Value(orientation ?? 0), orientation: .new(orientation ?? 0),
updatedAt: Value(TestUtils.date(updatedAt)), updatedAt: .new(TestUtils.date(updatedAt)),
checksum: _resolveUndefined(checksum, checksumOption, const Uuid().v4()), checksum: _resolveUndefined(checksum, checksumOption, const Uuid().v4()),
createdAt: Value(TestUtils.date(createdAt)), createdAt: .new(TestUtils.date(createdAt)),
type: Value(type ?? AssetType.image), type: .new(type ?? .image),
isFavorite: Value(isFavorite ?? false), isFavorite: .new(isFavorite ?? false),
iCloudId: Value(TestUtils.uuid(iCloudId)), iCloudId: .new(TestUtils.uuid(iCloudId)),
adjustmentTime: _resolveUndefined(adjustmentTime, adjustmentTimeOption, DateTime.now()), adjustmentTime: _resolveUndefined(adjustmentTime, adjustmentTimeOption, DateTime.now()),
latitude: Value(latitude ?? _random.nextDouble() * 180 - 90), latitude: .new(latitude ?? TestUtils.randDouble(-90, 90)),
longitude: Value(longitude ?? _random.nextDouble() * 360 - 180), longitude: .new(longitude ?? TestUtils.randDouble(-180, 180)),
), ),
); );
} }
@@ -238,24 +278,22 @@ class MediumRepositoryContext {
bool? isIosSharedAlbum, bool? isIosSharedAlbum,
String? linkedRemoteAlbumId, String? linkedRemoteAlbumId,
}) { }) {
id = TestUtils.uuid(id); id ??= TestUtils.uuid();
return db return db
.into(db.localAlbumEntity) .into(db.localAlbumEntity)
.insertReturning( .insertReturning(
LocalAlbumEntityCompanion( LocalAlbumEntityCompanion(
id: Value(id), id: .new(id),
name: Value(name ?? 'local_album_$id'), name: .new(name ?? 'local_album_$id'),
updatedAt: Value(TestUtils.date(updatedAt)), updatedAt: .new(TestUtils.date(updatedAt)),
backupSelection: Value(backupSelection ?? BackupSelection.none), backupSelection: .new(backupSelection ?? .none),
isIosSharedAlbum: Value(isIosSharedAlbum ?? false), isIosSharedAlbum: .new(isIosSharedAlbum ?? false),
linkedRemoteAlbumId: Value(linkedRemoteAlbumId), linkedRemoteAlbumId: .new(linkedRemoteAlbumId),
), ),
); );
} }
Future<void> newLocalAlbumAsset({required String albumId, required String assetId}) { Future<void> newLocalAlbumAsset({required String albumId, required String assetId}) => db
return db .into(db.localAlbumAssetEntity)
.into(db.localAlbumAssetEntity) .insert(LocalAlbumAssetEntityCompanion(albumId: .new(albumId), assetId: .new(assetId)));
.insert(LocalAlbumAssetEntityCompanion.insert(albumId: albumId, assetId: assetId));
}
} }
+12
View File
@@ -1,10 +1,22 @@
import 'dart:math';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
class TestUtils { class TestUtils {
static final _random = Random();
static String uuid([String? id]) => id ?? const Uuid().v4(); static String uuid([String? id]) => id ?? const Uuid().v4();
static DateTime date([DateTime? date]) => date ?? DateTime.now(); static DateTime date([DateTime? date]) => date ?? DateTime.now();
static DateTime now() => DateTime.now(); static DateTime now() => DateTime.now();
static DateTime yesterday() => DateTime.now().subtract(const Duration(days: 1)); static DateTime yesterday() => DateTime.now().subtract(const Duration(days: 1));
static DateTime tomorrow() => DateTime.now().add(const Duration(days: 1)); static DateTime tomorrow() => DateTime.now().add(const Duration(days: 1));
static T randElement<T>(List<T> list) => list[_random.nextInt(list.length)];
static int randInt([int? max]) => max != null ? _random.nextInt(max) : _random.nextInt(1 << 32);
static double randDouble([int? max, int? min]) {
final minValue = min ?? 0;
final maxValue = max ?? 1;
return minValue + _random.nextDouble() * (maxValue - minValue);
}
} }
+12 -3
View File
@@ -1641,7 +1641,7 @@
"name": "assetId", "name": "assetId",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Filter albums containing this asset ID (ignores shared parameter)", "description": "Filter albums containing this asset ID (ignores other parameters)",
"schema": { "schema": {
"format": "uuid", "format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
@@ -1649,10 +1649,19 @@
} }
}, },
{ {
"name": "shared", "name": "isOwned",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Filter by shared status: true = only shared, false = not shared, undefined = all owned albums", "description": "Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter",
"schema": {
"type": "boolean"
}
},
{
"name": "isShared",
"required": false,
"in": "query",
"description": "Filter by shared status: true = only shared, false = not shared, undefined = no filter",
"schema": { "schema": {
"type": "boolean" "type": "boolean"
} }
-1
View File
@@ -1 +0,0 @@
24.15.0
+5 -8
View File
@@ -2,6 +2,11 @@
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "2.7.5", "version": "2.7.5",
"description": "Auto-generated TypeScript SDK for the Immich API", "description": "Auto-generated TypeScript SDK for the Immich API",
"repository": {
"type": "git",
"url": "git+https://github.com/immich-app/immich.git",
"directory": "open-api/typescript-sdk"
},
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",
"types": "./build/index.d.ts", "types": "./build/index.d.ts",
@@ -21,13 +26,5 @@
"devDependencies": { "devDependencies": {
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"typescript": "^6.0.0" "typescript": "^6.0.0"
},
"repository": {
"type": "git",
"url": "git+https://github.com/immich-app/immich.git",
"directory": "open-api/typescript-sdk"
},
"volta": {
"node": "24.15.0"
} }
} }
+5 -3
View File
@@ -3657,16 +3657,18 @@ export function getUserStatisticsAdmin({ id, isFavorite, isTrashed, visibility }
/** /**
* List all albums * List all albums
*/ */
export function getAllAlbums({ assetId, shared }: { export function getAllAlbums({ assetId, isOwned, isShared }: {
assetId?: string; assetId?: string;
shared?: boolean; isOwned?: boolean;
isShared?: boolean;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: AlbumResponseDto[]; data: AlbumResponseDto[];
}>(`/albums${QS.query(QS.explode({ }>(`/albums${QS.query(QS.explode({
assetId, assetId,
shared isOwned,
isShared
}))}`, { }))}`, {
...opts ...opts
})); }));
+73 -39
View File
@@ -246,7 +246,7 @@ importers:
version: 64.0.0(eslint@10.2.1(jiti@2.6.1)) version: 64.0.0(eslint@10.2.1(jiti@2.6.1))
exiftool-vendored: exiftool-vendored:
specifier: ^35.0.0 specifier: ^35.0.0
version: 35.18.0 version: 35.19.0
globals: globals:
specifier: ^17.0.0 specifier: ^17.0.0
version: 17.5.0 version: 17.5.0
@@ -444,8 +444,8 @@ importers:
specifier: 4.4.0 specifier: 4.4.0
version: 4.4.0 version: 4.4.0
exiftool-vendored: exiftool-vendored:
specifier: ^35.0.0 specifier: ^35.20.0
version: 35.18.0 version: 35.20.0
express: express:
specifier: ^5.1.0 specifier: ^5.1.0
version: 5.2.1 version: 5.2.1
@@ -480,11 +480,11 @@ importers:
specifier: ^9.0.2 specifier: ^9.0.2
version: 9.0.3 version: 9.0.3
kysely: kysely:
specifier: 0.28.16 specifier: 0.29.0
version: 0.28.16 version: 0.29.0
kysely-postgres-js: kysely-postgres-js:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.0.0(kysely@0.28.16)(postgres@3.4.9) version: 3.0.0(kysely@0.29.0)(postgres@3.4.9)
lodash: lodash:
specifier: ^4.17.21 specifier: ^4.17.21
version: 4.18.1 version: 4.18.1
@@ -505,10 +505,10 @@ importers:
version: 6.2.0(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 6.2.0(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2)
nestjs-kysely: nestjs-kysely:
specifier: 3.1.2 specifier: 3.1.2
version: 3.1.2(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(kysely@0.28.16)(reflect-metadata@0.2.2) version: 3.1.2(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(kysely@0.29.0)(reflect-metadata@0.2.2)
nestjs-otel: nestjs-otel:
specifier: ^7.0.0 specifier: ^8.0.0
version: 7.0.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19) version: 8.0.2(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)
nestjs-zod: nestjs-zod:
specifier: ^5.3.0 specifier: ^5.3.0
version: 5.3.0(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.2(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(class-transformer@0.5.1)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6) version: 5.3.0(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.2(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(class-transformer@0.5.1)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6)
@@ -3785,8 +3785,8 @@ packages:
peerDependencies: peerDependencies:
'@opentelemetry/api': ^1.0.0 '@opentelemetry/api': ^1.0.0
'@opentelemetry/host-metrics@0.36.2': '@opentelemetry/host-metrics@0.38.3':
resolution: {integrity: sha512-eMdea86cfIqx3cdFpcKU3StrjqFkQDIVp7NANVnVWO8O6hDw/DBwGwu4Gi1wJCuoQ2JVwKNWQxUTSRheB6O29Q==} resolution: {integrity: sha512-8iSOA8VPGoB5p/RIC8n/dcSe4cluCEWoznWENZfXR8sWQOQvergFu7v798xp7S5WQlZo1zfn1nVXx8dbyQ9m6Q==}
engines: {node: ^18.19.0 || >=20.6.0} engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies: peerDependencies:
'@opentelemetry/api': ^1.3.0 '@opentelemetry/api': ^1.3.0
@@ -5477,6 +5477,7 @@ packages:
'@ungap/structured-clone@1.3.0': '@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@valibot/to-json-schema@1.6.0': '@valibot/to-json-schema@1.6.0':
resolution: {integrity: sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A==} resolution: {integrity: sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A==}
@@ -7547,13 +7548,26 @@ packages:
resolution: {integrity: sha512-9ENCWzUiFy6F/O4jSX50ygSGrTOtvoqJFWE0zAOl7VL/EFooLWNF0LkaNSox0ibbIsQz5rWSKi0TPlEbF4qBIw==} resolution: {integrity: sha512-9ENCWzUiFy6F/O4jSX50ygSGrTOtvoqJFWE0zAOl7VL/EFooLWNF0LkaNSox0ibbIsQz5rWSKi0TPlEbF4qBIw==}
os: [win32] os: [win32]
exiftool-vendored.exe@13.58.0:
resolution: {integrity: sha512-pV7SjQeOu4Q77DWuyF+hlRYWVlRcSAqfqTTujBZeGUy/Q9+RPAy877YgSZIxKOYW1TxmmL8KyBGxaG0JKYG8BQ==}
os: [win32]
exiftool-vendored.pl@13.57.0: exiftool-vendored.pl@13.57.0:
resolution: {integrity: sha512-7HYhrIygbfKD+E/sUF9L8YEs7qCEFLFWKoeevJllnD9jxVvZ09tfFsjbBPQ7SAgGwWSHW//SVULFHLgrO8JsBw==} resolution: {integrity: sha512-7HYhrIygbfKD+E/sUF9L8YEs7qCEFLFWKoeevJllnD9jxVvZ09tfFsjbBPQ7SAgGwWSHW//SVULFHLgrO8JsBw==}
os: ['!win32'] os: ['!win32']
hasBin: true hasBin: true
exiftool-vendored@35.18.0: exiftool-vendored.pl@13.58.0:
resolution: {integrity: sha512-QBtYNz71VAwZWqxFP1iWWS9qwOx3b9MSpk0GAMyIfS8gupUWsOyhn4i2WrB4OlRSQPuQ2YeKSw2fygi6E0LGiw==} resolution: {integrity: sha512-+Z2xhZrYLMu/anO/s14AaS/K5HMJ5Cw9C3KefIeYNpkZRN4RRBJHm7R34yjj9Pv+elqYRZrQV9NcqvkBLn/68w==}
os: ['!win32']
hasBin: true
exiftool-vendored@35.19.0:
resolution: {integrity: sha512-c7/W5VA0f2XKllRgFSBOoTSRMlRVn13QT/TudOS3RN64VRfcj51vQQcRwtmdwYuRrzRf7lsh6gCQpmm3vr8Lww==}
engines: {node: '>=20.0.0'}
exiftool-vendored@35.20.0:
resolution: {integrity: sha512-Yn66dSBaWGcUaSbm5Nl4G28rxtceLlWf4PstqJMbLix9sN7w0okWHPEvdudiP56Q5Cjl7v3TLyKKwowUFlbD8g==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
expect-type@1.3.0: expect-type@1.3.0:
@@ -8713,6 +8727,10 @@ packages:
resolution: {integrity: sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww==} resolution: {integrity: sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
kysely@0.29.0:
resolution: {integrity: sha512-LrQfPUeTW7MXbMvT62moEMnpMTuj9TO3lqjCeLKjM975PJ4Alrl/43f2tlDX7xOsNptKgH4LSNGwIbXwEkLg4g==}
engines: {node: '>=22.0.0'}
langium@3.3.1: langium@3.3.1:
resolution: {integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==} resolution: {integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
@@ -9525,9 +9543,9 @@ packages:
kysely: 0.x kysely: 0.x
reflect-metadata: ^0.1.13 || ^0.2.2 reflect-metadata: ^0.1.13 || ^0.2.2
nestjs-otel@7.0.1: nestjs-otel@8.0.2:
resolution: {integrity: sha512-NKce9aAJ263rcqaj3etHmv5KE+VALBqjGkPmZYvaesIb7AT7WBA3YXiEXmkJdKsnF2ZwmNFUJXCQWPn91Hrc8A==} resolution: {integrity: sha512-IQZ4MRb54WqRPooFPWrbqdOt+RckwaBjPoQy+axm9Jtri0DlOM6B3mSfzKHAJOwjj6YFaq10DXLOcYrqI8IsXA==}
engines: {node: '>= 20'} engines: {node: '>= 22'}
peerDependencies: peerDependencies:
'@nestjs/common': '>= 11 < 12' '@nestjs/common': '>= 11 < 12'
'@nestjs/core': '>= 11 < 12' '@nestjs/core': '>= 11 < 12'
@@ -10900,10 +10918,6 @@ packages:
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
hasBin: true hasBin: true
response-time@2.3.4:
resolution: {integrity: sha512-fiyq1RvW5/Br6iAtT8jN1XrNY8WPu2+yEypLbaijWry8WDZmn12azG9p/+c+qpEebURLlQmqCB8BNSu7ji+xQQ==}
engines: {node: '>= 0.8.0'}
responselike@3.0.0: responselike@3.0.0:
resolution: {integrity: sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==} resolution: {integrity: sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==}
engines: {node: '>=14.16'} engines: {node: '>=14.16'}
@@ -11579,8 +11593,8 @@ packages:
resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==}
engines: {node: ^14.18.0 || >=16.0.0} engines: {node: ^14.18.0 || >=16.0.0}
systeminformation@5.23.8: systeminformation@5.31.5:
resolution: {integrity: sha512-Osd24mNKe6jr/YoXLLK3k8TMdzaxDffhpCxgkfgBHcapykIkd50HXThM3TCEuHO2pPuCsSx2ms/SunqhU5MmsQ==} resolution: {integrity: sha512-5SyLdip4/3alxD4Kh+63bUQTJmu7YMfYQTC+koZy7X73HgNqZSD2P4wOZQWtUncvPvcEmnfIjCoygN4MRoEejQ==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android] os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android]
hasBin: true hasBin: true
@@ -12133,6 +12147,7 @@ packages:
uuid@10.0.0: uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true hasBin: true
uuid@11.1.0: uuid@11.1.0:
@@ -12145,6 +12160,7 @@ packages:
uuid@8.3.2: uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true hasBin: true
valibot@1.3.1: valibot@1.3.1:
@@ -16297,10 +16313,10 @@ snapshots:
'@opentelemetry/sdk-trace-base': 2.7.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.7.0(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.40.0 '@opentelemetry/semantic-conventions': 1.40.0
'@opentelemetry/host-metrics@0.36.2(@opentelemetry/api@1.9.1)': '@opentelemetry/host-metrics@0.38.3(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.0
systeminformation: 5.23.8 systeminformation: 5.31.5
'@opentelemetry/instrumentation-http@0.215.0(@opentelemetry/api@1.9.1)': '@opentelemetry/instrumentation-http@0.215.0(@opentelemetry/api@1.9.1)':
dependencies: dependencies:
@@ -17985,7 +18001,7 @@ snapshots:
obug: 2.1.1 obug: 2.1.1
std-env: 4.1.0 std-env: 4.1.0
tinyrainbow: 3.1.0 tinyrainbow: 3.1.0
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/expect@3.2.4': '@vitest/expect@3.2.4':
dependencies: dependencies:
@@ -20308,9 +20324,14 @@ snapshots:
exiftool-vendored.exe@13.57.0: exiftool-vendored.exe@13.57.0:
optional: true optional: true
exiftool-vendored.exe@13.58.0:
optional: true
exiftool-vendored.pl@13.57.0: {} exiftool-vendored.pl@13.57.0: {}
exiftool-vendored@35.18.0: exiftool-vendored.pl@13.58.0: {}
exiftool-vendored@35.19.0:
dependencies: dependencies:
'@photostructure/tz-lookup': 11.5.0 '@photostructure/tz-lookup': 11.5.0
'@types/luxon': 3.7.1 '@types/luxon': 3.7.1
@@ -20321,6 +20342,17 @@ snapshots:
optionalDependencies: optionalDependencies:
exiftool-vendored.exe: 13.57.0 exiftool-vendored.exe: 13.57.0
exiftool-vendored@35.20.0:
dependencies:
'@photostructure/tz-lookup': 11.5.0
'@types/luxon': 3.7.1
batch-cluster: 17.3.1
exiftool-vendored.pl: 13.58.0
he: 1.2.0
luxon: 3.7.2
optionalDependencies:
exiftool-vendored.exe: 13.58.0
expect-type@1.3.0: {} expect-type@1.3.0: {}
exponential-backoff@3.1.3: {} exponential-backoff@3.1.3: {}
@@ -21708,8 +21740,16 @@ snapshots:
optionalDependencies: optionalDependencies:
postgres: 3.4.9 postgres: 3.4.9
kysely-postgres-js@3.0.0(kysely@0.29.0)(postgres@3.4.9):
dependencies:
kysely: 0.29.0
optionalDependencies:
postgres: 3.4.9
kysely@0.28.16: {} kysely@0.28.16: {}
kysely@0.29.0: {}
langium@3.3.1: langium@3.3.1:
dependencies: dependencies:
chevrotain: 11.0.3 chevrotain: 11.0.3
@@ -22781,21 +22821,20 @@ snapshots:
reflect-metadata: 0.2.2 reflect-metadata: 0.2.2
rxjs: 7.8.2 rxjs: 7.8.2
nestjs-kysely@3.1.2(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(kysely@0.28.16)(reflect-metadata@0.2.2): nestjs-kysely@3.1.2(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(kysely@0.29.0)(reflect-metadata@0.2.2):
dependencies: dependencies:
'@nestjs/common': 11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(@nestjs/websockets@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(@nestjs/websockets@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2)
kysely: 0.28.16 kysely: 0.29.0
reflect-metadata: 0.2.2 reflect-metadata: 0.2.2
tslib: 2.8.1 tslib: 2.8.1
nestjs-otel@7.0.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19): nestjs-otel@8.0.2(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19):
dependencies: dependencies:
'@nestjs/common': 11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(@nestjs/websockets@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(@nestjs/websockets@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.0
'@opentelemetry/host-metrics': 0.36.2(@opentelemetry/api@1.9.1) '@opentelemetry/host-metrics': 0.38.3(@opentelemetry/api@1.9.0)
response-time: 2.3.4
tslib: 2.8.1 tslib: 2.8.1
nestjs-zod@5.3.0(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.2(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(class-transformer@0.5.1)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6): nestjs-zod@5.3.0(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.2(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(class-transformer@0.5.1)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6):
@@ -24322,11 +24361,6 @@ snapshots:
path-parse: 1.0.7 path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0 supports-preserve-symlinks-flag: 1.0.0
response-time@2.3.4:
dependencies:
depd: 2.0.0
on-headers: 1.1.0
responselike@3.0.0: responselike@3.0.0:
dependencies: dependencies:
lowercase-keys: 3.0.0 lowercase-keys: 3.0.0
@@ -25238,7 +25272,7 @@ snapshots:
dependencies: dependencies:
'@pkgr/core': 0.2.9 '@pkgr/core': 0.2.9
systeminformation@5.23.8: {} systeminformation@5.31.5: {}
tabbable@6.4.0: {} tabbable@6.4.0: {}
-1
View File
@@ -1 +0,0 @@
24.15.0
+13 -2
View File
@@ -55,12 +55,23 @@ run = [
env._.path = "./node_modules/.bin" env._.path = "./node_modules/.bin"
run = "email dev -p 3050 --dir src/emails" run = "email dev -p 3050 --dir src/emails"
[tasks.checklist] [tasks.ci-unit]
run = [ run = [
{ task = ":install" }, { task = ":install" },
{ task = ":format" }, { task = ":format" },
{ task = ":lint" }, { task = ":lint" },
{ task = ":check" }, { task = ":check" },
{ task = ":test-medium --run" },
{ task = ":test --run" }, { task = ":test --run" },
] ]
[tasks.ci-medium]
run = [
{ task = ":install" },
{ task = ":test-medium --run" },
]
[tasks.checklist]
run = [
{ task = ":ci-unit" },
{ task = ":ci-medium" },
]
+3 -6
View File
@@ -72,7 +72,7 @@
"cookie": "^1.0.2", "cookie": "^1.0.2",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cron": "4.4.0", "cron": "4.4.0",
"exiftool-vendored": "^35.0.0", "exiftool-vendored": "^35.20.0",
"express": "^5.1.0", "express": "^5.1.0",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
@@ -84,7 +84,7 @@
"jose": "^6.0.0", "jose": "^6.0.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"kysely": "0.28.16", "kysely": "0.29.0",
"kysely-postgres-js": "^3.0.0", "kysely-postgres-js": "^3.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"luxon": "^3.4.2", "luxon": "^3.4.2",
@@ -93,7 +93,7 @@
"nest-commander": "^3.16.0", "nest-commander": "^3.16.0",
"nestjs-cls": "^6.0.0", "nestjs-cls": "^6.0.0",
"nestjs-kysely": "3.1.2", "nestjs-kysely": "3.1.2",
"nestjs-otel": "^7.0.0", "nestjs-otel": "^8.0.0",
"nestjs-zod": "^5.3.0", "nestjs-zod": "^5.3.0",
"nodemailer": "^8.0.0", "nodemailer": "^8.0.0",
"openid-client": "^6.3.3", "openid-client": "^6.3.3",
@@ -167,9 +167,6 @@
"vite-tsconfig-paths": "^6.0.0", "vite-tsconfig-paths": "^6.0.0",
"vitest": "^3.0.0" "vitest": "^3.0.0"
}, },
"volta": {
"node": "24.15.0"
},
"overrides": { "overrides": {
"sharp": "^0.34.5" "sharp": "^0.34.5"
} }
@@ -25,11 +25,11 @@ describe(AlbumController.name, () => {
}); });
it('should reject an invalid shared param', async () => { it('should reject an invalid shared param', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/albums?shared=invalid'); const { status, body } = await request(ctx.getHttpServer()).get('/albums?isShared=invalid');
expect(status).toEqual(400); expect(status).toEqual(400);
expect(body).toEqual( expect(body).toEqual(
factory.responses.validationError([ factory.responses.validationError([
{ path: ['shared'], message: 'Invalid option: expected one of "true"|"false"' }, { path: ['isShared'], message: 'Invalid option: expected one of "true"|"false"' },
]), ]),
); );
}); });
+20
View File
@@ -395,6 +395,26 @@ export const columns = {
'asset.height', 'asset.height',
'asset.isEdited', 'asset.isEdited',
], ],
syncPartnerAsset: [
'asset.id',
'asset.ownerId',
'asset.originalFileName',
'asset.thumbhash',
'asset.checksum',
'asset.fileCreatedAt',
'asset.fileModifiedAt',
'asset.localDateTime',
'asset.type',
'asset.deletedAt',
'asset.visibility',
'asset.duration',
'asset.livePhotoVideoId',
'asset.stackId',
'asset.libraryId',
'asset.width',
'asset.height',
'asset.isEdited',
],
syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'], syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'],
syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'], syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'],
syncUser: ['id', 'name', 'email', 'avatarColor', 'deletedAt', 'updateId', 'profileImagePath', 'profileChangedAt'], syncUser: ['id', 'name', 'email', 'avatarColor', 'deletedAt', 'updateId', 'profileImagePath', 'profileChangedAt'],
+6 -3
View File
@@ -65,10 +65,13 @@ const UpdateAlbumSchema = z
const GetAlbumsSchema = z const GetAlbumsSchema = z
.object({ .object({
shared: stringToBool isOwned: stringToBool
.optional() .optional()
.describe('Filter by shared status: true = only shared, false = not shared, undefined = all owned albums'), .describe('Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter'),
assetId: z.uuidv4().optional().describe('Filter albums containing this asset ID (ignores shared parameter)'), isShared: stringToBool
.optional()
.describe('Filter by shared status: true = only shared, false = not shared, undefined = no filter'),
assetId: z.uuidv4().optional().describe('Filter albums containing this asset ID (ignores other parameters)'),
}) })
.meta({ id: 'GetAlbumsDto' }); .meta({ id: 'GetAlbumsDto' });
+46 -163
View File
@@ -185,7 +185,7 @@ where
group by group by
"album_asset"."albumId" "album_asset"."albumId"
-- AlbumRepository.getOwned -- AlbumRepository.getAll
select select
"album".*, "album".*,
( (
@@ -242,172 +242,55 @@ from
"album" "album"
inner join "album_user" on "album_user"."albumId" = "album"."id" inner join "album_user" on "album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2 and "album_user"."userId" = $2
and "album_user"."role" = 'owner'
where where
"album"."deletedAt" is null "album"."deletedAt" is null
order by
"album"."createdAt" desc
-- AlbumRepository.getShared
select
"album".*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"album_user"."role",
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"album_user"."userId" = $1 desc,
"user"."name" asc
) as agg
) as "albumUsers",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"shared_link".*
from
"shared_link"
where
"shared_link"."albumId" = "album"."id"
) as agg
) as "sharedLinks"
from
"album"
inner join (
select
"album_user"."albumId" as "id"
from
"album_user"
where
"album_user"."userId" = $2
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."role" != 'owner'
)
union
select
"shared_link"."albumId" as "id"
from
"shared_link"
where
"shared_link"."userId" = $3
and "shared_link"."albumId" is not null
) as "matching" on "matching"."id" = "album"."id"
inner join "album_user" on "album_user"."albumId" = "album"."id"
and "album_user"."role" = 'owner' and "album_user"."role" = 'owner'
where and (
"album"."deletedAt" is null exists (
order by select
"album"."createdAt" desc from
"album_user" as "au"
-- AlbumRepository.getNotShared where
select "au"."albumId" = "album"."id"
"album".*, and "au"."role" != 'owner'
( )
select or exists (
coalesce(json_agg(agg), '[]') select
from from
( "shared_link"
select where
"shared_link".* "shared_link"."albumId" = "album"."id"
from )
"shared_link"
where
"shared_link"."albumId" = "album"."id"
) as agg
) as "sharedLinks",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"album_user"."role",
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"album_user"."userId" = $1 desc,
"user"."name" asc
) as agg
) as "albumUsers"
from
"album"
inner join "album_user" on "album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2
and "album_user"."role" = 'owner'
where
"album"."deletedAt" is null
and not exists (
select
from
"album_user" as "au"
where
"au"."albumId" = "album"."id"
and "au"."role" != 'owner'
) )
and not exists ( order by
select "album"."createdAt" desc
from
"shared_link" -- AlbumRepository.getAllIds
where select
"shared_link"."albumId" = "album"."id" "album"."id"
from
"album"
inner join "album_user" on "album_user"."albumId" = "album"."id"
and "album_user"."userId" = $1
where
"album"."deletedAt" is null
and "album_user"."role" = 'owner'
and (
exists (
select
from
"album_user" as "au"
where
"au"."albumId" = "album"."id"
and "au"."role" != 'owner'
)
or exists (
select
from
"shared_link"
where
"shared_link"."albumId" = "album"."id"
)
) )
order by order by
"album"."createdAt" desc "album"."createdAt" desc
+24 -4
View File
@@ -42,6 +42,16 @@ select
"memory_asset"."memoriesId" = "memory"."id" "memory_asset"."memoriesId" = "memory"."id"
and "asset"."visibility" = 'timeline' and "asset"."visibility" = 'timeline'
and "asset"."deletedAt" is null and "asset"."deletedAt" is null
and not exists (
select
$1 as "one"
from
"asset_face"
inner join "person" on "person"."id" = "asset_face"."personId"
where
"asset_face"."assetId" = "asset"."id"
and "person"."isHidden" = $2
)
order by order by
"asset"."fileCreatedAt" asc "asset"."fileCreatedAt" asc
) as agg ) as agg
@@ -51,7 +61,7 @@ from
"memory" "memory"
where where
"deletedAt" is null "deletedAt" is null
and "ownerId" = $1 and "ownerId" = $3
order by order by
"memoryAt" desc "memoryAt" desc
@@ -71,6 +81,16 @@ select
"memory_asset"."memoriesId" = "memory"."id" "memory_asset"."memoriesId" = "memory"."id"
and "asset"."visibility" = 'timeline' and "asset"."visibility" = 'timeline'
and "asset"."deletedAt" is null and "asset"."deletedAt" is null
and not exists (
select
$1 as "one"
from
"asset_face"
inner join "person" on "person"."id" = "asset_face"."personId"
where
"asset_face"."assetId" = "asset"."id"
and "person"."isHidden" = $2
)
order by order by
"asset"."fileCreatedAt" asc "asset"."fileCreatedAt" asc
) as agg ) as agg
@@ -81,14 +101,14 @@ from
where where
( (
"showAt" is null "showAt" is null
or "showAt" <= $1 or "showAt" <= $3
) )
and ( and (
"hideAt" is null "hideAt" is null
or "hideAt" >= $2 or "hideAt" >= $4
) )
and "deletedAt" is null and "deletedAt" is null
and "ownerId" = $3 and "ownerId" = $5
order by order by
"memoryAt" desc "memoryAt" desc
+9 -9
View File
@@ -739,7 +739,6 @@ select
"asset"."localDateTime", "asset"."localDateTime",
"asset"."type", "asset"."type",
"asset"."deletedAt", "asset"."deletedAt",
"asset"."isFavorite",
"asset"."visibility", "asset"."visibility",
"asset"."duration", "asset"."duration",
"asset"."livePhotoVideoId", "asset"."livePhotoVideoId",
@@ -748,14 +747,15 @@ select
"asset"."width", "asset"."width",
"asset"."height", "asset"."height",
"asset"."isEdited", "asset"."isEdited",
$1 as "isFavorite",
"asset"."updateId" "asset"."updateId"
from from
"asset" as "asset" "asset" as "asset"
where where
"asset"."updateId" < $1 "asset"."updateId" < $2
and "asset"."updateId" <= $2 and "asset"."updateId" <= $3
and "asset"."updateId" >= $3 and "asset"."updateId" >= $4
and "ownerId" = $4 and "ownerId" = $5
order by order by
"asset"."updateId" asc "asset"."updateId" asc
@@ -791,7 +791,6 @@ select
"asset"."localDateTime", "asset"."localDateTime",
"asset"."type", "asset"."type",
"asset"."deletedAt", "asset"."deletedAt",
"asset"."isFavorite",
"asset"."visibility", "asset"."visibility",
"asset"."duration", "asset"."duration",
"asset"."livePhotoVideoId", "asset"."livePhotoVideoId",
@@ -800,19 +799,20 @@ select
"asset"."width", "asset"."width",
"asset"."height", "asset"."height",
"asset"."isEdited", "asset"."isEdited",
$1 as "isFavorite",
"asset"."updateId" "asset"."updateId"
from from
"asset" as "asset" "asset" as "asset"
where where
"asset"."updateId" < $1 "asset"."updateId" < $2
and "asset"."updateId" > $2 and "asset"."updateId" > $3
and "ownerId" in ( and "ownerId" in (
select select
"sharedById" "sharedById"
from from
"partner" "partner"
where where
"sharedWithId" = $3 "sharedWithId" = $4
) )
order by order by
"asset"."updateId" asc "asset"."updateId" asc
+30 -80
View File
@@ -13,7 +13,7 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database'; import { columns } from 'src/database';
import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { AlbumUserCreateDto } from 'src/dtos/album.dto'; import { AlbumUserCreateDto, MapAlbumDto } from 'src/dtos/album.dto';
import { AlbumUserRole } from 'src/enum'; import { AlbumUserRole } from 'src/enum';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { AlbumTable } from 'src/schema/tables/album.table'; import { AlbumTable } from 'src/schema/tables/album.table';
@@ -183,98 +183,48 @@ export class AlbumRepository {
); );
} }
@GenerateSql({ params: [DummyValue.UUID] }) private buildAlbumBaseQuery(ownerId: string, { isOwned, isShared }: { isOwned?: boolean; isShared?: boolean }) {
getOwned(ownerId: string) {
return this.db return this.db
.selectFrom('album') .selectFrom('album')
.selectAll('album')
.innerJoin('album_user', (join) => .innerJoin('album_user', (join) =>
join join.onRef('album_user.albumId', '=', 'album.id').on('album_user.userId', '=', ownerId),
.onRef('album_user.albumId', '=', 'album.id')
.on('album_user.userId', '=', ownerId)
.on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)),
) )
.where('album.deletedAt', 'is', null) .where('album.deletedAt', 'is', null)
.$if(isOwned === true, (qb) => qb.where('album_user.role', '=', sql.lit(AlbumUserRole.Owner)))
.$if(isOwned === false, (qb) => qb.where('album_user.role', '!=', sql.lit(AlbumUserRole.Owner)))
.$if(isShared !== undefined, (qb) =>
qb.where((eb) => {
const isSharedAlbum = eb.or([
eb.exists(
eb
.selectFrom('album_user as au')
.whereRef('au.albumId', '=', 'album.id')
.where('au.role', '!=', sql.lit(AlbumUserRole.Owner)),
),
eb.exists(eb.selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id')),
]);
return isShared ? isSharedAlbum : eb.not(isSharedAlbum);
}),
);
}
@GenerateSql({ params: [DummyValue.UUID, { isOwned: true, isShared: true }] })
getAll(ownerId: string, options: { isOwned?: boolean; isShared?: boolean } = {}): Promise<MapAlbumDto[]> {
return this.buildAlbumBaseQuery(ownerId, options)
.selectAll('album')
.select(withAlbumUsers(ownerId)) .select(withAlbumUsers(ownerId))
.select(withSharedLink) .select(withSharedLink)
.orderBy('album.createdAt', 'desc') .orderBy('album.createdAt', 'desc')
.execute(); .execute();
} }
/** @GenerateSql({ params: [DummyValue.UUID, { isOwned: true, isShared: true }] })
* Get albums shared with and shared by owner. async getAllIds(ownerId: string, options: { isOwned?: boolean; isShared?: boolean } = {}): Promise<string[]> {
*/ const rows = await this.buildAlbumBaseQuery(ownerId, options)
@GenerateSql({ params: [DummyValue.UUID] }) .select('album.id')
getShared(ownerId: string) {
return this.db
.selectFrom('album')
.selectAll('album')
.innerJoin(
(eb) =>
eb
.selectFrom('album_user')
.select('album_user.albumId as id')
.where('album_user.userId', '=', ownerId)
.where(
'album_user.albumId',
'in',
eb
.selectFrom('album_user')
.select('album_user.albumId')
.where('album_user.role', '!=', sql.lit(AlbumUserRole.Owner)),
)
.union(
eb
.selectFrom('shared_link')
.where('shared_link.userId', '=', ownerId)
.where('shared_link.albumId', 'is not', null)
.select('shared_link.albumId as id')
.$narrowType<{ id: NotNull }>(),
)
.as('matching'),
(join) => join.onRef('matching.id', '=', 'album.id'),
)
.innerJoin('album_user', (join) =>
join.onRef('album_user.albumId', '=', 'album.id').on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)),
)
.where('album.deletedAt', 'is', null)
.select(withAlbumUsers(ownerId))
.select(withSharedLink)
.orderBy('album.createdAt', 'desc')
.execute();
}
/**
* Get albums of owner that are _not_ shared
*/
@GenerateSql({ params: [DummyValue.UUID] })
getNotShared(ownerId: string) {
return this.db
.selectFrom('album')
.selectAll('album')
.innerJoin('album_user', (join) =>
join
.onRef('album_user.albumId', '=', 'album.id')
.on('album_user.userId', '=', ownerId)
.on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)),
)
.where('album.deletedAt', 'is', null)
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('album_user as au')
.whereRef('au.albumId', '=', 'album.id')
.where('au.role', '!=', sql.lit(AlbumUserRole.Owner)),
),
),
)
.where(({ not, exists, selectFrom }) =>
not(exists(selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id'))),
)
.select(withSharedLink)
.select(withAlbumUsers(ownerId))
.orderBy('album.createdAt', 'desc') .orderBy('album.createdAt', 'desc')
.execute(); .execute();
return rows.map((r) => r.id);
} }
async restoreAll(userId: string): Promise<void> { async restoreAll(userId: string): Promise<void> {
@@ -765,6 +765,7 @@ export class AssetRepository {
getTimeBucket(timeBucket: string, options: TimeBucketOptions, auth: AuthDto) { getTimeBucket(timeBucket: string, options: TimeBucketOptions, auth: AuthDto) {
const order = options.order ?? 'desc'; const order = options.order ?? 'desc';
const query = this.db const query = this.db
.$pickTables<'asset' | 'asset_exif' | 'album_asset' | 'stack'>()
.with('cte', (qb) => .with('cte', (qb) =>
qb qb
.selectFrom('asset') .selectFrom('asset')
+1 -5
View File
@@ -9,7 +9,7 @@ import { CLS_ID, ClsModuleOptions } from 'nestjs-cls';
import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces'; import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { citiesFile, excludePaths, IWorker } from 'src/constants'; import { citiesFile, IWorker } from 'src/constants';
import { Telemetry } from 'src/decorators'; import { Telemetry } from 'src/decorators';
import { EnvSchema } from 'src/dtos/env.dto'; import { EnvSchema } from 'src/dtos/env.dto';
import { import {
@@ -328,10 +328,6 @@ const getEnv = (): EnvData => {
otel: { otel: {
metrics: { metrics: {
hostMetrics: telemetries.has(ImmichTelemetry.Host), hostMetrics: telemetries.has(ImmichTelemetry.Host),
apiMetrics: {
enable: telemetries.has(ImmichTelemetry.Api),
ignoreRoutes: excludePaths,
},
}, },
}, },
@@ -1,7 +1,8 @@
import { schemaDiff, schemaFromCode, schemaFromDatabase } from '@immich/sql-tools'; import { schemaDiff, schemaFromCode, schemaFromDatabase } from '@immich/sql-tools';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import AsyncLock from 'async-lock'; import AsyncLock from 'async-lock';
import { FileMigrationProvider, Kysely, Migrator, sql } from 'kysely'; import { Kysely, sql } from 'kysely';
import { FileMigrationProvider, Migrator } from 'kysely/migration';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { readdir } from 'node:fs/promises'; import { readdir } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
+14 -2
View File
@@ -66,9 +66,21 @@ export class MemoryRepository implements IBulkAsset {
.selectAll('asset') .selectAll('asset')
.innerJoin('memory_asset', 'asset.id', 'memory_asset.assetId') .innerJoin('memory_asset', 'asset.id', 'memory_asset.assetId')
.whereRef('memory_asset.memoriesId', '=', 'memory.id') .whereRef('memory_asset.memoriesId', '=', 'memory.id')
.orderBy('asset.fileCreatedAt', 'asc')
.where('asset.visibility', '=', sql.lit(AssetVisibility.Timeline)) .where('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
.where('asset.deletedAt', 'is', null), .where('asset.deletedAt', 'is', null)
.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom('asset_face')
.innerJoin('person', 'person.id', 'asset_face.personId')
.select((eb) => eb.val(1).as('one'))
.whereRef('asset_face.assetId', '=', 'asset.id')
.where('person.isHidden', '=', true),
),
),
)
.orderBy('asset.fileCreatedAt', 'asc'),
).as('assets'), ).as('assets'),
) )
.selectAll('memory') .selectAll('memory')
+4 -2
View File
@@ -595,7 +595,8 @@ class PartnerAssetsSync extends BaseSync {
@GenerateSql({ params: [dummyBackfillOptions, DummyValue.UUID], stream: true }) @GenerateSql({ params: [dummyBackfillOptions, DummyValue.UUID], stream: true })
getBackfill(options: SyncBackfillOptions, partnerId: string) { getBackfill(options: SyncBackfillOptions, partnerId: string) {
return this.backfillQuery('asset', options) return this.backfillQuery('asset', options)
.select(columns.syncAsset) .select(columns.syncPartnerAsset)
.select(sql.val(false).as('isFavorite'))
.select('asset.updateId') .select('asset.updateId')
.where('ownerId', '=', partnerId) .where('ownerId', '=', partnerId)
.stream(); .stream();
@@ -614,7 +615,8 @@ class PartnerAssetsSync extends BaseSync {
@GenerateSql({ params: [dummyQueryOptions], stream: true }) @GenerateSql({ params: [dummyQueryOptions], stream: true })
getUpserts(options: SyncQueryOptions) { getUpserts(options: SyncQueryOptions) {
return this.upsertQuery('asset', options) return this.upsertQuery('asset', options)
.select(columns.syncAsset) .select(columns.syncPartnerAsset)
.select(sql.val(false).as('isFavorite'))
.select('asset.updateId') .select('asset.updateId')
.where('ownerId', 'in', (eb) => .where('ownerId', 'in', (eb) =>
eb.selectFrom('partner').select(['sharedById']).where('sharedWithId', '=', options.userId), eb.selectFrom('partner').select(['sharedById']).where('sharedWithId', '=', options.userId),
@@ -14,7 +14,7 @@ import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic
import { snakeCase, startCase } from 'lodash'; import { snakeCase, startCase } from 'lodash';
import { MetricService } from 'nestjs-otel'; import { MetricService } from 'nestjs-otel';
import { copyMetadataFromFunctionToFunction } from 'nestjs-otel/lib/opentelemetry.utils'; import { copyMetadataFromFunctionToFunction } from 'nestjs-otel/lib/opentelemetry.utils';
import { serverVersion } from 'src/constants'; import { excludePaths, serverVersion } from 'src/constants';
import { ImmichTelemetry, MetadataKey } from 'src/enum'; import { ImmichTelemetry, MetadataKey } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -60,6 +60,9 @@ export const bootstrapTelemetry = (port: number) => {
if (instance) { if (instance) {
throw new Error('OpenTelemetry SDK already started'); throw new Error('OpenTelemetry SDK already started');
} }
const { telemetry } = new ConfigRepository().getEnv();
instance = new NodeSDK({ instance = new NodeSDK({
resource: resourceFromAttributes({ resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: `immich`, [ATTR_SERVICE_NAME]: `immich`,
@@ -68,7 +71,10 @@ export const bootstrapTelemetry = (port: number) => {
metricReader: new PrometheusExporter({ port }), metricReader: new PrometheusExporter({ port }),
contextManager: new AsyncLocalStorageContextManager(), contextManager: new AsyncLocalStorageContextManager(),
instrumentations: [ instrumentations: [
new HttpInstrumentation(), new HttpInstrumentation({
enabled: telemetry.metrics.has(ImmichTelemetry.Api),
ignoreIncomingRequestHook: (request) => excludePaths.some((item) => request.url?.startsWith(item)),
}),
new IORedisInstrumentation(), new IORedisInstrumentation(),
new NestInstrumentation(), new NestInstrumentation(),
new PgInstrumentation(), new PgInstrumentation(),
@@ -0,0 +1,10 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// isFavorite was incorrectly included in partner asset sync on server <=2.X.X
await sql`DELETE FROM session_sync_checkpoint WHERE type in ('PartnerAssetV1', 'PartnerAssetBackfillV1')`.execute(db);
}
export async function down(): Promise<void> {
// Not implemented
}
@@ -41,7 +41,7 @@ export class NotificationTable {
type!: Generated<NotificationType>; type!: Generated<NotificationType>;
@Column({ type: 'jsonb', nullable: true }) @Column({ type: 'jsonb', nullable: true })
data!: any | null; data!: unknown | null;
@Column() @Column()
title!: string; title!: string;
+63 -15
View File
@@ -26,18 +26,16 @@ describe(AlbumService.name, () => {
describe('getStatistics', () => { describe('getStatistics', () => {
it('should get the album count', async () => { it('should get the album count', async () => {
mocks.album.getOwned.mockResolvedValue([]); mocks.album.getAll.mockResolvedValue([]);
mocks.album.getShared.mockResolvedValue([]);
mocks.album.getNotShared.mockResolvedValue([]);
await expect(sut.getStatistics(authStub.admin)).resolves.toEqual({ await expect(sut.getStatistics(authStub.admin)).resolves.toEqual({
owned: 0, owned: 0,
shared: 0, shared: 0,
notShared: 0, notShared: 0,
}); });
expect(mocks.album.getOwned).toHaveBeenCalledWith(authStub.admin.user.id); expect(mocks.album.getAll).toHaveBeenCalledWith(authStub.admin.user.id, { isOwned: true });
expect(mocks.album.getShared).toHaveBeenCalledWith(authStub.admin.user.id); expect(mocks.album.getAll).toHaveBeenCalledWith(authStub.admin.user.id, { isShared: true });
expect(mocks.album.getNotShared).toHaveBeenCalledWith(authStub.admin.user.id); expect(mocks.album.getAll).toHaveBeenCalledWith(authStub.admin.user.id, { isOwned: true, isShared: false });
}); });
}); });
@@ -46,7 +44,7 @@ describe(AlbumService.name, () => {
const album = AlbumFactory.from().albumUser().build(); const album = AlbumFactory.from().albumUser().build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!; const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const sharedWithUserAlbum = AlbumFactory.from().owner(owner).albumUser().build(); const sharedWithUserAlbum = AlbumFactory.from().owner(owner).albumUser().build();
mocks.album.getOwned.mockResolvedValue([getForAlbum(album), getForAlbum(sharedWithUserAlbum)]); mocks.album.getAll.mockResolvedValue([getForAlbum(album), getForAlbum(sharedWithUserAlbum)]);
mocks.album.getMetadataForIds.mockResolvedValue([ mocks.album.getMetadataForIds.mockResolvedValue([
{ {
albumId: album.id, albumId: album.id,
@@ -68,6 +66,7 @@ describe(AlbumService.name, () => {
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
expect(result[0].id).toEqual(album.id); expect(result[0].id).toEqual(album.id);
expect(result[1].id).toEqual(sharedWithUserAlbum.id); expect(result[1].id).toEqual(sharedWithUserAlbum.id);
expect(mocks.album.getAll).toHaveBeenCalledWith(owner.id, { isOwned: undefined, isShared: undefined });
}); });
it('gets list of albums that have a specific asset', async () => { it('gets list of albums that have a specific asset', async () => {
@@ -98,7 +97,7 @@ describe(AlbumService.name, () => {
it('gets list of albums that are shared', async () => { it('gets list of albums that are shared', async () => {
const album = AlbumFactory.from().albumUser().build(); const album = AlbumFactory.from().albumUser().build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!; const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getShared.mockResolvedValue([getForAlbum(album)]); mocks.album.getAll.mockResolvedValue([getForAlbum(album)]);
mocks.album.getMetadataForIds.mockResolvedValue([ mocks.album.getMetadataForIds.mockResolvedValue([
{ {
albumId: album.id, albumId: album.id,
@@ -109,16 +108,16 @@ describe(AlbumService.name, () => {
}, },
]); ]);
const result = await sut.getAll(AuthFactory.create(owner), { shared: true }); const result = await sut.getAll(AuthFactory.create(owner), { isShared: true });
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].id).toEqual(album.id); expect(result[0].id).toEqual(album.id);
expect(mocks.album.getShared).toHaveBeenCalledTimes(1); expect(mocks.album.getAll).toHaveBeenCalledWith(owner.id, expect.objectContaining({ isShared: true }));
}); });
it('gets list of albums that are NOT shared', async () => { it('gets list of albums that are NOT shared', async () => {
const album = AlbumFactory.create(); const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!; const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getNotShared.mockResolvedValue([getForAlbum(album)]); mocks.album.getAll.mockResolvedValue([getForAlbum(album)]);
mocks.album.getMetadataForIds.mockResolvedValue([ mocks.album.getMetadataForIds.mockResolvedValue([
{ {
albumId: album.id, albumId: album.id,
@@ -129,17 +128,66 @@ describe(AlbumService.name, () => {
}, },
]); ]);
const result = await sut.getAll(AuthFactory.create(owner), { shared: false }); const result = await sut.getAll(AuthFactory.create(owner), { isShared: false });
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].id).toEqual(album.id); expect(result[0].id).toEqual(album.id);
expect(mocks.album.getNotShared).toHaveBeenCalledTimes(1); expect(mocks.album.getAll).toHaveBeenCalledWith(owner.id, expect.objectContaining({ isShared: false }));
});
it('gets only owned albums when isOwned=true', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getAll.mockResolvedValue([getForAlbum(album)]);
mocks.album.getMetadataForIds.mockResolvedValue([
{ albumId: album.id, assetCount: 0, startDate: null, endDate: null, lastModifiedAssetTimestamp: null },
]);
const result = await sut.getAll(AuthFactory.create(owner), { isOwned: true });
expect(result).toHaveLength(1);
expect(mocks.album.getAll).toHaveBeenCalledWith(owner.id, expect.objectContaining({ isOwned: true }));
});
it('gets only shared-with-me albums when isOwned=false', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getAll.mockResolvedValue([getForAlbum(album)]);
mocks.album.getMetadataForIds.mockResolvedValue([
{ albumId: album.id, assetCount: 0, startDate: null, endDate: null, lastModifiedAssetTimestamp: null },
]);
const result = await sut.getAll(AuthFactory.create(owner), { isOwned: false });
expect(result).toHaveLength(1);
expect(mocks.album.getAll).toHaveBeenCalledWith(owner.id, expect.objectContaining({ isOwned: false }));
});
it('gets owned shared-out albums when isOwned=true and isShared=true', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getAll.mockResolvedValue([getForAlbum(album)]);
mocks.album.getMetadataForIds.mockResolvedValue([
{ albumId: album.id, assetCount: 0, startDate: null, endDate: null, lastModifiedAssetTimestamp: null },
]);
const result = await sut.getAll(AuthFactory.create(owner), { isOwned: true, isShared: true });
expect(result).toHaveLength(1);
expect(mocks.album.getAll).toHaveBeenCalledWith(owner.id, { isOwned: true, isShared: true });
});
it('returns empty list when isOwned=false and isShared=false', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getAll.mockResolvedValue([]);
const result = await sut.getAll(AuthFactory.create(owner), { isOwned: false, isShared: false });
expect(result).toHaveLength(0);
expect(mocks.album.getAll).toHaveBeenCalledWith(owner.id, { isOwned: false, isShared: false });
}); });
}); });
it('counts assets correctly', async () => { it('counts assets correctly', async () => {
const album = AlbumFactory.create(); const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!; const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getOwned.mockResolvedValue([getForAlbum(album)]); mocks.album.getAll.mockResolvedValue([getForAlbum(album)]);
mocks.album.getMetadataForIds.mockResolvedValue([ mocks.album.getMetadataForIds.mockResolvedValue([
{ {
albumId: album.id, albumId: album.id,
@@ -153,7 +201,7 @@ describe(AlbumService.name, () => {
const result = await sut.getAll(AuthFactory.create(owner), {}); const result = await sut.getAll(AuthFactory.create(owner), {});
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].assetCount).toEqual(1); expect(result[0].assetCount).toEqual(1);
expect(mocks.album.getOwned).toHaveBeenCalledTimes(1); expect(mocks.album.getAll).toHaveBeenCalledTimes(1);
}); });
describe('create', () => { describe('create', () => {
+13 -14
View File
@@ -8,7 +8,6 @@ import {
CreateAlbumDto, CreateAlbumDto,
GetAlbumsDto, GetAlbumsDto,
mapAlbum, mapAlbum,
MapAlbumDto,
UpdateAlbumDto, UpdateAlbumDto,
UpdateAlbumUserDto, UpdateAlbumUserDto,
} from 'src/dtos/album.dto'; } from 'src/dtos/album.dto';
@@ -26,9 +25,9 @@ import { getPreferences } from 'src/utils/preferences';
export class AlbumService extends BaseService { export class AlbumService extends BaseService {
async getStatistics(auth: AuthDto): Promise<AlbumStatisticsResponseDto> { async getStatistics(auth: AuthDto): Promise<AlbumStatisticsResponseDto> {
const [owned, shared, notShared] = await Promise.all([ const [owned, shared, notShared] = await Promise.all([
this.albumRepository.getOwned(auth.user.id), this.albumRepository.getAll(auth.user.id, { isOwned: true }),
this.albumRepository.getShared(auth.user.id), this.albumRepository.getAll(auth.user.id, { isShared: true }),
this.albumRepository.getNotShared(auth.user.id), this.albumRepository.getAll(auth.user.id, { isOwned: true, isShared: false }),
]); ]);
return { return {
@@ -38,18 +37,18 @@ export class AlbumService extends BaseService {
}; };
} }
async getAll({ user: { id: ownerId } }: AuthDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> { async getAll(
{ user: { id: ownerId } }: AuthDto,
{ assetId, isOwned, isShared }: GetAlbumsDto,
): Promise<AlbumResponseDto[]> {
await this.albumRepository.updateThumbnails(); await this.albumRepository.updateThumbnails();
let albums: MapAlbumDto[]; const albums = assetId
if (assetId) { ? await this.albumRepository.getByAssetId(ownerId, assetId)
albums = await this.albumRepository.getByAssetId(ownerId, assetId); : await this.albumRepository.getAll(ownerId, { isOwned, isShared });
} else if (shared === true) {
albums = await this.albumRepository.getShared(ownerId); if (albums.length === 0) {
} else if (shared === false) { return [];
albums = await this.albumRepository.getNotShared(ownerId);
} else {
albums = await this.albumRepository.getOwned(ownerId);
} }
// Get asset count for each album. Then map the result to an object: // Get asset count for each album. Then map the result to an object:
+5 -5
View File
@@ -4,7 +4,7 @@ import { AssetFactory } from 'test/factories/asset.factory';
import { AuthFactory } from 'test/factories/auth.factory'; import { AuthFactory } from 'test/factories/auth.factory';
import { PartnerFactory } from 'test/factories/partner.factory'; import { PartnerFactory } from 'test/factories/partner.factory';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { getForAlbum, getForPartner } from 'test/mappers'; import { getForPartner } from 'test/mappers';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService, ServiceMocks } from 'test/utils';
describe(MapService.name, () => { describe(MapService.name, () => {
@@ -82,15 +82,15 @@ describe(MapService.name, () => {
}; };
mocks.partner.getAll.mockResolvedValue([]); mocks.partner.getAll.mockResolvedValue([]);
mocks.map.getMapMarkers.mockResolvedValue([marker]); mocks.map.getMapMarkers.mockResolvedValue([marker]);
mocks.album.getOwned.mockResolvedValue([getForAlbum(AlbumFactory.create())]); const album1 = AlbumFactory.create();
mocks.album.getShared.mockResolvedValue([ const album2 = AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build();
getForAlbum(AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build()), mocks.album.getAllIds.mockResolvedValue([album1.id, album2.id]);
]);
const markers = await sut.getMapMarkers(auth, { withSharedAlbums: true }); const markers = await sut.getMapMarkers(auth, { withSharedAlbums: true });
expect(markers).toHaveLength(1); expect(markers).toHaveLength(1);
expect(markers[0]).toEqual(marker); expect(markers[0]).toEqual(marker);
expect(mocks.album.getAllIds).toHaveBeenCalledWith(auth.user.id);
}); });
}); });
+1 -9
View File
@@ -13,15 +13,7 @@ export class MapService extends BaseService {
userIds.push(...partnerIds); userIds.push(...partnerIds);
} }
// TODO convert to SQL join const albumIds = options.withSharedAlbums ? await this.albumRepository.getAllIds(auth.user.id) : [];
const albumIds: string[] = [];
if (options.withSharedAlbums) {
const [ownedAlbums, sharedAlbums] = await Promise.all([
this.albumRepository.getOwned(auth.user.id),
this.albumRepository.getShared(auth.user.id),
]);
albumIds.push(...ownedAlbums.map((album) => album.id), ...sharedAlbums.map((album) => album.id));
}
return this.mapRepository.getMapMarkers(userIds, albumIds, options); return this.mapRepository.getMapMarkers(userIds, albumIds, options);
} }
+7 -6
View File
@@ -28,25 +28,26 @@ describe(MemoryService.name, () => {
}); });
describe('search', () => { describe('search', () => {
it('should search memories', async () => { it('should search memories with assets', async () => {
const [userId] = newUuids(); const [userId] = newUuids();
const asset = AssetFactory.create(); const asset = AssetFactory.create();
const memory1 = MemoryFactory.from({ ownerId: userId }).asset(asset).build(); const memory1 = MemoryFactory.from({ ownerId: userId }).asset(asset).build();
const memory2 = MemoryFactory.create({ ownerId: userId }); const memory2 = MemoryFactory.create({ ownerId: userId });
mocks.memory.search.mockResolvedValue([getForMemory(memory1), getForMemory(memory2)]); mocks.memory.search.mockResolvedValue([getForMemory(memory1), getForMemory(memory2)]);
await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toEqual( await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ id: memory1.id, assets: [expect.objectContaining({ id: asset.id })] }), expect.objectContaining({
expect.objectContaining({ id: memory2.id, assets: [] }), id: memory1.id,
assets: expect.arrayContaining([expect.objectContaining({ id: asset.id })]),
}),
]), ]),
); );
}); });
it('should map ', async () => { it('should map empty result', async () => {
mocks.memory.search.mockResolvedValue([]); mocks.memory.search.mockResolvedValue([]);
await expect(sut.search(factory.auth(), {})).resolves.toEqual([]); await expect(sut.search(factory.auth(), {})).resolves.toEqual([]);
}); });
}); });
+4 -1
View File
@@ -1,5 +1,6 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { Memory } from 'src/database';
import { OnJob } from 'src/decorators'; import { OnJob } from 'src/decorators';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
@@ -71,7 +72,9 @@ export class MemoryService extends BaseService {
async search(auth: AuthDto, dto: MemorySearchDto) { async search(auth: AuthDto, dto: MemorySearchDto) {
const memories = await this.memoryRepository.search(auth.user.id, dto); const memories = await this.memoryRepository.search(auth.user.id, dto);
return memories.map((memory) => mapMemory(memory, auth)); return memories
.filter((memory: Memory) => memory.assets && memory.assets.length > 0)
.map((memory: Memory) => mapMemory(memory, auth));
} }
statistics(auth: AuthDto, dto: MemorySearchDto) { statistics(auth: AuthDto, dto: MemorySearchDto) {
+1
View File
@@ -373,6 +373,7 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
const visibility = options.visibility == null ? AssetVisibility.Timeline : options.visibility; const visibility = options.visibility == null ? AssetVisibility.Timeline : options.visibility;
return kysely return kysely
.$pickTables<'asset' | 'tag_asset' | 'asset_exif' | 'asset_file' | 'album_asset' | 'ocr_search' | 'smart_search'>()
.withPlugin(joinDeduplicationPlugin) .withPlugin(joinDeduplicationPlugin)
.selectFrom('asset') .selectFrom('asset')
.where('asset.visibility', '=', visibility) .where('asset.visibility', '=', visibility)
@@ -54,10 +54,6 @@ export const envData: EnvData = {
otel: { otel: {
metrics: { metrics: {
hostMetrics: false, hostMetrics: false,
apiMetrics: {
enable: false,
ignoreRoutes: [],
},
}, },
}, },
-1
View File
@@ -1 +0,0 @@
24.15.0
+7 -1
View File
@@ -42,11 +42,17 @@ run = "pnpm run check:svelte"
[tasks.check] [tasks.check]
run = { tasks = [":check-typescript", ":check-svelte"] } run = { tasks = [":check-typescript", ":check-svelte"] }
[tasks.checklist] [tasks.ci-unit]
depends = ["//:sdk:install", "//:sdk:build"]
run = [ run = [
{ task = ":install" }, { task = ":install" },
{ task = ":format" }, { task = ":format" },
{ task = ":check" }, { task = ":check" },
{ task = ":test --run" }, { task = ":test --run" },
]
[tasks.checklist]
run = [
{ task = ":ci-unit" },
{ task = ":lint" }, { task = ":lint" },
] ]

Some files were not shown because too many files have changed in this diff Show More