Compare commits

..

79 Commits

Author SHA1 Message Date
Alex be4b1438b8 feat(mobile): switch iOS code signing to fastlane match
- Replace manual certificate and provisioning profile handling with fastlane match
- Match automatically syncs certificates and profiles from a private git repository
- Simplifies CI/CD workflow by removing 8 secrets (replaced with 2: MATCH_PASSWORD and MATCH_GIT_BASIC_AUTHORIZATION)
- Add new lanes: sync_certificates and regenerate_certificates for easier maintenance
- When certificates expire, just run 'fastlane regenerate_certificates' locally

Benefits:
- Single source of truth for code signing
- Automatic certificate/profile management
- Easier onboarding for new team members
- Simpler secret rotation when certificates expire

Required new GitHub secrets:
- MATCH_PASSWORD: Encryption password for the match repository
- MATCH_GIT_BASIC_AUTHORIZATION: base64(username:token) for repo access

Removed secrets (no longer needed):
- IOS_CERTIFICATE_P12
- IOS_CERTIFICATE_PASSWORD
- IOS_PROVISIONING_PROFILE
- IOS_PROVISIONING_PROFILE_SHARE_EXTENSION
- IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION
- IOS_DEVELOPMENT_PROVISIONING_PROFILE
- IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION
- IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION
2026-01-05 21:56:49 -06:00
Jason Rasmussen 984f06ac40 refactor: asset viewer (#25059) 2026-01-05 21:02:01 +00:00
renovate[bot] 9d4a12dfd4 chore(deps): update node.js to v24.12.0 (#25046)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 20:13:34 +00:00
renovate[bot] 94730567ab fix(deps): update formatjs monorepo (major) (#25049)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 20:59:06 +01:00
Jason Rasmussen 57db5e64de chore(web): bump immich/ui for tooltips (#24632)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-01-05 19:51:03 +00:00
Jason Rasmussen 4d32968f2b refactor: redirect code (#25054) 2026-01-05 14:39:28 -05:00
renovate[bot] 10989e6927 fix(deps): update typescript-projects (#25047)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-01-05 16:49:09 +00:00
Jason Rasmussen 62cc12be3c refactor: asset from param (#25041) 2026-01-05 11:26:58 -05:00
Jason Rasmussen 1874557b95 fix: empty action context menu (#25043) 2026-01-05 11:26:23 -05:00
renovate[bot] 9a78547bf0 chore(deps): update dependency @types/node to ^24.10.4 (#25044)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 16:19:55 +00:00
renovate[bot] 0b1bd9deb1 chore(deps): update dependency vite-tsconfig-paths to v6 (#25048)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 17:18:22 +01:00
renovate[bot] 7202179d63 chore(deps): update grafana/grafana docker tag to v12.3.1 (#25045)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 17:16:58 +01:00
Jason Rasmussen 519a7df4cd refactor: trash page actions (#25039) 2026-01-05 10:48:55 -05:00
renovate[bot] 3762728c84 chore(deps): update docker.io/valkey/valkey:9 docker digest to 5463044 (#24800)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 16:47:19 +01:00
renovate[bot] bc3fa2b3fb chore(deps): update prom/prometheus docker digest to 2b6f734 (#24801)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 16:46:53 +01:00
Jason Rasmussen 57fca378bc refactor: page container (#25038) 2026-01-05 10:44:29 -05:00
Flozza eb718145c0 docs: config options for hardware transcoding (#24853) 2026-01-05 16:40:53 +01:00
Felipe Cury c87c1866ae fix: grammar in trigger_description string (#24867)
Fix typo in trigger_description string
2026-01-05 15:36:48 +00:00
Nikhil Alapati b190423d96 fix(server): migrate motion part of live photo (#24688)
Co-authored-by: Nikhil Alapati <nikhilalapati@meta.com>
2026-01-05 15:26:45 +00:00
Daniel Ramos edd3ab7cc9 feat(server): implement switchable logging formats (console/json) (#24791)
* feat(server): add LogFormat enum and configuration

* feat(server): add structured logging formatters

* feat(server): implement switchable logging formats (console/json)

* Revert "feat(server): add LogFormat enum and configuration"

This reverts commit 565e95ae68.

* feat(server): implement JSON logging using NestJS native support

* refactor: rename LOG_FORMAT to IMMICH_LOG_FORMAT for consistency

* docs: add IMMICH_LOG_FORMAT documentation

* chore: format environment-variables.md

* chore: format monitoring.md
2026-01-05 09:21:02 -06:00
Jason Rasmussen 4147f1d912 fix: duplicate api call on new library page (#25036) 2026-01-05 10:03:44 -05:00
Jason Rasmussen e4311da1a4 fix: shared-link-mapper (#24794) 2026-01-05 10:03:35 -05:00
Matthew Momjian b7bb118c00 chore(deployment): add healthcheck option for DB (#25024) 2026-01-05 14:30:33 +01:00
Yaros 21f7314907 feat(web): undo delete single asset (#24439) 2026-01-05 13:31:02 +01:00
Timon 2541011eaa fix(web): duplicate key error and enable expiration editing for expired shared links (#24686)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-01-05 12:12:44 +00:00
Alex 18d8cc4449 fix: search input has incorrect focus state after closing the search filter modal (#24886) 2026-01-05 12:38:43 +01:00
Ahmed Mahmoud Aref 8e8a2f997e feat: show asset owners for editors in shared albums (#24890) 2026-01-05 11:31:23 +00:00
Jorge Montejo 86e5c611ec fix: import config from json (#25030)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-01-05 11:28:08 +00:00
skrmc e700bb5467 fix(mobile): hide delete action for remote-only assets (#25010) 2026-01-05 07:02:39 +00:00
GustavJones a1aa2b807b feat(web): Add coordinate pair location searching. (#24799)
* feat(web): Add coordinate pair searching within the change location modal.

Adds simple logic to try and parse a coordinate pair in the format
`LATITUDE, LONGITUDE` as provided from Google Maps if a coordinate is
copied to update the coordinates automatically.

* Add checks for valid coordinate pairs

* Update formatting and fix linting issues
2026-01-04 13:16:23 -06:00
immich-tofu[bot] abea5a53de chore: linting (#7532)
* chore: linting

* fix: broken tests

* fix: formatting
2026-01-04 16:05:56 +00:00
Matthew Momjian bcf6685643 chore(server): Vchord 1.0 support (#23845)
vc 1
2026-01-04 00:01:11 -05:00
Diego Saint Esteben bd27898ea9 fix(server): update exiftool-vendored to v34.3 for correct colon-less timezone parsing (#24979) 2026-01-02 20:31:31 +01:00
Savely Krasovsky 3321c1a9df feat(ml): update ONNX Runtime, OpenVINO and ROCm stack (#23458) 2026-01-01 12:17:55 -05:00
renovate[bot] 72a898d89d chore(deps): update github-actions (#24606)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-31 12:23:36 +00:00
Rahul Kumar Saini a16c5955d7 feat(server): Support camera make, model, and lensModel in Storage Template (#24650)
* add support for make, model, lensModel in storage template

* no pkg lock

* Apply suggestion from @danieldietzler

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>

* query and formatting

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2025-12-29 21:55:06 +00:00
Keanu Czirjak e87bfa548a fix(web): let slideshow videos play (#19601) (#24914)
Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2025-12-29 18:03:55 +00:00
Min Idzelis 369a30e227 fix: canceling a bucket while findMonthGroupForAsset is waiting fails (#24898) 2025-12-29 09:28:37 -06:00
Lauritz Tieste 0df618feee feat: Hide/show controls when zoom state changes (#24784)
feat: hide/show controls based on zoom state in asset viewer
2025-12-27 16:02:42 -06:00
Daniel Dietzler 363b9276eb fix: album card timezone (#24855) 2025-12-26 21:40:07 -06:00
idubnori 36d7dd9319 feat(mobile): album options to kebab menu (#24204)
* feat(mobile): refactor album options into kebab menu for improved UX

* feat(mobile): update BaseActionButton to use iconColor for text styling and add delete button color in DriftRemoteAlbumOption

* feat: const Divider(height: 1)

* fix(mobile): update icon color for album options menu button

* chore: refactor

* chore: refactor

* add test

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-26 18:46:05 +00:00
Peter Ombodi a57c4d9a9e fix(drift backup notifier): add lifecycle guards and dispose logging (#24806)
* fix(drift backup notifier): add lifecycle guards and dispose logging

* fix(drift backup notifier): re-read notifiers in callbacks to avoid disposed backup notifier

* fix(drift backup notifier): increase the log level to warning.

---------

Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com>
2025-12-26 18:44:07 +00:00
Marcin Wróblewski 724948d36d feat(mobile): use tabular figures in backup info card (#24820)
* feat(mobile): use tabular figures in backup info card

during large (initial) backups current non-tabular figures are jumping around the UI, making the UI hard to follow. this change makes sure there’s no jump in text width between e.g. 7888 to 7111

* chore: use const
2025-12-25 22:27:33 -06:00
Min Idzelis 83f8065f10 fix: autogrow textarea bugs during animation (#24481) 2025-12-24 13:21:08 +01:00
renovate[bot] e63e8e2517 chore(deps): update machine-learning (#24610)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-23 03:12:13 +00:00
Jason Rasmussen 01e3b8e5df refactor: form modals (#24790) 2025-12-22 14:15:23 -05:00
Jason Rasmussen 5a7c9a252c feat: disable admin setup (#24628) 2025-12-22 14:15:08 -05:00
Jason Rasmussen f99f5f4f91 refactor: map setting modal (#24789) 2025-12-22 13:54:14 -05:00
Jason Rasmussen 8ad27c7cea refactor: slideshow modal (#24788) 2025-12-22 18:44:53 +00:00
Jason Rasmussen edc21ed746 fix(web): stale album info (#24787) 2025-12-22 19:38:57 +01:00
Jason Rasmussen dd744f8ee3 refactor: album edit modal (#24786) 2025-12-22 13:33:49 -05:00
Min Idzelis f6f9a3abb4 fix: task never rejected on cancel, add tests (#24418) 2025-12-22 13:12:43 -05:00
Jason Rasmussen 1c156a179b feat: shared link edit (#24783) 2025-12-22 11:47:06 -05:00
Jason Rasmussen 952f189d8b feat: prefer admin settings page over users page (#24780) 2025-12-22 11:31:22 -05:00
Jason Rasmussen 40e750e8be refactor: api key service (#24779) 2025-12-22 11:09:11 -05:00
Jason Rasmussen c7510d572a chore: move models (#24778) 2025-12-22 15:23:57 +00:00
Jason Rasmussen 165f9e15ee feat: modal routes (#24726)
feat: new user route
2025-12-22 15:04:08 +00:00
Mert dfdbb773ce fix(web): display jxl original (#24766)
display jxl original
2025-12-21 20:10:22 -06:00
bo0tzz f053ce548d fix: product keys wording in commercial guidelines faq (#24765) 2025-12-21 19:35:21 +00:00
bo0tzz d7c28470ee feat: focus jumped-to item in timeline (#24738) 2025-12-21 08:52:52 -06:00
Alex 28f6064240 feat: workflow ui (#24190)
* feat: workflow ui

* wip

* wip

* wip

* pr feedback

* refactor: picker field

* use showDialog directly

* better test

* refactor step selection modal

* move enable button to info form

* use  for Props

* pr feedback

* refactor ActionItem

* refactor ActionItem

* more refactor

* fix: new schemaformfield has value of the same type

* chore: clean up
2025-12-20 21:07:07 -06:00
Alex 4b3b458bb6 chore: update info.plist app version in bump-version (#24722) 2025-12-20 21:02:11 -06:00
Sergey Katsubo 4736b4e3e8 chore(server): improve log messages (#24744)
* Clarify the "asset not found" log during thumbnail generation: it's about database

* Move not found sidecars to verbose level instead of "old=null, new=null" at debug

* Log memory creation at default level

* Add explicit log for missing exif date time

Instead of: Date and time is undefined using exifTag undefined for asset ...

* Log database migration start/end at default level

Currently, these messages are logged as "debug". But they are not printed
when debug or verbose level is set. This is due to the known limitation:
SystemConfigService sets LogLevel later on, after migrations run.
2025-12-20 21:00:34 -06:00
Paul Makles a17f188e97 fix(maintenance): prevent enable/disable maintenance CLI hanging on occasion (#24713)
* fix(maintenance): prevent CLI hanging on occassion
fix(maintenance): always ack messages
fix(maintenance): ensure Redis is connected first

* chore(maintenance): validate app restart responses

* chore: mock the app restart callback

* fix: ack may not exist depending on caller

* refactor: move one shot into app.repository

* fix: send correct state in one shot

* chore: log restart event
2025-12-19 17:13:00 -05:00
Jason Rasmussen 5b80323326 refactor: library service (#24725) 2025-12-19 13:20:35 -05:00
Jason Rasmussen 1425b3da6b refactor: admin card (#24723) 2025-12-19 12:47:04 -05:00
Daniel Dietzler 3d2196b0f2 refactor: asset update medium tests (#24718) 2025-12-19 16:25:04 +00:00
github-actions 50d7956c07 chore: version v2.4.1 2025-12-19 15:03:03 +00:00
Théo 22d3fd3b92 fix(docs): add & fix missing alt text to store badge images (#24637)
* Fix email footer: add missing alt text to store badge images

* fix: apply consistent formatting using Prettier

---------

Co-authored-by: divulgacheur <contact@theopeltier.me>
2025-12-19 09:00:31 -06:00
Luis Nachtigall a469e86b32 fix(web): search-bar usability improvements (#24705)
* fix(search): improve search type dropdown accessibility and focus management

* fix(search): fix search options button accessibility position in search bar

* fix(search): removed unnecessary selection logic
2025-12-19 14:59:41 +00:00
Timon 138c9232df chore: update ApiKeySecretModal to use monospace font (#24690)
style(web): update ApiKeySecretModal to use monospace font for readonly textarea
2025-12-19 08:58:49 -06:00
Timon 2e1f8625ec fix(web): timezone dropdown showing "No results" when seconds are set to 00 (#24662)
* Fix timezone dropdown showing "No results" when seconds are set to 00

* remove comments

* add test for #23615
2025-12-19 08:58:17 -06:00
Daniel Dietzler f7cbb7417c fix: dateTimeOriginal timezone updates (#24712) 2025-12-19 08:42:44 -06:00
Alex 125de91c71 fix: merged video in On This Device played with incorrect dimension (#24656)
* fix: merged video in On This Device played with incorrect dimension

* chore: pr feedback
2025-12-18 20:59:58 -06:00
Timon c9b58f5893 fix(web): auto-start slideshow when confirming settings modal (#24629)
feat(web): auto-start slideshow when confirming settings modal
2025-12-18 21:58:22 +00:00
Timon 640fd7308b fix(mobile): infinite loading screen when hiding UI in map viewer on iOS (#24563)
* fix with logging

* remove logging

* analyze
2025-12-18 21:07:58 +00:00
shenlong 557a79f747 chore(mobile): log failures from share upload intent (#24680)
chore: log failures from share intent upload

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-12-18 14:30:55 -06:00
Yaros 5ade152bc5 fix(web): shared link expiry does not save (#24569)
* fix(web): shared link expiry does not save

* chore: fix lint errors

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-18 06:19:31 +00:00
bo0tzz 827bf1ef18 fix: pass bumped version through outputs (#24649) 2025-12-17 17:06:54 -06:00
286 changed files with 10237 additions and 5131 deletions
+1 -1
View File
@@ -1 +1 @@
24.11.1
24.12.0
+23 -58
View File
@@ -26,21 +26,9 @@ on:
required: true
APP_STORE_CONNECT_API_KEY:
required: true
IOS_CERTIFICATE_P12:
MATCH_PASSWORD:
required: true
IOS_CERTIFICATE_PASSWORD:
required: true
IOS_PROVISIONING_PROFILE:
required: true
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION:
required: true
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION:
required: true
IOS_DEVELOPMENT_PROVISIONING_PROFILE:
required: true
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION:
required: true
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION:
MATCH_GIT_BASIC_AUTHORIZATION:
required: true
FASTLANE_TEAM_ID:
required: true
@@ -193,6 +181,21 @@ jobs:
runs-on: macos-latest
steps:
- name: Generate token for ios-certs repo
id: token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
owner: immich-app
repositories: immich,ios-certs
- name: Set up match authorization
id: match-auth
run: |
# Create base64-encoded authorization for match
echo "base64_token=$(echo -n 'x-access-token:${{ steps.token.outputs.token }}' | base64)" >> $GITHUB_OUTPUT
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
@@ -240,64 +243,26 @@ jobs:
mkdir -p ~/.appstoreconnect/private_keys
echo "$API_KEY_CONTENT" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8
- name: Import Certificate and Provisioning Profiles
- name: Create keychain for match
env:
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
ENVIRONMENT: ${{ inputs.environment || 'development' }}
working-directory: ./mobile/ios
KEYCHAIN_PASSWORD: ${{ github.run_id }}
run: |
# Decode certificate
echo "$IOS_CERTIFICATE_P12" | base64 --decode > certificate.p12
# Decode provisioning profiles based on environment
if [[ "$ENVIRONMENT" == "development" ]]; then
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE" | base64 --decode > profile_dev.mobileprovision
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION" | base64 --decode > profile_dev_share.mobileprovision
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION" | base64 --decode > profile_dev_widget.mobileprovision
ls -lh profile_dev*.mobileprovision
else
echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > profile.mobileprovision
echo "$IOS_PROVISIONING_PROFILE_SHARE_EXTENSION" | base64 --decode > profile_share.mobileprovision
echo "$IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION" | base64 --decode > profile_widget.mobileprovision
ls -lh profile*.mobileprovision
fi
- name: Create keychain and import certificate
env:
KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
working-directory: ./mobile/ios
run: |
# Create keychain
# Create a temporary keychain for CI
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
# Import certificate
security import certificate.p12 -k build.keychain -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain
# Verify certificate was imported
security find-identity -v -p codesigning build.keychain
- name: Build and deploy to TestFlight
env:
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ steps.match-auth.outputs.base64_token }}
KEYCHAIN_NAME: build.keychain
KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ github.run_id }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
ENVIRONMENT: ${{ inputs.environment || 'development' }}
BUNDLE_ID_SUFFIX: ${{ inputs.environment == 'production' && '' || 'development' }}
GITHUB_REF: ${{ github.ref }}
working-directory: ./mobile/ios
run: |
+1 -1
View File
@@ -87,7 +87,7 @@ jobs:
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }}
container:
image: ghcr.io/immich-app/mdq:main@sha256:237cdae7783609c96f18037a513d38088713cf4a2e493a3aa136d0c45490749a
image: ghcr.io/immich-app/mdq:main@sha256:ab9f163cd5d5cec42704a26ca2769ecf3f10aa8e7bae847f1d527cdf075946e6
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
steps:
+3 -3
View File
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
category: '/language:${{matrix.language}}'
+9 -5
View File
@@ -45,6 +45,7 @@ jobs:
needs: [merge_translations]
outputs:
ref: ${{ steps.push-tag.outputs.commit_long_sha }}
version: ${{ steps.output.outputs.version }}
permissions: {} # No job-level permissions are needed because it uses the app-token
steps:
- name: Generate a token
@@ -62,7 +63,7 @@ jobs:
ref: main
- name: Install uv
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
@@ -80,13 +81,16 @@ jobs:
MOBILE_BUMP: ${{ inputs.mobileBump }}
run: misc/release/pump-version.sh -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}"
- id: output
run: echo "version=$IMMICH_VERSION" >> $GITHUB_OUTPUT
- name: Commit and tag
id: push-tag
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
with:
default_author: github_actions
message: 'chore: version ${{ env.IMMICH_VERSION }}'
tag: ${{ env.IMMICH_VERSION }}
message: 'chore: version ${{ steps.output.outputs.version }}'
tag: ${{ steps.output.outputs.version }}
push: true
build_mobile:
@@ -119,7 +123,7 @@ jobs:
prepare_release:
runs-on: ubuntu-latest
needs: build_mobile
needs: [build_mobile, bump_version]
permissions:
actions: read # To download the app artifact
# No content permissions are needed because it uses the app-token
@@ -147,7 +151,7 @@ jobs:
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
draft: true
tag_name: ${{ env.IMMICH_VERSION }}
tag_name: ${{ needs.bump_version.outputs.version }}
token: ${{ steps.generate-token.outputs.token }}
generate_release_notes: true
body_path: misc/release/notes.tmpl
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
ref: main
- name: Install uv
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
+4 -4
View File
@@ -571,12 +571,12 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
# with:
# python-version: 3.11
# cache: 'uv'
with:
python-version: 3.11
#cache: 'uv'
- name: Install dependencies
run: |
uv sync --extra cpu
+1 -1
View File
@@ -1 +1 @@
24.11.1
24.12.0
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.104",
"version": "2.2.105",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
@@ -20,7 +20,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^24.10.3",
"@types/node": "^24.10.4",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
@@ -36,7 +36,7 @@
"typescript": "^5.3.3",
"typescript-eslint": "^8.28.0",
"vite": "^7.0.0",
"vite-tsconfig-paths": "^5.0.0",
"vite-tsconfig-paths": "^6.0.0",
"vitest": "^3.0.0",
"vitest-fetch-mock": "^0.4.0",
"yaml": "^2.3.1"
@@ -69,6 +69,6 @@
"micromatch": "^4.0.8"
},
"volta": {
"node": "24.11.1"
"node": "24.12.0"
}
}
+3 -1
View File
@@ -127,7 +127,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
image: docker.io/valkey/valkey:9@sha256:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63
healthcheck:
test: redis-cli ping || exit 1
@@ -146,6 +146,8 @@ services:
ports:
- 5432:5432
shm_size: 128mb
healthcheck:
disable: false
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
# immich-prometheus:
# container_name: immich_prometheus
+5 -3
View File
@@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
image: docker.io/valkey/valkey:9@sha256:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63
healthcheck:
test: redis-cli ping || exit 1
restart: always
@@ -77,13 +77,15 @@ services:
- 5432:5432
shm_size: 128mb
restart: always
healthcheck:
disable: false
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
immich-prometheus:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:d936808bdea528155c0154a922cd42fd75716b8bb7ba302641350f9f3eaeba09
image: prom/prometheus@sha256:2b6f734e372c1b4717008f7d0a0152316aedd4d13ae17ef1e3268dbfaf68041b
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
@@ -95,7 +97,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:12.3.0-ubuntu@sha256:cee936306135e1925ab21dffa16f8a411535d16ab086bef2309339a8e74d62df
image: grafana/grafana:12.3.1-ubuntu@sha256:d57f1365197aec34c4d80869d8ca45bb7787c7663904950dab214dfb40c1c2fd
volumes:
- grafana-data:/var/lib/grafana
+3 -1
View File
@@ -49,7 +49,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
image: docker.io/valkey/valkey:9@sha256:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63
healthcheck:
test: redis-cli ping || exit 1
restart: always
@@ -69,6 +69,8 @@ services:
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
shm_size: 128mb
restart: always
healthcheck:
disable: false
volumes:
model-cache:
+1 -1
View File
@@ -1 +1 @@
24.11.1
24.12.0
+1 -1
View File
@@ -22,7 +22,7 @@ For organizations seeking to resell Immich, we have established the following gu
- Do not misrepresent your reseller site or services as being officially affiliated with or endorsed by Immich or our development team.
- For small resellers who wish to contribute financially to Immich's development, we recommend directing your customers to purchase licenses directly from us rather than attempting to broker revenue-sharing arrangements. We ask that you refrain from misrepresenting reseller activities as directly supporting our development work.
- For small resellers who wish to contribute financially to Immich's development, we recommend directing your customers to purchase product keys directly from us rather than attempting to broker revenue-sharing arrangements. We ask that you refrain from misrepresenting reseller activities as directly supporting our development work.
When in doubt or if you have an edge case scenario, we encourage you to contact us directly via email to discuss the use of our trademark. We can provide clear guidance on what is acceptable and what is not. You can reach out at: questions@immich.app
@@ -22,7 +22,7 @@ Immich is known to work with Postgres versions `>= 14, < 19`.
VectorChord is known to work with pgvector versions `>= 0.7, < 0.9`.
The Immich server will check the VectorChord version on startup to ensure compatibility, and refuse to start if a compatible version is not found.
The current accepted range for VectorChord is `>= 0.3, < 0.6`.
The current accepted range for VectorChord is `>= 0.3, < 2.0`.
:::
## Specifying the connection URL
@@ -71,6 +71,22 @@ For RKMPP to work:
5. (Optional) Enable hardware decoding for optimal performance.
<details>
<summary>immich.json</summary>
If you use a [configuration file](/install/config-file.md), use the `accel` option to select the hardware (e.g. `qsv` for Intel or `nvenc` for Nvidia). Set `accelDecode` to `true` if you want hardware decoding.
```json
{
"ffmpeg": {
"accel": "qsv",
"accelDecode": true
}
}
```
</details>
#### Single Compose File
Some platforms, including Unraid and Portainer, do not support multiple Compose files as of writing. As an alternative, you can "inline" the relevant contents of the [`hwaccel.transcoding.yml`][hw-file] file into the `immich-server` service directly.
+36
View File
@@ -112,4 +112,40 @@ You can then make a new panel, specifying Prometheus as the data source for it.
-- TODO: add images and more details here
## Structured Logging
In addition to Prometheus metrics, Immich supports structured JSON logging which is ideal for log aggregation systems like Grafana Loki, ELK Stack, Datadog, Splunk, and others.
### Configuration
By default, Immich outputs human-readable console logs. To enable JSON logging, set the `IMMICH_LOG_FORMAT` environment variable:
```bash
IMMICH_LOG_FORMAT=json
```
:::tip
The default is `IMMICH_LOG_FORMAT=console` for human-readable logs with colors during development. For production deployments using log aggregation, use `IMMICH_LOG_FORMAT=json`.
:::
### JSON Log Format
When enabled, logs are output in structured JSON format:
```json
{"level":"log","pid":36,"timestamp":1766533331507,"message":"Initialized websocket server","context":"WebsocketRepository"}
{"level":"warn","pid":48,"timestamp":1766533331629,"message":"Unable to open /build/www/index.html, skipping SSR.","context":"ApiService"}
{"level":"error","pid":36,"timestamp":1766533331690,"message":"Failed to load plugin immich-core:","context":"Error"}
```
This format includes:
- `level`: Log level (log, warn, error, etc.)
- `pid`: Process ID
- `timestamp`: Unix timestamp in milliseconds
- `message`: Log message
- `context`: Service or component that generated the log
For more information on log formats, see [`IMMICH_LOG_FORMAT`](/install/environment-variables.md#general).
[prom-file]: https://github.com/immich-app/immich/releases/latest/download/prometheus.yml
@@ -34,6 +34,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
| `IMMICH_LOG_FORMAT` | Log output format (`console`, `json`) | `console` | server | api, microservices |
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `/data` | server | api, microservices |
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
@@ -43,6 +44,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api |
\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution.
+1 -1
View File
@@ -57,6 +57,6 @@
"node": ">=20"
},
"volta": {
"node": "24.11.1"
"node": "24.12.0"
}
}
+4
View File
@@ -1,4 +1,8 @@
[
{
"label": "v2.4.1",
"url": "https://docs.v2.4.1.archive.immich.app"
},
{
"label": "v2.4.0",
"url": "https://docs.v2.4.0.archive.immich.app"
+1 -1
View File
@@ -1 +1 @@
24.11.1
24.12.0
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "2.4.0",
"version": "2.4.1",
"description": "",
"main": "index.js",
"type": "module",
@@ -26,7 +26,7 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^24.10.3",
"@types/node": "^24.10.4",
"@types/oidc-provider": "^9.0.0",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
@@ -36,7 +36,7 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^62.0.0",
"exiftool-vendored": "^34.0.0",
"exiftool-vendored": "^34.3.0",
"globals": "^16.0.0",
"jose": "^5.6.3",
"luxon": "^3.4.4",
@@ -54,6 +54,6 @@
"vitest": "^3.0.0"
},
"volta": {
"node": "24.11.1"
"node": "24.12.0"
}
}
+12 -26
View File
@@ -20,7 +20,6 @@ describe('/shared-links', () => {
let user1: LoginResponseDto;
let user2: LoginResponseDto;
let album: AlbumResponseDto;
let metadataAlbum: AlbumResponseDto;
let deletedAlbum: AlbumResponseDto;
let linkWithDeletedAlbum: SharedLinkResponseDto;
let linkWithPassword: SharedLinkResponseDto;
@@ -41,18 +40,9 @@ describe('/shared-links', () => {
[asset1, asset2] = await Promise.all([utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken)]);
[album, deletedAlbum, metadataAlbum] = await Promise.all([
[album, deletedAlbum] = await Promise.all([
createAlbum({ createAlbumDto: { albumName: 'album' } }, { headers: asBearerAuth(user1.accessToken) }),
createAlbum({ createAlbumDto: { albumName: 'deleted album' } }, { headers: asBearerAuth(user2.accessToken) }),
createAlbum(
{
createAlbumDto: {
albumName: 'metadata album',
assetIds: [asset1.id],
},
},
{ headers: asBearerAuth(user1.accessToken) },
),
]);
[linkWithDeletedAlbum, linkWithAlbum, linkWithAssets, linkWithPassword, linkWithMetadata, linkWithoutMetadata] =
@@ -75,14 +65,14 @@ describe('/shared-links', () => {
password: 'foo',
}),
utils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: metadataAlbum.id,
type: SharedLinkType.Individual,
assetIds: [asset1.id],
showMetadata: true,
slug: 'metadata-album',
slug: 'metadata-slug',
}),
utils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
albumId: metadataAlbum.id,
type: SharedLinkType.Individual,
assetIds: [asset1.id],
showMetadata: false,
}),
]);
@@ -95,9 +85,7 @@ describe('/shared-links', () => {
const resp = await request(shareUrl).get(`/${linkWithMetadata.key}`);
expect(resp.status).toBe(200);
expect(resp.header['content-type']).toContain('text/html');
expect(resp.text).toContain(
`<meta name="description" content="${metadataAlbum.assets.length} shared photos &amp; videos" />`,
);
expect(resp.text).toContain(`<meta name="description" content="1 shared photos &amp; videos" />`);
});
it('should have correct asset count in meta tag for empty album', async () => {
@@ -144,9 +132,7 @@ describe('/shared-links', () => {
const resp = await request(baseUrl).get(`/s/${linkWithMetadata.slug}`);
expect(resp.status).toBe(200);
expect(resp.header['content-type']).toContain('text/html');
expect(resp.text).toContain(
`<meta name="description" content="${metadataAlbum.assets.length} shared photos &amp; videos" />`,
);
expect(resp.text).toContain(`<meta name="description" content="1 shared photos &amp; videos" />`);
});
});
@@ -271,12 +257,12 @@ describe('/shared-links', () => {
);
});
it('should return metadata for album shared link', async () => {
it('should return metadata for individual shared link', async () => {
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithMetadata.key });
expect(status).toBe(200);
expect(body.assets).toHaveLength(0);
expect(body.album).toBeDefined();
expect(body.assets).toHaveLength(1);
expect(body.album).not.toBeDefined();
});
it('should not return metadata for album shared link without metadata', async () => {
@@ -284,7 +270,7 @@ describe('/shared-links', () => {
expect(status).toBe(200);
expect(body.assets).toHaveLength(1);
expect(body.album).toBeDefined();
expect(body.album).not.toBeDefined();
const asset = body.assets[0];
expect(asset).not.toHaveProperty('exifInfo');
+66 -1
View File
@@ -5,6 +5,7 @@
"acknowledge": "Acknowledge",
"action": "Action",
"action_common_update": "Update",
"action_description": "A set of action to perform on the filtered assets",
"actions": "Actions",
"active": "Active",
"active_count": "Active: {count}",
@@ -15,9 +16,13 @@
"add_a_location": "Add a location",
"add_a_name": "Add a name",
"add_a_title": "Add a title",
"add_action": "Add action",
"add_action_description": "Click to add an action to perform",
"add_birthday": "Add a birthday",
"add_endpoint": "Add endpoint",
"add_exclusion_pattern": "Add exclusion pattern",
"add_filter": "Add filter",
"add_filter_description": "Click to add a filter condition",
"add_location": "Add location",
"add_more_users": "Add more users",
"add_partner": "Add partner",
@@ -36,6 +41,7 @@
"add_to_shared_album": "Add to shared album",
"add_upload_to_stack": "Add upload to stack",
"add_url": "Add URL",
"add_workflow_step": "Add workflow step",
"added_to_archive": "Added to archive",
"added_to_favorites": "Added to favorites",
"added_to_favorites_count": "Added {count, number} to favorites",
@@ -467,6 +473,7 @@
"album_remove_user": "Remove user?",
"album_remove_user_confirmation": "Are you sure you want to remove {user}?",
"album_search_not_found": "No albums found matching your search",
"album_selected": "Album selected",
"album_share_no_users": "Looks like you have shared this album with all users or you don't have any user to share with.",
"album_summary": "Album summary",
"album_updated": "Album updated",
@@ -488,6 +495,7 @@
"albums_default_sort_order_description": "Initial asset sort order when creating new albums.",
"albums_feature_description": "Collections of assets that can be shared with other users.",
"albums_on_device_count": "Albums on device ({count})",
"albums_selected": "{count, plural, one {# album selected} other {# albums selected}}",
"all": "All",
"all_albums": "All albums",
"all_people": "All people",
@@ -524,10 +532,12 @@
"archived_count": "{count, plural, other {Archived #}}",
"are_these_the_same_person": "Are these the same person?",
"are_you_sure_to_do_this": "Are you sure you want to do this?",
"array_field_not_fully_supported": "Array fields require manual JSON editing",
"asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping",
"asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping",
"asset_added_to_album": "Added to album",
"asset_adding_to_album": "Adding to album…",
"asset_created": "Asset created",
"asset_description_updated": "Asset description has been updated",
"asset_filename_is_offline": "Asset {filename} is offline",
"asset_has_unassigned_faces": "Asset has unassigned faces",
@@ -711,6 +721,8 @@
"change_password_form_password_mismatch": "Passwords do not match",
"change_password_form_reenter_new_password": "Re-enter New Password",
"change_pin_code": "Change PIN code",
"change_trigger": "Change trigger",
"change_trigger_prompt": "Are you sure you want to change the trigger? This will remove all existing actions and filters.",
"change_your_password": "Change your password",
"changed_visibility_successfully": "Changed visibility successfully",
"charging": "Charging",
@@ -787,6 +799,7 @@
"create_album": "Create album",
"create_album_page_untitled": "Untitled",
"create_api_key": "Create API key",
"create_first_workflow": "Create first workflow",
"create_library": "Create Library",
"create_link": "Create link",
"create_link_to_share": "Create link to share",
@@ -801,6 +814,7 @@
"create_tag": "Create tag",
"create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.",
"create_user": "Create user",
"create_workflow": "Create workflow",
"created": "Created",
"created_at": "Created",
"creating_linked_albums": "Creating linked albums...",
@@ -867,6 +881,7 @@
"deselect_all": "Deselect All",
"details": "Details",
"direction": "Direction",
"disable": "Disable",
"disabled": "Disabled",
"disallow_edits": "Disallow edits",
"discord": "Discord",
@@ -929,11 +944,13 @@
"edit_tag": "Edit tag",
"edit_title": "Edit Title",
"edit_user": "Edit user",
"edit_workflow": "Edit workflow",
"editor": "Editor",
"editor_close_without_save_prompt": "The changes will not be saved",
"editor_close_without_save_title": "Close editor?",
"editor_crop_tool_h2_aspect_ratios": "Aspect ratios",
"editor_crop_tool_h2_rotation": "Rotation",
"editor_mode": "Editor mode",
"email": "Email",
"email_notifications": "Email notifications",
"empty_folder": "This folder is empty",
@@ -1014,6 +1031,7 @@
"unable_to_complete_oauth_login": "Unable to complete OAuth login",
"unable_to_connect": "Unable to connect",
"unable_to_copy_to_clipboard": "Cannot copy to clipboard, make sure you are accessing the page through https",
"unable_to_create": "Unable to create workflow",
"unable_to_create_admin_account": "Unable to create admin account",
"unable_to_create_api_key": "Unable to create a new API Key",
"unable_to_create_library": "Unable to create library",
@@ -1024,6 +1042,7 @@
"unable_to_delete_exclusion_pattern": "Unable to delete exclusion pattern",
"unable_to_delete_shared_link": "Unable to delete shared link",
"unable_to_delete_user": "Unable to delete user",
"unable_to_delete_workflow": "Unable to delete workflow",
"unable_to_download_files": "Unable to download files",
"unable_to_edit_exclusion_pattern": "Unable to edit exclusion pattern",
"unable_to_empty_trash": "Unable to empty trash",
@@ -1074,6 +1093,7 @@
"unable_to_update_settings": "Unable to update settings",
"unable_to_update_timeline_display_status": "Unable to update timeline display status",
"unable_to_update_user": "Unable to update user",
"unable_to_update_workflow": "Unable to update workflow",
"unable_to_upload_file": "Unable to upload file"
},
"exclusion_pattern": "Exclusion pattern",
@@ -1126,8 +1146,10 @@
"filename": "Filename",
"filetype": "Filetype",
"filter": "Filter",
"filter_description": "Conditions to filter the target assets",
"filter_people": "Filter people",
"filter_places": "Filter places",
"filters": "Filters",
"find_them_fast": "Find them fast by name with search",
"first": "First",
"fix_incorrect_match": "Fix incorrect match",
@@ -1143,6 +1165,7 @@
"general": "General",
"geolocation_instruction_location": "Click on an asset with GPS coordinates to use its location, or select a location directly from the map",
"get_help": "Get Help",
"get_people_error": "Error getting people",
"get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network",
"getting_started": "Getting Started",
"go_back": "Go back",
@@ -1175,6 +1198,7 @@
"hide_named_person": "Hide person {name}",
"hide_password": "Hide password",
"hide_person": "Hide person",
"hide_schema": "Hide schema",
"hide_text_recognition": "Hide text recognition",
"hide_unnamed_people": "Hide unnamed people",
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
@@ -1247,6 +1271,8 @@
"ios_debug_info_processing_ran_at": "Processing ran {dateTime}",
"items_count": "{count, plural, one {# item} other {# items}}",
"jobs": "Jobs",
"json_editor": "JSON editor",
"json_error": "JSON error",
"keep": "Keep",
"keep_all": "Keep All",
"keep_this_delete_others": "Keep this, delete others",
@@ -1416,11 +1442,13 @@
"monthly_title_text_date_format": "MMMM y",
"more": "More",
"move": "Move",
"move_down": "Move down",
"move_off_locked_folder": "Move out of locked folder",
"move_to": "Move to",
"move_to_lock_folder_action_prompt": "{count} added to the locked folder",
"move_to_locked_folder": "Move to locked folder",
"move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder",
"move_up": "Move up",
"moved_to_archive": "Moved {count, plural, one {# asset} other {# assets}} to archive",
"moved_to_library": "Moved {count, plural, one {# asset} other {# assets}} to library",
"moved_to_trash": "Moved to trash",
@@ -1430,6 +1458,7 @@
"my_albums": "My albums",
"name": "Name",
"name_or_nickname": "Name or nickname",
"name_required": "Name is required",
"navigate": "Navigate",
"navigate_to_time": "Navigate to Time",
"network_requirement_photos_upload": "Use cellular data to backup photos",
@@ -1454,6 +1483,7 @@
"next": "Next",
"next_memory": "Next memory",
"no": "No",
"no_actions_added": "No actions added yet",
"no_albums_message": "Create an album to organize your photos and videos",
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
"no_albums_yet": "It looks like you do not have any albums yet.",
@@ -1463,11 +1493,13 @@
"no_cast_devices_found": "No cast devices found",
"no_checksum_local": "No checksum available - cannot fetch local assets",
"no_checksum_remote": "No checksum available - cannot fetch remote asset",
"no_configuration_needed": "No configuration needed",
"no_devices": "No authorized devices",
"no_duplicates_found": "No duplicates were found.",
"no_exif_info_available": "No exif info available",
"no_explore_results_message": "Upload more photos to explore your collection.",
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
"no_filters_added": "No filters added yet",
"no_libraries_message": "Create an external library to view your photos and videos",
"no_local_assets_found": "No local assets found with this checksum",
"no_location_set": "No location set",
@@ -1563,6 +1595,7 @@
"people": "People",
"people_edits_count": "Edited {count, plural, one {# person} other {# people}}",
"people_feature_description": "Browsing photos and videos grouped by people",
"people_selected": "{count, plural, one {# person selected} other {# people selected}}",
"people_sidebar_description": "Display a link to People in the sidebar",
"permanent_deletion_warning": "Permanent deletion warning",
"permanent_deletion_warning_setting_description": "Show a warning when permanently deleting assets",
@@ -1587,6 +1620,8 @@
"person_age_years": "{years, plural, other {# years}} old",
"person_birthdate": "Born on {date}",
"person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
"person_recognized": "Person recognized",
"person_selected": "Person selected",
"photo_shared_all_users": "Looks like you shared your photos with all users or you don't have any user to share with.",
"photos": "Photos",
"photos_and_videos": "Photos & Videos",
@@ -1836,17 +1871,22 @@
"second": "Second",
"see_all_people": "See all people",
"select": "Select",
"select_album": "Select album",
"select_album_cover": "Select album cover",
"select_albums": "Select albums",
"select_all": "Select all",
"select_all_duplicates": "Select all duplicates",
"select_all_in": "Select all in {group}",
"select_avatar_color": "Select avatar color",
"select_count": "{count, plural, one {Select #} other {Select #}}",
"select_face": "Select face",
"select_featured_photo": "Select featured photo",
"select_from_computer": "Select from computer",
"select_keep_all": "Select keep all",
"select_library_owner": "Select library owner",
"select_new_face": "Select new face",
"select_people": "Select people",
"select_person": "Select person",
"select_person_to_tag": "Select a person to tag",
"select_photos": "Select photos",
"select_trash_all": "Select trash all",
@@ -1982,6 +2022,7 @@
"show_password": "Show password",
"show_person_options": "Show person options",
"show_progress_bar": "Show Progress Bar",
"show_schema": "Show schema",
"show_search_options": "Show search options",
"show_shared_links": "Show shared links",
"show_slideshow_transition": "Show slideshow transition",
@@ -2109,6 +2150,13 @@
"trash_page_select_assets_btn": "Select assets",
"trash_page_title": "Trash ({count})",
"trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.",
"trigger": "Trigger",
"trigger_asset_uploaded": "Asset Uploaded",
"trigger_asset_uploaded_description": "Triggered when a new asset is uploaded",
"trigger_description": "An event that kicks off the workflow",
"trigger_person_recognized": "Person Recognized",
"trigger_person_recognized_description": "Triggered when a person is detected",
"trigger_type": "Trigger type",
"troubleshoot": "Troubleshoot",
"type": "Type",
"unable_to_change_pin_code": "Unable to change PIN code",
@@ -2139,7 +2187,9 @@
"unstack": "Un-stack",
"unstack_action_prompt": "{count} unstacked",
"unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}",
"unsupported_field_type": "Unsupported field type",
"untagged": "Untagged",
"untitled_workflow": "Untitled workflow",
"up_next": "Up next",
"update_location_action_prompt": "Update the location of {count} selected assets with:",
"updated_at": "Updated",
@@ -2185,6 +2235,7 @@
"utilities": "Utilities",
"validate": "Validate",
"validate_endpoint_error": "Please enter a valid URL",
"validation_error": "Validation error",
"variables": "Variables",
"version": "Version",
"version_announcement_closing": "Your friend, Alex",
@@ -2216,6 +2267,8 @@
"viewer_stack_use_as_main_asset": "Use as Main Asset",
"viewer_unstack": "Un-Stack",
"visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}",
"visual": "Visual",
"visual_builder": "Visual builder",
"waiting": "Waiting",
"waiting_count": "Waiting: {count}",
"warning": "Warning",
@@ -2224,7 +2277,19 @@
"welcome_to_immich": "Welcome to Immich",
"width": "Width",
"wifi_name": "Wi-Fi Name",
"workflow": "Workflow",
"workflow_delete_prompt": "Are you sure you want to delete this workflow?",
"workflow_deleted": "Workflow deleted",
"workflow_description": "Workflow description",
"workflow_info": "Workflow info",
"workflow_json": "Workflow JSON",
"workflow_json_help": "Edit the workflow configuration in JSON format. Changes will sync to the visual builder.",
"workflow_name": "Workflow name",
"workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?",
"workflow_summary": "Workflow summary",
"workflow_update_success": "Workflow updated successfully",
"workflow_updated": "Workflow updated",
"workflows": "Workflows",
"workflows_help_text": "Workflows automate actions on your assets based on triggers and filters",
"wrong_pin_code": "Wrong PIN code",
"year": "Year",
"years_ago": "{years, plural, one {# year} other {# years}} ago",
+38 -20
View File
@@ -1,8 +1,8 @@
ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:e39286476f84ffedf7c3564b0b74e32c9e1193ec9ca32ee8a11f8c09dbf6aafe AS builder-cpu
FROM python:3.11-bookworm@sha256:667cf70698924920f29ebdb8d749ab665811503b87093d4f11826d114fd7255e AS builder-cpu
FROM builder-cpu AS builder-openvino
FROM python:3.13-slim-trixie@sha256:0222b795db95bf7412cede36ab46a266cfb31f632e64051aac9806dabf840a61 AS builder-openvino
FROM builder-cpu AS builder-cuda
@@ -22,20 +22,18 @@ FROM builder-cpu AS builder-rknn
# Warning: 25GiB+ disk space required to pull this image
# TODO: find a way to reduce the image size
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:6cda50e312f3aac068cea9ec06c560ca1f522ad546bc8b3d2cf06da0fe8e8a76 AS builder-rocm
FROM rocm/dev-ubuntu-24.04:6.4.4-complete@sha256:31418ac10a3769a71eaef330c07280d1d999d7074621339b8f93c484c35f6078 AS builder-rocm
# renovate: datasource=github-releases depName=Microsoft/onnxruntime
ARG ONNXRUNTIME_VERSION="v1.22.1"
WORKDIR /code
RUN apt-get update && apt-get install -y --no-install-recommends wget git python3.10-venv
RUN wget -nv https://github.com/Kitware/CMake/releases/download/v3.30.1/cmake-3.30.1-linux-x86_64.sh && \
chmod +x cmake-3.30.1-linux-x86_64.sh && \
mkdir -p /code/cmake-3.30.1-linux-x86_64 && \
./cmake-3.30.1-linux-x86_64.sh --skip-license --prefix=/code/cmake-3.30.1-linux-x86_64 && \
rm cmake-3.30.1-linux-x86_64.sh
ENV PATH=/code/cmake-3.30.1-linux-x86_64/bin:${PATH}
RUN apt-get update && apt-get install -y --no-install-recommends wget git
RUN wget -nv https://github.com/Kitware/CMake/releases/download/v3.31.9/cmake-3.31.9-linux-x86_64.sh && \
chmod +x cmake-3.31.9-linux-x86_64.sh && \
mkdir -p /code/cmake-3.31.9-linux-x86_64 && \
./cmake-3.31.9-linux-x86_64.sh --skip-license --prefix=/code/cmake-3.31.9-linux-x86_64 && \
rm cmake-3.31.9-linux-x86_64.sh
RUN git clone --single-branch --branch "${ONNXRUNTIME_VERSION}" --recursive "https://github.com/Microsoft/onnxruntime" onnxruntime
WORKDIR /code/onnxruntime
@@ -45,9 +43,26 @@ COPY ./patches/* /tmp/
RUN git apply /tmp/*.patch
RUN /bin/sh ./dockerfiles/scripts/install_common_deps.sh
ENV PATH=/opt/rocm-venv/bin:/code/cmake-3.31.9-linux-x86_64/bin:${PATH}
ENV CCACHE_DIR="/ccache"
# Note: the `parallel` setting uses a substantial amount of RAM
RUN ./build.sh --allow_running_as_root --config Release --build_wheel --update --build --parallel 17 --cmake_extra_defines\
ONNXRUNTIME_VERSION="${ONNXRUNTIME_VERSION}" --skip_tests --use_rocm --rocm_home=/opt/rocm
RUN --mount=type=cache,target=/ccache \
./build.sh \
--allow_running_as_root \
--config Release \
--build_wheel \
--update \
--build \
--parallel 17 \
--cmake_extra_defines \
ONNXRUNTIME_VERSION="${ONNXRUNTIME_VERSION}" \
CMAKE_HIP_ARCHITECTURES="gfx900;gfx906;gfx908;gfx90a;gfx940;gfx941;gfx942;gfx1030;gfx1100;gfx1101;gfx1102;gfx1200;gfx1201" \
--skip_tests \
--use_rocm \
--rocm_home=/opt/rocm \
--use_cache \
--compile_no_warning_as_error
RUN mv /code/onnxruntime/build/Linux/Release/dist/*.whl /opt/
FROM builder-${DEVICE} AS builder
@@ -68,20 +83,23 @@ RUN if [ "$DEVICE" = "rocm" ]; then \
uv pip install /opt/onnxruntime_rocm-*.whl; \
fi
FROM python:3.11-slim-bookworm@sha256:2c5bc243b1cc47985ee4fb768bb0bbd4490481c5d0897a62da31b7f30b7304a7 AS prod-cpu
FROM python:3.11-slim-bookworm@sha256:917ec0e42cd6af87657a768449c2f604a6b67c7ab8e10ff917b8724799f816d3 AS prod-cpu
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
MACHINE_LEARNING_MODEL_ARENA=false
FROM python:3.11-slim-bookworm@sha256:2c5bc243b1cc47985ee4fb768bb0bbd4490481c5d0897a62da31b7f30b7304a7 AS prod-openvino
FROM python:3.13-slim-trixie@sha256:0222b795db95bf7412cede36ab46a266cfb31f632e64051aac9806dabf840a61 AS prod-openvino
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-core_1.0.17384.11_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-opencl_1.0.17384.11_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/intel-opencl-icd_24.31.30508.7_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.24.8/intel-igc-core-2_2.24.8+20344_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.24.8/intel-igc-opencl-2_2.24.8+20344_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/25.48.36300.8/intel-opencl-icd_25.48.36300.8-0_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
wget -nv https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/libigdgmm12_22.4.1_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/25.48.36300.8/libigdgmm12_22.8.2_amd64.deb && \
dpkg -i *.deb && \
rm *.deb && \
apt-get remove wget -yqq && \
@@ -102,7 +120,7 @@ COPY --from=builder-cuda /usr/local/bin/python3 /usr/local/bin/python3
COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11
COPY --from=builder-cuda /usr/local/lib/libpython3.11.so /usr/local/lib/libpython3.11.so
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:6cda50e312f3aac068cea9ec06c560ca1f522ad546bc8b3d2cf06da0fe8e8a76 AS prod-rocm
FROM rocm/dev-ubuntu-24.04:6.4.4-complete@sha256:31418ac10a3769a71eaef330c07280d1d999d7074621339b8f93c484c35f6078 AS prod-rocm
FROM prod-cpu AS prod-armnn
+1 -1
View File
@@ -36,7 +36,7 @@ from .schemas import (
T,
)
MultiPartParser.max_file_size = 2**26 # spools to disk if payload is 64 MiB or larger
MultiPartParser.spool_max_size = 2**26 # spools to disk if payload is 64 MiB or larger
model_cache = ModelCache(revalidate=settings.model_ttl > 0)
thread_pool: ThreadPoolExecutor | None = None
@@ -0,0 +1,33 @@
diff --git a/dockerfiles/scripts/install_common_deps.sh b/dockerfiles/scripts/install_common_deps.sh
index bbb672a99e..0dc652fbda 100644
--- a/dockerfiles/scripts/install_common_deps.sh
+++ b/dockerfiles/scripts/install_common_deps.sh
@@ -8,16 +8,23 @@ apt-get update && apt-get install -y --no-install-recommends \
curl \
libcurl4-openssl-dev \
libssl-dev \
- python3-dev
+ python3-dev \
+ ccache
# Dependencies: conda
-wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-4.5.11-Linux-x86_64.sh -O ~/miniconda.sh --no-check-certificate && /bin/bash ~/miniconda.sh -b -p /opt/miniconda
+wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-py312_25.9.1-1-Linux-x86_64.sh -O ~/miniconda.sh && /bin/bash ~/miniconda.sh -b -p /opt/miniconda
rm ~/miniconda.sh
/opt/miniconda/bin/conda clean -ya
-pip install numpy
-pip install packaging
-pip install "wheel>=0.35.1"
+# Dependencies: venv and packages
+/opt/miniconda/bin/python3 -m venv /opt/rocm-venv
+/opt/rocm-venv/bin/pip install --no-cache-dir --upgrade pip
+/opt/rocm-venv/bin/pip install --no-cache-dir \
+ "numpy==2.3.4" \
+ "packaging==25.0" \
+ "wheel==0.45.1" \
+ "setuptools==80.9.0"
+
rm -rf /opt/miniconda/pkgs
# Dependencies: cmake
@@ -1,13 +0,0 @@
diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt
index 2714e6f59..a69da76b4 100644
--- a/cmake/CMakeLists.txt
+++ b/cmake/CMakeLists.txt
@@ -338,7 +338,7 @@ if (onnxruntime_USE_ROCM)
if (ROCM_VERSION_DEV VERSION_LESS "6.2")
message(FATAL_ERROR "CMAKE_HIP_ARCHITECTURES is not set when ROCm version < 6.2")
else()
- set(CMAKE_HIP_ARCHITECTURES "gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx940;gfx941;gfx942;gfx1200;gfx1201")
+ set(CMAKE_HIP_ARCHITECTURES "gfx900;gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx1102;gfx940;gfx941;gfx942;gfx1200;gfx1201")
endif()
endif()
+8 -16
View File
@@ -1,9 +1,9 @@
[project]
name = "immich-ml"
version = "2.4.0"
version = "2.4.1"
description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.10,<4.0"
requires-python = ">=3.11,<4.0"
readme = "README.md"
dependencies = [
"aiocache>=0.12.1,<1.0",
@@ -12,7 +12,7 @@ dependencies = [
"gunicorn>=21.1.0",
"huggingface-hub>=0.20.1,<1.0",
"insightface>=0.7.3,<1.0",
"numpy<2",
"numpy>=2.3.4",
"opencv-python-headless>=4.7.0.72,<5.0",
"orjson>=3.9.5",
"pillow>=9.5.0,<11.0",
@@ -49,24 +49,16 @@ lint = [
dev = ["locust>=2.15.1", { include-group = "test" }, { include-group = "lint" }]
[project.optional-dependencies]
cpu = ["onnxruntime>=1.15.0,<2"]
cuda = ["onnxruntime-gpu>=1.17.0,<2"]
openvino = ["onnxruntime-openvino>=1.17.1,<1.19.0"]
armnn = ["onnxruntime>=1.15.0,<2"]
rknn = ["onnxruntime>=1.15.0,<2", "rknn-toolkit-lite2>=2.3.0,<3"]
cpu = ["onnxruntime>=1.23.2,<2"]
cuda = ["onnxruntime-gpu>=1.23.2,<2"]
openvino = ["onnxruntime-openvino>=1.23.0,<2"]
armnn = ["onnxruntime>=1.23.2,<2"]
rknn = ["onnxruntime>=1.23.2,<2", "rknn-toolkit-lite2>=2.3.0,<3"]
rocm = []
[tool.uv]
compile-bytecode = true
[[tool.uv.index]]
name = "cuda12"
url = "https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple/"
explicit = true
[tool.uv.sources]
onnxruntime-gpu = { index = "cuda12" }
[tool.hatch.build.targets.sdist]
include = ["immich_ml"]
+804 -1171
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -90,6 +90,7 @@ fi
sed -i "s/\"android\.injected\.version\.name\" => \"$CURRENT_SERVER\",/\"android\.injected\.version\.name\" => \"$NEXT_SERVER\",/" mobile/android/fastlane/Fastfile
sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.injected\.version\.code\" => $NEXT_MOBILE,/" mobile/android/fastlane/Fastfile
sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml
perl -i -p0e "s/(<key>CFBundleShortVersionString<\/key>\s*<string>)$CURRENT_SERVER(<\/string>)/\${1}$NEXT_SERVER\${2}/s" mobile/ios/Runner/Info.plist
./misc/release/archive-version.js "$NEXT_SERVER"
+2 -2
View File
@@ -1,9 +1,9 @@
experimental_monorepo_root = true
[tools]
node = "24.11.1"
node = "24.12.0"
flutter = "3.35.7"
pnpm = "10.24.0"
pnpm = "10.27.0"
terragrunt = "0.93.10"
opentofu = "1.10.7"
java = "25.0.1"
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3029,
"android.injected.version.name" => "2.4.0",
"android.injected.version.code" => 3030,
"android.injected.version.name" => "2.4.1",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
+1 -1
View File
@@ -80,7 +80,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.2.1</string>
<string>2.4.1</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
+66 -22
View File
@@ -21,6 +21,20 @@ platform :ios do
CODE_SIGN_IDENTITY = "Apple Distribution: Hau Tran (#{TEAM_ID})"
BASE_BUNDLE_ID = "app.alextran.immich"
# App identifiers for production
PROD_APP_IDENTIFIERS = [
"app.alextran.immich",
"app.alextran.immich.ShareExtension",
"app.alextran.immich.Widget"
]
# App identifiers for development
DEV_APP_IDENTIFIERS = [
"app.alextran.immich.development",
"app.alextran.immich.development.ShareExtension",
"app.alextran.immich.development.Widget"
]
# Helper method to get App Store Connect API key
def get_api_key
app_store_connect_api_key(
@@ -32,6 +46,17 @@ platform :ios do
)
end
# Helper method to sync certificates and profiles using match
def sync_code_signing(app_identifiers:, readonly: true)
match(
type: "appstore",
app_identifier: app_identifiers,
readonly: readonly,
keychain_name: ENV["KEYCHAIN_NAME"] || "login.keychain",
keychain_password: ENV["KEYCHAIN_PASSWORD"] || ""
)
end
# Helper method to get version from pubspec.yaml
def get_version_from_pubspec
require 'yaml'
@@ -54,7 +79,7 @@ end
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}",
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix} AppStore",
profile_name: "match AppStore #{BASE_BUNDLE_ID}#{bundle_suffix}",
targets: ["Runner"]
)
@@ -65,7 +90,7 @@ end
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension",
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension AppStore",
profile_name: "match AppStore #{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension",
targets: ["ShareExtension"]
)
@@ -76,7 +101,7 @@ end
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget",
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget AppStore",
profile_name: "match AppStore #{BASE_BUNDLE_ID}#{bundle_suffix}.Widget",
targets: ["WidgetExtension"]
)
end
@@ -115,9 +140,9 @@ end
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: {
provisioningProfiles: {
"#{app_identifier}" => "#{app_identifier} AppStore",
"#{app_identifier}.ShareExtension" => "#{app_identifier}.ShareExtension AppStore",
"#{app_identifier}.Widget" => "#{app_identifier}.Widget AppStore"
"#{app_identifier}" => "match AppStore #{app_identifier}",
"#{app_identifier}.ShareExtension" => "match AppStore #{app_identifier}.ShareExtension",
"#{app_identifier}.Widget" => "match AppStore #{app_identifier}.Widget"
},
signingStyle: "manual",
signingCertificate: CODE_SIGN_IDENTITY
@@ -136,10 +161,8 @@ end
lane :gha_testflight_dev do
api_key = get_api_key
# Install development provisioning profiles
install_provisioning_profile(path: "profile_dev.mobileprovision")
install_provisioning_profile(path: "profile_dev_share.mobileprovision")
install_provisioning_profile(path: "profile_dev_widget.mobileprovision")
# Sync certificates and profiles using match
sync_code_signing(app_identifiers: DEV_APP_IDENTIFIERS)
# Configure code signing for dev bundle IDs
configure_code_signing(bundle_id_suffix: "development")
@@ -157,11 +180,8 @@ end
lane :gha_release_prod do
api_key = get_api_key
# Install provisioning profiles
install_provisioning_profile(path: "profile.mobileprovision")
install_provisioning_profile(path: "profile_share.mobileprovision")
install_provisioning_profile(path: "profile_widget.mobileprovision")
# Sync certificates and profiles using match
sync_code_signing(app_identifiers: PROD_APP_IDENTIFIERS)
# Configure code signing for production bundle IDs
configure_code_signing
@@ -215,10 +235,8 @@ end
# Use the same build process as production, just skip the upload
# This ensures PR builds validate the same way as production builds
# Install provisioning profiles (use development profiles for PR builds)
install_provisioning_profile(path: "profile_dev.mobileprovision")
install_provisioning_profile(path: "profile_dev_share.mobileprovision")
install_provisioning_profile(path: "profile_dev_widget.mobileprovision")
# Sync certificates and profiles using match
sync_code_signing(app_identifiers: DEV_APP_IDENTIFIERS)
# Configure code signing for dev bundle IDs
configure_code_signing(bundle_id_suffix: "development")
@@ -233,9 +251,9 @@ end
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: {
provisioningProfiles: {
"#{BASE_BUNDLE_ID}.development" => "#{BASE_BUNDLE_ID}.development AppStore",
"#{BASE_BUNDLE_ID}.development.ShareExtension" => "#{BASE_BUNDLE_ID}.development.ShareExtension AppStore",
"#{BASE_BUNDLE_ID}.development.Widget" => "#{BASE_BUNDLE_ID}.development.Widget AppStore"
"#{BASE_BUNDLE_ID}.development" => "match AppStore #{BASE_BUNDLE_ID}.development",
"#{BASE_BUNDLE_ID}.development.ShareExtension" => "match AppStore #{BASE_BUNDLE_ID}.development.ShareExtension",
"#{BASE_BUNDLE_ID}.development.Widget" => "match AppStore #{BASE_BUNDLE_ID}.development.Widget"
},
signingStyle: "manual",
signingCertificate: CODE_SIGN_IDENTITY
@@ -243,4 +261,30 @@ end
)
end
desc "Sync all certificates and profiles (run locally to update match repo)"
lane :sync_certificates do
# Sync production certificates and profiles
match(
type: "appstore",
app_identifier: PROD_APP_IDENTIFIERS,
readonly: false
)
# Sync development certificates and profiles
match(
type: "appstore",
app_identifier: DEV_APP_IDENTIFIERS,
readonly: false
)
end
desc "Regenerate all certificates and profiles (use when expired)"
lane :regenerate_certificates do
# Nuke existing certificates
match_nuke(type: "appstore")
# Generate new ones
sync_certificates
end
end
+19
View File
@@ -0,0 +1,19 @@
git_url(ENV["MATCH_GIT_URL"] || "https://github.com/immich-app/ios-certs")
storage_mode("git")
type("appstore")
team_id("2F67MQ8R79")
app_identifier([
"app.alextran.immich",
"app.alextran.immich.ShareExtension",
"app.alextran.immich.Widget",
"app.alextran.immich.development",
"app.alextran.immich.development.ShareExtension",
"app.alextran.immich.development.Widget"
])
# For all available options run `fastlane match --help`
# The docs are available on https://docs.fastlane.tools/actions/match
+24
View File
@@ -39,6 +39,30 @@ iOS Release to TestFlight
iOS Manual Release
### ios gha_build_only
```sh
[bundle exec] fastlane ios gha_build_only
```
iOS Build Only (no TestFlight upload)
### ios sync_certificates
```sh
[bundle exec] fastlane ios sync_certificates
```
Sync all certificates and profiles (run locally to update match repo)
### ios regenerate_certificates
```sh
[bundle exec] fastlane ios regenerate_certificates
```
Regenerate all certificates and profiles (use when expired)
----
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
+36 -30
View File
@@ -6,6 +6,8 @@ import 'package:immich_mobile/infrastructure/repositories/local_asset.repository
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
typedef _AssetVideoDimension = ({double? width, double? height, bool isFlipped});
class AssetService {
final RemoteAssetRepository _remoteAssetRepository;
final DriftLocalAssetRepository _localAssetRepository;
@@ -58,44 +60,48 @@ class AssetService {
}
Future<double> getAspectRatio(BaseAsset asset) async {
bool isFlipped;
double? width;
double? height;
final dimension = asset is LocalAsset
? await _getLocalAssetDimensions(asset)
: await _getRemoteAssetDimensions(asset as RemoteAsset);
if (asset.hasRemote) {
final exif = await getExif(asset);
isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
width = asset.width?.toDouble();
height = asset.height?.toDouble();
} else if (asset is LocalAsset) {
isFlipped = CurrentPlatform.isAndroid && (asset.orientation == 90 || asset.orientation == 270);
width = asset.width?.toDouble();
height = asset.height?.toDouble();
} else {
isFlipped = false;
if (dimension.width == null || dimension.height == null || dimension.height == 0) {
return 1.0;
}
return dimension.isFlipped ? dimension.height! / dimension.width! : dimension.width! / dimension.height!;
}
Future<_AssetVideoDimension> _getLocalAssetDimensions(LocalAsset asset) async {
double? width = asset.width?.toDouble();
double? height = asset.height?.toDouble();
int orientation = asset.orientation;
if (width == null || height == null) {
if (asset.hasRemote) {
final id = asset is LocalAsset ? asset.remoteId! : (asset as RemoteAsset).id;
final remoteAsset = await _remoteAssetRepository.get(id);
width = remoteAsset?.width?.toDouble();
height = remoteAsset?.height?.toDouble();
} else {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
final localAsset = await _localAssetRepository.get(id);
width = localAsset?.width?.toDouble();
height = localAsset?.height?.toDouble();
}
final fetched = await _localAssetRepository.get(asset.id);
width = fetched?.width?.toDouble();
height = fetched?.height?.toDouble();
orientation = fetched?.orientation ?? 0;
}
final orientedWidth = isFlipped ? height : width;
final orientedHeight = isFlipped ? width : height;
if (orientedWidth != null && orientedHeight != null && orientedHeight > 0) {
return orientedWidth / orientedHeight;
// On Android, local assets need orientation correction for 90°/270° rotations
// On iOS, the Photos framework pre-corrects dimensions
final isFlipped = CurrentPlatform.isAndroid && (orientation == 90 || orientation == 270);
return (width: width, height: height, isFlipped: isFlipped);
}
Future<_AssetVideoDimension> _getRemoteAssetDimensions(RemoteAsset asset) async {
double? width = asset.width?.toDouble();
double? height = asset.height?.toDouble();
if (width == null || height == null) {
final fetched = await _remoteAssetRepository.get(asset.id);
width = fetched?.width?.toDouble();
height = fetched?.height?.toDouble();
}
return 1.0;
final exif = await getExif(asset);
final isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
return (width: width, height: height, isFlipped: isFlipped);
}
Future<List<(String, String)>> getPlaces(String userId) {
@@ -171,67 +171,6 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
unawaited(context.pushRoute(DriftActivitiesRoute(album: _album)));
}
Future<void> showOptionSheet(BuildContext context) async {
final user = ref.watch(currentUserProvider);
final isOwner = user != null ? user.id == _album.ownerId : false;
final canAddPhotos =
await ref.read(remoteAlbumServiceProvider).getUserRole(_album.id, user?.id ?? '') == AlbumUserRole.editor;
unawaited(
showModalBottomSheet(
context: context,
backgroundColor: context.colorScheme.surface,
isScrollControlled: false,
builder: (context) {
return DriftRemoteAlbumOption(
onDeleteAlbum: isOwner
? () async {
await deleteAlbum(context);
if (context.mounted) {
context.pop();
}
}
: null,
onAddUsers: isOwner
? () async {
await addUsers(context);
context.pop();
}
: null,
onAddPhotos: isOwner || canAddPhotos
? () async {
await addAssets(context);
context.pop();
}
: null,
onToggleAlbumOrder: isOwner
? () async {
await toggleAlbumOrder();
context.pop();
}
: null,
onEditAlbum: isOwner
? () async {
context.pop();
await showEditTitleAndDescription(context);
}
: null,
onCreateSharedLink: isOwner
? () async {
context.pop();
unawaited(context.pushRoute(SharedLinkEditRoute(albumId: _album.id)));
}
: null,
onShowOptions: () {
context.pop();
context.pushRoute(DriftAlbumOptionsRoute(album: _album));
},
);
},
),
);
}
@override
Widget build(BuildContext context) {
final user = ref.watch(currentUserProvider);
@@ -249,8 +188,16 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
child: Timeline(
appBar: RemoteAlbumSliverAppBar(
icon: Icons.photo_album_outlined,
onShowOptions: () => showOptionSheet(context),
onToggleAlbumOrder: isOwner ? () => toggleAlbumOrder() : null,
kebabMenu: _AlbumKebabMenu(
album: _album,
onDeleteAlbum: () => deleteAlbum(context),
onAddUsers: () => addUsers(context),
onAddPhotos: () => addAssets(context),
onToggleAlbumOrder: () => toggleAlbumOrder(),
onEditAlbum: () => showEditTitleAndDescription(context),
onCreateSharedLink: () => unawaited(context.pushRoute(SharedLinkEditRoute(albumId: _album.id))),
onShowOptions: () => context.pushRoute(DriftAlbumOptionsRoute(album: _album)),
),
onEditTitle: isOwner ? () => showEditTitleAndDescription(context) : null,
onActivity: () => showActivity(context),
),
@@ -414,3 +361,77 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {
);
}
}
class _AlbumKebabMenu extends ConsumerWidget {
final RemoteAlbum album;
final VoidCallback? onDeleteAlbum;
final VoidCallback? onAddUsers;
final VoidCallback? onAddPhotos;
final VoidCallback? onToggleAlbumOrder;
final VoidCallback? onEditAlbum;
final VoidCallback? onCreateSharedLink;
final VoidCallback? onShowOptions;
const _AlbumKebabMenu({
required this.album,
this.onDeleteAlbum,
this.onAddUsers,
this.onAddPhotos,
this.onToggleAlbumOrder,
this.onEditAlbum,
this.onCreateSharedLink,
this.onShowOptions,
});
double _calculateScrollProgress(FlexibleSpaceBarSettings? settings) {
if (settings?.maxExtent == null || settings?.minExtent == null) {
return 1.0;
}
final deltaExtent = settings!.maxExtent - settings.minExtent;
if (deltaExtent <= 0.0) {
return 1.0;
}
return (1.0 - (settings.currentExtent - settings.minExtent) / deltaExtent).clamp(0.0, 1.0);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
final scrollProgress = _calculateScrollProgress(settings);
final iconColor = Color.lerp(Colors.white, context.primaryColor, scrollProgress);
final iconShadows = [
if (scrollProgress < 0.95)
Shadow(offset: const Offset(0, 2), blurRadius: 5, color: Colors.black.withValues(alpha: 0.5))
else
const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
];
final user = ref.watch(currentUserProvider);
final isOwner = user != null && user.id == album.ownerId;
return FutureBuilder<bool>(
future: ref
.read(remoteAlbumServiceProvider)
.getUserRole(album.id, user?.id ?? '')
.then((role) => role == AlbumUserRole.editor),
builder: (context, snapshot) {
final canAddPhotos = snapshot.data ?? false;
return DriftRemoteAlbumOption(
iconColor: iconColor,
iconShadows: iconShadows,
onDeleteAlbum: isOwner ? onDeleteAlbum : null,
onAddUsers: isOwner ? onAddUsers : null,
onAddPhotos: isOwner || canAddPhotos ? onAddPhotos : null,
onToggleAlbumOrder: isOwner ? onToggleAlbumOrder : null,
onEditAlbum: isOwner ? onEditAlbum : null,
onCreateSharedLink: isOwner ? onCreateSharedLink : null,
onShowOptions: onShowOptions,
);
},
);
}
}
@@ -53,7 +53,7 @@ class BaseActionButton extends ConsumerWidget {
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
leadingIcon: Icon(iconData, color: effectiveIconColor),
onPressed: onPressed,
child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16)),
child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16, color: iconColor)),
);
}
@@ -92,6 +92,8 @@ class AssetViewer extends ConsumerStatefulWidget {
if (asset.isVideo || asset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
ref.read(videoPlayerControlsProvider.notifier).pause();
// Hide controls by default for videos and motion photos
ref.read(assetViewerProvider.notifier).setControls(false);
}
}
}
@@ -525,7 +527,13 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
if (scaleState != PhotoViewScaleState.initial) {
ref.read(assetViewerProvider.notifier).setControls(false);
ref.read(videoPlayerControlsProvider.notifier).pause();
return;
}
if (!showingBottomSheet) {
ref.read(assetViewerProvider.notifier).setControls(true);
}
}
@@ -119,7 +119,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
const DeleteActionButton(source: ActionSource.timeline),
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
if (multiselect.hasLocal) const UploadActionButton(source: ActionSource.timeline),
@@ -12,6 +12,8 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -114,6 +116,14 @@ class _DriftMapState extends ConsumerState<DriftMap> {
return;
}
// When the AssetViewer is open, the DriftMap route stays alive in the background.
// If we continue to update bounds, the map-scoped timeline service gets recreated and the previous one disposed,
// which can invalidate the TimelineService instance that was passed into AssetViewerRoute (causing "loading forever").
final currentRoute = ref.read(currentRouteNameProvider);
if (currentRoute == AssetViewerRoute.name || currentRoute == GalleryViewerRoute.name) {
return;
}
final bounds = await controller.getVisibleRegion();
unawaited(
_reloadMutex.run(() async {
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
class DriftRemoteAlbumOption extends ConsumerWidget {
const DriftRemoteAlbumOption({
@@ -14,6 +15,8 @@ class DriftRemoteAlbumOption extends ConsumerWidget {
this.onToggleAlbumOrder,
this.onEditAlbum,
this.onShowOptions,
this.iconColor,
this.iconShadows,
});
final VoidCallback? onAddPhotos;
@@ -24,73 +27,131 @@ class DriftRemoteAlbumOption extends ConsumerWidget {
final VoidCallback? onToggleAlbumOrder;
final VoidCallback? onEditAlbum;
final VoidCallback? onShowOptions;
final Color? iconColor;
final List<Shadow>? iconShadows;
@override
Widget build(BuildContext context, WidgetRef ref) {
TextStyle textStyle = Theme.of(context).textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w600);
final theme = context.themeData;
final menuChildren = <Widget>[];
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24.0),
child: ListView(
shrinkWrap: true,
children: [
if (onEditAlbum != null)
ListTile(
leading: const Icon(Icons.edit),
title: Text('edit_album'.t(context: context), style: textStyle),
onTap: onEditAlbum,
),
if (onAddPhotos != null)
ListTile(
leading: const Icon(Icons.add_a_photo),
title: Text('add_photos'.t(context: context), style: textStyle),
onTap: onAddPhotos,
),
if (onAddUsers != null)
ListTile(
leading: const Icon(Icons.group_add),
title: Text('album_viewer_page_share_add_users'.t(context: context), style: textStyle),
onTap: onAddUsers,
),
if (onLeaveAlbum != null)
ListTile(
leading: const Icon(Icons.person_remove_rounded),
title: Text('leave_album'.t(context: context), style: textStyle),
onTap: onLeaveAlbum,
),
if (onToggleAlbumOrder != null)
ListTile(
leading: const Icon(Icons.swap_vert_rounded),
title: Text('change_display_order'.t(context: context), style: textStyle),
onTap: onToggleAlbumOrder,
),
if (onCreateSharedLink != null)
ListTile(
leading: const Icon(Icons.link),
title: Text('create_shared_link'.t(context: context), style: textStyle),
onTap: onCreateSharedLink,
),
if (onShowOptions != null)
ListTile(
leading: const Icon(Icons.settings),
title: Text('options'.t(context: context), style: textStyle),
onTap: onShowOptions,
),
if (onDeleteAlbum != null) ...[
const Divider(indent: 16, endIndent: 16),
ListTile(
leading: Icon(Icons.delete, color: context.isDarkTheme ? Colors.red[400] : Colors.red[800]),
title: Text(
'delete_album'.t(context: context),
style: textStyle.copyWith(color: context.isDarkTheme ? Colors.red[400] : Colors.red[800]),
),
onTap: onDeleteAlbum,
),
],
],
if (onEditAlbum != null) {
menuChildren.add(
BaseActionButton(
label: 'edit_album'.t(context: context),
iconData: Icons.edit,
onPressed: onEditAlbum,
menuItem: true,
),
);
}
if (onAddPhotos != null) {
menuChildren.add(
BaseActionButton(
label: 'add_photos'.t(context: context),
iconData: Icons.add_a_photo,
onPressed: onAddPhotos,
menuItem: true,
),
);
}
if (onAddUsers != null) {
menuChildren.add(
BaseActionButton(
label: 'album_viewer_page_share_add_users'.t(context: context),
iconData: Icons.group_add,
onPressed: onAddUsers,
menuItem: true,
),
);
}
if (onLeaveAlbum != null) {
menuChildren.add(
BaseActionButton(
label: 'leave_album'.t(context: context),
iconData: Icons.person_remove_rounded,
onPressed: onLeaveAlbum,
menuItem: true,
),
);
}
if (onToggleAlbumOrder != null) {
menuChildren.add(
BaseActionButton(
label: 'change_display_order'.t(context: context),
iconData: Icons.swap_vert_rounded,
onPressed: onToggleAlbumOrder,
menuItem: true,
),
);
}
if (onCreateSharedLink != null) {
menuChildren.add(
BaseActionButton(
label: 'create_shared_link'.t(context: context),
iconData: Icons.link,
onPressed: onCreateSharedLink,
menuItem: true,
),
);
}
if (onShowOptions != null) {
menuChildren.add(
BaseActionButton(
label: 'options'.t(context: context),
iconData: Icons.settings,
onPressed: onShowOptions,
menuItem: true,
),
);
}
if (onDeleteAlbum != null) {
menuChildren.add(const Divider(height: 1));
menuChildren.add(
BaseActionButton(
label: 'delete_album'.t(context: context),
iconData: Icons.delete,
iconColor: context.isDarkTheme ? Colors.red[400] : Colors.red[800],
onPressed: onDeleteAlbum,
menuItem: true,
),
);
}
return MenuAnchor(
consumeOutsideTap: true,
style: MenuStyle(
backgroundColor: WidgetStatePropertyAll(theme.scaffoldBackgroundColor),
surfaceTintColor: const WidgetStatePropertyAll(Colors.grey),
elevation: const WidgetStatePropertyAll(4),
shape: const WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
),
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
),
menuChildren: [
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 150),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: menuChildren,
),
),
],
builder: (context, controller, child) {
return IconButton(
icon: Icon(Icons.more_vert_rounded, color: iconColor ?? Colors.white, shadows: iconShadows),
onPressed: () => controller.isOpen ? controller.close() : controller.open(),
);
},
);
}
}
@@ -11,6 +11,7 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/share_intent_service.dart';
import 'package:immich_mobile/services/upload.service.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart';
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
@@ -25,6 +26,7 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
final AppRouter router;
final UploadService _uploadService;
final ShareIntentService _shareIntentService;
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
ShareIntentUploadStateNotifier(this.router, this._uploadService, this._shareIntentService) : super([]) {
_uploadService.taskStatusStream.listen(_updateUploadStatus);
@@ -86,6 +88,21 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
for (final attachment in state)
if (attachment.id == taskId.toInt()) attachment.copyWith(status: uploadStatus) else attachment,
];
if (task.status == TaskStatus.failed) {
String? error;
final exception = task.exception;
if (exception != null && exception is TaskHttpException) {
final message = tryJsonDecode(exception.description)?['message'] as String?;
if (message != null) {
final responseCode = exception.httpResponseCode;
error = "${exception.exceptionType}, response code $responseCode: $message";
}
}
error ??= task.exception?.toString();
_logger.warning("Upload failed for asset: ${task.task.filename}, error: $error");
}
}
void _taskProgressCallback(TaskProgressUpdate update) {
@@ -5,16 +5,21 @@ import 'package:immich_mobile/providers/sync_status.provider.dart';
final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
final syncStatusNotifier = ref.read(syncStatusProvider.notifier);
final backupProvider = ref.read(driftBackupProvider.notifier);
final manager = BackgroundSyncManager(
onRemoteSyncStart: () {
syncStatusNotifier.startRemoteSync();
backupProvider.updateError(BackupError.none);
final backupProvider = ref.read(driftBackupProvider.notifier);
if (backupProvider.mounted) {
backupProvider.updateError(BackupError.none);
}
},
onRemoteSyncComplete: (isSuccess) {
syncStatusNotifier.completeRemoteSync();
backupProvider.updateError(isSuccess == true ? BackupError.none : BackupError.syncFailed);
final backupProvider = ref.read(driftBackupProvider.notifier);
if (backupProvider.mounted) {
backupProvider.updateError(isSuccess == true ? BackupError.none : BackupError.syncFailed);
}
},
onRemoteSyncError: syncStatusNotifier.errorRemoteSync,
onLocalSyncStart: syncStatusNotifier.startLocalSync,
@@ -212,8 +212,8 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
),
) {
{
_uploadService.taskStatusStream.listen(_handleTaskStatusUpdate);
_uploadService.taskProgressStream.listen(_handleTaskProgressUpdate);
_statusSubscription = _uploadService.taskStatusStream.listen(_handleTaskStatusUpdate);
_progressSubscription = _uploadService.taskProgressStream.listen(_handleTaskProgressUpdate);
}
}
@@ -224,6 +224,10 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
/// Remove upload item from state
void _removeUploadItem(String taskId) {
if (!mounted) {
_logger.warning("Skip _removeUploadItem: notifier disposed");
return;
}
if (state.uploadItems.containsKey(taskId)) {
final updatedItems = Map<String, DriftUploadStatus>.from(state.uploadItems);
updatedItems.remove(taskId);
@@ -232,6 +236,10 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
void _handleTaskStatusUpdate(TaskStatusUpdate update) {
if (!mounted) {
_logger.warning("Skip _handleTaskStatusUpdate: notifier disposed");
return;
}
final taskId = update.task.taskId;
switch (update.status) {
@@ -291,6 +299,10 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
void _handleTaskProgressUpdate(TaskProgressUpdate update) {
if (!mounted) {
_logger.warning("Skip _handleTaskProgressUpdate: notifier disposed");
return;
}
final taskId = update.task.taskId;
final filename = update.task.displayName;
final progress = update.progress;
@@ -332,7 +344,15 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
Future<void> getBackupStatus(String userId) async {
if (!mounted) {
_logger.warning("Skip getBackupStatus (pre-call): notifier disposed");
return;
}
final counts = await _uploadService.getBackupCounts(userId);
if (!mounted) {
_logger.warning("Skip getBackupStatus (post-call): notifier disposed");
return;
}
state = state.copyWith(
totalCount: counts.total,
@@ -343,6 +363,10 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
void updateError(BackupError error) async {
if (!mounted) {
_logger.warning("Skip updateError: notifier disposed");
return;
}
state = state.copyWith(error: error);
}
@@ -360,10 +384,18 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
Future<void> cancel() async {
if (!mounted) {
_logger.warning("Skip cancel (pre-call): notifier disposed");
return;
}
dPrint(() => "Canceling backup tasks...");
state = state.copyWith(enqueueCount: 0, enqueueTotalCount: 0, isCanceling: true, error: BackupError.none);
final activeTaskCount = await _uploadService.cancelBackup();
if (!mounted) {
_logger.warning("Skip cancel (post-call): notifier disposed");
return;
}
if (activeTaskCount > 0) {
dPrint(() => "$activeTaskCount tasks left, continuing to cancel...");
@@ -376,9 +408,17 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
Future<void> handleBackupResume(String userId) async {
if (!mounted) {
_logger.warning("Skip handleBackupResume (pre-call): notifier disposed");
return;
}
_logger.info("Resuming backup tasks...");
state = state.copyWith(error: BackupError.none);
final tasks = await _uploadService.getActiveTasks(kBackupGroup);
if (!mounted) {
_logger.warning("Skip handleBackupResume (post-call): notifier disposed");
return;
}
_logger.info("Found ${tasks.length} tasks");
if (tasks.isEmpty) {
@@ -53,6 +53,7 @@ class BackupInfoCard extends StatelessWidget {
info,
style: context.textTheme.titleLarge?.copyWith(
color: context.colorScheme.onSurface.withAlpha(isLoading ? 50 : 255),
fontFeatures: const [FontFeature.tabularFigures()],
),
),
if (isLoading)
@@ -24,15 +24,13 @@ class RemoteAlbumSliverAppBar extends ConsumerStatefulWidget {
const RemoteAlbumSliverAppBar({
super.key,
this.icon = Icons.camera,
this.onShowOptions,
this.onToggleAlbumOrder,
required this.kebabMenu,
this.onEditTitle,
this.onActivity,
});
final IconData icon;
final void Function()? onShowOptions;
final void Function()? onToggleAlbumOrder;
final Widget kebabMenu;
final void Function()? onEditTitle;
final void Function()? onActivity;
@@ -91,21 +89,12 @@ class _MesmerizingSliverAppBarState extends ConsumerState<RemoteAlbumSliverAppBa
onPressed: () => context.maybePop(),
),
actions: [
if (widget.onToggleAlbumOrder != null)
IconButton(
icon: Icon(Icons.swap_vert_rounded, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onToggleAlbumOrder,
),
if (currentAlbum.isActivityEnabled && currentAlbum.isShared)
IconButton(
icon: Icon(Icons.chat_outlined, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onActivity,
),
if (widget.onShowOptions != null)
IconButton(
icon: Icon(Icons.more_vert, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onShowOptions,
),
widget.kebabMenu,
],
title: Builder(
builder: (context) {
+4 -2
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 2.4.0
- API version: 2.4.1
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
@@ -199,6 +199,7 @@ Class | Method | HTTP request | Description
*PeopleApi* | [**updatePeople**](doc//PeopleApi.md#updatepeople) | **PUT** /people | Update people
*PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person
*PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin
*PluginsApi* | [**getPluginTriggers**](doc//PluginsApi.md#getplugintriggers) | **GET** /plugins/triggers | List all plugin triggers
*PluginsApi* | [**getPlugins**](doc//PluginsApi.md#getplugins) | **GET** /plugins | List all plugins
*QueuesApi* | [**emptyQueue**](doc//QueuesApi.md#emptyqueue) | **DELETE** /queues/{name}/jobs | Empty a queue
*QueuesApi* | [**getQueue**](doc//QueuesApi.md#getqueue) | **GET** /queues/{name} | Retrieve a queue
@@ -465,9 +466,10 @@ Class | Method | HTTP request | Description
- [PinCodeSetupDto](doc//PinCodeSetupDto.md)
- [PlacesResponseDto](doc//PlacesResponseDto.md)
- [PluginActionResponseDto](doc//PluginActionResponseDto.md)
- [PluginContext](doc//PluginContext.md)
- [PluginContextType](doc//PluginContextType.md)
- [PluginFilterResponseDto](doc//PluginFilterResponseDto.md)
- [PluginResponseDto](doc//PluginResponseDto.md)
- [PluginTriggerResponseDto](doc//PluginTriggerResponseDto.md)
- [PluginTriggerType](doc//PluginTriggerType.md)
- [PurchaseResponse](doc//PurchaseResponse.md)
- [PurchaseUpdate](doc//PurchaseUpdate.md)
+2 -1
View File
@@ -217,9 +217,10 @@ part 'model/pin_code_reset_dto.dart';
part 'model/pin_code_setup_dto.dart';
part 'model/places_response_dto.dart';
part 'model/plugin_action_response_dto.dart';
part 'model/plugin_context.dart';
part 'model/plugin_context_type.dart';
part 'model/plugin_filter_response_dto.dart';
part 'model/plugin_response_dto.dart';
part 'model/plugin_trigger_response_dto.dart';
part 'model/plugin_trigger_type.dart';
part 'model/purchase_response.dart';
part 'model/purchase_update.dart';
+51
View File
@@ -73,6 +73,57 @@ class PluginsApi {
return null;
}
/// List all plugin triggers
///
/// Retrieve a list of all available plugin triggers.
///
/// Note: This method returns the HTTP [Response].
Future<Response> getPluginTriggersWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/plugins/triggers';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// List all plugin triggers
///
/// Retrieve a list of all available plugin triggers.
Future<List<PluginTriggerResponseDto>?> getPluginTriggers() async {
final response = await getPluginTriggersWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<PluginTriggerResponseDto>') as List)
.cast<PluginTriggerResponseDto>()
.toList(growable: false);
}
return null;
}
/// List all plugins
///
/// Retrieve a list of plugins available to the authenticated user.
+10 -3
View File
@@ -160,7 +160,9 @@ class SharedLinksApi {
/// Parameters:
///
/// * [String] albumId:
Future<Response> getAllSharedLinksWithHttpInfo({ String? albumId, }) async {
///
/// * [String] id:
Future<Response> getAllSharedLinksWithHttpInfo({ String? albumId, String? id, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/shared-links';
@@ -174,6 +176,9 @@ class SharedLinksApi {
if (albumId != null) {
queryParams.addAll(_queryParams('', 'albumId', albumId));
}
if (id != null) {
queryParams.addAll(_queryParams('', 'id', id));
}
const contentTypes = <String>[];
@@ -196,8 +201,10 @@ class SharedLinksApi {
/// Parameters:
///
/// * [String] albumId:
Future<List<SharedLinkResponseDto>?> getAllSharedLinks({ String? albumId, }) async {
final response = await getAllSharedLinksWithHttpInfo( albumId: albumId, );
///
/// * [String] id:
Future<List<SharedLinkResponseDto>?> getAllSharedLinks({ String? albumId, String? id, }) async {
final response = await getAllSharedLinksWithHttpInfo( albumId: albumId, id: id, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
+4 -2
View File
@@ -482,12 +482,14 @@ class ApiClient {
return PlacesResponseDto.fromJson(value);
case 'PluginActionResponseDto':
return PluginActionResponseDto.fromJson(value);
case 'PluginContext':
return PluginContextTypeTransformer().decode(value);
case 'PluginContextType':
return PluginContextTypeTypeTransformer().decode(value);
case 'PluginFilterResponseDto':
return PluginFilterResponseDto.fromJson(value);
case 'PluginResponseDto':
return PluginResponseDto.fromJson(value);
case 'PluginTriggerResponseDto':
return PluginTriggerResponseDto.fromJson(value);
case 'PluginTriggerType':
return PluginTriggerTypeTypeTransformer().decode(value);
case 'PurchaseResponse':
+2 -2
View File
@@ -127,8 +127,8 @@ String parameterToString(dynamic value) {
if (value is Permission) {
return PermissionTypeTransformer().encode(value).toString();
}
if (value is PluginContext) {
return PluginContextTypeTransformer().encode(value).toString();
if (value is PluginContextType) {
return PluginContextTypeTypeTransformer().encode(value).toString();
}
if (value is PluginTriggerType) {
return PluginTriggerTypeTypeTransformer().encode(value).toString();
+2 -2
View File
@@ -32,7 +32,7 @@ class PluginActionResponseDto {
Object? schema;
List<PluginContext> supportedContexts;
List<PluginContextType> supportedContexts;
String title;
@@ -90,7 +90,7 @@ class PluginActionResponseDto {
methodName: mapValueOfType<String>(json, r'methodName')!,
pluginId: mapValueOfType<String>(json, r'pluginId')!,
schema: mapValueOfType<Object>(json, r'schema'),
supportedContexts: PluginContext.listFromJson(json[r'supportedContexts']),
supportedContexts: PluginContextType.listFromJson(json[r'supportedContexts']),
title: mapValueOfType<String>(json, r'title')!,
);
}
@@ -11,9 +11,9 @@
part of openapi.api;
class PluginContext {
class PluginContextType {
/// Instantiate a new enum with the provided [value].
const PluginContext._(this.value);
const PluginContextType._(this.value);
/// The underlying value of this enum member.
final String value;
@@ -23,24 +23,24 @@ class PluginContext {
String toJson() => value;
static const asset = PluginContext._(r'asset');
static const album = PluginContext._(r'album');
static const person = PluginContext._(r'person');
static const asset = PluginContextType._(r'asset');
static const album = PluginContextType._(r'album');
static const person = PluginContextType._(r'person');
/// List of all possible values in this [enum][PluginContext].
static const values = <PluginContext>[
/// List of all possible values in this [enum][PluginContextType].
static const values = <PluginContextType>[
asset,
album,
person,
];
static PluginContext? fromJson(dynamic value) => PluginContextTypeTransformer().decode(value);
static PluginContextType? fromJson(dynamic value) => PluginContextTypeTypeTransformer().decode(value);
static List<PluginContext> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginContext>[];
static List<PluginContextType> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginContextType>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginContext.fromJson(row);
final value = PluginContextType.fromJson(row);
if (value != null) {
result.add(value);
}
@@ -50,16 +50,16 @@ class PluginContext {
}
}
/// Transformation class that can [encode] an instance of [PluginContext] to String,
/// and [decode] dynamic data back to [PluginContext].
class PluginContextTypeTransformer {
factory PluginContextTypeTransformer() => _instance ??= const PluginContextTypeTransformer._();
/// Transformation class that can [encode] an instance of [PluginContextType] to String,
/// and [decode] dynamic data back to [PluginContextType].
class PluginContextTypeTypeTransformer {
factory PluginContextTypeTypeTransformer() => _instance ??= const PluginContextTypeTypeTransformer._();
const PluginContextTypeTransformer._();
const PluginContextTypeTypeTransformer._();
String encode(PluginContext data) => data.value;
String encode(PluginContextType data) => data.value;
/// Decodes a [dynamic value][data] to a PluginContext.
/// Decodes a [dynamic value][data] to a PluginContextType.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
@@ -67,12 +67,12 @@ class PluginContextTypeTransformer {
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
PluginContext? decode(dynamic data, {bool allowNull = true}) {
PluginContextType? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'asset': return PluginContext.asset;
case r'album': return PluginContext.album;
case r'person': return PluginContext.person;
case r'asset': return PluginContextType.asset;
case r'album': return PluginContextType.album;
case r'person': return PluginContextType.person;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
@@ -82,7 +82,7 @@ class PluginContextTypeTransformer {
return null;
}
/// Singleton [PluginContextTypeTransformer] instance.
static PluginContextTypeTransformer? _instance;
/// Singleton [PluginContextTypeTypeTransformer] instance.
static PluginContextTypeTypeTransformer? _instance;
}
+2 -2
View File
@@ -32,7 +32,7 @@ class PluginFilterResponseDto {
Object? schema;
List<PluginContext> supportedContexts;
List<PluginContextType> supportedContexts;
String title;
@@ -90,7 +90,7 @@ class PluginFilterResponseDto {
methodName: mapValueOfType<String>(json, r'methodName')!,
pluginId: mapValueOfType<String>(json, r'pluginId')!,
schema: mapValueOfType<Object>(json, r'schema'),
supportedContexts: PluginContext.listFromJson(json[r'supportedContexts']),
supportedContexts: PluginContextType.listFromJson(json[r'supportedContexts']),
title: mapValueOfType<String>(json, r'title')!,
);
}
+107
View File
@@ -0,0 +1,107 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class PluginTriggerResponseDto {
/// Returns a new [PluginTriggerResponseDto] instance.
PluginTriggerResponseDto({
required this.contextType,
required this.type,
});
PluginContextType contextType;
PluginTriggerType type;
@override
bool operator ==(Object other) => identical(this, other) || other is PluginTriggerResponseDto &&
other.contextType == contextType &&
other.type == type;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(contextType.hashCode) +
(type.hashCode);
@override
String toString() => 'PluginTriggerResponseDto[contextType=$contextType, type=$type]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'contextType'] = this.contextType;
json[r'type'] = this.type;
return json;
}
/// Returns a new [PluginTriggerResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PluginTriggerResponseDto? fromJson(dynamic value) {
upgradeDto(value, "PluginTriggerResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return PluginTriggerResponseDto(
contextType: PluginContextType.fromJson(json[r'contextType'])!,
type: PluginTriggerType.fromJson(json[r'type'])!,
);
}
return null;
}
static List<PluginTriggerResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginTriggerResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginTriggerResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PluginTriggerResponseDto> mapFromJson(dynamic json) {
final map = <String, PluginTriggerResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PluginTriggerResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PluginTriggerResponseDto-objects as value to a dart map
static Map<String, List<PluginTriggerResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PluginTriggerResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PluginTriggerResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'contextType',
'type',
};
}
+2 -76
View File
@@ -40,7 +40,7 @@ class WorkflowResponseDto {
String ownerId;
WorkflowResponseDtoTriggerTypeEnum triggerType;
PluginTriggerType triggerType;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowResponseDto &&
@@ -105,7 +105,7 @@ class WorkflowResponseDto {
id: mapValueOfType<String>(json, r'id')!,
name: mapValueOfType<String>(json, r'name'),
ownerId: mapValueOfType<String>(json, r'ownerId')!,
triggerType: WorkflowResponseDtoTriggerTypeEnum.fromJson(json[r'triggerType'])!,
triggerType: PluginTriggerType.fromJson(json[r'triggerType'])!,
);
}
return null;
@@ -165,77 +165,3 @@ class WorkflowResponseDto {
};
}
class WorkflowResponseDtoTriggerTypeEnum {
/// Instantiate a new enum with the provided [value].
const WorkflowResponseDtoTriggerTypeEnum._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const assetCreate = WorkflowResponseDtoTriggerTypeEnum._(r'AssetCreate');
static const personRecognized = WorkflowResponseDtoTriggerTypeEnum._(r'PersonRecognized');
/// List of all possible values in this [enum][WorkflowResponseDtoTriggerTypeEnum].
static const values = <WorkflowResponseDtoTriggerTypeEnum>[
assetCreate,
personRecognized,
];
static WorkflowResponseDtoTriggerTypeEnum? fromJson(dynamic value) => WorkflowResponseDtoTriggerTypeEnumTypeTransformer().decode(value);
static List<WorkflowResponseDtoTriggerTypeEnum> listFromJson(dynamic json, {bool growable = false,}) {
final result = <WorkflowResponseDtoTriggerTypeEnum>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = WorkflowResponseDtoTriggerTypeEnum.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [WorkflowResponseDtoTriggerTypeEnum] to String,
/// and [decode] dynamic data back to [WorkflowResponseDtoTriggerTypeEnum].
class WorkflowResponseDtoTriggerTypeEnumTypeTransformer {
factory WorkflowResponseDtoTriggerTypeEnumTypeTransformer() => _instance ??= const WorkflowResponseDtoTriggerTypeEnumTypeTransformer._();
const WorkflowResponseDtoTriggerTypeEnumTypeTransformer._();
String encode(WorkflowResponseDtoTriggerTypeEnum data) => data.value;
/// Decodes a [dynamic value][data] to a WorkflowResponseDtoTriggerTypeEnum.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
WorkflowResponseDtoTriggerTypeEnum? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'AssetCreate': return WorkflowResponseDtoTriggerTypeEnum.assetCreate;
case r'PersonRecognized': return WorkflowResponseDtoTriggerTypeEnum.personRecognized;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [WorkflowResponseDtoTriggerTypeEnumTypeTransformer] instance.
static WorkflowResponseDtoTriggerTypeEnumTypeTransformer? _instance;
}
+20 -3
View File
@@ -18,6 +18,7 @@ class WorkflowUpdateDto {
this.enabled,
this.filters = const [],
this.name,
this.triggerType,
});
List<WorkflowActionItemDto> actions;
@@ -48,13 +49,22 @@ class WorkflowUpdateDto {
///
String? name;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
PluginTriggerType? triggerType;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowUpdateDto &&
_deepEquality.equals(other.actions, actions) &&
other.description == description &&
other.enabled == enabled &&
_deepEquality.equals(other.filters, filters) &&
other.name == name;
other.name == name &&
other.triggerType == triggerType;
@override
int get hashCode =>
@@ -63,10 +73,11 @@ class WorkflowUpdateDto {
(description == null ? 0 : description!.hashCode) +
(enabled == null ? 0 : enabled!.hashCode) +
(filters.hashCode) +
(name == null ? 0 : name!.hashCode);
(name == null ? 0 : name!.hashCode) +
(triggerType == null ? 0 : triggerType!.hashCode);
@override
String toString() => 'WorkflowUpdateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name]';
String toString() => 'WorkflowUpdateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name, triggerType=$triggerType]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -87,6 +98,11 @@ class WorkflowUpdateDto {
} else {
// json[r'name'] = null;
}
if (this.triggerType != null) {
json[r'triggerType'] = this.triggerType;
} else {
// json[r'triggerType'] = null;
}
return json;
}
@@ -104,6 +120,7 @@ class WorkflowUpdateDto {
enabled: mapValueOfType<bool>(json, r'enabled'),
filters: WorkflowFilterItemDto.listFromJson(json[r'filters']),
name: mapValueOfType<String>(json, r'name'),
triggerType: PluginTriggerType.fromJson(json[r'triggerType']),
);
}
return null;
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 2.4.0+3029
version: 2.4.1+3030
environment:
sdk: '>=3.8.0 <4.0.0'
@@ -87,6 +87,25 @@ void main() {
verify(() => mockLocalAssetRepository.get('local-1')).called(1);
});
test('uses fetched asset orientation when dimensions are missing on Android', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
// Original asset has default orientation 0, but dimensions are missing
final localAsset = TestUtils.createLocalAsset(id: 'local-1', width: null, height: null, orientation: 0);
// Fetched asset has 90° orientation and proper dimensions
final fetchedAsset = TestUtils.createLocalAsset(id: 'local-1', width: 1920, height: 1080, orientation: 90);
when(() => mockLocalAssetRepository.get('local-1')).thenAnswer((_) async => fetchedAsset);
final result = await sut.getAspectRatio(localAsset);
// Should flip dimensions since fetched asset has 90° orientation
expect(result, 1080 / 1920);
verify(() => mockLocalAssetRepository.get('local-1')).called(1);
});
test('returns 1.0 when dimensions are still unavailable after fetching', () async {
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: null, height: null);
@@ -112,7 +131,9 @@ void main() {
expect(result, 1.0);
});
test('handles local asset with remoteId and uses exif from remote', () async {
test('handles local asset with remoteId using local orientation not remote exif', () async {
// When a LocalAsset has a remoteId (merged), we should use local orientation
// because the width/height come from the local asset (pre-corrected on iOS)
final localAsset = TestUtils.createLocalAsset(
id: 'local-1',
remoteId: 'remote-1',
@@ -121,9 +142,24 @@ void main() {
orientation: 0,
);
final exif = const ExifInfo(orientation: '6');
final result = await sut.getAspectRatio(localAsset);
when(() => mockRemoteAssetRepository.getExif('remote-1')).thenAnswer((_) async => exif);
expect(result, 1920 / 1080);
// Should not call remote exif for LocalAsset
verifyNever(() => mockRemoteAssetRepository.getExif(any()));
});
test('handles local asset with remoteId and 90 degree rotation on Android', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final localAsset = TestUtils.createLocalAsset(
id: 'local-1',
remoteId: 'remote-1',
width: 1920,
height: 1080,
orientation: 90,
);
final result = await sut.getAspectRatio(localAsset);
@@ -0,0 +1,500 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/presentation/widgets/remote_album/drift_album_option.widget.dart';
import '../../../widget_tester_extensions.dart';
void main() {
group('DriftRemoteAlbumOption', () {
testWidgets('shows kebab menu icon button', (tester) async {
await tester.pumpConsumerWidget(
const DriftRemoteAlbumOption(),
);
expect(find.byIcon(Icons.more_vert_rounded), findsOneWidget);
});
testWidgets('opens menu when icon button is tapped', (tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.edit), findsOneWidget);
});
testWidgets('shows edit album option when onEditAlbum is provided',
(tester) async {
bool editCalled = false;
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () => editCalled = true,
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.edit), findsOneWidget);
await tester.tap(find.byIcon(Icons.edit));
await tester.pumpAndSettle();
expect(editCalled, isTrue);
});
testWidgets('hides edit album option when onEditAlbum is null',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onAddPhotos: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.edit), findsNothing);
});
testWidgets('shows add photos option when onAddPhotos is provided',
(tester) async {
bool addPhotosCalled = false;
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onAddPhotos: () => addPhotosCalled = true,
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.add_a_photo), findsOneWidget);
await tester.tap(find.byIcon(Icons.add_a_photo));
await tester.pumpAndSettle();
expect(addPhotosCalled, isTrue);
});
testWidgets('hides add photos option when onAddPhotos is null',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.add_a_photo), findsNothing);
});
testWidgets('shows add users option when onAddUsers is provided',
(tester) async {
bool addUsersCalled = false;
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onAddUsers: () => addUsersCalled = true,
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.group_add), findsOneWidget);
await tester.tap(find.byIcon(Icons.group_add));
await tester.pumpAndSettle();
expect(addUsersCalled, isTrue);
});
testWidgets('hides add users option when onAddUsers is null',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.group_add), findsNothing);
});
testWidgets('shows leave album option when onLeaveAlbum is provided',
(tester) async {
bool leaveAlbumCalled = false;
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onLeaveAlbum: () => leaveAlbumCalled = true,
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.person_remove_rounded), findsOneWidget);
await tester.tap(find.byIcon(Icons.person_remove_rounded));
await tester.pumpAndSettle();
expect(leaveAlbumCalled, isTrue);
});
testWidgets('hides leave album option when onLeaveAlbum is null',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.person_remove_rounded), findsNothing);
});
testWidgets(
'shows toggle album order option when onToggleAlbumOrder is provided',
(tester) async {
bool toggleOrderCalled = false;
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onToggleAlbumOrder: () => toggleOrderCalled = true,
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.swap_vert_rounded), findsOneWidget);
await tester.tap(find.byIcon(Icons.swap_vert_rounded));
await tester.pumpAndSettle();
expect(toggleOrderCalled, isTrue);
});
testWidgets('hides toggle album order option when onToggleAlbumOrder is null',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.swap_vert_rounded), findsNothing);
});
testWidgets(
'shows create shared link option when onCreateSharedLink is provided',
(tester) async {
bool createSharedLinkCalled = false;
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onCreateSharedLink: () => createSharedLinkCalled = true,
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.link), findsOneWidget);
await tester.tap(find.byIcon(Icons.link));
await tester.pumpAndSettle();
expect(createSharedLinkCalled, isTrue);
});
testWidgets('hides create shared link option when onCreateSharedLink is null',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.link), findsNothing);
});
testWidgets('shows options option when onShowOptions is provided',
(tester) async {
bool showOptionsCalled = false;
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onShowOptions: () => showOptionsCalled = true,
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.settings), findsOneWidget);
await tester.tap(find.byIcon(Icons.settings));
await tester.pumpAndSettle();
expect(showOptionsCalled, isTrue);
});
testWidgets('hides options option when onShowOptions is null',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.settings), findsNothing);
});
testWidgets('shows delete album option when onDeleteAlbum is provided',
(tester) async {
bool deleteAlbumCalled = false;
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onDeleteAlbum: () => deleteAlbumCalled = true,
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.delete), findsOneWidget);
await tester.tap(find.byIcon(Icons.delete));
await tester.pumpAndSettle();
expect(deleteAlbumCalled, isTrue);
});
testWidgets('hides delete album option when onDeleteAlbum is null',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.delete), findsNothing);
});
testWidgets('shows divider before delete album option', (tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
onDeleteAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byType(Divider), findsOneWidget);
});
testWidgets('shows all options when all callbacks are provided',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
onAddPhotos: () {},
onAddUsers: () {},
onLeaveAlbum: () {},
onToggleAlbumOrder: () {},
onCreateSharedLink: () {},
onShowOptions: () {},
onDeleteAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.edit), findsOneWidget);
expect(find.byIcon(Icons.add_a_photo), findsOneWidget);
expect(find.byIcon(Icons.group_add), findsOneWidget);
expect(find.byIcon(Icons.person_remove_rounded), findsOneWidget);
expect(find.byIcon(Icons.swap_vert_rounded), findsOneWidget);
expect(find.byIcon(Icons.link), findsOneWidget);
expect(find.byIcon(Icons.settings), findsOneWidget);
expect(find.byIcon(Icons.delete), findsOneWidget);
expect(find.byType(Divider), findsOneWidget);
});
testWidgets('shows no options when all callbacks are null', (tester) async {
await tester.pumpConsumerWidget(
const DriftRemoteAlbumOption(),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.edit), findsNothing);
expect(find.byIcon(Icons.add_a_photo), findsNothing);
expect(find.byIcon(Icons.group_add), findsNothing);
expect(find.byIcon(Icons.person_remove_rounded), findsNothing);
expect(find.byIcon(Icons.swap_vert_rounded), findsNothing);
expect(find.byIcon(Icons.link), findsNothing);
expect(find.byIcon(Icons.settings), findsNothing);
expect(find.byIcon(Icons.delete), findsNothing);
});
testWidgets('uses custom icon color when provided', (tester) async {
const customColor = Colors.red;
await tester.pumpConsumerWidget(
const DriftRemoteAlbumOption(
iconColor: customColor,
),
);
final iconButton = tester.widget<IconButton>(find.byType(IconButton));
final icon = iconButton.icon as Icon;
expect(icon.color, equals(customColor));
});
testWidgets('uses default white color when iconColor is null',
(tester) async {
await tester.pumpConsumerWidget(
const DriftRemoteAlbumOption(),
);
final iconButton = tester.widget<IconButton>(find.byType(IconButton));
final icon = iconButton.icon as Icon;
expect(icon.color, equals(Colors.white));
});
testWidgets('applies icon shadows when provided', (tester) async {
final shadows = [
const Shadow(offset: Offset(0, 2), blurRadius: 5, color: Colors.black),
];
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
iconShadows: shadows,
),
);
final iconButton = tester.widget<IconButton>(find.byType(IconButton));
final icon = iconButton.icon as Icon;
expect(icon.shadows, equals(shadows));
});
group('owner vs non-owner scenarios', () {
testWidgets('owner sees all management options', (tester) async {
// Simulating owner scenario - all callbacks provided
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
onAddPhotos: () {},
onAddUsers: () {},
onToggleAlbumOrder: () {},
onCreateSharedLink: () {},
onShowOptions: () {},
onDeleteAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
// Owner should see all management options
expect(find.byIcon(Icons.edit), findsOneWidget);
expect(find.byIcon(Icons.add_a_photo), findsOneWidget);
expect(find.byIcon(Icons.group_add), findsOneWidget);
expect(find.byIcon(Icons.swap_vert_rounded), findsOneWidget);
expect(find.byIcon(Icons.link), findsOneWidget);
expect(find.byIcon(Icons.delete), findsOneWidget);
// Owner should NOT see leave album
expect(find.byIcon(Icons.person_remove_rounded), findsNothing);
});
testWidgets('non-owner with editor role sees limited options',
(tester) async {
// Simulating non-owner with editor role - can add photos, show options, leave
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onAddPhotos: () {},
onShowOptions: () {},
onLeaveAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
// Editor can add photos
expect(find.byIcon(Icons.add_a_photo), findsOneWidget);
// Can see options
expect(find.byIcon(Icons.settings), findsOneWidget);
// Can leave album
expect(find.byIcon(Icons.person_remove_rounded), findsOneWidget);
// Cannot see owner-only options
expect(find.byIcon(Icons.edit), findsNothing);
expect(find.byIcon(Icons.group_add), findsNothing);
expect(find.byIcon(Icons.swap_vert_rounded), findsNothing);
expect(find.byIcon(Icons.link), findsNothing);
expect(find.byIcon(Icons.delete), findsNothing);
});
testWidgets('non-owner viewer sees minimal options', (tester) async {
// Simulating viewer - can only show options and leave
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onShowOptions: () {},
onLeaveAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
// Can see options
expect(find.byIcon(Icons.settings), findsOneWidget);
// Can leave album
expect(find.byIcon(Icons.person_remove_rounded), findsOneWidget);
// Cannot see any other options
expect(find.byIcon(Icons.edit), findsNothing);
expect(find.byIcon(Icons.add_a_photo), findsNothing);
expect(find.byIcon(Icons.group_add), findsNothing);
expect(find.byIcon(Icons.swap_vert_rounded), findsNothing);
expect(find.byIcon(Icons.link), findsNothing);
expect(find.byIcon(Icons.delete), findsNothing);
});
});
});
}
+103 -9
View File
@@ -8020,6 +8020,55 @@
"x-immich-state": "Alpha"
}
},
"/plugins/triggers": {
"get": {
"description": "Retrieve a list of all available plugin triggers.",
"operationId": "getPluginTriggers",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/PluginTriggerResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "List all plugin triggers",
"tags": [
"Plugins"
],
"x-immich-history": [
{
"version": "v2.3.0",
"state": "Added"
},
{
"version": "v2.3.0",
"state": "Alpha"
}
],
"x-immich-permission": "plugin.read",
"x-immich-state": "Alpha"
}
},
"/plugins/{id}": {
"get": {
"description": "Retrieve information about a specific plugin by its ID.",
@@ -10332,6 +10381,21 @@
"format": "uuid",
"type": "string"
}
},
{
"name": "id",
"required": false,
"in": "query",
"x-immich-history": [
{
"version": "v2.5.0",
"state": "Added"
}
],
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
@@ -14268,7 +14332,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "2.4.0",
"version": "2.4.1",
"contact": {}
},
"tags": [
@@ -18282,7 +18346,7 @@
},
"supportedContexts": {
"items": {
"$ref": "#/components/schemas/PluginContext"
"$ref": "#/components/schemas/PluginContextType"
},
"type": "array"
},
@@ -18301,7 +18365,7 @@
],
"type": "object"
},
"PluginContext": {
"PluginContextType": {
"enum": [
"asset",
"album",
@@ -18329,7 +18393,7 @@
},
"supportedContexts": {
"items": {
"$ref": "#/components/schemas/PluginContext"
"$ref": "#/components/schemas/PluginContextType"
},
"type": "array"
},
@@ -18401,6 +18465,29 @@
],
"type": "object"
},
"PluginTriggerResponseDto": {
"properties": {
"contextType": {
"allOf": [
{
"$ref": "#/components/schemas/PluginContextType"
}
]
},
"type": {
"allOf": [
{
"$ref": "#/components/schemas/PluginTriggerType"
}
]
}
},
"required": [
"contextType",
"type"
],
"type": "object"
},
"PluginTriggerType": {
"enum": [
"AssetCreate",
@@ -23316,11 +23403,11 @@
"type": "string"
},
"triggerType": {
"enum": [
"AssetCreate",
"PersonRecognized"
],
"type": "string"
"allOf": [
{
"$ref": "#/components/schemas/PluginTriggerType"
}
]
}
},
"required": [
@@ -23358,6 +23445,13 @@
},
"name": {
"type": "string"
},
"triggerType": {
"allOf": [
{
"$ref": "#/components/schemas/PluginTriggerType"
}
]
}
},
"type": "object"
+1 -1
View File
@@ -1 +1 @@
24.11.1
24.12.0
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "2.4.0",
"version": "2.4.1",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",
@@ -19,7 +19,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^24.10.3",
"@types/node": "^24.10.4",
"typescript": "^5.3.3"
},
"repository": {
@@ -28,6 +28,6 @@
"directory": "open-api/typescript-sdk"
},
"volta": {
"node": "24.11.1"
"node": "24.12.0"
}
}
+29 -15
View File
@@ -1,6 +1,6 @@
/**
* Immich
* 2.4.0
* 2.4.1
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@@ -942,7 +942,7 @@ export type PluginActionResponseDto = {
methodName: string;
pluginId: string;
schema: object | null;
supportedContexts: PluginContext[];
supportedContexts: PluginContextType[];
title: string;
};
export type PluginFilterResponseDto = {
@@ -951,7 +951,7 @@ export type PluginFilterResponseDto = {
methodName: string;
pluginId: string;
schema: object | null;
supportedContexts: PluginContext[];
supportedContexts: PluginContextType[];
title: string;
};
export type PluginResponseDto = {
@@ -966,6 +966,10 @@ export type PluginResponseDto = {
updatedAt: string;
version: string;
};
export type PluginTriggerResponseDto = {
contextType: PluginContextType;
"type": PluginTriggerType;
};
export type QueueResponseDto = {
isPaused: boolean;
name: QueueName;
@@ -1750,7 +1754,7 @@ export type WorkflowResponseDto = {
id: string;
name: string | null;
ownerId: string;
triggerType: TriggerType;
triggerType: PluginTriggerType;
};
export type WorkflowActionItemDto = {
actionConfig?: object;
@@ -1774,6 +1778,7 @@ export type WorkflowUpdateDto = {
enabled?: boolean;
filters?: WorkflowFilterItemDto[];
name?: string;
triggerType?: PluginTriggerType;
};
/**
* List all activities
@@ -3656,6 +3661,17 @@ export function getPlugins(opts?: Oazapfts.RequestOpts) {
...opts
}));
}
/**
* List all plugin triggers
*/
export function getPluginTriggers(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: PluginTriggerResponseDto[];
}>("/plugins/triggers", {
...opts
}));
}
/**
* Retrieve a plugin
*/
@@ -4202,14 +4218,16 @@ export function lockSession({ id }: {
/**
* Retrieve all shared links
*/
export function getAllSharedLinks({ albumId }: {
export function getAllSharedLinks({ albumId, id }: {
albumId?: string;
id?: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: SharedLinkResponseDto[];
}>(`/shared-links${QS.query(QS.explode({
albumId
albumId,
id
}))}`, {
...opts
}));
@@ -5418,11 +5436,15 @@ export enum PartnerDirection {
SharedBy = "shared-by",
SharedWith = "shared-with"
}
export enum PluginContext {
export enum PluginContextType {
Asset = "asset",
Album = "album",
Person = "person"
}
export enum PluginTriggerType {
AssetCreate = "AssetCreate",
PersonRecognized = "PersonRecognized"
}
export enum QueueJobStatus {
Active = "active",
Failed = "failed",
@@ -5639,11 +5661,3 @@ export enum OAuthTokenEndpointAuthMethod {
ClientSecretPost = "client_secret_post",
ClientSecretBasic = "client_secret_basic"
}
export enum TriggerType {
AssetCreate = "AssetCreate",
PersonRecognized = "PersonRecognized"
}
export enum PluginTriggerType {
AssetCreate = "AssetCreate",
PersonRecognized = "PersonRecognized"
}
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.0.1",
"description": "Monorepo for Immich",
"private": true,
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a",
"packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a",
"engines": {
"pnpm": ">=10.0.0"
}
+50 -18
View File
@@ -1,30 +1,36 @@
{
"name": "immich-core",
"version": "2.0.0",
"version": "2.0.1",
"title": "Immich Core",
"description": "Core workflow capabilities for Immich",
"author": "Immich Team",
"wasm": {
"path": "dist/plugin.wasm"
},
"filters": [
{
"methodName": "filterFileName",
"title": "Filter by filename",
"description": "Filter assets by filename pattern using text matching or regular expressions",
"supportedContexts": ["asset"],
"supportedContexts": [
"asset"
],
"schema": {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"title": "Filename pattern",
"description": "Text or regex pattern to match against filename"
},
"matchType": {
"type": "string",
"enum": ["contains", "regex", "exact"],
"title": "Match type",
"enum": [
"contains",
"regex",
"exact"
],
"default": "contains",
"description": "Type of pattern matching to perform"
},
@@ -34,43 +40,57 @@
"description": "Whether matching should be case-sensitive"
}
},
"required": ["pattern"]
"required": [
"pattern"
]
}
},
{
"methodName": "filterFileType",
"title": "Filter by file type",
"description": "Filter assets by file type",
"supportedContexts": ["asset"],
"supportedContexts": [
"asset"
],
"schema": {
"type": "object",
"properties": {
"fileTypes": {
"type": "array",
"title": "File types",
"items": {
"type": "string",
"enum": ["IMAGE", "VIDEO"]
"enum": [
"image",
"video"
]
},
"description": "Allowed file types"
}
},
"required": ["fileTypes"]
"required": [
"fileTypes"
]
}
},
{
"methodName": "filterPerson",
"title": "Filter by person",
"description": "Filter by detected person",
"supportedContexts": ["person"],
"supportedContexts": [
"person"
],
"schema": {
"type": "object",
"properties": {
"personIds": {
"type": "array",
"title": "Person IDs",
"items": {
"type": "string"
},
"description": "List of person to match"
"description": "List of person to match",
"subType": "people-picker"
},
"matchAny": {
"type": "boolean",
@@ -78,24 +98,29 @@
"description": "Match any name (true) or require all names (false)"
}
},
"required": ["personIds"]
"required": [
"personIds"
]
}
}
],
"actions": [
{
"methodName": "actionArchive",
"title": "Archive",
"description": "Move the asset to archive",
"supportedContexts": ["asset"],
"supportedContexts": [
"asset"
],
"schema": {}
},
{
"methodName": "actionFavorite",
"title": "Favorite",
"description": "Mark the asset as favorite or unfavorite",
"supportedContexts": ["asset"],
"supportedContexts": [
"asset"
],
"schema": {
"type": "object",
"properties": {
@@ -111,16 +136,23 @@
"methodName": "actionAddToAlbum",
"title": "Add to Album",
"description": "Add the item to a specified album",
"supportedContexts": ["asset", "person"],
"supportedContexts": [
"asset",
"person"
],
"schema": {
"type": "object",
"properties": {
"albumId": {
"type": "string",
"description": "Target album ID"
"title": "Album ID",
"description": "Target album ID",
"subType": "album-picker"
}
},
"required": ["albumId"]
"required": [
"albumId"
]
}
}
]
+107 -107
View File
@@ -15,9 +15,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz",
"integrity": "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
"integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
"cpu": [
"ppc64"
],
@@ -32,9 +32,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.1.tgz",
"integrity": "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
"integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
"cpu": [
"arm"
],
@@ -49,9 +49,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.1.tgz",
"integrity": "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
"integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
"cpu": [
"arm64"
],
@@ -66,9 +66,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.1.tgz",
"integrity": "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
"integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
"cpu": [
"x64"
],
@@ -83,9 +83,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz",
"integrity": "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
"integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
"cpu": [
"arm64"
],
@@ -100,9 +100,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.1.tgz",
"integrity": "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
"integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
"cpu": [
"x64"
],
@@ -117,9 +117,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.1.tgz",
"integrity": "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
"integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
"cpu": [
"arm64"
],
@@ -134,9 +134,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.1.tgz",
"integrity": "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
"integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
"cpu": [
"x64"
],
@@ -151,9 +151,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.1.tgz",
"integrity": "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
"integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
"cpu": [
"arm"
],
@@ -168,9 +168,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.1.tgz",
"integrity": "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
"integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
"cpu": [
"arm64"
],
@@ -185,9 +185,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.1.tgz",
"integrity": "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
"integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
"cpu": [
"ia32"
],
@@ -202,9 +202,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.1.tgz",
"integrity": "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
"integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
"cpu": [
"loong64"
],
@@ -219,9 +219,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.1.tgz",
"integrity": "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
"integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
"cpu": [
"mips64el"
],
@@ -236,9 +236,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.1.tgz",
"integrity": "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
"integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
"cpu": [
"ppc64"
],
@@ -253,9 +253,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.1.tgz",
"integrity": "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
"integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
"cpu": [
"riscv64"
],
@@ -270,9 +270,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.1.tgz",
"integrity": "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
"integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
"cpu": [
"s390x"
],
@@ -287,9 +287,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.1.tgz",
"integrity": "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
"integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
"cpu": [
"x64"
],
@@ -304,9 +304,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.1.tgz",
"integrity": "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
"integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
"cpu": [
"arm64"
],
@@ -321,9 +321,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.1.tgz",
"integrity": "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
"integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
"cpu": [
"x64"
],
@@ -338,9 +338,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.1.tgz",
"integrity": "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
"integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
"cpu": [
"arm64"
],
@@ -355,9 +355,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.1.tgz",
"integrity": "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
"integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
"cpu": [
"x64"
],
@@ -372,9 +372,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.1.tgz",
"integrity": "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
"integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
"cpu": [
"arm64"
],
@@ -389,9 +389,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.1.tgz",
"integrity": "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
"integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
"cpu": [
"x64"
],
@@ -406,9 +406,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.1.tgz",
"integrity": "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
"integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
"cpu": [
"arm64"
],
@@ -423,9 +423,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.1.tgz",
"integrity": "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
"integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
"cpu": [
"ia32"
],
@@ -440,9 +440,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.1.tgz",
"integrity": "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
"integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
"cpu": [
"x64"
],
@@ -467,9 +467,9 @@
}
},
"node_modules/esbuild": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz",
"integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==",
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -480,32 +480,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.1",
"@esbuild/android-arm": "0.27.1",
"@esbuild/android-arm64": "0.27.1",
"@esbuild/android-x64": "0.27.1",
"@esbuild/darwin-arm64": "0.27.1",
"@esbuild/darwin-x64": "0.27.1",
"@esbuild/freebsd-arm64": "0.27.1",
"@esbuild/freebsd-x64": "0.27.1",
"@esbuild/linux-arm": "0.27.1",
"@esbuild/linux-arm64": "0.27.1",
"@esbuild/linux-ia32": "0.27.1",
"@esbuild/linux-loong64": "0.27.1",
"@esbuild/linux-mips64el": "0.27.1",
"@esbuild/linux-ppc64": "0.27.1",
"@esbuild/linux-riscv64": "0.27.1",
"@esbuild/linux-s390x": "0.27.1",
"@esbuild/linux-x64": "0.27.1",
"@esbuild/netbsd-arm64": "0.27.1",
"@esbuild/netbsd-x64": "0.27.1",
"@esbuild/openbsd-arm64": "0.27.1",
"@esbuild/openbsd-x64": "0.27.1",
"@esbuild/openharmony-arm64": "0.27.1",
"@esbuild/sunos-x64": "0.27.1",
"@esbuild/win32-arm64": "0.27.1",
"@esbuild/win32-ia32": "0.27.1",
"@esbuild/win32-x64": "0.27.1"
"@esbuild/aix-ppc64": "0.27.2",
"@esbuild/android-arm": "0.27.2",
"@esbuild/android-arm64": "0.27.2",
"@esbuild/android-x64": "0.27.2",
"@esbuild/darwin-arm64": "0.27.2",
"@esbuild/darwin-x64": "0.27.2",
"@esbuild/freebsd-arm64": "0.27.2",
"@esbuild/freebsd-x64": "0.27.2",
"@esbuild/linux-arm": "0.27.2",
"@esbuild/linux-arm64": "0.27.2",
"@esbuild/linux-ia32": "0.27.2",
"@esbuild/linux-loong64": "0.27.2",
"@esbuild/linux-mips64el": "0.27.2",
"@esbuild/linux-ppc64": "0.27.2",
"@esbuild/linux-riscv64": "0.27.2",
"@esbuild/linux-s390x": "0.27.2",
"@esbuild/linux-x64": "0.27.2",
"@esbuild/netbsd-arm64": "0.27.2",
"@esbuild/netbsd-x64": "0.27.2",
"@esbuild/openbsd-arm64": "0.27.2",
"@esbuild/openbsd-x64": "0.27.2",
"@esbuild/openharmony-arm64": "0.27.2",
"@esbuild/sunos-x64": "0.27.2",
"@esbuild/win32-arm64": "0.27.2",
"@esbuild/win32-ia32": "0.27.2",
"@esbuild/win32-x64": "0.27.2"
}
},
"node_modules/typescript": {
+1471 -673
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1 +1 @@
24.11.1
24.12.0
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "2.4.0",
"version": "2.4.1",
"description": "",
"author": "",
"private": true,
@@ -47,7 +47,7 @@
"@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/exporter-prometheus": "^0.208.0",
"@opentelemetry/instrumentation-http": "^0.208.0",
"@opentelemetry/instrumentation-ioredis": "^0.56.0",
"@opentelemetry/instrumentation-ioredis": "^0.57.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.55.0",
"@opentelemetry/instrumentation-pg": "^0.61.0",
"@opentelemetry/resources": "^2.0.1",
@@ -70,7 +70,7 @@
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"cron": "4.3.5",
"exiftool-vendored": "^34.0.0",
"exiftool-vendored": "^34.3.0",
"express": "^5.1.0",
"fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2",
@@ -134,7 +134,7 @@
"@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^2.0.0",
"@types/node": "^24.10.3",
"@types/node": "^24.10.4",
"@types/nodemailer": "^7.0.0",
"@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5",
@@ -162,11 +162,11 @@
"typescript": "^5.9.2",
"typescript-eslint": "^8.28.0",
"unplugin-swc": "^1.4.5",
"vite-tsconfig-paths": "^5.0.0",
"vite-tsconfig-paths": "^6.0.0",
"vitest": "^3.0.0"
},
"volta": {
"node": "24.11.1"
"node": "24.12.0"
},
"overrides": {
"sharp": "^0.34.5"
+1 -1
View File
@@ -5,7 +5,7 @@ import { SemVer } from 'semver';
import { ApiTag, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
export const POSTGRES_VERSION_RANGE = '>=14.0.0';
export const VECTORCHORD_VERSION_RANGE = '>=0.3 <0.6';
export const VECTORCHORD_VERSION_RANGE = '>=0.3 <2';
export const VECTORS_VERSION_RANGE = '>=0.2 <0.4';
export const VECTOR_VERSION_RANGE = '>=0.5 <1';
+12 -1
View File
@@ -1,7 +1,7 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { PluginResponseDto } from 'src/dtos/plugin.dto';
import { PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto';
import { Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { PluginService } from 'src/services/plugin.service';
@@ -12,6 +12,17 @@ import { UUIDParamDto } from 'src/validation';
export class PluginController {
constructor(private service: PluginService) {}
@Get('triggers')
@Authenticated({ permission: Permission.PluginRead })
@Endpoint({
summary: 'List all plugin triggers',
description: 'Retrieve a list of all available plugin triggers.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
getPluginTriggers(): PluginTriggerResponseDto[] {
return this.service.getTriggers();
}
@Get()
@Authenticated({ permission: Permission.PluginRead })
@Endpoint({
+9 -2
View File
@@ -1,6 +1,6 @@
import { Transform, Type } from 'class-transformer';
import { IsEnum, IsInt, IsString, Matches } from 'class-validator';
import { DatabaseSslMode, ImmichEnvironment, LogLevel } from 'src/enum';
import { DatabaseSslMode, ImmichEnvironment, LogFormat, LogLevel } from 'src/enum';
import { IsIPRange, Optional, ValidateBoolean } from 'src/validation';
export class EnvDto {
@@ -48,6 +48,10 @@ export class EnvDto {
@Optional()
IMMICH_LOG_LEVEL?: LogLevel;
@IsEnum(LogFormat)
@Optional()
IMMICH_LOG_FORMAT?: LogFormat;
@Optional()
@Matches(/^\//, { message: 'IMMICH_MEDIA_LOCATION must be an absolute path' })
IMMICH_MEDIA_LOCATION?: string;
@@ -58,7 +62,7 @@ export class EnvDto {
IMMICH_MICROSERVICES_METRICS_PORT?: number;
@ValidateBoolean({ optional: true })
IMMICH_PLUGINS_ENABLED?: boolean;
IMMICH_ALLOW_EXTERNAL_PLUGINS?: boolean;
@Optional()
@Matches(/^\//, { message: 'IMMICH_PLUGINS_INSTALL_FOLDER must be an absolute path' })
@@ -113,6 +117,9 @@ export class EnvDto {
@Optional()
IMMICH_THIRD_PARTY_SUPPORT_URL?: string;
@ValidateBoolean({ optional: true })
IMMICH_ALLOW_SETUP?: boolean;
@IsIPRange({ requireCIDR: false }, { each: true })
@Transform(({ value }) =>
value && typeof value === 'string'
+12 -5
View File
@@ -1,9 +1,16 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { PluginAction, PluginFilter } from 'src/database';
import { PluginContext } from 'src/enum';
import { PluginContext as PluginContextType, PluginTriggerType } from 'src/enum';
import type { JSONSchema } from 'src/types/plugin-schema.types';
import { ValidateEnum } from 'src/validation';
export class PluginTriggerResponseDto {
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType' })
type!: PluginTriggerType;
@ValidateEnum({ enum: PluginContextType, name: 'PluginContextType' })
contextType!: PluginContextType;
}
export class PluginResponseDto {
id!: string;
name!: string;
@@ -24,8 +31,8 @@ export class PluginFilterResponseDto {
title!: string;
description!: string;
@ValidateEnum({ enum: PluginContext, name: 'PluginContext' })
supportedContexts!: PluginContext[];
@ValidateEnum({ enum: PluginContextType, name: 'PluginContextType' })
supportedContexts!: PluginContextType[];
schema!: JSONSchema | null;
}
@@ -36,8 +43,8 @@ export class PluginActionResponseDto {
title!: string;
description!: string;
@ValidateEnum({ enum: PluginContext, name: 'PluginContext' })
supportedContexts!: PluginContext[];
@ValidateEnum({ enum: PluginContextType, name: 'PluginContextType' })
supportedContexts!: PluginContextType[];
schema!: JSONSchema | null;
}
+17 -29
View File
@@ -1,7 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import _ from 'lodash';
import { SharedLink } from 'src/database';
import { HistoryBuilder, Property } from 'src/decorators';
import { AlbumResponseDto, mapAlbumWithoutAssets } from 'src/dtos/album.dto';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { SharedLinkType } from 'src/enum';
@@ -10,6 +10,10 @@ import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } f
export class SharedLinkSearchDto {
@ValidateUUID({ optional: true })
albumId?: string;
@ValidateUUID({ optional: true })
@Property({ history: new HistoryBuilder().added('v2.5.0') })
id?: string;
}
export class SharedLinkCreateDto {
@@ -113,10 +117,10 @@ export class SharedLinkResponseDto {
slug!: string | null;
}
export function mapSharedLink(sharedLink: SharedLink): SharedLinkResponseDto {
const linkAssets = sharedLink.assets || [];
export function mapSharedLink(sharedLink: SharedLink, options: { stripAssetMetadata: boolean }): SharedLinkResponseDto {
const assets = sharedLink.assets || [];
return {
const response = {
id: sharedLink.id,
description: sharedLink.description,
password: sharedLink.password,
@@ -125,35 +129,19 @@ export function mapSharedLink(sharedLink: SharedLink): SharedLinkResponseDto {
type: sharedLink.type,
createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt,
assets: linkAssets.map((asset) => mapAsset(asset)),
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
allowUpload: sharedLink.allowUpload,
allowDownload: sharedLink.allowDownload,
showMetadata: sharedLink.showExif,
slug: sharedLink.slug,
};
}
export function mapSharedLinkWithoutMetadata(sharedLink: SharedLink): SharedLinkResponseDto {
const linkAssets = sharedLink.assets || [];
const albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset);
const assets = _.uniqBy([...linkAssets, ...albumAssets], (asset) => asset.id);
return {
id: sharedLink.id,
description: sharedLink.description,
password: sharedLink.password,
userId: sharedLink.userId,
key: sharedLink.key.toString('base64url'),
type: sharedLink.type,
createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt,
assets: assets.map((asset) => mapAsset(asset, { stripMetadata: true })),
assets: assets.map((asset) => mapAsset(asset, { stripMetadata: options.stripAssetMetadata })),
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
allowUpload: sharedLink.allowUpload,
allowDownload: sharedLink.allowDownload,
showMetadata: sharedLink.showExif,
slug: sharedLink.slug,
};
// unless we select sharedLink.album.sharedLinks this will be wrong
if (response.album) {
response.album.hasSharedLink = true;
response.album.shared = true;
}
return response;
}
+4
View File
@@ -48,6 +48,9 @@ export class WorkflowCreateDto {
}
export class WorkflowUpdateDto {
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType', optional: true })
triggerType?: PluginTriggerType;
@IsString()
@IsNotEmpty()
@Optional()
@@ -74,6 +77,7 @@ export class WorkflowUpdateDto {
export class WorkflowResponseDto {
id!: string;
ownerId!: string;
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType' })
triggerType!: PluginTriggerType;
name!: string | null;
description!: string;
@@ -7,14 +7,22 @@ export const ImmichFooter = () => (
<Column align="center" className="w-6/12 sm:w-full">
<div>
<Link href="https://play.google.com/store/apps/details?id=app.alextran.immich" className="object-contain">
<Img className="max-w-full" src={`https://immich.app/img/google-play-badge.png`} />
<Img
alt="Get it on Google Play"
className="max-w-full"
src={`https://immich.app/img/google-play-badge.png`}
/>
</Link>
</div>
</Column>
<Column align="center" className="w-6/12 sm:w-full">
<div className="h-full p-6">
<Link href="https://apps.apple.com/sg/app/immich/id1613945652">
<Img src={`https://immich.app/img/ios-app-store-badge.png`} alt="Immich" className="max-w-full" />
<Img
alt="Download on the App Store"
className="max-w-full"
src={`https://immich.app/img/ios-app-store-badge.png`}
/>
</Link>
</div>
</Column>
+5
View File
@@ -454,6 +454,11 @@ export enum LogLevel {
Fatal = 'fatal',
}
export enum LogFormat {
Console = 'console',
Json = 'json',
}
export enum ApiCustomExtension {
Permission = 'x-immich-permission',
AdminOnly = 'x-immich-admin-only',
@@ -37,7 +37,13 @@ export class MaintenanceWebsocketRepository implements OnGatewayConnection, OnGa
afterInit(websocketServer: Server) {
this.logger.log('Initialized websocket server');
websocketServer.on('AppRestart', () => this.appRepository.exitApp());
websocketServer.on('AppRestart', (event: ArgsOf<'AppRestart'>, ack?: (ok: 'ok') => void) => {
this.logger.log(`Restarting due to event... ${JSON.stringify(event)}`);
ack?.('ok');
this.appRepository.exitApp();
});
}
clientBroadcast<T extends keyof ClientEventMap>(event: T, ...data: ClientEventMap[T]) {
+3 -23
View File
@@ -1,37 +1,17 @@
import { PluginContext, PluginTriggerType } from 'src/enum';
import { JSONSchema } from 'src/types/plugin-schema.types';
export type PluginTrigger = {
name: string;
type: PluginTriggerType;
description: string;
context: PluginContext;
schema: JSONSchema | null;
contextType: PluginContext;
};
export const pluginTriggers: PluginTrigger[] = [
{
name: 'Asset Uploaded',
type: PluginTriggerType.AssetCreate,
description: 'Triggered when a new asset is uploaded',
context: PluginContext.Asset,
schema: {
type: 'object',
properties: {
assetType: {
type: 'string',
description: 'Type of the asset',
default: 'ALL',
enum: ['Image', 'Video', 'All'],
},
},
},
contextType: PluginContext.Asset,
},
{
name: 'Person Recognized',
type: PluginTriggerType.PersonRecognized,
description: 'Triggered when a person is detected in an asset',
context: PluginContext.Person,
schema: null,
contextType: PluginContext.Person,
},
];
@@ -493,6 +493,9 @@ select
"asset"."fileCreatedAt",
"asset_exif"."timeZone",
"asset_exif"."fileSizeInByte",
"asset_exif"."make",
"asset_exif"."model",
"asset_exif"."lensModel",
(
select
coalesce(json_agg(agg), '[]')
@@ -529,6 +532,9 @@ select
"asset"."fileCreatedAt",
"asset_exif"."timeZone",
"asset_exif"."fileSizeInByte",
"asset_exif"."make",
"asset_exif"."model",
"asset_exif"."lensModel",
(
select
coalesce(json_agg(agg), '[]')
+3 -1
View File
@@ -7,6 +7,8 @@ from
"workflow"
where
"id" = $1
order by
"createdAt" desc
-- WorkflowRepository.getWorkflowsByOwner
select
@@ -16,7 +18,7 @@ from
where
"ownerId" = $1
order by
"name"
"createdAt" desc
-- WorkflowRepository.getWorkflowsByTrigger
select
+27
View File
@@ -1,5 +1,10 @@
import { Injectable } from '@nestjs/common';
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';
import { Server as SocketIO } from 'socket.io';
import { ExitCode } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { AppRestartEvent } from 'src/repositories/event.repository';
@Injectable()
export class AppRepository {
@@ -17,4 +22,26 @@ export class AppRepository {
setCloseFn(fn: () => Promise<void>) {
this.closeFn = fn;
}
async sendOneShotAppRestart(state: AppRestartEvent): Promise<void> {
const server = new SocketIO();
const { redis } = new ConfigRepository().getEnv();
const pubClient = new Redis({ ...redis, lazyConnect: true });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
server.adapter(createAdapter(pubClient, subClient));
// => corresponds to notification.service.ts#onAppRestart
server.emit('AppRestartV1', state, async () => {
const responses = await server.serverSideEmitWithAck('AppRestart', state);
if (responses.some((response) => response !== 'ok')) {
throw new Error("One or more node(s) returned a non-'ok' response to our restart request!");
}
pubClient.disconnect();
subClient.disconnect();
});
}
}
@@ -324,6 +324,9 @@ export class AssetJobRepository {
'asset.fileCreatedAt',
'asset_exif.timeZone',
'asset_exif.fileSizeInByte',
'asset_exif.make',
'asset_exif.model',
'asset_exif.lensModel',
])
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
.where('asset.deletedAt', 'is', null);
@@ -8,6 +8,8 @@ const getEnv = () => {
const resetEnv = () => {
for (const env of [
'IMMICH_ALLOW_EXTERNAL_PLUGINS',
'IMMICH_ALLOW_SETUP',
'IMMICH_ENV',
'IMMICH_WORKERS_INCLUDE',
'IMMICH_WORKERS_EXCLUDE',
@@ -75,6 +77,9 @@ describe('getEnv', () => {
configFile: undefined,
logLevel: undefined,
});
expect(config.plugins.external).toEqual({ allow: false });
expect(config.setup).toEqual({ allow: true });
});
describe('IMMICH_MEDIA_LOCATION', () => {
@@ -84,6 +89,32 @@ describe('getEnv', () => {
});
});
describe('IMMICH_ALLOW_EXTERNAL_PLUGINS', () => {
it('should disable plugins', () => {
process.env.IMMICH_ALLOW_EXTERNAL_PLUGINS = 'false';
const config = getEnv();
expect(config.plugins.external).toEqual({ allow: false });
});
it('should throw an error for invalid value', () => {
process.env.IMMICH_ALLOW_EXTERNAL_PLUGINS = 'invalid';
expect(() => getEnv()).toThrowError('IMMICH_ALLOW_EXTERNAL_PLUGINS must be a boolean value');
});
});
describe('IMMICH_ALLOW_SETUP', () => {
it('should disable setup', () => {
process.env.IMMICH_ALLOW_SETUP = 'false';
const { setup } = getEnv();
expect(setup).toEqual({ allow: false });
});
it('should throw an error for invalid value', () => {
process.env.IMMICH_ALLOW_SETUP = 'invalid';
expect(() => getEnv()).toThrowError('IMMICH_ALLOW_SETUP must be a boolean value');
});
});
describe('database', () => {
it('should use defaults', () => {
const { database } = getEnv();
+19 -4
View File
@@ -17,6 +17,7 @@ import {
ImmichHeader,
ImmichTelemetry,
ImmichWorker,
LogFormat,
LogLevel,
QueueName,
} from 'src/enum';
@@ -29,6 +30,7 @@ export interface EnvData {
environment: ImmichEnvironment;
configFile?: string;
logLevel?: LogLevel;
logFormat?: LogFormat;
buildMetadata: {
build?: string;
@@ -90,6 +92,10 @@ export interface EnvData {
redis: RedisOptions;
setup: {
allow: boolean;
};
telemetry: {
apiPort: number;
microservicesPort: number;
@@ -104,8 +110,10 @@ export interface EnvData {
workers: ImmichWorker[];
plugins: {
enabled: boolean;
installFolder?: string;
external: {
allow: boolean;
installFolder?: string;
};
};
noColor: boolean;
@@ -227,6 +235,7 @@ const getEnv = (): EnvData => {
environment,
configFile: dto.IMMICH_CONFIG_FILE,
logLevel: dto.IMMICH_LOG_LEVEL,
logFormat: dto.IMMICH_LOG_FORMAT || LogFormat.Console,
buildMetadata: {
build: dto.IMMICH_BUILD,
@@ -313,6 +322,10 @@ const getEnv = (): EnvData => {
corePlugin: join(buildFolder, 'corePlugin'),
},
setup: {
allow: dto.IMMICH_ALLOW_SETUP ?? true,
},
storage: {
ignoreMountCheckErrors: !!dto.IMMICH_IGNORE_MOUNT_CHECK_ERRORS,
mediaLocation: dto.IMMICH_MEDIA_LOCATION,
@@ -327,8 +340,10 @@ const getEnv = (): EnvData => {
workers,
plugins: {
enabled: !!dto.IMMICH_PLUGINS_ENABLED,
installFolder: dto.IMMICH_PLUGINS_INSTALL_FOLDER,
external: {
allow: dto.IMMICH_ALLOW_EXTERNAL_PLUGINS ?? false,
installFolder: dto.IMMICH_PLUGINS_INSTALL_FOLDER,
},
},
noColor: !!dto.NO_COLOR,
@@ -358,7 +358,7 @@ export class DatabaseRepository {
}
async runMigrations(): Promise<void> {
this.logger.debug('Running migrations');
this.logger.log('Running migrations');
const migrator = this.createMigrator();
@@ -379,7 +379,7 @@ export class DatabaseRepository {
throw error;
}
this.logger.debug('Finished running migrations');
this.logger.log('Finished running migrations');
}
async migrateFilePaths(sourceFolder: string, targetFolder: string): Promise<void> {
+15 -6
View File
@@ -2,7 +2,7 @@ import { ConsoleLogger, Inject, Injectable, Scope } from '@nestjs/common';
import { isLogLevelEnabled } from '@nestjs/common/services/utils/is-log-level-enabled.util';
import { ClsService } from 'nestjs-cls';
import { Telemetry } from 'src/decorators';
import { LogLevel } from 'src/enum';
import { LogFormat, LogLevel } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
type LogDetails = any;
@@ -27,10 +27,12 @@ export class MyConsoleLogger extends ConsoleLogger {
constructor(
private cls: ClsService | undefined,
options?: { color?: boolean; context?: string },
options?: { json?: boolean; color?: boolean; context?: string },
) {
super(options?.context || MyConsoleLogger.name);
this.isColorEnabled = options?.color || false;
super(options?.context || MyConsoleLogger.name, {
json: options?.json ?? false,
});
this.isColorEnabled = !options?.json && (options?.color || false);
}
isLevelEnabled(level: LogLevel) {
@@ -79,10 +81,17 @@ export class LoggingRepository {
@Inject(ConfigRepository) configRepository: ConfigRepository | undefined,
) {
let noColor = false;
let logFormat = LogFormat.Console;
if (configRepository) {
noColor = configRepository.getEnv().noColor;
const env = configRepository.getEnv();
noColor = env.noColor;
logFormat = env.logFormat ?? logFormat;
}
this.logger = new MyConsoleLogger(cls, { context: LoggingRepository.name, color: !noColor });
this.logger = new MyConsoleLogger(cls, {
context: LoggingRepository.name,
json: logFormat === LogFormat.Json,
color: !noColor,
});
}
static create(context?: string) {
@@ -12,6 +12,7 @@ import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
export type SharedLinkSearchOptions = {
userId: string;
id?: string;
albumId?: string;
};
@@ -118,7 +119,7 @@ export class SharedLinkRepository {
}
@GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }] })
getAll({ userId, albumId }: SharedLinkSearchOptions) {
getAll({ userId, id, albumId }: SharedLinkSearchOptions) {
return this.db
.selectFrom('shared_link')
.selectAll('shared_link')
@@ -176,6 +177,7 @@ export class SharedLinkRepository {
.select((eb) => eb.fn.toJson('album').$castTo<Album | null>().as('album'))
.where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.Individual), eb('album.id', 'is not', null)]))
.$if(!!albumId, (eb) => eb.where('shared_link.albumId', '=', albumId!))
.$if(!!id, (eb) => eb.where('shared_link.id', '=', id!))
.orderBy('shared_link.createdAt', 'desc')
.distinctOn(['shared_link.createdAt'])
.execute();
+12 -2
View File
@@ -12,12 +12,22 @@ export class WorkflowRepository {
@GenerateSql({ params: [DummyValue.UUID] })
getWorkflow(id: string) {
return this.db.selectFrom('workflow').selectAll().where('id', '=', id).executeTakeFirst();
return this.db
.selectFrom('workflow')
.selectAll()
.where('id', '=', id)
.orderBy('createdAt', 'desc')
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getWorkflowsByOwner(ownerId: string) {
return this.db.selectFrom('workflow').selectAll().where('ownerId', '=', ownerId).orderBy('name').execute();
return this.db
.selectFrom('workflow')
.selectAll()
.where('ownerId', '=', ownerId)
.orderBy('createdAt', 'desc')
.execute();
}
@GenerateSql({ params: [PluginTriggerType.AssetCreate] })
+30 -4
View File
@@ -144,14 +144,28 @@ export class AssetService extends BaseService {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids });
const assetDto = _.omitBy({ isFavorite, visibility, duplicateId }, _.isUndefined);
const exifDto = _.omitBy({ latitude, longitude, rating, description, dateTimeOriginal }, _.isUndefined);
const exifDto = _.omitBy(
{
latitude,
longitude,
rating,
description,
dateTimeOriginal,
},
_.isUndefined,
);
const extractedTimeZone = dateTimeOriginal ? DateTime.fromISO(dateTimeOriginal, { setZone: true }).zone : undefined;
if (Object.keys(exifDto).length > 0) {
await this.assetRepository.updateAllExif(ids, exifDto);
}
if ((dateTimeRelative !== undefined && dateTimeRelative !== 0) || timeZone !== undefined) {
await this.assetRepository.updateDateTimeOriginal(ids, dateTimeRelative, timeZone);
if (
(dateTimeRelative !== undefined && dateTimeRelative !== 0) ||
timeZone !== undefined ||
extractedTimeZone?.type === 'fixed'
) {
await this.assetRepository.updateDateTimeOriginal(ids, dateTimeRelative, timeZone ?? extractedTimeZone?.name);
}
if (Object.keys(assetDto).length > 0) {
@@ -436,7 +450,19 @@ export class AssetService extends BaseService {
rating?: number;
}) {
const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto;
const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined);
const extractedTimeZone = dateTimeOriginal ? DateTime.fromISO(dateTimeOriginal, { setZone: true }).zone : undefined;
const writes = _.omitBy(
{
description,
dateTimeOriginal,
timeZone: extractedTimeZone?.type === 'fixed' ? extractedTimeZone.name : undefined,
latitude,
longitude,
rating,
},
_.isUndefined,
);
if (Object.keys(writes).length > 0) {
await this.assetRepository.upsertExif(
updateLockedColumns({
+5
View File
@@ -165,6 +165,11 @@ export class AuthService extends BaseService {
}
async adminSignUp(dto: SignUpDto): Promise<UserAdminResponseDto> {
const { setup } = this.configRepository.getEnv();
if (!setup.allow) {
throw new BadRequestException('Admin setup is disabled');
}
const adminUser = await this.userRepository.getAdmin();
if (adminUser) {
throw new BadRequestException('The server already has an admin');
+4
View File
@@ -89,6 +89,7 @@ describe(CliService.name, () => {
alreadyDisabled: true,
});
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalledTimes(0);
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
expect(mocks.event.emit).toHaveBeenCalledTimes(0);
});
@@ -99,6 +100,7 @@ describe(CliService.name, () => {
alreadyDisabled: false,
});
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalled();
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: false,
});
@@ -114,6 +116,7 @@ describe(CliService.name, () => {
}),
);
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalledTimes(0);
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
expect(mocks.event.emit).toHaveBeenCalledTimes(0);
});
@@ -126,6 +129,7 @@ describe(CliService.name, () => {
}),
);
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalled();
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: true,
secret: expect.stringMatching(/^\w{128}$/),
+3 -4
View File
@@ -5,7 +5,7 @@ import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { createMaintenanceLoginUrl, generateMaintenanceSecret, sendOneShotAppRestart } from 'src/utils/maintenance';
import { createMaintenanceLoginUrl, generateMaintenanceSecret } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc';
@Injectable()
@@ -55,8 +55,7 @@ export class CliService extends BaseService {
const state = { isMaintenanceMode: false as const };
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
sendOneShotAppRestart(state);
await this.appRepository.sendOneShotAppRestart(state);
return {
alreadyDisabled: false,
@@ -89,7 +88,7 @@ export class CliService extends BaseService {
secret,
});
sendOneShotAppRestart({
await this.appRepository.sendOneShotAppRestart({
isMaintenanceMode: true,
});
+5 -1
View File
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { OnEvent } from 'src/decorators';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { SystemMetadataKey } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';
import { MaintenanceModeState } from 'src/types';
import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance';
@@ -31,7 +32,10 @@ export class MaintenanceService extends BaseService {
}
@OnEvent({ name: 'AppRestart', server: true })
onRestart(): void {
onRestart(event: ArgOf<'AppRestart'>, ack?: (ok: 'ok') => void): void {
this.logger.log(`Restarting due to event... ${JSON.stringify(event)}`);
ack?.('ok');
this.appRepository.exitApp();
}

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