Compare commits

...

59 Commits

Author SHA1 Message Date
Thomas Way 9f82037d44 fix(mobile): isolate auth to app instance
Credentials are currently shared for the whole app group, which causes
debug, test flight and release versions to conflict with each other.
This results in really weird behaviour like one instance of the app
acknowledging syncs which the other instances aren't aware of.
2026-03-28 01:12:06 +00:00
Luis Nachtigall a277c6311f fix(mobile): streamline error handling for live photo saving (#27337) 2026-03-27 19:07:38 -05:00
Jason Rasmussen 5889c42eb6 refactor: asset select manager (#27329) 2026-03-27 14:23:33 -04:00
Jason Rasmussen 14cce0cba3 refactor: asset select manager (#27327) 2026-03-27 13:48:51 -04:00
Jason Rasmussen 9b80ffd9c6 refactor: selection mananger (#27325) 2026-03-27 12:41:52 -04:00
Luis Nachtigall 306a3b8c7f fix(mobile): images loads sometimes cancel too early (#27067)
* refactor listener tracking for image stream completers and fix early cancel call

* fix: improve cache listener identification in image stream tracking

* add documentation and test cases for listener tracking in ImageStreamCompleter

* fix: remove unnecessary image provision flag from listener tracking

* fix: override setImage method in cache aware listener tracker mixin

* fix: rename test file
2026-03-27 10:35:50 -04:00
Putu Prema be0fc403d8 fix(mobile): mismatch between system and app color when using low-chroma system color scheme (#27282)
use DynamicSchemeVariant.fidelity to preserve low-chroma system color scheme as the app color
2026-03-27 09:21:43 -05:00
Yaros c13fd9e4b5 fix(mobile): video icon not showing on memories (#27311) 2026-03-27 09:11:02 -05:00
Thomas 8724848fce chore(mobile): reduce spacing on video controls (#27313)
The spacing was required for the old slider, but the new one has its own
spacing and makes it redundant. There is too much now, and we've
received feedback that it should be less sparse. The default track
height of 16px is an improvement over the old track height, but it is
very thick. A middleground of 12px might be better.
2026-03-27 09:10:19 -05:00
Min Idzelis 2d950db940 refactor(web): replace intersection booleans with enum (#27306)
Change-Id: I0c9703d5960031142ae47fef23805e0a6a6a6964
2026-03-27 08:37:12 -04:00
Min Idzelis 4b9ebc2cff refactor(web): migrate isFaceEditMode from standalone store to assetViewerManager (#27307) 2026-03-27 13:20:15 +01:00
Saurav Sharma e2d26ebdea fix(web): prevent Safari from overwriting live photo image with video (#26898)
When downloading a live photo, Safari overwrites the image file with
the motion video because both share the same base filename. Append
'-motion' suffix to the video filename to prevent collision.

For example, IMG_1234.heic and IMG_1234.mov become IMG_1234.heic
and IMG_1234-motion.mov.

Fixes #23055
2026-03-26 14:37:05 -04:00
Phlogi 8c6adf7157 feat(server): resolve duplicates (#25316)
* feat(web): Synchronize information from deduplicated images

* Added new settings menu to the the deduplication tab.
* The toggable options in the settings are synchronization of: albums, favorites, ratings, description, visibility and location.
* When synchronizing the albums, the resolved images will be added to all albums of the duplicates.
* When synchronizing the favorite status, the resolved images will be marked as favorite, if at least one selectable image is marked as favorite.
* When synchronizing the ratings, the highest rating from the selectable images will be applied to the resolved image.
* When synchronizing the description, all descriptions from the selectable images will be merged into one description for the resolved image.
* When synchronizing the visibility, the most restrictive visibility setting from the selectable images will be applied to the resolved image.
* When synchronizing the location, if exactly one unique location exists among the selectable images, this location will be applied to the resolved image.
* There is no additional UI for these settings to keep the visual clutter minimal. The settings are applied automatically based on the user's preferences.

* Replace addAssetToAlbums with copyAsset

* fix linter

* feat(web): add duplicate sync fields and fix typo

* feat(web): add tag sync and enhance duplicate resolution

This update introduces tag synchronization for duplicate resolution,
ensuring all unique tag IDs from duplicates are applied to kept assets.
The visibility sync logic is updated to use a simplified ordering, as the hidden status items will never show up in a duplicate set.
Album synchronization now merges albums directly via addAssetsToAlbums; as the approach with copyAsset API endpoint was ineffiecient.
Description, rating, and location sync logic is improved for correctness.
and deduplication. i18n strings were added / updated.

* feat(server): move duplicate resolution to backend with sync and stacking

Moves duplicate metadata synchronization from frontend to backend, enabling robust
batch operations and proper validation. This is an improved refactor of PR #13851.

New endpoints:
- POST /duplicates/resolve - batch resolve with configurable metadata sync
- POST /duplicates/stack - create stacks from duplicate groups
- GET /duplicates - now includes suggestedKeepAssetIds based on file size and EXIF

Key changes:
- Move sync logic (albums, tags, favorites, ratings, descriptions, location, visibility) to server
- Add server-side metadata merge policies with proper conflict resolution
- Replace client-side resolution logic with new backend endpoints
- Add comprehensive E2E tests (70+ test cases) and unit tests
- Update OpenAPI specs and TypeScript SDK

No breaking changes - only additions to existing API.

* feat(preferences): enable all duplicate sync settings by default

* chore: clean up

* chore: clean up

* refactor: rename & clean up

* fix: preference upgrade

* chore: linting

* refactor(e2e): use updateAssets API for setAssetDuplicateId

* fix: visibility sync logic in duplicate resolution

* fix(duplicate): write description to exifUpdate

Previously the duplicate resolution populated assetUpdate.description even
though description belongs to exif info.

* fix(duplicate): remove redundant updateLockedColumns wrapper

updateAllExif already computes lockedProperties via distinctLocked
using Object.keys(options). The wrapper added a lockedProperties key
to the options object, causing the spurious string 'lockedProperties'
to be stored in the lockedProperties array.

* fix(duplicate): write merged tags to asset_exif to survive metadata re-extraction

During duplicate resolution, replaceAssetTags correctly wrote merged tag
IDs to the tag_asset table, but never updated asset_exif.tags or locked
the tags property. The subsequent SidecarWrite → AssetExtractMetadata
chain calls applyTagList, which destructively replaces tag_asset rows
with whatever is in asset_exif.tags — still the original per-asset tags,
not the merged set.

Write merged tag values to asset_exif.tags via updateAllExif (which also
locks the property via distinctLocked), and queue SidecarWrite when tags
change so they persist to the sidecar file.

* docs(duplicates): clarify location and tag sync behavior

* refactor(duplicate): remove sync settings, always sync all metadata on resolve

Remove DuplicateSyncSettingsDto and the per-field sync toggles
(albums, favorites, rating, description, visibility, location, tags).
Duplicate resolution now unconditionally syncs all metadata from
trashed assets to kept assets.

- Remove DuplicateSyncSettingsDto and settings field from DuplicateResolveDto
- Update DuplicateService to always run all sync logic without conditionals
- Delete DuplicateSettingsModal.svelte and settings gear button from UI
- Remove DuplicateSettings type and duplicateSettings persisted store
- Update unit and e2e tests to remove settings from resolve requests

* docs: update duplicates utility to reflect automatic metadata sync

* docs(web): replace duplicates info modal with link to documentation

* chore: clean up

* fix: add missing type cast to jsonAgg in duplicate repository getAll

* fix: skip persisting rating=0 in duplicate merge to avoid unnecessary sidecar write

---------

Co-authored-by: Toni <51962051+EinToni@users.noreply.github.com>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2026-03-26 18:33:55 +00:00
Mees Frensel 48fdd39d30 feat(web): use ui pin input element (#27200) 2026-03-26 18:24:46 +00:00
Jonathan Jogenfors 22bf7c2005 feat(server): add checksum algorithm field (#26573)
* feat: add checksum algorithm field

* fix comments

* chore: rename migration

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-03-26 18:20:25 +00:00
Mees Frensel 47b45453c8 chore(web): refactor activity status (#26956)
* chore(web): refactor activity status

* fix: size change

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-03-26 18:15:42 +00:00
Robin Wohlers-Reichel 448c069fb6 feat(web): add shortcuts to rotate images (#26927) 2026-03-26 19:13:01 +01:00
Diogo Tavares Sendim Fernandes 958f270f0d fix(web): keep map view open after closing asset viewer (#26980) 2026-03-26 18:11:05 +00:00
renovate[bot] 9f699fdfc3 chore(deps): update typescript-projects (#26973)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-03-26 19:02:27 +01:00
renovate[bot] 00da7b88a1 chore(deps): update dependency @types/node to ^24.12.0 (#26966)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 13:52:45 -04:00
Michel Heusschen 144a57ddff refactor(server): use helpers for shared link queries (#27088)
* fix(server): prevent album shared link from breaking after uploads

* update test

* add withSharedAssets helper

* remove options

* add more helpers

* update selects
2026-03-26 13:51:00 -04:00
Daniel Dietzler 1bd2d474d7 fix: various comamnd palette usages (#27304) 2026-03-26 17:45:14 +00:00
Jason Rasmussen b33874ef12 feat: add support for helmet configuration (#27058) 2026-03-26 17:41:23 +00:00
bo0tzz dbaf4b548b fix: pin success-check-action to correct tag (#27230) 2026-03-26 17:37:23 +00:00
Jason Rasmussen 7d58d5be12 refactor: memory manager (#27206) 2026-03-26 17:36:25 +00:00
renovate[bot] 42fe86d24c chore(deps): update grafana/grafana docker tag to v12.4.1 (#26969)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 18:31:08 +01:00
Michael Maycock eeb55c279b fix(web): preserve timezone when changing timestamp (Closes #25354) (#27095) 2026-03-26 17:30:47 +00:00
renovate[bot] 5c159d70a7 chore(deps): update node.js to v24.14.0 (#26972)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 18:30:38 +01:00
Paul Makles 44ae0fa7ed fix(database restores): don't assume onboarding has completed (#27052) 2026-03-26 18:30:14 +01:00
renovate[bot] f782782662 fix(deps): update dependency kysely to v0.28.14 [security] (#27068)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 18:29:53 +01:00
renovate[bot] 4436cab827 chore(deps): update dependency yaml to v2.8.3 [security] (#27293)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 18:28:46 +01:00
renovate[bot] 74789ad1c4 chore(deps): update dependency picomatch to v4.0.4 [security] (#27281)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 18:28:05 +01:00
Jason Rasmussen 7877097b3f refactor: asset viewing store (#27204) 2026-03-26 13:22:40 -04:00
Jason Rasmussen fb84c1cf61 chore: remove unused file (#27202) 2026-03-26 13:22:31 -04:00
Jason Rasmussen 940a1d4ab8 refactor: change location (#27201) 2026-03-26 13:22:14 -04:00
Jason Rasmussen fae25dbe65 chore: whitelist server deploy files (#27056) 2026-03-26 13:22:03 -04:00
Mert 8dd0d7f34c fix(server): memory fragmentation (#27027) 2026-03-26 18:21:52 +01:00
Jason Rasmussen 9b78f2c0ba chore: remove unused resources (#27055) 2026-03-26 13:21:36 -04:00
Timon 67cedfef17 feat(web): add RemoveFromAlbumAction to asset viewer nav bar (#27000) 2026-03-26 18:20:28 +01:00
Andreas Heinz c9c2322b9d feat(web): focus on face-editor search input (#27136) 2026-03-26 18:18:23 +01:00
Daniel Dietzler 389356149a refactor: actionable toasts (#27203) 2026-03-26 18:18:06 +01:00
Michel Heusschen 4812a2e2d8 fix(server): refresh unedited asset dimensions on metadata extraction (#27220) 2026-03-26 18:17:32 +01:00
Vogeluff 8f01d06927 feat(web): add a seperate tooltip for switching from dark to light mode (#27297) 2026-03-26 18:15:16 +01:00
github-actions a2ff075e9a chore: version v2.6.3 2026-03-26 16:23:35 +00:00
Brandon Wees d8b39906f9 fix: incorrect asset face sync (#27243)
* fix: incorrect asset face sync

* chore: sync sql
2026-03-26 09:39:02 -05:00
Michel Heusschen b36911a16b fix(server): filter out empty search suggestions (#27292)
* fix(server): filter out empty search suggestions

* make sql
2026-03-26 09:36:04 -05:00
Alex b074ee202e chore: move slideshow control button group to the left (#27287) 2026-03-26 14:31:11 +00:00
bo0tzz 78bb6cf926 chore: log id of existing asset on duplicate upload (#27266) 2026-03-26 11:50:53 +01:00
Yaros c980f5fc19 chore(docs): withPeople parameter description (#27262)
* fix(server): withPeople inconsistent

* fix: query failing in some occasions

* test: add medium tests for withPeople option

* Revert "test: add medium tests for withPeople option"

This reverts commit 6c1505ba6b.

* Revert "fix: query failing in some occasions"

This reverts commit 221feeca45.

* Revert "fix(server): withPeople inconsistent"

This reverts commit 4289a9f23d.

* chore: change endpoint description

* chore: generate open-api
2026-03-26 11:50:29 +01:00
Yaros a26d9e05ba fix(web): shifting motion image button (#27275) 2026-03-26 11:49:21 +01:00
bo0tzz c862163204 fix: explicitly specify repo in auto-close job (#27291) 2026-03-26 10:43:51 +00:00
Michel Heusschen 5fb8f9bf1a fix(web): prevent horizontal scroll bar in asset viewer side panel (#27270)
* fix(web): prevent horizontal scroll bar in asset viewer side panel

* simplify
2026-03-25 21:02:31 -05:00
Mees Frensel b9b5dba037 fix(web): crop square ratio i18n (#27257) 2026-03-25 14:05:43 -05:00
renovate[bot] 8bfa75087c chore(deps): update base-image to v202603251709 (major) (#27273)
chore(deps): update base-image to v202603251709

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-25 14:04:26 -05:00
bo0tzz 95280edd6c fix: let renovate update base images (#27272) 2026-03-25 18:00:40 +00:00
Mert a9666d2cef fix(mobile): remove upload timeout (#27237)
remove timeout
2026-03-24 14:40:48 -04:00
renovate[bot] 4af9edc20b chore(deps): update github-actions (#27215)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-24 14:31:00 +01:00
renovate[bot] c975fe5bc7 chore(deps): update github-actions (major) (#27225)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-24 12:40:10 +00:00
renovate[bot] 12a4d8e2ee chore(deps): update ghcr.io/jdx/mise docker tag to v2026.3.12 (#27224)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-24 12:06:19 +00:00
225 changed files with 5660 additions and 9460 deletions
+1 -1
View File
@@ -1 +1 @@
24.13.1
24.14.0
+3 -3
View File
@@ -66,7 +66,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: gh pr edit "$PR_NUMBER" --add-label "auto-closed:template"
run: gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --add-label "auto-closed:template"
close_llm:
runs-on: ubuntu-latest
@@ -113,7 +113,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: gh pr edit "$PR_NUMBER" --remove-label "auto-closed:template" || true
run: gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --remove-label "auto-closed:template" || true
- name: Check for remaining auto-closed labels
id: check_labels
@@ -121,7 +121,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
REMAINING=$(gh pr view "$PR_NUMBER" --json labels \
REMAINING=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json labels \
--jq '[.labels[].name | select(startswith("auto-closed:"))] | length')
echo "remaining=$REMAINING" >> "$GITHUB_OUTPUT"
+11 -11
View File
@@ -51,14 +51,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -79,7 +79,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -103,7 +103,7 @@ jobs:
- name: Restore Gradle Cache
id: cache-gradle-restore
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
~/.gradle/caches
@@ -114,7 +114,7 @@ jobs:
key: build-mobile-gradle-${{ runner.os }}-main
- name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
@@ -153,14 +153,14 @@ jobs:
fi
- name: Publish Android Artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: release-apk-signed
path: mobile/build/app/outputs/flutter-apk/*.apk
- name: Save Gradle Cache
id: cache-gradle-save
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
if: github.ref == 'refs/heads/main'
with:
path: |
@@ -185,13 +185,13 @@ jobs:
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
- name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2
uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
@@ -210,7 +210,7 @@ jobs:
working-directory: ./mobile
- name: Setup Ruby
uses: ruby/setup-ruby@v1
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
with:
ruby-version: '3.3'
bundler-cache: true
@@ -291,7 +291,7 @@ jobs:
security delete-keychain build.keychain || true
- name: Upload IPA artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: ios-release-ipa
path: mobile/ios/Runner.ipa
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
actions: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@748daafaf3aac877a36307f842a48d55db938ac8 # v0.0.31
uses: oasdiff/oasdiff-action/breaking@2a37bc82462349c03a533b8b608bebbaf57b3e60 # v0.0.33
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
+8 -8
View File
@@ -31,7 +31,7 @@ jobs:
working-directory: ./cli
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -42,7 +42,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -71,7 +71,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -83,13 +83,13 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io
@@ -104,7 +104,7 @@ jobs:
- name: Generate docker image tags
id: metadata
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
flavor: |
latest=false
@@ -115,7 +115,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
- name: Build and push image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
file: cli/Dockerfile
platforms: linux/amd64,linux/arm64
+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:4f9860d04c88f7f87861f8ee84bfeedaec15ed7ca5ca87bc7db44b036f81645f
image: ghcr.io/immich-app/mdq:main@sha256:df7188ba88abb0800d73cc97d3633280f0c0c3d4c441d678225067bf154150fb
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
steps:
+4 -4
View File
@@ -44,7 +44,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
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@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
uses: github/codeql-action/autobuild@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
# ️ 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@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
with:
category: '/language:${{matrix.language}}'
+8 -8
View File
@@ -23,14 +23,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -60,7 +60,7 @@ jobs:
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -90,7 +90,7 @@ jobs:
suffix: ['']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -132,7 +132,7 @@ jobs:
suffixes: '-rocm'
platforms: linux/amd64
runner-mapping: '{"linux/amd64": "pokedex-large"}'
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@61a0fc2b41524edcc7c9fffb8bb178e6b0ccf21d # multi-runner-build-workflow-v2.3.0
permissions:
contents: read
actions: read
@@ -155,7 +155,7 @@ jobs:
name: Build and Push Server
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@61a0fc2b41524edcc7c9fffb8bb178e6b0ccf21d # multi-runner-build-workflow-v2.3.0
permissions:
contents: read
actions: read
@@ -178,7 +178,7 @@ jobs:
runs-on: ubuntu-latest
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
with:
needs: ${{ toJSON(needs) }}
@@ -189,6 +189,6 @@ jobs:
runs-on: ubuntu-latest
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
with:
needs: ${{ toJSON(needs) }}
+5 -5
View File
@@ -21,14 +21,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -54,7 +54,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -67,7 +67,7 @@ jobs:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -86,7 +86,7 @@ jobs:
run: pnpm build
- name: Upload build output
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: docs-build-output
path: docs/build/
+3 -3
View File
@@ -20,7 +20,7 @@ jobs:
artifact: ${{ steps.get-artifact.outputs.result }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -119,7 +119,7 @@ jobs:
if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -131,7 +131,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2
uses: immich-app/devtools/actions/use-mise@035e80a7d4355d5f087ffb95db9e4a0944c04e56 # use-mise-action-v1.1.3
- name: Load parameters
id: parameters
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -29,7 +29,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2
uses: immich-app/devtools/actions/use-mise@035e80a7d4355d5f087ffb95db9e4a0944c04e56 # use-mise-action-v1.1.3
- name: Destroy Docs Subdomain
env:
+2 -2
View File
@@ -16,7 +16,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -29,7 +29,7 @@ jobs:
persist-credentials: true
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: Generate a token
id: generate_token
if: ${{ inputs.skip != true }}
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+2 -2
View File
@@ -14,13 +14,13 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Require PR to have a changelog label
uses: mheap/github-action-required-labels@8afbe8ae6ab7647d0c9f0cfa7c2f939650d22509 # v5.5.1
uses: mheap/github-action-required-labels@0ac283b4e65c1fb28ce6079dea5546ceca98ccbe # v5.5.2
with:
token: ${{ steps.token.outputs.token }}
mode: exactly
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+6 -6
View File
@@ -50,7 +50,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -63,10 +63,10 @@ jobs:
ref: main
- name: Install uv
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -124,7 +124,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -136,13 +136,13 @@ jobs:
persist-credentials: false
- name: Download APK
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
with:
draft: true
tag_name: ${{ needs.bump_version.outputs.version }}
+5 -5
View File
@@ -14,12 +14,12 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
- uses: mshick/add-pr-comment@ffd016c7e151d97d69d21a843022fd4cd5b96fe5 # v3.9.0
with:
github-token: ${{ steps.token.outputs.token }}
message-id: 'preview-status'
@@ -32,7 +32,7 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -48,14 +48,14 @@ jobs:
name: 'preview'
})
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
- uses: mshick/add-pr-comment@ffd016c7e151d97d69d21a843022fd4cd5b96fe5 # v3.9.0
if: ${{ github.event.pull_request.head.repo.fork }}
with:
github-token: ${{ steps.token.outputs.token }}
message-id: 'preview-status'
message: 'PRs from forks cannot have preview environments.'
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
- uses: mshick/add-pr-comment@ffd016c7e151d97d69d21a843022fd4cd5b96fe5 # v3.9.0
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
github-token: ${{ steps.token.outputs.token }}
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
working-directory: ./open-api/typescript-sdk
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -30,7 +30,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+4 -4
View File
@@ -20,14 +20,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -49,7 +49,7 @@ jobs:
working-directory: ./mobile
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -61,7 +61,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
+39 -39
View File
@@ -17,14 +17,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -63,7 +63,7 @@ jobs:
working-directory: ./server
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -75,7 +75,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -108,7 +108,7 @@ jobs:
working-directory: ./cli
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -119,7 +119,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -155,7 +155,7 @@ jobs:
working-directory: ./cli
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -166,7 +166,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -197,7 +197,7 @@ jobs:
working-directory: ./web
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -208,7 +208,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -241,7 +241,7 @@ jobs:
working-directory: ./web
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -252,7 +252,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -279,7 +279,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -290,7 +290,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -327,7 +327,7 @@ jobs:
working-directory: ./e2e
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -338,7 +338,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -373,7 +373,7 @@ jobs:
working-directory: ./server
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -385,7 +385,7 @@ jobs:
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -412,7 +412,7 @@ jobs:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -424,7 +424,7 @@ jobs:
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -464,7 +464,7 @@ jobs:
run: docker compose logs --no-color > docker-compose-logs.txt
working-directory: ./e2e
- name: Archive Docker logs
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: always()
with:
name: e2e-server-docker-logs-${{ matrix.runner }}
@@ -484,7 +484,7 @@ jobs:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -496,7 +496,7 @@ jobs:
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -522,7 +522,7 @@ jobs:
run: pnpm test:web
if: ${{ !cancelled() }}
- name: Archive e2e test (web) results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: success() || failure()
with:
name: e2e-web-test-results-${{ matrix.runner }}
@@ -533,7 +533,7 @@ jobs:
run: pnpm test:web:ui
if: ${{ !cancelled() }}
- name: Archive ui test (web) results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: success() || failure()
with:
name: e2e-ui-test-results-${{ matrix.runner }}
@@ -544,7 +544,7 @@ jobs:
run: pnpm test:web:maintenance
if: ${{ !cancelled() }}
- name: Archive maintenance tests (web) results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: success() || failure()
with:
name: e2e-maintenance-isolated-test-results-${{ matrix.runner }}
@@ -554,7 +554,7 @@ jobs:
run: docker compose logs --no-color > docker-compose-logs.txt
working-directory: ./e2e
- name: Archive Docker logs
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: always()
with:
name: e2e-web-docker-logs-${{ matrix.runner }}
@@ -566,7 +566,7 @@ jobs:
runs-on: ubuntu-latest
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
with:
needs: ${{ toJSON(needs) }}
mobile-unit-tests:
@@ -578,7 +578,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -588,7 +588,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
@@ -610,7 +610,7 @@ jobs:
working-directory: ./machine-learning
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -620,7 +620,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
python-version: 3.11
- name: Install dependencies
@@ -650,7 +650,7 @@ jobs:
working-directory: ./.github
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -661,7 +661,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -680,7 +680,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -701,7 +701,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -712,7 +712,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -763,7 +763,7 @@ jobs:
working-directory: ./server
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -774,7 +774,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
+4 -4
View File
@@ -24,14 +24,14 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2
uses: immich-app/devtools/actions/pre-job@f50e3b600b6ac1763ddb8f3dfc69093512b967a1 # pre-job-action-v2.0.3
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
@@ -47,7 +47,7 @@ jobs:
if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
uses: immich-app/devtools/actions/create-workflow-token@57ff6ebfd507b045514442683ff06ff1b2f6efbd # create-workflow-token-action-v1.0.2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -68,6 +68,6 @@ jobs:
permissions: {}
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
with:
needs: ${{ toJSON(needs) }}
+1 -1
View File
@@ -1 +1 @@
24.13.1
24.14.0
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.6.2",
"version": "2.6.3",
"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.11.0",
"@types/node": "^24.12.0",
"@vitest/coverage-v8": "^4.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
@@ -68,6 +68,6 @@
"micromatch": "^4.0.8"
},
"volta": {
"node": "24.13.1"
"node": "24.14.0"
}
}
+1
View File
@@ -90,6 +90,7 @@ services:
IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues
IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://docs.immich.app
IMMICH_THIRD_PARTY_SUPPORT_URL: https://docs.immich.app/community-guides
IMMICH_HELMET_FILE: 'true'
ports:
- 9230:9230
- 9231:9231
+1 -1
View File
@@ -97,7 +97,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:12.3.2-ubuntu@sha256:6cca4b429a1dc0d37d401dee54825c12d40056c3c6f3f56e3f0d6318ce77749b
image: grafana/grafana:12.4.1-ubuntu@sha256:1a20dea76a2778773df17dbc365db86b1a4f2d57772b8590b6311038a3acb1db
volumes:
- grafana-data:/var/lib/grafana
+1 -1
View File
@@ -1 +1 @@
24.13.1
24.14.0
+28
View File
@@ -0,0 +1,28 @@
# Duplicates Utility
Immich comes with a duplicates utility to help you detect assets that look visually similar. The duplicate detection feature relies on machine learning and is enabled by default. For more information about when the duplicate detection job runs, see [Jobs and Workers](/administration/jobs-workers). Once an asset has been processed and added to a duplicate group, it becomes available to review in the "Review duplicates" utility, which can be found [here](https://my.immich.app/utilities/duplicates).
## Reviewing duplicates
The review duplicates page allows the user to individually select which assets should be kept and which ones should be trashed. When more than one asset is kept, there is an option to automatically put the kept assets into a stack.
### Automatic preselection
When using "Deduplicate All" or viewing suggestions, Immich automatically preselects which assets to keep based on:
1. **Image size in bytes** — larger files are preferred as they typically have higher quality.
2. **Count of EXIF data** — assets with more metadata are preferred.
### Synchronizing metadata
When resolving duplicates, metadata from trashed assets is automatically synchronized to the kept assets. The following metadata is synchronized:
| Name | Description |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------- |
| Album | The kept assets will be added to _every_ album that the other assets in the group belong to. |
| Favorite | If any of the assets in the group have been added to favorites, every kept asset will also be added to favorites. |
| Rating | If one or more assets in the duplicate group have a rating, the highest rating is selected and synchronized to the kept assets. |
| Description | Descriptions from each asset are combined together and synchronized to all the kept assets. |
| Visibility | The most restrictive visibility is applied to the kept assets. |
| Location | Latitude and longitude are copied if all assets with geolocation data in the group share the same coordinates. |
| Tag | Tags from all assets in the group are merged and applied to every kept asset. |
+2 -2
View File
@@ -3,8 +3,8 @@
You may decide that you'd like to modify the style document which is used to
draw the maps in Immich. In addition to visual customization, this also allows
you to pick your own map tile provider instead of the default one. The default
`style.json` for [light theme](https://github.com/immich-app/immich/tree/main/server/resources/style-light.json)
and [dark theme](https://github.com/immich-app/immich/blob/main/server/resources/style-dark.json)
`style.json` for [light theme](https://tiles.immich.cloud/v1/style/light.json)
and [dark theme](https://tiles.immich.cloud/v1/style/dark.json)
can be used as a basis for creating your own style.
There are several sources for already-made `style.json` map themes, as well as
+17 -16
View File
@@ -29,22 +29,23 @@ These environment variables are used by the `docker-compose.yml` file and do **N
## General
| Variable | Description | Default | Containers | Workers |
| :---------------------------------- | :---------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- |
| `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 | |
| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
| `IMMICH_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 |
| Variable | Description | Default | Containers | Workers |
| :---------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- |
| `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 |
| `IMMICH_HELMET_FILE` | Path to a json file with [helmet](https://www.npmjs.com/package/helmet) options. Set to `false` to disable. Set to `true` to use `server/helmet.json`. | `false` | server | api, microservices |
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
| `IMMICH_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
@@ -58,6 +58,6 @@
"node": ">=20"
},
"volta": {
"node": "24.13.1"
"node": "24.14.0"
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
[
{
"label": "v2.6.2",
"url": "https://docs.v2.6.2.archive.immich.app"
"label": "v2.6.3",
"url": "https://docs.v2.6.3.archive.immich.app"
},
{
"label": "v2.5.6",
+1 -1
View File
@@ -1 +1 @@
24.13.1
24.14.0
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "2.6.2",
"version": "2.6.3",
"description": "",
"main": "index.js",
"type": "module",
@@ -32,7 +32,7 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^24.11.0",
"@types/node": "^24.12.0",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2",
@@ -58,6 +58,6 @@
"vitest": "^4.0.0"
},
"volta": {
"node": "24.13.1"
"node": "24.14.0"
}
}
+651
View File
@@ -0,0 +1,651 @@
import { LoginResponseDto } from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
describe('/duplicates', () => {
let admin: LoginResponseDto;
let user1: LoginResponseDto;
let user2: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
[user1, user2] = await Promise.all([
utils.userSetup(admin.accessToken, createUserDto.user1),
utils.userSetup(admin.accessToken, createUserDto.user2),
]);
});
beforeEach(async () => {
// Reset assets, albums, tags, and stacks between tests to ensure clean state for repeated test runs
// Note: We don't reset users since they're set up once in beforeAll
// Stack must be reset before asset due to foreign key constraint
await utils.resetDatabase(['stack', 'asset', 'album', 'tag']);
});
describe('GET /duplicates', () => {
it('should return empty array when no duplicates', async () => {
const { status, body } = await request(app)
.get('/duplicates')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([]);
});
it('should return duplicate groups with suggestedKeepAssetIds', async () => {
// Create assets with different file sizes for duplicate detection
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Manually set duplicateId on both assets to create a duplicate group
const duplicateId = '00000000-0000-4000-8000-000000000001';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.get('/duplicates')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([
{
duplicateId,
assets: expect.arrayContaining([
expect.objectContaining({ id: asset1.id }),
expect.objectContaining({ id: asset2.id }),
]),
suggestedKeepAssetIds: expect.any(Array),
},
]);
expect(body[0].suggestedKeepAssetIds.length).toBe(1);
});
});
describe('POST /duplicates/resolve', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.post('/duplicates/resolve')
.send({
groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }],
});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should return failure for non-existent duplicate group', async () => {
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }],
});
expect(status).toBe(200);
expect(body).toEqual({
status: 'COMPLETED',
results: [
{
duplicateId: uuidDto.dummy,
status: 'FAILED',
reason: expect.stringContaining('not found or access denied'),
},
],
});
});
it('should resolve duplicate group with keepers', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000002';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body).toEqual({
status: 'COMPLETED',
results: [
{
duplicateId,
status: 'SUCCESS',
},
],
});
// Verify side effects: duplicateId cleared on kept asset
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.duplicateId).toBeNull();
// Verify side effects: trashed asset is trashed and duplicateId cleared
const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id);
expect(trashedAsset.isTrashed).toBe(true);
expect(trashedAsset.duplicateId).toBeNull();
});
it('should reject when keepAssetIds and trashAssetIds overlap', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000003';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset1.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('FAILED');
expect(body.results[0].reason).toContain('disjoint');
});
it('should require keepAssetIds when partially trashing', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000004';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('FAILED');
expect(body.results[0].reason).toContain('must cover all assets');
});
it('should reject partial resolution (not all assets covered)', async () => {
const [asset1, asset2, asset3] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000010';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset3.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('FAILED');
expect(body.results[0].reason).toContain('must cover all assets');
});
it('should reject asset not in duplicate group', async () => {
const [asset1, asset2, outsideAsset] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000011';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [outsideAsset.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('FAILED');
expect(body.results[0].reason).toContain('not a member of duplicate group');
});
it('should allow trash-all without keepers', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000012';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id, asset2.id] }],
});
expect(status).toBe(200);
expect(body).toEqual({
status: 'COMPLETED',
results: [
{
duplicateId,
status: 'SUCCESS',
},
],
});
// Verify both assets are trashed
const [asset1Info, asset2Info] = await Promise.all([
utils.getAssetInfo(user1.accessToken, asset1.id),
utils.getAssetInfo(user1.accessToken, asset2.id),
]);
expect(asset1Info.isTrashed).toBe(true);
expect(asset1Info.duplicateId).toBeNull();
expect(asset2Info.isTrashed).toBe(true);
expect(asset2Info.duplicateId).toBeNull();
});
it('should reject cross-user duplicate group access', async () => {
const asset1 = await utils.createAsset(user1.accessToken);
const asset2 = await utils.createAsset(user2.accessToken);
const duplicateId = '00000000-0000-4000-8000-000000000013';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user2.accessToken, asset2.id, duplicateId);
// User1 tries to resolve a group containing user2's asset
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('FAILED');
expect(body.results[0].reason).toContain('not a member of duplicate group');
});
it('should synchronize favorites when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Mark one asset as favorite
await request(app)
.put('/assets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset2.id], isFavorite: true });
const duplicateId = '00000000-0000-4000-8000-000000000020';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify favorite was synchronized to keeper
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.isFavorite).toBe(true);
expect(keptAsset.duplicateId).toBeNull();
});
it('should synchronize visibility when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Archive one asset
await utils.archiveAssets(user1.accessToken, [asset2.id]);
const duplicateId = '00000000-0000-4000-8000-000000000021';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify visibility was synchronized to keeper
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.visibility).toBe('archive');
expect(keptAsset.duplicateId).toBeNull();
});
it('should synchronize rating when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Set rating on one asset
await request(app)
.put('/assets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset2.id], rating: 5 });
const duplicateId = '00000000-0000-4000-8000-000000000022';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify rating was synchronized to keeper
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.exifInfo?.rating).toBe(5);
expect(keptAsset.duplicateId).toBeNull();
});
it('should synchronize description when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Set description on one asset
await request(app)
.put('/assets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset2.id], description: 'Test description for duplicate' });
const duplicateId = '00000000-0000-4000-8000-000000000023';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify description was synchronized to keeper
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.exifInfo?.description).toBe('Test description for duplicate');
expect(keptAsset.duplicateId).toBeNull();
});
it('should synchronize location when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Set location on one asset
await request(app)
.put('/assets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset2.id], latitude: 40.7128, longitude: -74.006 });
const duplicateId = '00000000-0000-4000-8000-000000000024';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify location was synchronized to keeper
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.exifInfo?.latitude).toBe(40.7128);
expect(keptAsset.exifInfo?.longitude).toBe(-74.006);
expect(keptAsset.duplicateId).toBeNull();
});
it('should synchronize albums when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Create albums and add assets to different albums
const album1 = await utils.createAlbum(user1.accessToken, {
albumName: 'Album 1',
assetIds: [asset1.id],
});
const album2 = await utils.createAlbum(user1.accessToken, {
albumName: 'Album 2',
assetIds: [asset2.id],
});
const duplicateId = '00000000-0000-4000-8000-000000000025';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify keeper is now in both albums
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.duplicateId).toBeNull();
// Check albums directly
const { status: album1Status, body: album1Body } = await request(app)
.get(`/albums/${album1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
const { status: album2Status, body: album2Body } = await request(app)
.get(`/albums/${album2.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(album1Status).toBe(200);
expect(album2Status).toBe(200);
expect(album1Body.assets.map((a: any) => a.id)).toContain(asset1.id);
expect(album2Body.assets.map((a: any) => a.id)).toContain(asset1.id);
});
it('should synchronize tags when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Wait for metadata extraction to complete before adding tags
// Otherwise, metadata jobs will race and overwrite our tags
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
// Create tags and tag assets differently
const tags = await utils.upsertTags(user1.accessToken, ['tag1', 'tag2']);
await utils.tagAssets(user1.accessToken, tags[0].id, [asset1.id]);
await utils.tagAssets(user1.accessToken, tags[1].id, [asset2.id]);
const duplicateId = '00000000-0000-4000-8000-000000000026';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify keeper has both tags
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.duplicateId).toBeNull();
expect(keptAsset.tags).toBeDefined();
const tagIds = keptAsset.tags?.map((t) => t.id) || [];
expect(tagIds).toContain(tags[0].id);
expect(tagIds).toContain(tags[1].id);
});
it('should handle batch resolve with mixed success and failure', async () => {
// Create first group that will succeed
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId1 = '00000000-0000-4000-8000-000000000027';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId1);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId1);
// Create second group with non-existent duplicate ID (will fail)
const fakeId = '00000000-0000-4000-8000-000000000099';
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [
{ duplicateId: duplicateId1, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] },
{ duplicateId: fakeId, keepAssetIds: [], trashAssetIds: [] },
],
});
expect(status).toBe(200);
expect(body.status).toBe('COMPLETED');
expect(body.results).toHaveLength(2);
// First group should succeed
expect(body.results[0].duplicateId).toBe(duplicateId1);
expect(body.results[0].status).toBe('SUCCESS');
// Second group should fail
expect(body.results[1].duplicateId).toBe(fakeId);
expect(body.results[1].status).toBe('FAILED');
expect(body.results[1].reason).toContain('not found or access denied');
// Verify first group was actually resolved despite second failure
const asset1Info = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(asset1Info.duplicateId).toBeNull();
const asset2Info = await utils.getAssetInfo(user1.accessToken, asset2.id);
expect(asset2Info.isTrashed).toBe(true);
});
it('should trash assets when trash is enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000028';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
// Ensure trash is enabled (default)
const config = await utils.getSystemConfig(admin.accessToken);
expect(config.trash.enabled).toBe(true);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify asset is trashed (not deleted)
const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id);
expect(trashedAsset.isTrashed).toBe(true);
});
it('should delete assets when trash is disabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000029';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
// Disable trash
await request(app)
.put('/system-config')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
trash: { enabled: false, days: 30 },
});
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Asset should be marked as deleted (force delete)
const { status: getStatus } = await request(app)
.get(`/assets/${asset2.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
// Asset should still be accessible (soft deleted) but marked as deleted
expect(getStatus).toBe(200);
// Re-enable trash for other tests
await utils.resetAdminConfig(admin.accessToken);
});
});
});
+2
View File
@@ -2,6 +2,8 @@ export const uuidDto = {
invalid: 'invalid-uuid',
// valid uuid v4
notFound: '00000000-0000-4000-a000-000000000000',
dummy: '00000000-0000-4000-a000-000000000001',
dummy2: '00000000-0000-4000-a000-000000000002',
};
const adminLoginDto = {
@@ -10,7 +10,9 @@ describe('/admin/database-backups', () => {
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
admin = await utils.adminSetup({
onboarding: false,
});
await utils.resetBackups(admin.accessToken);
});
@@ -94,7 +96,9 @@ describe('/admin/database-backups', () => {
({ status, body }) => status === 200 && !body.maintenanceMode,
);
admin = await utils.adminSetup();
admin = await utils.adminSetup({
onboarding: false,
});
});
it.sequential('should not work when the server is configured', async () => {
+40 -2
View File
@@ -1,6 +1,7 @@
import { LoginResponseDto } from '@immich/sdk';
import { test } from '@playwright/test';
import { utils } from 'src/utils';
import { expect, test } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { testAssetDir, utils } from 'src/utils';
test.describe('Album', () => {
let admin: LoginResponseDto;
@@ -22,4 +23,41 @@ test.describe('Album', () => {
await page.reload();
await page.getByRole('button', { name: 'Select photos' }).waitFor();
});
test('should keep map view open after viewing an asset from the map and going back', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
const imagePath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
const mapAsset = await utils.createAsset(admin.accessToken, {
assetData: {
bytes: readFileSync(imagePath),
filename: 'thompson-springs.jpg',
},
});
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
const mapAlbum = await utils.createAlbum(admin.accessToken, {
albumName: 'Map Test Album',
assetIds: [mapAsset.id],
});
await page.goto(`/albums/${mapAlbum.id}`);
const mapButton = page.getByRole('button', { name: 'Map' });
await expect(mapButton).toBeVisible();
await mapButton.click();
const mapModal = page.getByRole('dialog');
await expect(mapModal).toBeVisible();
const mapMarker = mapModal.getByRole('img', { name: /Map marker/i }).first();
await expect(mapMarker).toBeVisible();
await mapMarker.click();
await page.waitForSelector('#immich-asset-viewer');
await page.getByRole('button', { name: 'Go back' }).click();
await expect(page.locator('#immich-asset-viewer')).not.toBeVisible();
await expect(mapModal).toBeVisible();
});
});
+3
View File
@@ -510,6 +510,9 @@ export const utils = {
createStack: (accessToken: string, assetIds: string[]) =>
createStack({ stackCreateDto: { assetIds } }, { headers: asBearerAuth(accessToken) }),
setAssetDuplicateId: (accessToken: string, assetId: string, duplicateId: string | null) =>
updateAssets({ assetBulkUpdateDto: { ids: [assetId], duplicateId } }, { headers: asBearerAuth(accessToken) }),
upsertTags: (accessToken: string, tags: string[]) =>
upsertTags({ tagUpsertDto: { tags } }, { headers: asBearerAuth(accessToken) }),
+8 -6
View File
@@ -866,6 +866,7 @@
"crop_aspect_ratio_fixed": "Fixed",
"crop_aspect_ratio_free": "Free",
"crop_aspect_ratio_original": "Original",
"crop_aspect_ratio_square": "Square",
"curated_object_page_title": "Things",
"current_device": "Current device",
"current_pin_code": "Current PIN code",
@@ -880,7 +881,7 @@
"daily_title_text_date": "E, MMM dd",
"daily_title_text_date_year": "E, MMM dd, yyyy",
"dark": "Dark",
"dark_theme": "Toggle dark theme",
"dark_theme": "Switch to dark theme",
"date": "Date",
"date_after": "Date after",
"date_and_time": "Date and Time",
@@ -891,10 +892,8 @@
"day": "Day",
"days": "Days",
"deduplicate_all": "Deduplicate All",
"deduplication_criteria_1": "Image size in bytes",
"deduplication_criteria_2": "Count of EXIF data",
"deduplication_info": "Deduplication Info",
"deduplication_info_description": "To automatically preselect assets and remove duplicates in bulk, we look at:",
"default_locale": "Default Locale",
"default_locale_description": "Format dates and numbers based on your browser locale",
"delete": "Delete",
"delete_action_confirmation_message": "Are you sure you want to delete this asset? This action will move the asset to the server's trash and will prompt if you want to delete it locally",
"delete_action_prompt": "{count} deleted",
@@ -970,7 +969,7 @@
"downloading_media": "Downloading media",
"drop_files_to_upload": "Drop files anywhere to upload",
"duplicates": "Duplicates",
"duplicates_description": "Resolve each group by indicating which, if any, are duplicates",
"duplicates_description": "Resolve each group by indicating which, if any, are duplicates.",
"duration": "Duration",
"edit": "Edit",
"edit_album": "Edit album",
@@ -1387,9 +1386,11 @@
"library_page_sort_title": "Album title",
"licenses": "Licenses",
"light": "Light",
"light_theme": "Switch to light theme",
"like": "Like",
"like_deleted": "Like deleted",
"link_motion_video": "Link motion video",
"link_to_docs": "For more information, refer to the <link>documentation</link>.",
"link_to_oauth": "Link to OAuth",
"linked_oauth_account": "Linked OAuth account",
"list": "List",
@@ -2394,6 +2395,7 @@
"viewer_remove_from_stack": "Remove from Stack",
"viewer_stack_use_as_main_asset": "Use as Main Asset",
"viewer_unstack": "Un-Stack",
"visibility": "Visibility",
"visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}",
"visual": "Visual",
"visual_builder": "Visual builder",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-i18n",
"version": "2.6.2",
"version": "2.6.3",
"private": true,
"scripts": {
"format": "prettier --cache --check .",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "immich-ml"
version = "2.6.2"
version = "2.6.3"
description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.11,<4.0"
+1 -1
View File
@@ -898,7 +898,7 @@ wheels = [
[[package]]
name = "immich-ml"
version = "2.6.2"
version = "2.6.3"
source = { editable = "." }
dependencies = [
{ name = "aiocache" },
+2 -2
View File
@@ -14,9 +14,9 @@ config_roots = [
]
[tools]
node = "24.13.1"
node = "24.14.0"
flutter = "3.35.7"
pnpm = "10.30.3"
pnpm = "10.32.1"
terragrunt = "0.99.4"
opentofu = "1.11.5"
java = "21.0.2"
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3040,
"android.injected.version.name" => "2.6.2",
"android.injected.version.code" => 3041,
"android.injected.version.name" => "2.6.3",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
+2 -2
View File
@@ -88,8 +88,8 @@ class NetworkApiImpl: NetworkApi {
}
}
if headers != UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] {
UserDefaults.group.set(headers, forKey: HEADERS_KEY)
if headers != UserDefaults.standard.dictionary(forKey: HEADERS_KEY) as? [String: String] {
UserDefaults.standard.set(headers, forKey: HEADERS_KEY)
URLSessionManager.shared.recreateSession()
}
}
+4 -10
View File
@@ -4,7 +4,6 @@ import native_video_player
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity"
let HEADERS_KEY = "immich.request_headers"
let SERVER_URLS_KEY = "immich.server_urls"
let APP_GROUP = "group.app.immich.share"
let COOKIE_EXPIRY_DAYS: TimeInterval = 400
enum AuthCookie: CaseIterable {
@@ -28,10 +27,6 @@ enum AuthCookie: CaseIterable {
static let names: Set<String> = Set(allCases.map(\.name))
}
extension UserDefaults {
static let group = UserDefaults(suiteName: APP_GROUP)!
}
/// Manages a shared URLSession with SSL configuration support.
/// Old sessions are kept alive by Dart's FFI retain until all isolates release them.
class URLSessionManager: NSObject {
@@ -55,7 +50,7 @@ class URLSessionManager: NSObject {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
return "Immich_iOS_\(version)"
}()
static let cookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: APP_GROUP)
static let cookieStorage = HTTPCookieStorage.shared
private static var serverUrls: [String] = []
private static var isSyncing = false
@@ -67,7 +62,7 @@ class URLSessionManager: NSObject {
delegate = URLSessionManagerDelegate()
session = Self.buildSession(delegate: delegate)
super.init()
Self.serverUrls = UserDefaults.group.stringArray(forKey: SERVER_URLS_KEY) ?? []
Self.serverUrls = UserDefaults.standard.stringArray(forKey: SERVER_URLS_KEY) ?? []
NotificationCenter.default.addObserver(
Self.self,
selector: #selector(Self.cookiesDidChange),
@@ -83,7 +78,7 @@ class URLSessionManager: NSObject {
static func setServerUrls(_ urls: [String]) {
guard urls != serverUrls else { return }
serverUrls = urls
UserDefaults.group.set(urls, forKey: SERVER_URLS_KEY)
UserDefaults.standard.set(urls, forKey: SERVER_URLS_KEY)
syncAuthCookies()
}
@@ -150,9 +145,8 @@ class URLSessionManager: NSObject {
config.httpCookieStorage = cookieStorage
config.httpMaximumConnectionsPerHost = 64
config.timeoutIntervalForRequest = 60
config.timeoutIntervalForResource = 300
var headers = UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] ?? [:]
var headers = UserDefaults.standard.dictionary(forKey: HEADERS_KEY) as? [String: String] ?? [:]
headers["User-Agent"] = headers["User-Agent"] ?? userAgent
config.httpAdditionalHeaders = headers
+1 -1
View File
@@ -80,7 +80,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.6.2</string>
<string>2.6.3</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@@ -207,6 +207,11 @@ class DriftMemoryPage extends HookConsumerWidget {
WidgetsBinding.instance.addPostFrameCallback((_) {
DriftMemoryPage.setMemory(ref, memories[pageNumber]);
});
// Update currentAsset to the first asset of the new memory
if (memories[pageNumber].assets.isNotEmpty) {
currentAsset.value = memories[pageNumber].assets.first;
}
}
currentAssetPage.value = 0;
@@ -71,16 +71,13 @@ class ViewerBottomBar extends ConsumerWidget {
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.only(top: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
if (!isReadonlyModeEnabled)
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
if (!isReadonlyModeEnabled)
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
],
),
),
),
@@ -3,24 +3,21 @@ import 'dart:ui' as ui;
import 'package:flutter/foundation.dart' show InformationCollector;
import 'package:flutter/painting.dart';
import 'package:immich_mobile/presentation/widgets/images/cache_aware_listener_tracker.mixin.dart';
/// A [MultiFrameImageStreamCompleter] with support for listener tracking
/// which makes resource cleanup possible when no longer needed.
/// Codec is disposed through the MultiFrameImageStreamCompleter's internals onDispose method
class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
void Function()? _onLastListenerRemoved;
int _listenerCount = 0;
// True once any image or the codec has been provided.
// Until then the image cache holds one listener, so "last real listener gone"
// is _listenerCount == 1, not 0.
bool didProvideImage = false;
class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter with CacheAwareListenerTrackerMixin {
AnimatedImageStreamCompleter._({
required super.codec,
required super.scale,
required bool hadInitialImage,
super.informationCollector,
void Function()? onLastListenerRemoved,
}) : _onLastListenerRemoved = onLastListenerRemoved;
}) {
setupListenerTracking(hadInitialImage: hadInitialImage, onLastListenerRemoved: onLastListenerRemoved);
}
factory AnimatedImageStreamCompleter({
required Stream<Object> stream,
@@ -33,23 +30,21 @@ class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
final self = AnimatedImageStreamCompleter._(
codec: codecCompleter.future,
scale: scale,
hadInitialImage: initialImage != null,
informationCollector: informationCollector,
onLastListenerRemoved: onLastListenerRemoved,
);
if (initialImage != null) {
self.didProvideImage = true;
self.setImage(initialImage);
}
stream.listen(
(item) {
if (item is ImageInfo) {
self.didProvideImage = true;
self.setImage(item);
} else if (item is ui.Codec) {
if (!codecCompleter.isCompleted) {
self.didProvideImage = true;
codecCompleter.complete(item);
}
}
@@ -70,27 +65,4 @@ class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
return self;
}
@override
void addListener(ImageStreamListener listener) {
super.addListener(listener);
_listenerCount++;
}
@override
void removeListener(ImageStreamListener listener) {
super.removeListener(listener);
_listenerCount--;
final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage;
final bool noListenersAfterCodec = _listenerCount == 0 && didProvideImage;
if (onlyCacheListenerLeft || noListenersAfterCodec) {
final onLastListenerRemoved = _onLastListenerRemoved;
if (onLastListenerRemoved != null) {
_onLastListenerRemoved = null;
onLastListenerRemoved();
}
}
}
}
@@ -0,0 +1,84 @@
import 'package:flutter/painting.dart';
/// Tracks listeners on an [ImageStreamCompleter] to safely cancel in-flight
/// network requests without interfering with [ImageCache] internals.
///
/// ### Problem
/// Cancelling fetches when the listener count drops to 1 (cache only) or 0
/// is unsafe due to three framework behaviours:
///
/// 1. **Memory-pressure eviction** — `ImageCache.clear()` removes the cache
/// listener while UI widgets still need the image. A count-based check
/// would cancel the active fetch, leaving the UI with no image.
/// 2. **Synchronous detach during `putIfAbsent`** — When an `initialImage`
/// is provided, the cache attaches, receives the frame, and detaches
/// synchronously *before* the UI widget can attach. Count reaches 0 and
/// would trigger a false cancel.
/// 3. **Listener misidentification** — After the cache detaches (via 1 or 2),
/// the next UI listener could be mistaken for the cache listener, causing
/// incorrect cancellations when that widget is disposed.
///
/// ### Solution: First-Listener Heuristic
/// The cache is always the first listener attached (via `putIfAbsent`). This
/// mixin records that identity once and uses it for all subsequent decisions:
///
/// * **Identity locking** — The first listener is assumed to be the cache.
/// Once identified, `_hasIdentifiedCacheListener` prevents reassignment.
/// * **Targeted cancellation** — Cancel only when the identified cache
/// listener is the sole remaining listener and no image has been delivered.
/// * **Sync-removal bypass** — When `hadInitialImage` is set, the first
/// synchronous removal of the cache listener is ignored so the fetch
/// survives until the UI attaches.
mixin CacheAwareListenerTrackerMixin on ImageStreamCompleter {
void Function()? _onLastListenerRemoved;
int _listenerCount = 0;
bool _hadInitialImage = false;
bool _hasIgnoredFirstSyncRemoval = false;
ImageStreamListener? _cacheListener;
bool _hasIdentifiedCacheListener = false;
/// Initializes the tracking state. Must be called in the subclass constructor.
void setupListenerTracking({required bool hadInitialImage, void Function()? onLastListenerRemoved}) {
_hadInitialImage = hadInitialImage;
_onLastListenerRemoved = onLastListenerRemoved;
}
@override
void addListener(ImageStreamListener listener) {
if (!_hasIdentifiedCacheListener) {
_hasIdentifiedCacheListener = true;
_cacheListener = listener;
}
_listenerCount++;
super.addListener(listener);
}
@override
void removeListener(ImageStreamListener listener) {
super.removeListener(listener);
_listenerCount--;
final bool isCacheListener = listener == _cacheListener;
if (isCacheListener) {
_cacheListener = null;
}
if (_hadInitialImage && !_hasIgnoredFirstSyncRemoval && isCacheListener) {
_hasIgnoredFirstSyncRemoval = true;
return;
}
final bool onlyCacheListenerLeft = _listenerCount == 1 && _cacheListener != null;
final bool completelyAbandoned = _listenerCount == 0;
if (onlyCacheListenerLeft || completelyAbandoned) {
final onLastListenerRemoved = _onLastListenerRemoved;
if (onLastListenerRemoved != null) {
_onLastListenerRemoved = null;
onLastListenerRemoved();
}
}
}
}
@@ -6,14 +6,10 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:immich_mobile/presentation/widgets/images/cache_aware_listener_tracker.mixin.dart';
/// An ImageStreamCompleter with support for loading multiple images.
class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
void Function()? _onLastListenerRemoved;
int _listenerCount = 0;
// True once setImage() has been called at least once.
bool didProvideImage = false;
class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter with CacheAwareListenerTrackerMixin {
/// The constructor to create an OneFramePlaceholderImageStreamCompleter. The [images]
/// should be the primary images to display (typically asynchronously as they load).
/// The [initialImage] is an optional image that will be emitted synchronously
@@ -24,14 +20,14 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
InformationCollector? informationCollector,
void Function()? onLastListenerRemoved,
}) {
setupListenerTracking(hadInitialImage: initialImage != null, onLastListenerRemoved: onLastListenerRemoved);
if (initialImage != null) {
didProvideImage = true;
setImage(initialImage);
}
_onLastListenerRemoved = onLastListenerRemoved;
images.listen(
(image) {
didProvideImage = true;
setImage(image);
},
onError: (Object error, StackTrace stack) {
@@ -45,26 +41,4 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
},
);
}
@override
void addListener(ImageStreamListener listener) {
super.addListener(listener);
_listenerCount = _listenerCount + 1;
}
@override
void removeListener(ImageStreamListener listener) {
super.removeListener(listener);
_listenerCount = _listenerCount - 1;
final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage;
final bool noListenersAfterImage = _listenerCount == 0 && didProvideImage;
final onLastListenerRemoved = _onLastListenerRemoved;
if (onLastListenerRemoved != null && (noListenersAfterImage || onlyCacheListenerLeft)) {
_onLastListenerRemoved = null;
onLastListenerRemoved();
}
}
}
+1 -1
View File
@@ -109,7 +109,7 @@ class DownloadService {
return result != null;
} on PlatformException catch (error, stack) {
// Handle saving MotionPhotos on iOS
if (error.code == 'PHPhotosErrorDomain (-1)') {
if (error.code.startsWith('PHPhotosErrorDomain')) {
final result = await _fileMediaRepository.saveImageWithFile(imageFilePath, title: task.filename);
return result != null;
}
+10 -2
View File
@@ -19,8 +19,16 @@ abstract final class DynamicTheme {
// Some palettes do not generate surface container colors accurately,
// so we regenerate all colors using the primary color
_theme = ImmichTheme(
light: ColorScheme.fromSeed(seedColor: primaryColor, brightness: Brightness.light),
dark: ColorScheme.fromSeed(seedColor: primaryColor, brightness: Brightness.dark),
light: ColorScheme.fromSeed(
seedColor: primaryColor,
brightness: Brightness.light,
dynamicSchemeVariant: DynamicSchemeVariant.fidelity,
),
dark: ColorScheme.fromSeed(
seedColor: primaryColor,
brightness: Brightness.dark,
dynamicSchemeVariant: DynamicSchemeVariant.fidelity,
),
);
}
} catch (error) {
+1
View File
@@ -62,6 +62,7 @@ ThemeData getThemeData({required ColorScheme colorScheme, required Locale locale
),
chipTheme: const ChipThemeData(side: BorderSide.none),
sliderTheme: const SliderThemeData(
trackHeight: 12,
// ignore: deprecated_member_use
year2023: false,
),
@@ -66,9 +66,9 @@ class VideoControls extends HookConsumerWidget {
final isLoaded = duration != Duration.zero;
return Padding(
padding: const EdgeInsets.all(24),
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 12),
child: Column(
spacing: 16,
spacing: 4,
children: [
Row(
children: [
@@ -77,8 +77,8 @@ class VideoControls extends HookConsumerWidget {
padding: const EdgeInsets.all(12),
constraints: const BoxConstraints(),
icon: isFinished
? const Icon(Icons.replay, color: Colors.white, size: 32, shadows: _controlShadows)
: AnimatedPlayPause(color: Colors.white, size: 32, playing: isPlaying, shadows: _controlShadows),
? const Icon(Icons.replay, color: Colors.white, shadows: _controlShadows)
: AnimatedPlayPause(color: Colors.white, playing: isPlaying, shadows: _controlShadows),
onPressed: () => _toggle(ref, isCasting),
),
const Spacer(),
@@ -91,7 +91,7 @@ class VideoControls extends HookConsumerWidget {
shadows: _controlShadows,
),
),
const SizedBox(width: 16),
const SizedBox(width: 12),
],
),
Slider(
+4 -1
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.6.2
- API version: 2.6.3
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
@@ -156,6 +156,7 @@ Class | Method | HTTP request | Description
*DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | Delete a duplicate
*DuplicatesApi* | [**deleteDuplicates**](doc//DuplicatesApi.md#deleteduplicates) | **DELETE** /duplicates | Delete duplicates
*DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | Retrieve duplicates
*DuplicatesApi* | [**resolveDuplicates**](doc//DuplicatesApi.md#resolveduplicates) | **POST** /duplicates/resolve | Resolve duplicate groups
*FacesApi* | [**createFace**](doc//FacesApi.md#createface) | **POST** /faces | Create a face
*FacesApi* | [**deleteFace**](doc//FacesApi.md#deleteface) | **DELETE** /faces/{id} | Delete a face
*FacesApi* | [**getFaces**](doc//FacesApi.md#getfaces) | **GET** /faces | Retrieve faces for asset
@@ -422,6 +423,8 @@ Class | Method | HTTP request | Description
- [DownloadResponseDto](doc//DownloadResponseDto.md)
- [DownloadUpdate](doc//DownloadUpdate.md)
- [DuplicateDetectionConfig](doc//DuplicateDetectionConfig.md)
- [DuplicateResolveDto](doc//DuplicateResolveDto.md)
- [DuplicateResolveGroupDto](doc//DuplicateResolveGroupDto.md)
- [DuplicateResponseDto](doc//DuplicateResponseDto.md)
- [EmailNotificationsResponse](doc//EmailNotificationsResponse.md)
- [EmailNotificationsUpdate](doc//EmailNotificationsUpdate.md)
+2
View File
@@ -161,6 +161,8 @@ part 'model/download_response.dart';
part 'model/download_response_dto.dart';
part 'model/download_update.dart';
part 'model/duplicate_detection_config.dart';
part 'model/duplicate_resolve_dto.dart';
part 'model/duplicate_resolve_group_dto.dart';
part 'model/duplicate_response_dto.dart';
part 'model/email_notifications_response.dart';
part 'model/email_notifications_update.dart';
+59
View File
@@ -163,4 +163,63 @@ class DuplicatesApi {
}
return null;
}
/// Resolve duplicate groups
///
/// Resolve duplicate groups by synchronizing metadata across assets and deleting/trashing duplicates.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [DuplicateResolveDto] duplicateResolveDto (required):
Future<Response> resolveDuplicatesWithHttpInfo(DuplicateResolveDto duplicateResolveDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/duplicates/resolve';
// ignore: prefer_final_locals
Object? postBody = duplicateResolveDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Resolve duplicate groups
///
/// Resolve duplicate groups by synchronizing metadata across assets and deleting/trashing duplicates.
///
/// Parameters:
///
/// * [DuplicateResolveDto] duplicateResolveDto (required):
Future<List<BulkIdResponseDto>?> resolveDuplicates(DuplicateResolveDto duplicateResolveDto,) async {
final response = await resolveDuplicatesWithHttpInfo(duplicateResolveDto,);
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<BulkIdResponseDto>') as List)
.cast<BulkIdResponseDto>()
.toList(growable: false);
}
return null;
}
}
+4
View File
@@ -368,6 +368,10 @@ class ApiClient {
return DownloadUpdate.fromJson(value);
case 'DuplicateDetectionConfig':
return DuplicateDetectionConfig.fromJson(value);
case 'DuplicateResolveDto':
return DuplicateResolveDto.fromJson(value);
case 'DuplicateResolveGroupDto':
return DuplicateResolveGroupDto.fromJson(value);
case 'DuplicateResponseDto':
return DuplicateResponseDto.fromJson(value);
case 'EmailNotificationsResponse':
+3
View File
@@ -27,6 +27,7 @@ class BulkIdErrorReason {
static const noPermission = BulkIdErrorReason._(r'no_permission');
static const notFound = BulkIdErrorReason._(r'not_found');
static const unknown = BulkIdErrorReason._(r'unknown');
static const validation = BulkIdErrorReason._(r'validation');
/// List of all possible values in this [enum][BulkIdErrorReason].
static const values = <BulkIdErrorReason>[
@@ -34,6 +35,7 @@ class BulkIdErrorReason {
noPermission,
notFound,
unknown,
validation,
];
static BulkIdErrorReason? fromJson(dynamic value) => BulkIdErrorReasonTypeTransformer().decode(value);
@@ -76,6 +78,7 @@ class BulkIdErrorReasonTypeTransformer {
case r'no_permission': return BulkIdErrorReason.noPermission;
case r'not_found': return BulkIdErrorReason.notFound;
case r'unknown': return BulkIdErrorReason.unknown;
case r'validation': return BulkIdErrorReason.validation;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
+21 -1
View File
@@ -14,6 +14,7 @@ class BulkIdResponseDto {
/// Returns a new [BulkIdResponseDto] instance.
BulkIdResponseDto({
this.error,
this.errorMessage,
required this.id,
required this.success,
});
@@ -21,6 +22,14 @@ class BulkIdResponseDto {
/// Error reason if failed
BulkIdResponseDtoErrorEnum? error;
///
/// 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.
///
String? errorMessage;
/// ID
String id;
@@ -30,6 +39,7 @@ class BulkIdResponseDto {
@override
bool operator ==(Object other) => identical(this, other) || other is BulkIdResponseDto &&
other.error == error &&
other.errorMessage == errorMessage &&
other.id == id &&
other.success == success;
@@ -37,11 +47,12 @@ class BulkIdResponseDto {
int get hashCode =>
// ignore: unnecessary_parenthesis
(error == null ? 0 : error!.hashCode) +
(errorMessage == null ? 0 : errorMessage!.hashCode) +
(id.hashCode) +
(success.hashCode);
@override
String toString() => 'BulkIdResponseDto[error=$error, id=$id, success=$success]';
String toString() => 'BulkIdResponseDto[error=$error, errorMessage=$errorMessage, id=$id, success=$success]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -49,6 +60,11 @@ class BulkIdResponseDto {
json[r'error'] = this.error;
} else {
// json[r'error'] = null;
}
if (this.errorMessage != null) {
json[r'errorMessage'] = this.errorMessage;
} else {
// json[r'errorMessage'] = null;
}
json[r'id'] = this.id;
json[r'success'] = this.success;
@@ -65,6 +81,7 @@ class BulkIdResponseDto {
return BulkIdResponseDto(
error: BulkIdResponseDtoErrorEnum.fromJson(json[r'error']),
errorMessage: mapValueOfType<String>(json, r'errorMessage'),
id: mapValueOfType<String>(json, r'id')!,
success: mapValueOfType<bool>(json, r'success')!,
);
@@ -136,6 +153,7 @@ class BulkIdResponseDtoErrorEnum {
static const noPermission = BulkIdResponseDtoErrorEnum._(r'no_permission');
static const notFound = BulkIdResponseDtoErrorEnum._(r'not_found');
static const unknown = BulkIdResponseDtoErrorEnum._(r'unknown');
static const validation = BulkIdResponseDtoErrorEnum._(r'validation');
/// List of all possible values in this [enum][BulkIdResponseDtoErrorEnum].
static const values = <BulkIdResponseDtoErrorEnum>[
@@ -143,6 +161,7 @@ class BulkIdResponseDtoErrorEnum {
noPermission,
notFound,
unknown,
validation,
];
static BulkIdResponseDtoErrorEnum? fromJson(dynamic value) => BulkIdResponseDtoErrorEnumTypeTransformer().decode(value);
@@ -185,6 +204,7 @@ class BulkIdResponseDtoErrorEnumTypeTransformer {
case r'no_permission': return BulkIdResponseDtoErrorEnum.noPermission;
case r'not_found': return BulkIdResponseDtoErrorEnum.notFound;
case r'unknown': return BulkIdResponseDtoErrorEnum.unknown;
case r'validation': return BulkIdResponseDtoErrorEnum.validation;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
+100
View File
@@ -0,0 +1,100 @@
//
// 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 DuplicateResolveDto {
/// Returns a new [DuplicateResolveDto] instance.
DuplicateResolveDto({
this.groups = const [],
});
/// List of duplicate groups to resolve
List<DuplicateResolveGroupDto> groups;
@override
bool operator ==(Object other) => identical(this, other) || other is DuplicateResolveDto &&
_deepEquality.equals(other.groups, groups);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(groups.hashCode);
@override
String toString() => 'DuplicateResolveDto[groups=$groups]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'groups'] = this.groups;
return json;
}
/// Returns a new [DuplicateResolveDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static DuplicateResolveDto? fromJson(dynamic value) {
upgradeDto(value, "DuplicateResolveDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return DuplicateResolveDto(
groups: DuplicateResolveGroupDto.listFromJson(json[r'groups']),
);
}
return null;
}
static List<DuplicateResolveDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <DuplicateResolveDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = DuplicateResolveDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, DuplicateResolveDto> mapFromJson(dynamic json) {
final map = <String, DuplicateResolveDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = DuplicateResolveDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of DuplicateResolveDto-objects as value to a dart map
static Map<String, List<DuplicateResolveDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<DuplicateResolveDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = DuplicateResolveDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'groups',
};
}
+121
View File
@@ -0,0 +1,121 @@
//
// 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 DuplicateResolveGroupDto {
/// Returns a new [DuplicateResolveGroupDto] instance.
DuplicateResolveGroupDto({
required this.duplicateId,
this.keepAssetIds = const [],
this.trashAssetIds = const [],
});
String duplicateId;
/// Asset IDs to keep
List<String> keepAssetIds;
/// Asset IDs to trash or delete
List<String> trashAssetIds;
@override
bool operator ==(Object other) => identical(this, other) || other is DuplicateResolveGroupDto &&
other.duplicateId == duplicateId &&
_deepEquality.equals(other.keepAssetIds, keepAssetIds) &&
_deepEquality.equals(other.trashAssetIds, trashAssetIds);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(duplicateId.hashCode) +
(keepAssetIds.hashCode) +
(trashAssetIds.hashCode);
@override
String toString() => 'DuplicateResolveGroupDto[duplicateId=$duplicateId, keepAssetIds=$keepAssetIds, trashAssetIds=$trashAssetIds]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'duplicateId'] = this.duplicateId;
json[r'keepAssetIds'] = this.keepAssetIds;
json[r'trashAssetIds'] = this.trashAssetIds;
return json;
}
/// Returns a new [DuplicateResolveGroupDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static DuplicateResolveGroupDto? fromJson(dynamic value) {
upgradeDto(value, "DuplicateResolveGroupDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return DuplicateResolveGroupDto(
duplicateId: mapValueOfType<String>(json, r'duplicateId')!,
keepAssetIds: json[r'keepAssetIds'] is Iterable
? (json[r'keepAssetIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
trashAssetIds: json[r'trashAssetIds'] is Iterable
? (json[r'trashAssetIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
);
}
return null;
}
static List<DuplicateResolveGroupDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <DuplicateResolveGroupDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = DuplicateResolveGroupDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, DuplicateResolveGroupDto> mapFromJson(dynamic json) {
final map = <String, DuplicateResolveGroupDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = DuplicateResolveGroupDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of DuplicateResolveGroupDto-objects as value to a dart map
static Map<String, List<DuplicateResolveGroupDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<DuplicateResolveGroupDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = DuplicateResolveGroupDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'duplicateId',
'keepAssetIds',
'trashAssetIds',
};
}
+14 -3
View File
@@ -15,6 +15,7 @@ class DuplicateResponseDto {
DuplicateResponseDto({
this.assets = const [],
required this.duplicateId,
this.suggestedKeepAssetIds = const [],
});
/// Duplicate assets
@@ -23,24 +24,30 @@ class DuplicateResponseDto {
/// Duplicate group ID
String duplicateId;
/// Suggested asset IDs to keep based on file size and EXIF data
List<String> suggestedKeepAssetIds;
@override
bool operator ==(Object other) => identical(this, other) || other is DuplicateResponseDto &&
_deepEquality.equals(other.assets, assets) &&
other.duplicateId == duplicateId;
other.duplicateId == duplicateId &&
_deepEquality.equals(other.suggestedKeepAssetIds, suggestedKeepAssetIds);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(assets.hashCode) +
(duplicateId.hashCode);
(duplicateId.hashCode) +
(suggestedKeepAssetIds.hashCode);
@override
String toString() => 'DuplicateResponseDto[assets=$assets, duplicateId=$duplicateId]';
String toString() => 'DuplicateResponseDto[assets=$assets, duplicateId=$duplicateId, suggestedKeepAssetIds=$suggestedKeepAssetIds]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'assets'] = this.assets;
json[r'duplicateId'] = this.duplicateId;
json[r'suggestedKeepAssetIds'] = this.suggestedKeepAssetIds;
return json;
}
@@ -55,6 +62,9 @@ class DuplicateResponseDto {
return DuplicateResponseDto(
assets: AssetResponseDto.listFromJson(json[r'assets']),
duplicateId: mapValueOfType<String>(json, r'duplicateId')!,
suggestedKeepAssetIds: json[r'suggestedKeepAssetIds'] is Iterable
? (json[r'suggestedKeepAssetIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
);
}
return null;
@@ -104,6 +114,7 @@ class DuplicateResponseDto {
static const requiredKeys = <String>{
'assets',
'duplicateId',
'suggestedKeepAssetIds',
};
}
+1 -1
View File
@@ -379,7 +379,7 @@ class MetadataSearchDto {
///
bool? withExif;
/// Include assets with people
/// Include people data in response
///
/// 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
+1 -1
View File
@@ -273,7 +273,7 @@ class RandomSearchDto {
///
bool? withExif;
/// Include assets with people
/// Include people data in response
///
/// 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
+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.6.2+3040
version: 2.6.3+3041
environment:
sdk: '>=3.8.0 <4.0.0'
@@ -0,0 +1,183 @@
import 'dart:ui' as ui;
import 'package:flutter/painting.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/presentation/widgets/images/cache_aware_listener_tracker.mixin.dart';
class TestImageCompleter extends ImageStreamCompleter with CacheAwareListenerTrackerMixin {
bool wasCancelled = false;
TestImageCompleter({required bool hadInitialImage}) {
setupListenerTracking(
hadInitialImage: hadInitialImage,
onLastListenerRemoved: () {
wasCancelled = true;
},
);
}
@override
void setImage(ImageInfo image) {
super.setImage(image);
}
}
void main() {
late ImageCache cache;
late ImageStreamListener uiListener;
setUp(() {
// Create a fresh, real Flutter ImageCache for every test
cache = ImageCache();
uiListener = ImageStreamListener((_, __) {});
});
group('CacheAwareListenerTrackerMixin with Real ImageCache', () {
testWidgets('cancels fetch when UI detaches before completion', (WidgetTester tester) async {
final completer = TestImageCompleter(hadInitialImage: false);
final key = Object();
// 1. Request image from the real cache (simulating the provider)
final stream = cache.putIfAbsent(key, () => completer)!;
// 2. UI attaches
stream.addListener(uiListener);
expect(completer.wasCancelled, isFalse);
// 3. Simulate asynchronous network delay...
await tester.pump(const Duration(milliseconds: 150));
// 4. User scrolls away before network finishes. UI detaches.
stream.removeListener(uiListener);
expect(completer.wasCancelled, isTrue);
});
testWidgets('survives cache eviction while UI listener is still attached', (WidgetTester tester) async {
final completer = TestImageCompleter(hadInitialImage: false);
final key = Object();
// 1. Request image and attach UI
final stream = cache.putIfAbsent(key, () => completer)!;
stream.addListener(uiListener);
// 2. Simulate app going to background -> OS Memory Warning -> Cache clears
cache.clear();
// Even though the real cache just aggressively detached its listener,
// the stream MUST survive because the UI widget is still on screen!
expect(completer.wasCancelled, isFalse);
// 3. UI widget finally detaches
stream.removeListener(uiListener);
expect(completer.wasCancelled, isTrue);
});
testWidgets('survives synchronous cache detach during putIfAbsent with initialImage', (WidgetTester tester) async {
final completer = TestImageCompleter(hadInitialImage: true);
final key = Object();
// Run image creation outside FakeAsync zone to avoid hang
late ui.Image dummyImage;
await tester.runAsync(() async {
dummyImage = await createTestImage(width: 1, height: 1);
});
final initialImageInfo = ImageInfo(image: dummyImage);
final stream = cache.putIfAbsent(key, () {
completer.setImage(initialImageInfo);
return completer;
})!;
expect(completer.wasCancelled, isFalse);
stream.addListener(uiListener);
expect(completer.wasCancelled, isFalse);
stream.removeListener(uiListener);
expect(completer.wasCancelled, isTrue);
});
testWidgets('fires cleanup on full abandonment even after successful fetch', (WidgetTester tester) async {
final completer = TestImageCompleter(hadInitialImage: false);
final key = Object();
final stream = cache.putIfAbsent(key, () => completer)!;
stream.addListener(uiListener);
await tester.pump(const Duration(milliseconds: 100));
// Run image creation outside FakeAsync zone to avoid hang
late ui.Image dummyImage;
await tester.runAsync(() async {
dummyImage = await createTestImage(width: 1, height: 1);
});
completer.setImage(ImageInfo(image: dummyImage));
stream.removeListener(uiListener);
// The stream is completely abandoned (0 listeners), so it fires the cleanup hook.
// Since the image is already downloaded, canceling the network token is a safe no-op.
expect(completer.wasCancelled, isTrue);
});
testWidgets('Multiple UI listeners — only all detached, should cancel', (WidgetTester tester) async {
final completer = TestImageCompleter(hadInitialImage: false);
final key = Object();
final stream = cache.putIfAbsent(key, () => completer)!;
final uiListener2 = ImageStreamListener((_, __) {});
stream.addListener(uiListener);
stream.addListener(uiListener2);
// First UI detach leaves cache + one UI → no cancel
stream.removeListener(uiListener);
expect(completer.wasCancelled, isFalse);
// Second UI detach leaves only cache → cancel
stream.removeListener(uiListener2);
expect(completer.wasCancelled, isTrue);
});
testWidgets('Listener misidentification: new listener after cache eviction is not treated as cache', (WidgetTester tester) async {
final completer = TestImageCompleter(hadInitialImage: false);
final key = Object();
final stream = cache.putIfAbsent(key, () => completer)!;
// UI attaches
stream.addListener(uiListener);
// Cache eviction removes the cache listener
cache.clear();
expect(completer.wasCancelled, isFalse);
// A second UI listener attaches — must NOT be treated as cache
final uiListener2 = ImageStreamListener((_, __) {});
stream.addListener(uiListener2);
// Remove first UI listener; second UI still active → no cancel
stream.removeListener(uiListener);
expect(completer.wasCancelled, isFalse);
// Remove second UI listener; completely abandoned → cancel
stream.removeListener(uiListener2);
expect(completer.wasCancelled, isTrue);
});
testWidgets('No UI listener ever attaches (cache-only) — cache detaches should cancel', (WidgetTester tester) async {
final completer = TestImageCompleter(hadInitialImage: false);
final key = Object();
cache.putIfAbsent(key, () => completer);
// Cache eviction removes the only listener
cache.clear();
expect(completer.wasCancelled, isTrue);
});
});
}
+125 -6
View File
@@ -5285,6 +5285,65 @@
"x-immich-state": "Stable"
}
},
"/duplicates/resolve": {
"post": {
"description": "Resolve duplicate groups by synchronizing metadata across assets and deleting/trashing duplicates.",
"operationId": "resolveDuplicates",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DuplicateResolveDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/BulkIdResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Resolve duplicate groups",
"tags": [
"Duplicates"
],
"x-immich-history": [
{
"version": "v3.0.0",
"state": "Added"
},
{
"version": "v3.0.0",
"state": "Alpha"
}
],
"x-immich-permission": "duplicate.delete",
"x-immich-state": "Alpha"
}
},
"/duplicates/{id}": {
"delete": {
"description": "Delete a single duplicate asset specified by its ID.",
@@ -15166,7 +15225,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "2.6.2",
"version": "2.6.3",
"contact": {}
},
"tags": [
@@ -17299,7 +17358,8 @@
"duplicate",
"no_permission",
"not_found",
"unknown"
"unknown",
"validation"
],
"type": "string"
},
@@ -17311,10 +17371,14 @@
"duplicate",
"no_permission",
"not_found",
"unknown"
"unknown",
"validation"
],
"type": "string"
},
"errorMessage": {
"type": "string"
},
"id": {
"description": "ID",
"type": "string"
@@ -17828,6 +17892,52 @@
],
"type": "object"
},
"DuplicateResolveDto": {
"properties": {
"groups": {
"description": "List of duplicate groups to resolve",
"items": {
"$ref": "#/components/schemas/DuplicateResolveGroupDto"
},
"minItems": 1,
"type": "array"
}
},
"required": [
"groups"
],
"type": "object"
},
"DuplicateResolveGroupDto": {
"properties": {
"duplicateId": {
"format": "uuid",
"type": "string"
},
"keepAssetIds": {
"description": "Asset IDs to keep",
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
},
"trashAssetIds": {
"description": "Asset IDs to trash or delete",
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
}
},
"required": [
"duplicateId",
"keepAssetIds",
"trashAssetIds"
],
"type": "object"
},
"DuplicateResponseDto": {
"properties": {
"assets": {
@@ -17840,11 +17950,20 @@
"duplicateId": {
"description": "Duplicate group ID",
"type": "string"
},
"suggestedKeepAssetIds": {
"description": "Suggested asset IDs to keep based on file size and EXIF data",
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
}
},
"required": [
"assets",
"duplicateId"
"duplicateId",
"suggestedKeepAssetIds"
],
"type": "object"
},
@@ -19129,7 +19248,7 @@
"type": "boolean"
},
"withPeople": {
"description": "Include assets with people",
"description": "Include people data in response",
"type": "boolean"
},
"withStacked": {
@@ -20868,7 +20987,7 @@
"type": "boolean"
},
"withPeople": {
"description": "Include assets with people",
"description": "Include people data in response",
"type": "boolean"
},
"withStacked": {
+1 -1
View File
@@ -1 +1 @@
24.13.1
24.14.0
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "2.6.2",
"version": "2.6.3",
"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.11.0",
"@types/node": "^24.12.0",
"typescript": "^5.3.3"
},
"repository": {
@@ -28,6 +28,6 @@
"directory": "open-api/typescript-sdk"
},
"volta": {
"node": "24.13.1"
"node": "24.14.0"
}
}
+36 -5
View File
@@ -1,6 +1,6 @@
/**
* Immich
* 2.6.2
* 2.6.3
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@@ -725,6 +725,7 @@ export type BulkIdsDto = {
export type BulkIdResponseDto = {
/** Error reason if failed */
error?: Error;
errorMessage?: string;
/** ID */
id: string;
/** Whether operation succeeded */
@@ -1163,6 +1164,19 @@ export type DuplicateResponseDto = {
assets: AssetResponseDto[];
/** Duplicate group ID */
duplicateId: string;
/** Suggested asset IDs to keep based on file size and EXIF data */
suggestedKeepAssetIds: string[];
};
export type DuplicateResolveGroupDto = {
duplicateId: string;
/** Asset IDs to keep */
keepAssetIds: string[];
/** Asset IDs to trash or delete */
trashAssetIds: string[];
};
export type DuplicateResolveDto = {
/** List of duplicate groups to resolve */
groups: DuplicateResolveGroupDto[];
};
export type PersonResponseDto = {
/** Person date of birth */
@@ -1741,7 +1755,7 @@ export type MetadataSearchDto = {
withDeleted?: boolean;
/** Include EXIF data in response */
withExif?: boolean;
/** Include assets with people */
/** Include people data in response */
withPeople?: boolean;
/** Include stacked assets */
withStacked?: boolean;
@@ -1855,7 +1869,7 @@ export type RandomSearchDto = {
withDeleted?: boolean;
/** Include EXIF data in response */
withExif?: boolean;
/** Include assets with people */
/** Include people data in response */
withPeople?: boolean;
/** Include stacked assets */
withStacked?: boolean;
@@ -4531,6 +4545,21 @@ export function getAssetDuplicates(opts?: Oazapfts.RequestOpts) {
...opts
}));
}
/**
* Resolve duplicate groups
*/
export function resolveDuplicates({ duplicateResolveDto }: {
duplicateResolveDto: DuplicateResolveDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: BulkIdResponseDto[];
}>("/duplicates/resolve", oazapfts.json({
...opts,
method: "POST",
body: duplicateResolveDto
})));
}
/**
* Delete a duplicate
*/
@@ -6893,13 +6922,15 @@ export enum BulkIdErrorReason {
Duplicate = "duplicate",
NoPermission = "no_permission",
NotFound = "not_found",
Unknown = "unknown"
Unknown = "unknown",
Validation = "validation"
}
export enum Error {
Duplicate = "duplicate",
NoPermission = "no_permission",
NotFound = "not_found",
Unknown = "unknown"
Unknown = "unknown",
Validation = "validation"
}
export enum Permission {
All = "all",
+2 -2
View File
@@ -1,9 +1,9 @@
{
"name": "immich-monorepo",
"version": "2.6.2",
"version": "2.6.3",
"description": "Monorepo for Immich",
"private": true,
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
"engines": {
"pnpm": ">=10.0.0"
}
+107 -107
View File
@@ -15,9 +15,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
"cpu": [
"ppc64"
],
@@ -32,9 +32,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
"cpu": [
"arm"
],
@@ -49,9 +49,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
"cpu": [
"arm64"
],
@@ -66,9 +66,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
"cpu": [
"x64"
],
@@ -83,9 +83,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
"cpu": [
"arm64"
],
@@ -100,9 +100,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
"cpu": [
"x64"
],
@@ -117,9 +117,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
"cpu": [
"arm64"
],
@@ -134,9 +134,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
"cpu": [
"x64"
],
@@ -151,9 +151,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
"cpu": [
"arm"
],
@@ -168,9 +168,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
"cpu": [
"arm64"
],
@@ -185,9 +185,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
"cpu": [
"ia32"
],
@@ -202,9 +202,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
"cpu": [
"loong64"
],
@@ -219,9 +219,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
"cpu": [
"mips64el"
],
@@ -236,9 +236,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
"cpu": [
"ppc64"
],
@@ -253,9 +253,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
"cpu": [
"riscv64"
],
@@ -270,9 +270,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
"cpu": [
"s390x"
],
@@ -287,9 +287,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
"cpu": [
"x64"
],
@@ -304,9 +304,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
"cpu": [
"arm64"
],
@@ -321,9 +321,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
"cpu": [
"x64"
],
@@ -338,9 +338,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
"cpu": [
"arm64"
],
@@ -355,9 +355,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
"cpu": [
"x64"
],
@@ -372,9 +372,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
"cpu": [
"arm64"
],
@@ -389,9 +389,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
"cpu": [
"x64"
],
@@ -406,9 +406,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
"cpu": [
"arm64"
],
@@ -423,9 +423,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
"cpu": [
"ia32"
],
@@ -440,9 +440,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
"cpu": [
"x64"
],
@@ -467,9 +467,9 @@
}
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -480,32 +480,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.3",
"@esbuild/android-arm": "0.27.3",
"@esbuild/android-arm64": "0.27.3",
"@esbuild/android-x64": "0.27.3",
"@esbuild/darwin-arm64": "0.27.3",
"@esbuild/darwin-x64": "0.27.3",
"@esbuild/freebsd-arm64": "0.27.3",
"@esbuild/freebsd-x64": "0.27.3",
"@esbuild/linux-arm": "0.27.3",
"@esbuild/linux-arm64": "0.27.3",
"@esbuild/linux-ia32": "0.27.3",
"@esbuild/linux-loong64": "0.27.3",
"@esbuild/linux-mips64el": "0.27.3",
"@esbuild/linux-ppc64": "0.27.3",
"@esbuild/linux-riscv64": "0.27.3",
"@esbuild/linux-s390x": "0.27.3",
"@esbuild/linux-x64": "0.27.3",
"@esbuild/netbsd-arm64": "0.27.3",
"@esbuild/netbsd-x64": "0.27.3",
"@esbuild/openbsd-arm64": "0.27.3",
"@esbuild/openbsd-x64": "0.27.3",
"@esbuild/openharmony-arm64": "0.27.3",
"@esbuild/sunos-x64": "0.27.3",
"@esbuild/win32-arm64": "0.27.3",
"@esbuild/win32-ia32": "0.27.3",
"@esbuild/win32-x64": "0.27.3"
"@esbuild/aix-ppc64": "0.27.4",
"@esbuild/android-arm": "0.27.4",
"@esbuild/android-arm64": "0.27.4",
"@esbuild/android-x64": "0.27.4",
"@esbuild/darwin-arm64": "0.27.4",
"@esbuild/darwin-x64": "0.27.4",
"@esbuild/freebsd-arm64": "0.27.4",
"@esbuild/freebsd-x64": "0.27.4",
"@esbuild/linux-arm": "0.27.4",
"@esbuild/linux-arm64": "0.27.4",
"@esbuild/linux-ia32": "0.27.4",
"@esbuild/linux-loong64": "0.27.4",
"@esbuild/linux-mips64el": "0.27.4",
"@esbuild/linux-ppc64": "0.27.4",
"@esbuild/linux-riscv64": "0.27.4",
"@esbuild/linux-s390x": "0.27.4",
"@esbuild/linux-x64": "0.27.4",
"@esbuild/netbsd-arm64": "0.27.4",
"@esbuild/netbsd-x64": "0.27.4",
"@esbuild/openbsd-arm64": "0.27.4",
"@esbuild/openbsd-x64": "0.27.4",
"@esbuild/openharmony-arm64": "0.27.4",
"@esbuild/sunos-x64": "0.27.4",
"@esbuild/win32-arm64": "0.27.4",
"@esbuild/win32-ia32": "0.27.4",
"@esbuild/win32-x64": "0.27.4"
}
},
"node_modules/typescript": {
+1075 -1110
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -27,6 +27,10 @@
"matchUpdateTypes": ["major"],
"enabled": false
},
{
"matchPackageNames": ["ghcr.io/immich-app/base-server-*"],
"maxMajorIncrement": 0
},
{
"matchPackageNames": ["ruby"],
"groupName": "ruby",
+1 -1
View File
@@ -1 +1 @@
24.13.1
24.14.0
+3 -3
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/immich-app/base-server-dev:202603031112@sha256:837536db5fd9e432f0f474ef9b61712fe3b3815821c3e4edf5e5b0b1f1ed30ad AS builder
FROM ghcr.io/immich-app/base-server-dev:202603251709@sha256:2bf3053c732fcb87ec90c3c614632ac44847423468ccc57fd935bff771828d9d AS builder
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp \
@@ -52,7 +52,7 @@ FROM builder AS plugins
ARG TARGETPLATFORM
COPY --from=ghcr.io/jdx/mise:2026.1.1@sha256:a55c391f7582f34c58bce1a85090cd526596402ba77fc32b06c49b8404ef9c14 /usr/local/bin/mise /usr/local/bin/mise
COPY --from=ghcr.io/jdx/mise:2026.3.12@sha256:0210678cbf58413806531a27adb2c7daf1c37238e56e8f7ea381d73521571775 /usr/local/bin/mise /usr/local/bin/mise
WORKDIR /usr/src/app
COPY ./plugins/mise.toml ./plugins/
@@ -71,7 +71,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
cd plugins && mise run build
FROM ghcr.io/immich-app/base-server-prod:202603031112@sha256:bb8c8645ee61977140121e56ba09db7ae656a7506f9a6af1be8461b4d81fdf03
FROM ghcr.io/immich-app/base-server-prod:202603251709@sha256:17de30977ff87aa06758a56ad7f10d6b5c97bf9dab76e4ec4177a2a8d1b2b5f3
WORKDIR /usr/src/app
ENV NODE_ENV=production \
+1 -1
View File
@@ -1,5 +1,5 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:202603031112@sha256:837536db5fd9e432f0f474ef9b61712fe3b3815821c3e4edf5e5b0b1f1ed30ad AS dev
FROM ghcr.io/immich-app/base-server-dev:202603251709@sha256:2bf3053c732fcb87ec90c3c614632ac44847423468ccc57fd935bff771828d9d AS dev
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
+6 -7
View File
@@ -15,13 +15,12 @@ log_message() {
log_message "Initializing Immich $IMMICH_SOURCE_REF"
# TODO: Update to mimalloc v3 when verified memory isn't released issue is fixed
# lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.3"
# if [ -f "$lib_path" ]; then
# export LD_PRELOAD="$lib_path"
# else
# echo "skipping libmimalloc - path not found $lib_path"
# fi
lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.3"
if [ -f "$lib_path" ]; then
export LD_PRELOAD="$lib_path"
else
echo "skipping libmimalloc - path not found $lib_path"
fi
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:/usr/lib/jellyfin-ffmpeg/lib"
SERVER_HOME="$(readlink -f "$(dirname "$0")/..")"
+21
View File
@@ -0,0 +1,21 @@
{
"contentSecurityPolicy": {
"directives": {
"default-src": ["'self'"],
"script-src": ["'self'", "'wasm-unsafe-eval", "'unsafe-inline'", "https://www.gstatic.com"],
"style-src": ["'self'", "'unsafe-inline'"],
"img-src": ["'self'", "'data:'", "'blob:'"],
"connect-src": [
"'self'",
"blob:",
"https://pay.futo.org",
"https://static.immich.cloud",
"https://tiles.immich.cloud"
],
"worker-src": ["'self'", "blob:"],
"frame-src": ["'none'"],
"object-src": ["'none'"],
"base-uri": ["'self'"]
}
}
}
+10 -4
View File
@@ -1,10 +1,15 @@
{
"name": "immich",
"version": "2.6.2",
"version": "2.6.3",
"description": "",
"author": "",
"private": true,
"license": "GNU Affero General Public License version 3",
"files": [
"bin",
"dist",
"helmet.json"
],
"scripts": {
"build": "nest build",
"format": "prettier --cache --check .",
@@ -77,12 +82,13 @@
"fluent-ffmpeg": "^2.1.2",
"geo-tz": "^8.0.0",
"handlebars": "^4.7.8",
"helmet": "^8.1.0",
"i18n-iso-countries": "^7.6.0",
"ioredis": "^5.8.2",
"jose": "^5.10.0",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"kysely": "0.28.11",
"kysely": "0.28.14",
"kysely-postgres-js": "^3.0.0",
"lodash": "^4.17.21",
"luxon": "^3.4.2",
@@ -136,7 +142,7 @@
"@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^2.0.0",
"@types/node": "^24.11.0",
"@types/node": "^24.12.0",
"@types/nodemailer": "^7.0.0",
"@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5",
@@ -168,7 +174,7 @@
"vitest": "^3.0.0"
},
"volta": {
"node": "24.13.1"
"node": "24.14.0"
},
"overrides": {
"sharp": "^0.34.5"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -2,9 +2,10 @@ import { NestExpressApplication } from '@nestjs/platform-express';
import { json } from 'body-parser';
import compression from 'compression';
import cookieParser from 'cookie-parser';
import helmetMiddleware from 'helmet';
import { existsSync } from 'node:fs';
import sirv from 'sirv';
import { excludePaths, serverVersion } from 'src/constants';
import { IMMICH_SERVER_START, excludePaths, serverVersion } from 'src/constants';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { ConfigRepository } from 'src/repositories/config.repository';
@@ -39,7 +40,7 @@ export async function configureExpress(
},
) {
const configRepository = app.get(ConfigRepository);
const { environment, host, port, resourcePaths, network } = configRepository.getEnv();
const { environment, host, port, helmet, resourcePaths, network } = configRepository.getEnv();
const logger = await app.resolve(LoggingRepository);
logger.setContext('Bootstrap');
@@ -47,6 +48,12 @@ export async function configureExpress(
app.set('trust proxy', ['loopback', ...network.trustedProxies]);
app.set('etag', 'strong');
if (helmet.config) {
app.use(helmetMiddleware(helmet.config));
logger.log('Initialized helmet middleware');
}
app.use(cookieParser());
app.use(json({ limit: '10mb' }));
@@ -83,5 +90,5 @@ export async function configureExpress(
const server = await (host ? app.listen(port, host) : app.listen(port));
server.requestTimeout = 24 * 60 * 60 * 1000;
logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${environment}] `);
logger.log(`${IMMICH_SERVER_START} on ${await app.getUrl()} [v${serverVersion}] [${environment}] `);
}
+2
View File
@@ -29,6 +29,7 @@ import { ProcessRepository } from 'src/repositories/process.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { services } from 'src/services';
import { AuthService } from 'src/services/auth.service';
@@ -111,6 +112,7 @@ export class ApiModule extends BaseModule {}
StorageRepository,
ProcessRepository,
DatabaseRepository,
UserRepository,
SystemMetadataRepository,
AppRepository,
MaintenanceHealthRepository,
+2
View File
@@ -4,6 +4,8 @@ import { dirname, join } from 'node:path';
import { SemVer } from 'semver';
import { ApiTag, AudioCodec, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
export const IMMICH_SERVER_START = 'Immich Server is listening';
export const ErrorMessages = {
InconsistentMediaLocation:
'Detected an inconsistent media location. For more information, see https://docs.immich.app/errors#inconsistent-media-location',
@@ -0,0 +1,47 @@
import { DuplicateController } from 'src/controllers/duplicate.controller';
import { DuplicateService } from 'src/services/duplicate.service';
import request from 'supertest';
import { factory } from 'test/small.factory';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(DuplicateController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(DuplicateService);
beforeAll(async () => {
ctx = await controllerSetup(DuplicateController, [{ provide: DuplicateService, useValue: service }]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
ctx.reset();
});
describe('GET /duplicates', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/duplicates');
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('DELETE /duplicates', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).delete('/duplicates');
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('DELETE /duplicates/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).delete(`/duplicates/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/duplicates/123`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['id must be a UUID']));
});
});
});
+15 -3
View File
@@ -1,9 +1,9 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { DuplicateResponseDto } from 'src/dtos/duplicate.dto';
import { DuplicateResolveDto, DuplicateResponseDto } from 'src/dtos/duplicate.dto';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { DuplicateService } from 'src/services/duplicate.service';
@@ -48,4 +48,16 @@ export class DuplicateController {
deleteDuplicate(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
@Post('resolve')
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.DuplicateDelete })
@Endpoint({
summary: 'Resolve duplicate groups',
description: 'Resolve duplicate groups by synchronizing metadata across assets and deleting/trashing duplicates.',
history: new HistoryBuilder().added('v3.0.0').alpha('v3.0.0'),
})
resolveDuplicates(@Auth() auth: AuthDto, @Body() dto: DuplicateResolveDto): Promise<BulkIdResponseDto[]> {
return this.service.resolve(auth, dto);
}
}
+4
View File
@@ -5,6 +5,7 @@ import {
AssetFileType,
AssetType,
AssetVisibility,
ChecksumAlgorithm,
MemoryType,
Permission,
PluginContext,
@@ -112,6 +113,7 @@ export type Memory = {
export type Asset = {
id: string;
checksum: Buffer<ArrayBufferLike>;
checksumAlgorithm: ChecksumAlgorithm;
deviceAssetId: string;
deviceId: string;
fileCreatedAt: Date;
@@ -330,6 +332,7 @@ export const columns = {
asset: [
'asset.id',
'asset.checksum',
'asset.checksumAlgorithm',
'asset.deviceAssetId',
'asset.deviceId',
'asset.fileCreatedAt',
@@ -345,6 +348,7 @@ export const columns = {
'asset.type',
'asset.width',
'asset.height',
'asset.isEdited',
],
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'],
assetFilesForThumbnail: [
@@ -23,6 +23,7 @@ export enum BulkIdErrorReason {
NO_PERMISSION = 'no_permission',
NOT_FOUND = 'not_found',
UNKNOWN = 'unknown',
VALIDATION = 'validation',
}
export class BulkIdsDto {
@@ -37,4 +38,5 @@ export class BulkIdResponseDto {
success!: boolean;
@ApiPropertyOptional({ description: 'Error reason if failed', enum: BulkIdErrorReason })
error?: BulkIdErrorReason;
errorMessage?: string;
}
+2 -1
View File
@@ -13,7 +13,7 @@ import {
} from 'src/dtos/person.dto';
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { AssetStatus, AssetType, AssetVisibility, ChecksumAlgorithm } from 'src/enum';
import { ImageDimensions, MaybeDehydrated } from 'src/types';
import { getDimensions } from 'src/utils/asset.util';
import { hexOrBufferToBase64 } from 'src/utils/bytes';
@@ -148,6 +148,7 @@ export type MapAsset = {
updateId: string;
status: AssetStatus;
checksum: Buffer<ArrayBufferLike>;
checksumAlgorithm: ChecksumAlgorithm;
deviceAssetId: string;
deviceId: string;
duplicateId: string | null;
+26
View File
@@ -1,9 +1,35 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayMinSize, IsArray, ValidateNested } from 'class-validator';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { ValidateUUID } from 'src/validation';
export class DuplicateResponseDto {
@ApiProperty({ description: 'Duplicate group ID' })
duplicateId!: string;
@ApiProperty({ description: 'Duplicate assets' })
assets!: AssetResponseDto[];
@ValidateUUID({ each: true, description: 'Suggested asset IDs to keep based on file size and EXIF data' })
suggestedKeepAssetIds!: string[];
}
export class DuplicateResolveGroupDto {
@ValidateUUID()
duplicateId!: string;
@ValidateUUID({ each: true, description: 'Asset IDs to keep' })
keepAssetIds!: string[];
@ValidateUUID({ each: true, description: 'Asset IDs to trash or delete' })
trashAssetIds!: string[];
}
export class DuplicateResolveDto {
@ApiProperty({ description: 'List of duplicate groups to resolve' })
@ValidateNested({ each: true })
@IsArray()
@Type(() => DuplicateResolveGroupDto)
@ArrayMinSize(1)
groups!: DuplicateResolveGroupDto[];
}
+4
View File
@@ -42,6 +42,10 @@ export class EnvDto {
@Optional()
IMMICH_CONFIG_FILE?: string;
@IsString()
@Optional()
IMMICH_HELMET_FILE?: string;
@IsEnum(ImmichEnvironment)
@Optional()
IMMICH_ENV?: ImmichEnvironment;
+1 -1
View File
@@ -146,7 +146,7 @@ export class RandomSearchDto extends BaseSearchWithResultsDto {
@ValidateBoolean({ optional: true, description: 'Include stacked assets' })
withStacked?: boolean;
@ValidateBoolean({ optional: true, description: 'Include assets with people' })
@ValidateBoolean({ optional: true, description: 'Include people data in response' })
withPeople?: boolean;
}
+5
View File
@@ -37,6 +37,11 @@ export enum AssetType {
Other = 'OTHER',
}
export enum ChecksumAlgorithm {
sha1File = 'sha1', // sha1 checksum of the whole file contents
sha1Path = 'sha1-path', // sha1 checksum of "path:" plus the file path, currently used in external libraries, deprecated
}
export enum AssetFileType {
/**
* An full/large-size image extracted/converted from RAW photos
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { fork } from 'node:child_process';
import { dirname, join } from 'node:path';
import { IMMICH_SERVER_START } from 'src/constants';
@Injectable()
export class MaintenanceHealthRepository {
@@ -20,45 +21,27 @@ export class MaintenanceHealthRepository {
stdio: ['ignore', 'pipe', 'ignore', 'ipc'],
});
async function checkHealth() {
try {
const response = await fetch('http://127.0.0.1:33001/api/server/config');
const { isOnboarded } = await response.json();
if (isOnboarded) {
resolve();
} else {
reject(new Error('Server health check failed, no admin exists.'));
}
} catch (error) {
reject(error);
} finally {
if (worker.exitCode === null) {
worker.kill('SIGTERM');
}
}
}
let output = '',
alive = false;
let output = '';
worker.stdout?.on('data', (data) => {
if (alive) {
if (worker.exitCode !== null) {
return;
}
output += data;
if (output.includes('Immich Server is listening')) {
alive = true;
void checkHealth();
if (output.includes(IMMICH_SERVER_START)) {
resolve();
worker.kill('SIGTERM');
}
});
worker.on('exit', reject);
worker.on('error', reject);
worker.on('exit', (code, signal) => reject(`Server health check failed, server exited with ${signal ?? code}`));
worker.on('error', (error) => reject(`Server health check failed, process threw: ${error}`));
setTimeout(() => {
if (worker.exitCode === null) {
reject('Server health check failed, took too long to start.');
worker.kill('SIGTERM');
}
}, 20_000);
+10
View File
@@ -160,6 +160,16 @@ where
"session"."userId" = $1
and "session"."id" in ($2)
-- AccessRepository.duplicate.checkOwnerAccess
select
"asset"."duplicateId"
from
"asset"
where
"asset"."duplicateId" in ($1)
and "asset"."ownerId" = $2
and "asset"."deletedAt" is null
-- AccessRepository.memory.checkOwnerAccess
select
"memory"."id"

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