Compare commits

...

202 Commits

Author SHA1 Message Date
shenlong-tanwen
5ccc05feeb fix: foreground cloud sync 2026-01-22 20:37:44 +05:30
Alex
7cbfc12e0d chore: use context menu for user table (#25428)
* chore: use context menu for user table

* chore: reorder columns

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-01-22 07:44:08 -05:00
Mees Frensel
c320146538 fix: add scoped API permissions to map endpoints (#25423) 2026-01-22 07:43:29 -05:00
solluh
3304c8efd8 docs: update README_de_DE.md (#25443) 2026-01-22 12:55:44 +01:00
Daniel Dietzler
2dcb4efc40 fix: lock tags column on update (#25435) 2026-01-21 21:20:05 -05:00
Alex
2f1d1edf10 chore: use context menu for library table (#25429)
* chore: use context menu for library table

* chore: add user detail link and menu divider

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-01-21 15:07:11 -06:00
Jason Rasmussen
1b032339aa refactor(web): asset job actions (#25426) 2026-01-21 13:13:16 -05:00
Jason Rasmussen
dc82c13ddc refactor(web): user setting actions (#25424) 2026-01-21 13:13:07 -05:00
Jason Rasmussen
417af66f30 refactor(web): on person thumbnail (#25422) 2026-01-21 13:13:02 -05:00
Min Idzelis
280f906e4b feat: handle-error minor improvments (#25288)
* feat: handle-error minor improvments

* review comments

* Update web/src/lib/utils/handle-error.ts

Co-authored-by: Jason Rasmussen <jason@rasm.me>

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-01-21 16:46:08 +00:00
Alex
b669714bda chore: lower case text + facelift (#25263)
* chore: lower case text

* wip

* wip

* pr feedback

* pr feedback
2026-01-21 16:41:09 +00:00
Alex
0f6606848e fix: upload file without extension (#25419)
* fix: upload file without extension

* chore: fix foreground upload
2026-01-21 16:31:06 +00:00
aviv926
1a8671d940 feat(docs): add Free Up Space section (#25253)
* feat(docs): add Free Up Space tool section with usage details and warnings

* typo
2026-01-21 10:29:59 -06:00
shenlong
fb94ee80aa fix: prevent cloud id sync on app pause (#25332)
* fix: sever version not populated post auto-login

* saferun syncCloudIds

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-01-21 09:54:08 -06:00
Mees Frensel
083ee0b5fe fix(web): allow exiting pin setup flow (#25413) 2026-01-21 09:53:15 -06:00
Jason Rasmussen
0bae88bef6 refactor(web): person service actions (#25402)
* refactor(web): person service actions

* fix: timeline e2e tests
2026-01-21 10:40:09 -05:00
Daniel Dietzler
184f1a6d32 fix: tag update race condition (#25371) 2026-01-21 16:30:45 +01:00
Jason Rasmussen
248cb86143 chore: disable broken e2e timeline tests (#25417) 2026-01-21 10:14:08 -05:00
Daniel Dietzler
1649d87360 refactor: extract isEdited into its own column in asset_file (#25358) 2026-01-21 16:08:21 +01:00
Mees Frensel
8970566865 fix(web): handle deletion from asset viewer on map page (#25393) 2026-01-21 14:08:01 +00:00
Alex
0b4a96140e fix: don't include metadata when upload motion part of LivePhotos (#25400)
* fix: don't include metadata when upload motion part of LivePhotos

* fix: get original file name
2026-01-21 13:58:32 +00:00
Noel S
72caf8983c fix(mobile): indicators not showing on thumbnail tile after asset change in viewer (#25297)
* fixed indicators staying hidden

* remove logs

* explanation comment

* move import to correct place

* revert accidental change in null handling
2026-01-20 14:02:54 -06:00
Paul Makles
61a9d5cbc7 feat: restore database backups (#23978)
* feat: ProcessRepository#createSpawnDuplexStream

* test: write tests for ProcessRepository#createSpawnDuplexStream

* feat: StorageRepository#createGzip,createGunzip,createPlainReadStream

* feat: backups util (args, create, restore, progress)

* feat: wait on maintenance operation lock on boot

* chore: use backup util from backup.service.ts
test: update backup.service.ts tests with new util

* feat: list/delete backups (maintenance services)

* chore: open api
fix: missing action in cli.service.ts

* chore: add missing repositories to MaintenanceModule

* refactor: move logSecret into module init

* feat: initialise StorageCore in maintenance mode

* feat: authenticate websocket requests in maintenance mode

* test: add mock for new storage fns

* feat: add MaintenanceEphemeralStateRepository
refactor: cache the secret in memory

* test: update service worker tests

* feat: add external maintenance mode status

* feat: synchronised status, restore db action

* test: backup restore service tests

* refactor: DRY end maintenance

* feat: list and delete backup routes

* feat: start action on boot

* fix: should set status on restore end

* refactor: add maintenanceStore to hold writables

* feat: sync status to web app

* feat: web impl.

* test: various utils for testings

* test: web e2e tests

* test: e2e maintenance spec

* test: update cli spec

* chore: e2e lint

* chore: lint fixes

* chore: lint fixes

* feat: start restore flow route

* test: update e2e tests

* chore: remove neon lights on maintenance action pages

* fix: use 'startRestoreFlow' on onboarding page

* chore: ignore any library folder in `docker/`

* fix: load status on boot

* feat: upload backups

* refactor: permit any .sql(.gz) to be listed/restored

* feat: download backups from list

* fix: permit uploading just .sql files

* feat: restore just .sql files

* fix: don't show backups list if logged out

* feat: system integrity check in restore flow

* test: not providing failed backups in API anymore

* test: util should also not try to use failedBackups

* fix: actually assign inputStream

* test: correct test backup prep.

* fix: ensure task is defined to show error

* test: fix docker cp command

* test: update e2e web spec to select next button

* test: update e2e api tests

* test: refactor timeouts

* chore: remove `showDelete` from maint. settings

* chore: lint

* chore: lint

* fix: make sure backups are correctly sorted for clean up

* test: update service spec

* test: adjust e2e timeout

* test: increase web timeouts for ci

* chore: move gitignore changes

* chore: additional filename validation

* refactor: better typings for integrity API

* feat: higher accuracy progress tracking

* chore: delay lock retry

* refactor: remove old maintenance settings

* refactor: clean up tailwind classes

* refactor: use while loop rather than recursive calls

* test: update service specs

* chore: check canParse too

* chore: lint

* fix: logic error causing infinite loop

* refactor: use <ProgressBar /> from ui library

* fix: create or overwrite file

* chore: i18n pass, update progress bar

* fix: wrong translation string

* chore: update colour variables

* test: update web test for new maint. page

* chore: format, fix key

* test: update tests to be more linter complaint & use new routines

* chore: update onClick -> onAction, title -> breadcrumbs

* fix: use wrench icon in admin settings sidebar

* chore: add translation strings to accordion

* chore: lint

* refactor: move maintenance worker init into service

* refactor: `maintenanceStatus` -> `getMaintenanceStatus`
refactor: `integrityCheck` -> `detectPriorInstall`
chore: add `v2.4.0` version
refactor: `/backups/list` -> `/backups`
refactor: use sendFile in download route
refactor: use separate backups permissions
chore: correct descriptions
refactor: permit handler that doesn't return promise for sendfile

* refactor: move status impl into service
refactor: add active flag to maintenance status

* refactor: split into database backup controller

* test: split api e2e tests and passing

* fix: move end button into authed default maint page

* fix: also show in restore flow

* fix: import getMaintenanceStatus

* test: split web e2e tests

* refactor: ensure detect install is consistently named

* chore: ensure admin for detect install while out of maint.

* refactor: remove state repository

* test: update maint. worker service spec

* test: split backup service spec

* refactor: rename db backup routes

* refactor: instead of param, allow bulk backup deletion

* test: update sdk use in e2e test

* test: correct deleteBackup call

* fix: correct type for serverinstall response dto

* chore: validate filename for deletion

* test: wip

* test: backups no longer take path param

* refactor: scope util to database-backups instead of backups

* fix: update worker controller with new route

* chore: use new admin page actions

* chore: remove stray comment

* test: rename outdated test

* refactor: getter pattern for maintenance secret

* refactor: `createSpawnDuplexStream` -> `spawnDuplexStream`

* refactor: prefer `Object.assign`

* refactor: remove useless try {} block

* refactor: prefer `type Props`
refactor: prefer arrow function

* refactor: use luxon API for minutesAgo

* chore: remove change to gitignore

* refactor: prefer `type Props`

* refactor: remove async from onMount

* refactor: use luxon toRelative for relative time

* refactor: duplicate logic check

* chore: open api

* refactor: begin moving code into web//services

* refactor: don't use template string with $t

* test: use dialog role to match prompt

* refactor: split actions into flow/restore

* test: fix action value

* refactor: move more service calls into web//services

* chore: should void fn return

* chore: bump 2.4.0 to 2.5.0 in controller

* chore: bump 2.4.0 to 2.5.0 in controller

* refactor: use events for web//services

* chore: open api

* chore: open api

* refactor: don't await returned promise

* refactor: remove redundant check

* refactor: add `type: command` to actions

* refactor: split backup entries into own component

* refactor: split restore flow into separate components

* refactor(web): split BackupDelete event

* chore: stylings

* chore: stylings

* fix: don't log query failure on first boot

* feat: support pg_dumpall backups

* feat: display information about each backup

* chore: i18n

* feat: rollback to restore point on migrations failure

* feat: health check after restore

* chore: format

* refactor: split health check into separate function

* refactor: split health into repository
test: write tests covering rollbacks

* fix: omit 'health' requirement from createDbBackup

* test(e2e): rollback test

* fix: wrap text in backup entry

* fix: don't shrink context menu button

* fix: correct CREATE DB syntax for postgres

* test: rename backups generated by test

* feat: add filesize to backup response dto

* feat: restore list

* feat: ui work

* fix: e2e test

* fix: e2e test

* pr feedback

* pr feedback

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-01-20 09:22:28 -06:00
Min Idzelis
ca0d4b283a feat: zoom image improvements for reactive prop handlings (#25286) 2026-01-20 13:18:54 +01:00
renovate[bot]
2b4e4051f0 fix(deps): update typescript-projects (#25377)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-01-20 11:20:27 +00:00
renovate[bot]
0f3956f654 chore(deps): update dependency @types/node to ^24.10.8 (#25376)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-01-20 10:44:39 +00:00
Alex
99bd7d5f27 chore: sharing action button position (#25381) 2026-01-20 01:43:57 +00:00
Alex
fe1d0edf4c chore: mobile font tuning (#25349)
* chore: mobile font tuning

* chore: fix some paddings

* setting page tune

* chore: album sort dropdown button styling

* pr feedback

* tweak sync status card

* chore: refactor
2026-01-19 14:56:35 -06:00
Arne Schwarck
4ef699e9fa feat: allow /memory?id= in AndroidManifest (#25373)
Allow /memory?id=

<!-- Allow singular memory route like /memory?id=... -->
2026-01-19 14:56:24 -06:00
Brandon Wees
3e21174dd8 chore: web editor improvements (#25169) 2026-01-19 18:57:15 +00:00
Brandon Wees
1b56bb84f9 fix: mobile edit handling (#25315)
Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2026-01-19 12:22:53 -06:00
Marius
b3f5b8ede8 fix(mobile): album selector icon visibility (#25311)
Add explicit color to sort direction arrows and view mode toggle icons in album selector widget. Previously they were invisible in light view, when opening album selector from image viewer.
2026-01-19 12:18:32 -06:00
Jason Rasmussen
2b77dc8e1f refactor(web): workflow create action (#25369) 2026-01-19 12:41:28 -05:00
Jason Rasmussen
97a594556b refactor: sharing page actions (#25368) 2026-01-19 12:16:16 -05:00
Jason Rasmussen
4a7c4b6d15 refactor(web): routes (#25365) 2026-01-19 12:07:31 -05:00
Jason Rasmussen
a8198f9934 refactor: lock session (#25366)
refafctor: lock session
2026-01-19 11:47:58 -05:00
Jason Rasmussen
b123beae38 fix(server): api key update checks (#25363) 2026-01-19 10:20:06 -05:00
Mees Frensel
1ada7a8340 chore(deps): ignore @parcel/watcher build script (#25361) 2026-01-19 09:08:25 -05:00
Matthew Momjian
5d81cace23 chore(docs): update RAM req (#25344)
* RAM req

* Update requirements.md
2026-01-18 17:52:08 -06:00
yy
65f9a228ba fix: typos in comments and error messages (#25320) 2026-01-17 18:58:26 -06:00
Kolin
e6eca895ba fix(web): add min-width to setting input field (#25317)
Prevents input fields from collapsing in flex layouts, such as the extension field in storage template settings. Fixes #25298.
2026-01-16 16:31:06 -06:00
Jason Rasmussen
8196bd9bbd refactor(web): routes (#25313) 2026-01-16 16:11:09 -05:00
Daniel Dietzler
07675a2de4 feat: download original asset (#25302)
Co-authored-by: bwees <brandonwees@gmail.com>
2026-01-16 19:05:13 +00:00
Jason Rasmussen
a2b03f7650 refactor(web): user sidebar (#25292) 2026-01-16 11:17:35 -05:00
Savely Krasovsky
fdff591a11 feat: update intel compute driver (#25259) 2026-01-16 14:42:55 +00:00
Alex
e4443fa43e chore: dart http foreground upload (#24883)
* feat: bring back manual backup

* expose iCloud retrieval progress

* wip

* unify http upload method, check for connectivity on iOS

* handle LivePhotos progress

* feat: speed calculation

* wip

* better upload detail page

* handle error

* handle error

* pr feedback

* feat: share intent upload

* feat: manual upload

* feat: manual upload progress

* chore: styling

* refactor

* refactor

* remove unused logs

* fix: background android backup

* feat: add error section

* remove complete section

* remove empty state and prevent slot jumps

* more refactor

* fix: background test

* chore: add metadata to foreground upload

* fix: email and name get reset in auth provider

* pr feedback

* remove version check for metadata field in upload payload

* chore: fix unit test

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-01-15 20:10:08 -06:00
Jason Rasmussen
843d563178 refactor(web): admin page layout (#25281)
* refactor(web): admin page layout

* chore: remove unused props
2026-01-15 18:58:43 -05:00
Min Idzelis
256d62e22d feat: thumbhash improvments for reactive prop updates (#25287) 2026-01-15 18:57:43 -05:00
shenlong
91592aa48e fix(mobile): drop unique constraint on cloud_id (#25291)
fix: drop unique constraint on cloud_id

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-01-15 17:06:29 -06:00
shenlong
2ac113624b chore: remote unused sync_cloud_ids key (#25290)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-01-15 16:56:05 -06:00
renovate[bot]
0052979853 chore(deps): update dependency svelte to v5.46.4 [security] (#25284)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 22:10:17 +01:00
renovate[bot]
79b6c4ac70 chore(deps): update dependency @sveltejs/kit to v2.49.5 [security] (#25280)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 15:07:26 -05:00
Alex
95eb3e26c3 chore: sidebar spacing (#25278) 2026-01-15 10:35:01 -06:00
Alex
613dc858cb chore: tweak table text size (#25276) 2026-01-15 11:06:34 -05:00
shenlong
2f3fbd7dc5 fix: ignore duplicate cloud ID updates (#25271)
* fix: ignore duplicate remote updates

* update cloudId when any one of the ETag part is mismatched

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-01-15 09:15:56 -06:00
Min Idzelis
80a5444bf4 feat: redesign asset-viewer previous/next and hide when nav not possible (#24903) 2026-01-15 12:55:01 +01:00
Jason Rasmussen
d59ee7d2ae feat(web): immich/ui select component (#25268)
Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-01-14 19:38:13 -06:00
idubnori
7b3a298c6a fix: Swagger UI generates incorrect double-prefixed URLs (/api/api/...) (#25266)
fix: add ignoreGlobalPrefix option to Swagger options
2026-01-14 16:55:17 -06:00
Alex
0a62ec7e29 chore: album option modal styling (#25269)
* chore: album option modal styling

* header action button color
2026-01-14 16:52:33 -06:00
Jason Rasmussen
21802ab5ba fix(server): prevent duplicate metadata items from being sent (#25267) 2026-01-14 16:52:06 -06:00
Daniel Dietzler
56dfdfd033 refactor: album share and options modals (#25212)
* refactor: album share modals

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-01-14 15:18:02 -05:00
Jason Rasmussen
2190921c85 chore: await api key nested modal (#25265) 2026-01-14 14:02:44 -05:00
shenlong
9fa8de7baa feat: add cloud id during native sync (#20418)
* use adjustment time in iOS for hash reset

# Conflicts:
#	mobile/lib/infrastructure/repositories/local_album.repository.dart
#	mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart

* migration

* feat: sync cloudId and eTag on sync

* fixes fixes

* more fixes

* re-sync updated eTags

* add server version check & auto sync cloud ids on compatible servers

* fix test

* remove button from sync status page

* chore: modify for testing

* more changes

* chore: add commas in toString

* use cached provider in splash screen

* read upload service provider to prevent reset

* log errors from fetching cloud id mapping

* WIP: migrate cloud id - debug log

* ignore locked asset update

* bulk update metadata

* change log text

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-01-14 12:34:11 -06:00
Akash Karmakar
ed9448a6ee fix: dark mode appbar color (#24976)
* fix: dark mode appbar color

* update: using scrolledUnderElevation for sufaceTint change

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-01-14 17:47:34 +00:00
Noel S
15224a9ac5 fix(mobile): improve asset transition back to timeline (#24485)
* test

* wip

* fix: indicators popping in due to z height change of hero animation (fade in instead after animation)

* wip

* fix: selection outline changing to transparent before animation finish

* Remove unnecessary changes and follow conventions

* remove accidentally included files

* clean up

* new approach

* detect hero animation.

* wip

* Revert "new approach"

This reverts commit 13919f6d04.

* remove delayed animation

* wip

* wip (need to fix first open not triggering indicator hide)

* fix indicators not hiding on first hero animation

* Add back hiding selection background container

* revert accidental regression

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-01-14 10:40:24 -06:00
Alex
6e00fd92ef chore: use fontWeight for Text component (#25262) 2026-01-14 16:25:30 +00:00
Alex
6fdd1ce41a chore: use font-mono (#25250)
* chore: use font-mono

* chore: override variable
2026-01-14 11:21:48 -05:00
Jason Rasmussen
91d4cd6824 refactor: tables (#25226) 2026-01-14 07:56:09 -05:00
Ben
c7254a0c30 fix(docs): add missing mermaid dependency and configuration (#25247)
* fix(docs): add missing mermaid dependency and configuration

* fix: include pnpm-lock.yaml

* fix: docusaurus config format issue
2026-01-13 23:13:34 -05:00
Jason Rasmussen
38f01a6b7d fix(web): redirect to login (#25254) 2026-01-13 23:11:14 -05:00
Jason Rasmussen
f194a7ea3e fix: migration order (#25249) 2026-01-13 14:47:58 -06:00
Noel S
05a7ba98c1 fix(mobile): prevent system UI from hiding on drag down gesture (#25240)
* fix system ui briefly disappearing

* code style change
2026-01-13 19:40:24 +00:00
Alex
edc513a3df feat(web): 2026 font (#25174)
* feat(web): 2026 font

* chore: docs font

* spacing tweak

* tweak minimum font weight and update ui lib

* small tweaks

* docs: small tweaks

* more tweaks
2026-01-13 18:19:09 +00:00
Yaros
39212a049c feat(web): search albums by description (#25244)
feat: search albums by description
2026-01-13 11:56:59 -06:00
renovate[bot]
9b4f370834 chore(deps): update node.js to v24.13.0 (#25243)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 16:19:10 +00:00
Alex
aba85b036c feat(mobile): 2026 font (#25213) 2026-01-13 09:59:57 -06:00
juliancarrivick
6e86697996 fix(web): Handle upload failures from public users (#24826) 2026-01-13 15:15:54 +00:00
Daniel Dietzler
cc90c912f5 chore: bump base images manually (#25241) 2026-01-13 13:36:39 +01:00
renovate[bot]
efd20ef0d4 chore(deps): update prom/prometheus docker digest to 1f0f50f (#25233)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 12:33:16 +01:00
renovate[bot]
0c0aa1f3c3 fix(deps): update typescript-projects (#25070)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-01-13 12:32:38 +01:00
aviv926
231a475a17 feat: Cleanup docs (#25223)
Cleanup docs
2026-01-12 13:50:02 -06:00
Yaros
94ea83c415 fix(web): ocr button not clickable for stacked assets (#25210) 2026-01-12 18:22:37 +00:00
ppnplus
4b5b9baa78 Update Thai README (remove "under active development" lines) (#25208)
Update Thai README

I've removed lines related to the Beta versions ("Project is under active development...") to make it consistent with the current English version.
2026-01-12 18:16:16 +00:00
Alex
3bf0d5b99f fix: asset local type casting (#25214) 2026-01-12 17:07:33 +00:00
Peter Ombodi
8ed81ac3e1 feat(mobile): do not restore locally deleted assets during trash sync (Android) (#24218)
* feat(trash_sync): do not restore assets deleted locally only
small fixes

* feat(trash_sync): revert tag name

* feat(trash_sync): resolve merge conflicts

* refactor(trash_sync): consolidate local asset deletion logic

* feat(mobile): Add TrashOrigin enum
Replace isRestorable to sourse
change related logic in repo

* feat(mobile): fix format

* fix(mobile): fix restoration scope

* fix(mobile): Add coverage for ActionService deleteLocal paths
Update LocalSyncService tests
Set default value for source column

* fix(mobile): db - require trash origin and update drift schema

---------

Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com>
2026-01-12 21:46:36 +05:30
Hemendra Singh Shekhawat
7992fe85d6 fix(web): added background gradient for video time visibility (#25138)
* fix(web): added background gradient for video time visibility

* fix(web): removed background gradient and added shadow to text and icon
2026-01-12 09:46:23 -06:00
Yaros
afe925a55e fix(web): show relevant navbar options for partner assets (#24832)
* fix(web): show relevant navbar options for partner

* fix(web): AssetSelectControlBar on photos & search routes

* chore: remove duplicate AssetSelectControlBar from search

* chore: formatting fix

* chore: change let to const
2026-01-12 09:41:33 -06:00
Daniel Dietzler
5e3f5f2b55 fix: unlock properties after successful sidecar write (#25168) 2026-01-12 14:01:38 +01:00
Jason Rasmussen
d4ad523eb3 refactor(web): user app settings (#25177) 2026-01-10 07:58:50 -05:00
Brandon Wees
e8c80d88a5 feat: image editing (#24155) 2026-01-09 17:59:52 -05:00
Jason Rasmussen
76241a7b2b refactor: user settings (#25166) 2026-01-09 17:11:07 -05:00
Jason Rasmussen
1e4af9731d refactor: modals (#25163) 2026-01-09 15:05:20 -05:00
Noel S
88327fb872 fix(mobile): remove weird zooming behaviour on videos and play/pause button delay (#24006)
disable scale gestures
2026-01-09 13:14:07 -06:00
Jason Rasmussen
702499b97d refactor: modals (#25162) 2026-01-09 13:03:57 -05:00
shenlong
da248414af refactor(mobile): form & form field (#25042)
* refactor: form & form field

* chore: remove unused components

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-01-09 09:26:36 -06:00
renovate[bot]
af2c232c87 chore(deps): update github-actions (major) (#25160)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-09 14:55:39 +00:00
Yaros
cca037b03c fix(web): person asset count doesn't update when navigating (#24438) 2026-01-09 15:55:23 +01:00
renovate[bot]
1d71bb5a79 chore(deps): update ghcr.io/jdx/mise docker tag to v2026 (#25159)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-09 14:52:24 +00:00
renovate[bot]
ee4f2c735d chore(deps): update immich-app/devtools action to v1.1.1 (#25066)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-09 15:42:49 +01:00
Min Idzelis
4d559a63ec fix: properly fix asset-viewer delete action, add tests (#25149)
Update timeline manager before nav, add e2e regression tests
2026-01-09 09:20:42 -05:00
Robert Schäfer
573e9b0d52 refactor(dev): dockerify auth-server (#24377)
Description
-----------

A while ago I asked on Discord if you people would be interested in removing incompatibilities with rootless docker. See: https://discord.com/channels/979116623879368755/1071165397228855327/1442974448776122592

The e2e tests in `e2e/src/api/specs/oauth.e2e-spec.ts` depend on a docker feature [host-gateway](https://docs.docker.com/reference/cli/dockerd/#configure-host-gateway-ip) that seemingly does not work on rootless docker.

So the suggested change is to dockerify the `auth-server` and not run it on the docker host.

I would love to receive feedback on this PR and feel free to request further improvements. Things that come to my mind:

* Compile typescript instead of using `tsx`
* Add hot-reloading of source files in `auth-server/` for development
* Add `eslint` configuration for the new folder

How Has This Been Tested?
------------------------

I'm running both default and rootless docker on my machine with [docker contexts](https://docs.docker.com/engine/manage-resources/contexts/):
```
docker context ls
NAME         DESCRIPTION                               DOCKER ENDPOINT                     ERROR
default                                                unix:///var/run/docker.sock
rootless *                                             unix:///run/user/1000/docker.sock
```

If I follow the steps from the [documentation](https://docs.immich.app/developer/testing) then `oauth.e2e-spec.ts` will fail because the `auth-server` on my host can't be reached.

The tests pass after these steps:
1. `git switch refactor-auth-server-as-service`
2. `make e2e`
3. In another terminal `cd e2e`
4. `pnpm run test src/api/specs/oauth.e2e-spec.ts` passes

Checklist:
----------

- [x] I have performed a self-review of my own code
- [x] I have made corresponding changes to the documentation if applicable
- [x] I have no unrelated changes in the PR.
- [ ] I have confirmed that any new dependencies are strictly necessary.
- [ ] I have written tests for new code (if applicable)
- [ ] I have followed naming conventions/patterns in the surrounding code
- [ ] All code in `src/services/` uses repositories implementations for database calls, filesystem operations, etc.
- [ ] All code in `src/repositories/` is pretty basic/simple and does not have any immich specific logic (that belongs in `src/services/`)
2026-01-09 08:59:11 -05:00
bo0tzz
a2502109ab fix: use my.immich.app as url placeholder in docs (#25153) 2026-01-09 11:46:55 +00:00
Timon
3cdece4945 fix(server): Document HTTP 200 response for duplicate uploads in OpenAPI (#25148)
* fix(server): Document HTTP 200 response for duplicate uploads in OpenAPI

* fix 201

* rename
2026-01-08 23:52:31 -05:00
Daniel Dietzler
520b825511 refactor: album page (#25140) 2026-01-08 22:27:20 +00:00
Jason Rasmussen
191401f2f1 fix: add asset upload medium test (#25144) 2026-01-08 22:01:25 +00:00
Jason Rasmussen
8136d7fd54 refactor(web): tag service (#25142) 2026-01-08 16:37:58 -05:00
Timon
5d1e486478 fix(server): avoid upserting empty metadata array (#25143) 2026-01-08 22:33:35 +01:00
Brandon Wees
85b0b97ef2 fix(web): apply changes to cursor.current instead of asset (#25136) 2026-01-08 22:31:41 +01:00
Jason Rasmussen
471fab0591 refactor: delete confirm modal (#25135) 2026-01-08 15:59:26 -05:00
Jason Rasmussen
6997ed83c4 refactor(web): set birthdate (#25139) 2026-01-08 15:41:20 -05:00
Jason Rasmussen
a2ba36c16d feat: bulk asset metadata endpoints (#25133) 2026-01-08 14:52:16 -05:00
Alex
109c79125d fix: description does not rerender when navigating between assets (#25137) 2026-01-08 13:32:43 -06:00
Jason Rasmussen
fbd49e0b79 refactor: memory lane (#25134) 2026-01-08 12:40:17 -05:00
Alex
1f20b6471c feat: use fastlane sigh to manage signing profiles (#25089)
* feat: use fastlane sigh to manage signing profiles

* remove unused secrects

* remove unused fallback
2026-01-08 03:02:21 +00:00
Alex
1d6a9f6e80 feat: free up space (#24999)
* feat(server): Support camera `make`, `model`, and `lensModel` in Storage Template (#24650)

* add support for make, model, lensModel in storage template

* no pkg lock

* Apply suggestion from @danieldietzler

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

* query and formatting

---------

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

* wip: copy-writing

* feat: cutoff date preset options and filter options

* fix: don't include iCloud Shared Album

* chore: message about excluding shared album assets

* feat: show preview in a separate page

* feat: show clean up hint modal after success deletion

* pr feedback

* pr feedback

* pr feedback

---------

Co-authored-by: Rahul Kumar Saini <rahul-kumar-saini@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2026-01-07 20:55:28 -06:00
Min Idzelis
0a9f1a3cbf feat: cache asset info for prev/next navigation (#24482)
Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-01-07 19:10:29 -05:00
Jason Rasmussen
4f803832ad refactor: download action (#25124) 2026-01-07 22:01:20 +00:00
Mees Frensel
ef4aec7398 chore: refactor ErrorLayout (#25094)
* chore: refactor ErrorLayout

* Align links to top
2026-01-07 15:49:04 -06:00
Jason Rasmussen
5bb3492616 refactor: favorite action (#25121) 2026-01-07 21:21:19 +00:00
Min Idzelis
78229baeab feat: improve asset-viewer next/prev perf and standardize preloading behavior (#24422)
Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-01-07 21:17:12 +01:00
Timon
81f269e2a9 fix(docs): Use full git clone in CI to enable accurate last update times (#25120) 2026-01-07 17:19:43 +00:00
Timon
225b0f9377 chore: use setup-uv action to install python (#25109)
chore: update GitHub Actions workflow to use setup-uv action to install python
2026-01-07 10:46:04 -05:00
Alex
30b90f9baa fix: propagate iCloud Shared Album flag (#25060)
* fix: propagate iCloud Shared Album flag

* chore: add migration
2026-01-06 19:46:25 -06:00
Jason Rasmussen
1293e473ca refactor: cast button (#25101) 2026-01-06 18:51:19 -05:00
Jason Rasmussen
1a24a2d35e refactor: asset viewer navbar actions (#25091) 2026-01-06 17:35:37 -05:00
Jason Rasmussen
f0f1687c79 refactor: asset view navbar onclose (#25087) 2026-01-06 15:41:53 +00:00
lif
ded980bfc3 fix(web): improve text contrast in minimized upload panel (#25075)
The minimized upload status buttons in dark mode had poor text
contrast because they used `text-gray-200` on colored backgrounds.
Changed to `text-light` which provides better contrast for both
light and dark modes on `bg-primary` and `bg-danger` backgrounds.

Fixes #24683

Signed-off-by: majiayu000 <1835304752@qq.com>
2026-01-06 15:23:28 +00:00
fabb
4cb56edebf fix: enter now submits the date modals (#25053)
* fix: enter now submits the date modals

* use FormModal

* apply prettier

* fix unit test
2026-01-06 09:08:54 -06:00
Daniel Dietzler
c411151560 chore: docs for contributing (#25082) 2026-01-06 09:07:44 -06:00
Brandon Wees
f52bd9f38a feat: use prettier for i18n translations (#24623) 2026-01-06 15:02:10 +00:00
Mees Frensel
006d02cfaf fix(web): server stats layout (#25085)
fix: server stats layout
2026-01-06 09:10:38 -05:00
lif
263f96da87 fix(server): search statistics with personIds returns 500 (#25074)
The searchAssetBuilder was incorrectly adding withFacesAndPeople
select when personIds was provided. This caused a SQL error because
the subquery referenced asset.id which wasn't selected in statistics
queries (only count(*) was selected).

The fix removes personIds from the condition that triggers adding
faces data to the select. The hasPeople filter (for personIds) is
still applied correctly for filtering.

Fixes #25003

Signed-off-by: majiayu000 <1835304752@qq.com>
2026-01-06 08:54:12 -05:00
Calvin Bochulak
f22affd836 feat(web): star rating keyboard shortcut (#24620)
Co-authored-by: idubnori <i.dub.nori@gmail.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-01-06 12:56:29 +00:00
Yaros
f5667cefd4 fix(web): broken asset urls if shared link has photos in name (#24451) 2026-01-06 13:49:08 +01:00
Hemendra Singh Shekhawat
7efce389b2 fix(web): long text taking more width than expected in duplicate manager (#24547) 2026-01-06 12:47:41 +00:00
lif
f59cff4f5d fix(web): use asset date for change date popup when single asset selected (#25076) 2026-01-06 13:37:51 +01:00
Jason Rasmussen
984f06ac40 refactor: asset viewer (#25059) 2026-01-05 21:02:01 +00:00
renovate[bot]
9d4a12dfd4 chore(deps): update node.js to v24.12.0 (#25046)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 20:13:34 +00:00
renovate[bot]
94730567ab fix(deps): update formatjs monorepo (major) (#25049)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 20:59:06 +01:00
Jason Rasmussen
57db5e64de chore(web): bump immich/ui for tooltips (#24632)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-01-05 19:51:03 +00:00
Jason Rasmussen
4d32968f2b refactor: redirect code (#25054) 2026-01-05 14:39:28 -05:00
renovate[bot]
10989e6927 fix(deps): update typescript-projects (#25047)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-01-05 16:49:09 +00:00
Jason Rasmussen
62cc12be3c refactor: asset from param (#25041) 2026-01-05 11:26:58 -05:00
Jason Rasmussen
1874557b95 fix: empty action context menu (#25043) 2026-01-05 11:26:23 -05:00
renovate[bot]
9a78547bf0 chore(deps): update dependency @types/node to ^24.10.4 (#25044)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 16:19:55 +00:00
renovate[bot]
0b1bd9deb1 chore(deps): update dependency vite-tsconfig-paths to v6 (#25048)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 17:18:22 +01:00
renovate[bot]
7202179d63 chore(deps): update grafana/grafana docker tag to v12.3.1 (#25045)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 17:16:58 +01:00
Jason Rasmussen
519a7df4cd refactor: trash page actions (#25039) 2026-01-05 10:48:55 -05:00
renovate[bot]
3762728c84 chore(deps): update docker.io/valkey/valkey:9 docker digest to 5463044 (#24800)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 16:47:19 +01:00
renovate[bot]
bc3fa2b3fb chore(deps): update prom/prometheus docker digest to 2b6f734 (#24801)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 16:46:53 +01:00
Jason Rasmussen
57fca378bc refactor: page container (#25038) 2026-01-05 10:44:29 -05:00
Flozza
eb718145c0 docs: config options for hardware transcoding (#24853) 2026-01-05 16:40:53 +01:00
Felipe Cury
c87c1866ae fix: grammar in trigger_description string (#24867)
Fix typo in trigger_description string
2026-01-05 15:36:48 +00:00
Nikhil Alapati
b190423d96 fix(server): migrate motion part of live photo (#24688)
Co-authored-by: Nikhil Alapati <nikhilalapati@meta.com>
2026-01-05 15:26:45 +00:00
Daniel Ramos
edd3ab7cc9 feat(server): implement switchable logging formats (console/json) (#24791)
* feat(server): add LogFormat enum and configuration

* feat(server): add structured logging formatters

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

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

This reverts commit 565e95ae68.

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

* refactor: rename LOG_FORMAT to IMMICH_LOG_FORMAT for consistency

* docs: add IMMICH_LOG_FORMAT documentation

* chore: format environment-variables.md

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

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

* Add checks for valid coordinate pairs

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

* fix: broken tests

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

* no pkg lock

* Apply suggestion from @danieldietzler

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

* query and formatting

---------

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

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

* feat: const Divider(height: 1)

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

* chore: refactor

* chore: refactor

* add test

---------

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

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

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

---------

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

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

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

* wip

* wip

* wip

* pr feedback

* refactor: picker field

* use showDialog directly

* better test

* refactor step selection modal

* move enable button to info form

* use  for Props

* pr feedback

* refactor ActionItem

* refactor ActionItem

* more refactor

* fix: new schemaformfield has value of the same type

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

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

* Log memory creation at default level

* Add explicit log for missing exif date time

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

* Log database migration start/end at default level

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

* chore(maintenance): validate app restart responses

* chore: mock the app restart callback

* fix: ack may not exist depending on caller

* refactor: move one shot into app.repository

* fix: send correct state in one shot

* chore: log restart event
2025-12-19 17:13:00 -05:00
Jason Rasmussen
5b80323326 refactor: library service (#24725) 2025-12-19 13:20:35 -05:00
Jason Rasmussen
1425b3da6b refactor: admin card (#24723) 2025-12-19 12:47:04 -05:00
Daniel Dietzler
3d2196b0f2 refactor: asset update medium tests (#24718) 2025-12-19 16:25:04 +00:00
869 changed files with 66842 additions and 15384 deletions

2
.github/.nvmrc vendored
View File

@@ -1 +1 @@
24.11.1 24.13.0

View File

@@ -30,18 +30,6 @@ on:
required: true required: true
IOS_CERTIFICATE_PASSWORD: IOS_CERTIFICATE_PASSWORD:
required: true required: true
IOS_PROVISIONING_PROFILE:
required: true
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION:
required: true
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION:
required: true
IOS_DEVELOPMENT_PROVISIONING_PROFILE:
required: true
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION:
required: true
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION:
required: true
FASTLANE_TEAM_ID: FASTLANE_TEAM_ID:
required: true required: true
pull_request: pull_request:
@@ -96,7 +84,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
ref: ${{ inputs.ref || github.sha }} ref: ${{ inputs.ref || github.sha }}
persist-credentials: false persist-credentials: false
@@ -115,7 +103,7 @@ jobs:
- name: Restore Gradle Cache - name: Restore Gradle Cache
id: cache-gradle-restore id: cache-gradle-restore
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -165,14 +153,14 @@ jobs:
fi fi
- name: Publish Android Artifact - name: Publish Android Artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: release-apk-signed name: release-apk-signed
path: mobile/build/app/outputs/flutter-apk/*.apk path: mobile/build/app/outputs/flutter-apk/*.apk
- name: Save Gradle Cache - name: Save Gradle Cache
id: cache-gradle-save id: cache-gradle-save
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
with: with:
path: | path: |
@@ -194,7 +182,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with: with:
ref: ${{ inputs.ref || github.sha }} ref: ${{ inputs.ref || github.sha }}
persist-credentials: false persist-credentials: false
@@ -240,35 +228,14 @@ jobs:
mkdir -p ~/.appstoreconnect/private_keys mkdir -p ~/.appstoreconnect/private_keys
echo "$API_KEY_CONTENT" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8 echo "$API_KEY_CONTENT" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8
- name: Import Certificate and Provisioning Profiles - name: Import Certificate
env: env:
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }} IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
ENVIRONMENT: ${{ inputs.environment || 'development' }}
working-directory: ./mobile/ios working-directory: ./mobile/ios
run: | run: |
# Decode certificate # Decode certificate
echo "$IOS_CERTIFICATE_P12" | base64 --decode > certificate.p12 echo "$IOS_CERTIFICATE_P12" | base64 --decode > certificate.p12
# Decode provisioning profiles based on environment
if [[ "$ENVIRONMENT" == "development" ]]; then
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE" | base64 --decode > profile_dev.mobileprovision
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION" | base64 --decode > profile_dev_share.mobileprovision
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION" | base64 --decode > profile_dev_widget.mobileprovision
ls -lh profile_dev*.mobileprovision
else
echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > profile.mobileprovision
echo "$IOS_PROVISIONING_PROFILE_SHARE_EXTENSION" | base64 --decode > profile_share.mobileprovision
echo "$IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION" | base64 --decode > profile_widget.mobileprovision
ls -lh profile*.mobileprovision
fi
- name: Create keychain and import certificate - name: Create keychain and import certificate
env: env:
KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
@@ -319,7 +286,7 @@ jobs:
security delete-keychain build.keychain || true security delete-keychain build.keychain || true
- name: Upload IPA artifact - name: Upload IPA artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: ios-release-ipa name: ios-release-ipa
path: mobile/ios/Runner.ipa path: mobile/ios/Runner.ipa

View File

@@ -25,7 +25,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check out code - name: Check out code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}

View File

@@ -35,7 +35,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -78,7 +78,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout - name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -87,7 +87,7 @@ jobs:
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0

View File

@@ -35,7 +35,7 @@ jobs:
needs: [get_body, should_run] needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }} if: ${{ needs.should_run.outputs.should_run == 'true' }}
container: container:
image: ghcr.io/immich-app/mdq:main@sha256:237cdae7783609c96f18037a513d38088713cf4a2e493a3aa136d0c45490749a image: ghcr.io/immich-app/mdq:main@sha256:ab9f163cd5d5cec42704a26ca2769ecf3f10aa8e7bae847f1d527cdf075946e6
outputs: outputs:
checked: ${{ steps.get_checkbox.outputs.checked }} checked: ${{ steps.get_checkbox.outputs.checked }}
steps: steps:

View File

@@ -50,14 +50,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh # ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with: with:
category: '/language:${{matrix.language}}' category: '/language:${{matrix.language}}'

View File

@@ -60,10 +60,11 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code - name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
fetch-depth: 0
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
@@ -85,7 +86,7 @@ jobs:
run: pnpm build run: pnpm build
- name: Upload build output - name: Upload build output
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: docs-build-output name: docs-build-output
path: docs/build/ path: docs/build/

View File

@@ -125,13 +125,13 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code - name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0 uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1
- name: Load parameters - name: Load parameters
id: parameters id: parameters

View File

@@ -23,13 +23,13 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code - name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0 uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1
- name: Destroy Docs Subdomain - name: Destroy Docs Subdomain
env: env:

View File

@@ -22,7 +22,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: 'Checkout' - name: 'Checkout'
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
ref: ${{ github.event.pull_request.head.ref }} ref: ${{ github.event.pull_request.head.ref }}
token: ${{ steps.generate-token.outputs.token }} token: ${{ steps.generate-token.outputs.token }}

View File

@@ -56,14 +56,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout - name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
token: ${{ steps.generate-token.outputs.token }} token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true persist-credentials: true
ref: main ref: main
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4 uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
@@ -136,13 +136,13 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout - name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
token: ${{ steps.generate-token.outputs.token }} token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false persist-credentials: false
- name: Download APK - name: Download APK
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with: with:
name: release-apk-signed name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }} github-token: ${{ steps.generate-token.outputs.token }}

View File

@@ -23,14 +23,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout - name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
token: ${{ steps.generate-token.outputs.token }} token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true persist-credentials: true
ref: main ref: main
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4 uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
@@ -159,7 +159,7 @@ jobs:
- name: Create PR - name: Create PR
id: create-pr id: create-pr
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11 uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
with: with:
token: ${{ steps.generate-token.outputs.token }} token: ${{ steps.generate-token.outputs.token }}
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}' commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'

View File

@@ -58,7 +58,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout - name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
token: ${{ steps.generate-token.outputs.token }} token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false persist-credentials: false
@@ -74,7 +74,7 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Download APK - name: Download APK
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with: with:
name: release-apk-signed name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }} github-token: ${{ steps.generate-token.outputs.token }}

View File

@@ -22,7 +22,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}

View File

@@ -55,7 +55,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code - name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}

View File

@@ -69,7 +69,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code - name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -114,7 +114,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code - name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -161,7 +161,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code - name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -203,7 +203,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code - name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -247,7 +247,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code - name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -285,7 +285,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code - name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -298,9 +298,9 @@ jobs:
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml' cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install dependencies - name: Install dependencies
run: pnpm --filter=immich-web install --frozen-lockfile run: pnpm --filter=immich-i18n install --frozen-lockfile
- name: Format - name: Format
run: pnpm --filter=immich-web format:i18n run: pnpm --filter=immich-i18n format:fix
- name: Find file changes - name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-files id: verify-changed-files
@@ -333,7 +333,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code - name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -379,7 +379,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code - name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
persist-credentials: false persist-credentials: false
submodules: 'recursive' submodules: 'recursive'
@@ -418,7 +418,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code - name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
persist-credentials: false persist-credentials: false
submodules: 'recursive' submodules: 'recursive'
@@ -473,7 +473,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code - name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
persist-credentials: false persist-credentials: false
submodules: 'recursive' submodules: 'recursive'
@@ -505,7 +505,7 @@ jobs:
run: npx playwright test run: npx playwright test
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Archive test results - name: Archive test results
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: success() || failure() if: success() || failure()
with: with:
name: e2e-web-test-results-${{ matrix.runner }} name: e2e-web-test-results-${{ matrix.runner }}
@@ -534,7 +534,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -566,17 +566,14 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4 uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with:
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818) python-version: 3.11
# with:
# python-version: 3.11
# cache: 'uv'
- name: Install dependencies - name: Install dependencies
run: | run: |
uv sync --extra cpu uv sync --extra cpu
@@ -610,7 +607,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code - name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -639,7 +636,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -661,7 +658,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code - name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -723,7 +720,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code - name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}

31
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,31 @@
# Contributing to Immich
We appreciate every contribution, and we're happy about every new contributor. So please feel invited to help make Immich a better product!
## Getting started
To get you started quickly we have detailed guides for the dev setup on our [website](https://docs.immich.app/developer/setup). If you prefer, you can also use [Devcontainers](https://docs.immich.app/developer/devcontainers).
There are also additional resources about Immich's architecture, database migrations, the use of OpenAPI, and more in our [developer documentation](https://docs.immich.app/developer/architecture).
## General
Please try to keep pull requests as focused as possible. A PR should do exactly one thing and not bleed into other, unrelated areas. The smaller a PR, the fewer changes are likely needed, and the quicker it will likely be merged. For larger/more impactful PRs, please reach out to us first to discuss your plans. The best way to do this is through our [Discord](https://discord.immich.app). We have a dedicated `#contributing` channel there. Additionally, please fill out the entire template when opening a PR.
## Finding work
If you are looking for something to work on, there are discussions and issues with a `good-first-issue` label on them. These are always a good starting point. If none of them sound interesting or fit your skill set, feel free to reach out on our Discord. We're happy to help you find something to work on!
## Use of generative AI
We generally discourage PRs entirely generated by an LLM. For any part generated by an LLM, please put extra effort into your self-review. By using generative AI without proper self-review, the time you save ends up being more work we need to put in for proper reviews and code cleanup. Please keep that in mind when submitting code by an LLM. Clearly state the use of LLMs/(generative) AI in your pull request as requested by the template.
## Feature freezes
From time to time, we put a feature freeze on parts of the codebase. For us, this means we won't accept most PRs that make changes in that area. Exempted from this are simple bug fixes that require only minor changes. We will close feature PRs that target a feature-frozen area, even if that feature is highly requested and you put a lot of work into it. Please keep that in mind, and if you're ever uncertain if a PR would be accepted, reach out to us first (e.g., in the aforementioned `#contributing` channel). We hate to throw away work. Currently, we have feature freezes on:
* Sharing/Asset ownership
* (External) libraries
## Non-code contributions
If you want to contribute to Immich but you don't feel comfortable programming in our tech stack, there are other ways you can help the team. All our translations are done through [Weblate](https://hosted.weblate.org/projects/immich). These rely entirely on the community; if you speak a language that isn't fully translated yet, submitting translations there is greatly appreciated! If you like helping others, answering Q&A discussions here on GitHub and replying to people on our Discord is also always appreciated.

View File

@@ -1 +1 @@
24.11.1 24.13.0

View File

@@ -20,7 +20,7 @@
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9", "@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^24.10.3", "@types/node": "^24.10.8",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0", "byte-size": "^9.0.0",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
@@ -36,7 +36,7 @@
"typescript": "^5.3.3", "typescript": "^5.3.3",
"typescript-eslint": "^8.28.0", "typescript-eslint": "^8.28.0",
"vite": "^7.0.0", "vite": "^7.0.0",
"vite-tsconfig-paths": "^5.0.0", "vite-tsconfig-paths": "^6.0.0",
"vitest": "^3.0.0", "vitest": "^3.0.0",
"vitest-fetch-mock": "^0.4.0", "vitest-fetch-mock": "^0.4.0",
"yaml": "^2.3.1" "yaml": "^2.3.1"
@@ -69,6 +69,6 @@
"micromatch": "^4.0.8" "micromatch": "^4.0.8"
}, },
"volta": { "volta": {
"node": "24.11.1" "node": "24.13.0"
} }
} }

View File

@@ -127,7 +127,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f image: docker.io/valkey/valkey:9@sha256:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
@@ -146,6 +146,8 @@ services:
ports: ports:
- 5432:5432 - 5432:5432
shm_size: 128mb shm_size: 128mb
healthcheck:
disable: false
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics # set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
# immich-prometheus: # immich-prometheus:
# container_name: immich_prometheus # container_name: immich_prometheus

View File

@@ -56,7 +56,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f image: docker.io/valkey/valkey:9@sha256:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always
@@ -77,13 +77,15 @@ services:
- 5432:5432 - 5432:5432
shm_size: 128mb shm_size: 128mb
restart: always restart: always
healthcheck:
disable: false
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics # set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
immich-prometheus: immich-prometheus:
container_name: immich_prometheus container_name: immich_prometheus
ports: ports:
- 9090:9090 - 9090:9090
image: prom/prometheus@sha256:d936808bdea528155c0154a922cd42fd75716b8bb7ba302641350f9f3eaeba09 image: prom/prometheus@sha256:1f0f50f06acaceb0f5670d2c8a658a599affe7b0d8e78b898c1035653849a702
volumes: volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml - ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus - prometheus-data:/prometheus
@@ -95,7 +97,7 @@ services:
command: ['./run.sh', '-disable-reporting'] command: ['./run.sh', '-disable-reporting']
ports: ports:
- 3000:3000 - 3000:3000
image: grafana/grafana:12.3.0-ubuntu@sha256:cee936306135e1925ab21dffa16f8a411535d16ab086bef2309339a8e74d62df image: grafana/grafana:12.3.1-ubuntu@sha256:d57f1365197aec34c4d80869d8ca45bb7787c7663904950dab214dfb40c1c2fd
volumes: volumes:
- grafana-data:/var/lib/grafana - grafana-data:/var/lib/grafana

View File

@@ -49,7 +49,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f image: docker.io/valkey/valkey:9@sha256:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always
@@ -69,6 +69,8 @@ services:
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data - ${DB_DATA_LOCATION}:/var/lib/postgresql/data
shm_size: 128mb shm_size: 128mb
restart: always restart: always
healthcheck:
disable: false
volumes: volumes:
model-cache: model-cache:

View File

@@ -1 +1 @@
24.11.1 24.13.0

View File

@@ -22,7 +22,7 @@ For organizations seeking to resell Immich, we have established the following gu
- Do not misrepresent your reseller site or services as being officially affiliated with or endorsed by Immich or our development team. - Do not misrepresent your reseller site or services as being officially affiliated with or endorsed by Immich or our development team.
- For small resellers who wish to contribute financially to Immich's development, we recommend directing your customers to purchase licenses directly from us rather than attempting to broker revenue-sharing arrangements. We ask that you refrain from misrepresenting reseller activities as directly supporting our development work. - For small resellers who wish to contribute financially to Immich's development, we recommend directing your customers to purchase product keys directly from us rather than attempting to broker revenue-sharing arrangements. We ask that you refrain from misrepresenting reseller activities as directly supporting our development work.
When in doubt or if you have an edge case scenario, we encourage you to contact us directly via email to discuss the use of our trademark. We can provide clear guidance on what is acceptable and what is not. You can reach out at: questions@immich.app When in doubt or if you have an edge case scenario, we encourage you to contact us directly via email to discuss the use of our trademark. We can provide clear guidance on what is acceptable and what is not. You can reach out at: questions@immich.app

View File

@@ -22,7 +22,7 @@ Immich is known to work with Postgres versions `>= 14, < 19`.
VectorChord is known to work with pgvector versions `>= 0.7, < 0.9`. VectorChord is known to work with pgvector versions `>= 0.7, < 0.9`.
The Immich server will check the VectorChord version on startup to ensure compatibility, and refuse to start if a compatible version is not found. The Immich server will check the VectorChord version on startup to ensure compatibility, and refuse to start if a compatible version is not found.
The current accepted range for VectorChord is `>= 0.3, < 0.6`. The current accepted range for VectorChord is `>= 0.3, < 2.0`.
::: :::
## Specifying the connection URL ## Specifying the connection URL

View File

@@ -4,6 +4,10 @@ sidebar_position: 2
# Setup # Setup
:::warning
Make sure to read the [`CONTRIBUTING.md`](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md) before you dive into the code.
:::
:::note :::note
If there's a feature you're planning to work on, just give us a heads up in [#contributing](https://discord.com/channels/979116623879368755/1071165397228855327) on [our Discord](https://discord.immich.app) so we can: If there's a feature you're planning to work on, just give us a heads up in [#contributing](https://discord.com/channels/979116623879368755/1071165397228855327) on [our Discord](https://discord.immich.app) so we can:

View File

@@ -71,6 +71,22 @@ For RKMPP to work:
5. (Optional) Enable hardware decoding for optimal performance. 5. (Optional) Enable hardware decoding for optimal performance.
<details>
<summary>immich.json</summary>
If you use a [configuration file](/install/config-file.md), use the `accel` option to select the hardware (e.g. `qsv` for Intel or `nvenc` for Nvidia). Set `accelDecode` to `true` if you want hardware decoding.
```json
{
"ffmpeg": {
"accel": "qsv",
"accelDecode": true
}
}
```
</details>
#### Single Compose File #### Single Compose File
Some platforms, including Unraid and Portainer, do not support multiple Compose files as of writing. As an alternative, you can "inline" the relevant contents of the [`hwaccel.transcoding.yml`][hw-file] file into the `immich-server` service directly. Some platforms, including Unraid and Portainer, do not support multiple Compose files as of writing. As an alternative, you can "inline" the relevant contents of the [`hwaccel.transcoding.yml`][hw-file] file into the `immich-server` service directly.

View File

@@ -68,6 +68,56 @@ Now make sure that the local album is selected in the backup screen (steps 1-2 a
title="Upload button after photos selection" title="Upload button after photos selection"
/> />
## Free Up Space
The **Free Up Space** tool allows you to remove local media files from your device that have already been successfully backed up to your Immich server (and are not in the Immich trash). This helps reclaim storage on your mobile device without losing your memories.
### How it works
1. **Configuration:**
- **Cutoff Date:** You can select a cutoff date. The tool will only look for photos and videos **on or before** this date.
- **Filter Options:** You can choose to remove **All** assets, or restrict removal to **Photos only** or **Videos only**.
- **Keep Favorites:** By default, local assets marked as favorites are preserved on your device, even if they match the cutoff date.
2. **Scan & Review:** Before any files are removed, you are presented with a review screen to verify which items will be deleted.
3. **Deletion:** Confirmed items are moved to your device's native Trash/Recycle Bin. They will be permanently removed by the OS based on your system settings (usually after 30 days).
:::info Android Permissions
For the smoothest experience on Android, you should grant Immich special delete privileges. Without this, you may be prompted to confirm deletion for every single image.
Go to **Immich Settings > Advanced** and enable **"Media Management Access"**.
:::
### iCloud Photos (iOS Users)
If you use **iCloud Photos** alongside Immich, it is vital to understand how deletion affects your data. iCloud utilizes a two-way sync; this means deleting a photo from your iPhone to free up space will **also delete it from iCloud**.
:::warning iCloud & Backups
If you rely on iCloud as a secondary backup (part of a 3-2-1 backup strategy), using the Free Up Space feature in Immich will remove the file from both your phone and iCloud.
Once deleted, the photo will exist **only** on your Immich server (and your phone's "Recently Deleted" folder for 30 days).
When you use iCloud Photos and delete a photo or video on one device, it's also deleted on all other devices where you're signed in with the same Apple Account.
More information on the [Apple Support](https://support.apple.com/en-us/108922#iCloud_photo_library) website
**Shared Albums**
Assets that are part of an **iCloud Shared Album** are automatically excluded from the cleanup scan to ensure they remain viewable to others in the shared album.
:::
### External App Dependencies (WhatsApp, etc.)
:::danger WhatsApp & Local Files
Android applications like **WhatsApp** rely on local files to display media in chat history.
If Immich backs up your WhatsApp folder and you run **Free Up Space**, the local copies of these images will be deleted. Consequently, **media in your WhatsApp chats will appear blurry or missing.** You will only be able to view these photos inside the Immich app; they will no longer be visible within the WhatsApp interface.
**Recommendation:** If keeping chat history intact is important, please ensure you review the deletion list carefully or consider excluding WhatsApp folders from the backup if you intend to use this feature frequently.
:::
:::info reclaim storage
You must empty the system/gallery trash manually to reclaim storage.
:::
## Album Sync ## Album Sync
You can sync or mirror an album from your phone to the Immich server on your account. For example, if you select Recents, Camera and Videos album for backup, the corresponding album with the same name will be created on the server. Once the assets from those albums are uploaded, they will be put into the target albums automatically. You can sync or mirror an album from your phone to the Immich server on your account. For example, if you select Recents, Camera and Videos album for backup, the corresponding album with the same name will be created on the server. Once the assets from those albums are uploaded, they will be put into the target albums automatically.
@@ -95,11 +145,3 @@ Enter the cloud on the top right -> cog wheel on the top right -> select the syn
If you delete/move photos in the local album on your device, it will not be reflected in the album on the server **even if** you click Sync albums If you delete/move photos in the local album on your device, it will not be reflected in the album on the server **even if** you click Sync albums
It will only reflect files you add. It will only reflect files you add.
::: :::
If the same asset is in more than one album it will only sync to the first album it's in, after that it won't sync again even if the user clicks sync albums manually.
To overcome this limitation, the files must be removed from the ignore list by
App settings -> Advanced -> Duplicate Assets -> Clear
:::info
Cleaning duplicate assets from the list will cause all the previously uploaded duplicate files to be re-uploaded, the files will not actually be uploaded and will be rejected on the server side (due to duplication) but will be synchronized to the album and at the end will be added to the ignore list again at the end of the synchronization.
:::

View File

@@ -112,4 +112,40 @@ You can then make a new panel, specifying Prometheus as the data source for it.
-- TODO: add images and more details here -- TODO: add images and more details here
## Structured Logging
In addition to Prometheus metrics, Immich supports structured JSON logging which is ideal for log aggregation systems like Grafana Loki, ELK Stack, Datadog, Splunk, and others.
### Configuration
By default, Immich outputs human-readable console logs. To enable JSON logging, set the `IMMICH_LOG_FORMAT` environment variable:
```bash
IMMICH_LOG_FORMAT=json
```
:::tip
The default is `IMMICH_LOG_FORMAT=console` for human-readable logs with colors during development. For production deployments using log aggregation, use `IMMICH_LOG_FORMAT=json`.
:::
### JSON Log Format
When enabled, logs are output in structured JSON format:
```json
{"level":"log","pid":36,"timestamp":1766533331507,"message":"Initialized websocket server","context":"WebsocketRepository"}
{"level":"warn","pid":48,"timestamp":1766533331629,"message":"Unable to open /build/www/index.html, skipping SSR.","context":"ApiService"}
{"level":"error","pid":36,"timestamp":1766533331690,"message":"Failed to load plugin immich-core:","context":"Error"}
```
This format includes:
- `level`: Log level (log, warn, error, etc.)
- `pid`: Process ID
- `timestamp`: Unix timestamp in milliseconds
- `message`: Log message
- `context`: Service or component that generated the log
For more information on log formats, see [`IMMICH_LOG_FORMAT`](/install/environment-variables.md#general).
[prom-file]: https://github.com/immich-app/immich/releases/latest/download/prometheus.yml [prom-file]: https://github.com/immich-app/immich/releases/latest/download/prometheus.yml

View File

@@ -33,7 +33,7 @@ You can create a public link to share a group of photos or videos, or an album,
The public shared link is generated with a random URL, which acts as as a secret to avoid the link being guessed by unwanted parties, for instance. The public shared link is generated with a random URL, which acts as as a secret to avoid the link being guessed by unwanted parties, for instance.
``` ```
https://immich.yourdomain.com/share/JUckRMxlgpo7F9BpyqGk_cZEwDzaU_U5LU5_oNZp1ETIBa9dpQ0b5ghNm_22QVJfn3k https://my.immich.app/share/JUckRMxlgpo7F9BpyqGk_cZEwDzaU_U5LU5_oNZp1ETIBa9dpQ0b5ghNm_22QVJfn3k
``` ```
### Creating a public share link ### Creating a public share link

View File

@@ -34,6 +34,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices | | `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices | | `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices | | `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
| `IMMICH_LOG_FORMAT` | Log output format (`console`, `json`) | `console` | server | api, microservices |
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `/data` | server | api, microservices | | `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `/data` | server | api, microservices |
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices | | `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | | | `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
@@ -43,6 +44,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices | | `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api | | `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices | | `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api |
\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`. \*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution. `TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution.

View File

@@ -17,12 +17,17 @@ Hardware and software requirements for Immich:
- Immich runs well in a virtualized environment when running in a full virtual machine. - Immich runs well in a virtualized environment when running in a full virtual machine.
The use of Docker in LXC containers is [not recommended](https://pve.proxmox.com/wiki/Linux_Container), but may be possible for advanced users. The use of Docker in LXC containers is [not recommended](https://pve.proxmox.com/wiki/Linux_Container), but may be possible for advanced users.
If you have issues, we recommend that you switch to a supported VM deployment. If you have issues, we recommend that you switch to a supported VM deployment.
- **RAM**: Minimum 4GB, recommended 6GB. - **RAM**: Minimum 6GB, recommended 8GB.
- **CPU**: Minimum 2 cores, recommended 4 cores. - **CPU**: Minimum 2 cores, recommended 4 cores.
- **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions. - **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions.
- The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average. - The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average.
:::tip :::note RAM requirements
For a smooth experience, especially during asset upload, Immich requires at least 6GB of RAM.
For systems with only 4GB of RAM, Immich can be run with machine learning features disabled.
:::
:::tip Postgres setup
Good performance and a stable connection to the Postgres database is critical to a smooth Immich experience. Good performance and a stable connection to the Postgres database is critical to a smooth Immich experience.
The Postgres database files are typically between 1-3 GB in size. The Postgres database files are typically between 1-3 GB in size.
For this reason, the Postgres database (`DB_DATA_LOCATION`) should ideally use local SSD storage, and never a network share of any kind. For this reason, the Postgres database (`DB_DATA_LOCATION`) should ideally use local SSD storage, and never a network share of any kind.

View File

@@ -26,6 +26,12 @@ const config = {
locales: ['en'], locales: ['en'],
}, },
// Mermaid diagrams
markdown: {
mermaid: true,
},
themes: ['@docusaurus/theme-mermaid'],
plugins: [ plugins: [
async function myPlugin(context, options) { async function myPlugin(context, options) {
return { return {

View File

@@ -20,6 +20,7 @@
"@docusaurus/core": "~3.9.0", "@docusaurus/core": "~3.9.0",
"@docusaurus/preset-classic": "~3.9.0", "@docusaurus/preset-classic": "~3.9.0",
"@docusaurus/theme-common": "~3.9.0", "@docusaurus/theme-common": "~3.9.0",
"@docusaurus/theme-mermaid": "~3.9.0",
"@mdi/js": "^7.3.67", "@mdi/js": "^7.3.67",
"@mdi/react": "^1.6.1", "@mdi/react": "^1.6.1",
"@mdx-js/react": "^3.0.0", "@mdx-js/react": "^3.0.0",
@@ -57,6 +58,6 @@
"node": ">=20" "node": ">=20"
}, },
"volta": { "volta": {
"node": "24.11.1" "node": "24.13.0"
} }
} }

View File

@@ -8,19 +8,19 @@
@tailwind utilities; @tailwind utilities;
@font-face { @font-face {
font-family: 'Overpass'; font-family: 'GoogleSans';
src: url('/fonts/overpass/Overpass.ttf') format('truetype-variations'); src: url('/fonts/GoogleSans/GoogleSans.ttf') format('truetype-variations');
font-weight: 1 999; font-weight: 410 900;
font-style: normal; font-style: normal;
ascent-override: 106.25%; ascent-override: 106.25%;
size-adjust: 106.25%; size-adjust: 106.25%;
} }
@font-face { @font-face {
font-family: 'Overpass Mono'; font-family: 'GoogleSansCode';
src: url('/fonts/overpass/OverpassMono.ttf') format('truetype-variations'); src: url('/fonts/GoogleSansCode/GoogleSansCode.ttf') format('truetype-variations');
font-weight: 1 999; font-weight: 1 900;
font-style: normal; font-style: monospace;
ascent-override: 106.25%; ascent-override: 106.25%;
size-adjust: 106.25%; size-adjust: 106.25%;
} }
@@ -37,7 +37,8 @@ img {
/* You can override the default Infima variables here. */ /* You can override the default Infima variables here. */
:root { :root {
font-family: 'Overpass', sans-serif; font-family: 'GoogleSans', sans-serif;
letter-spacing: 0.1px;
--ifm-color-primary: #4250af; --ifm-color-primary: #4250af;
--ifm-color-primary-dark: #4250af; --ifm-color-primary-dark: #4250af;
--ifm-color-primary-darker: #4250af; --ifm-color-primary-darker: #4250af;
@@ -48,6 +49,16 @@ img {
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
} }
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: 'GoogleSans', sans-serif;
letter-spacing: 0.1px;
}
/* For readability concerns, you should choose a lighter palette in dark mode. */ /* For readability concerns, you should choose a lighter palette in dark mode. */
[data-theme='dark'] { [data-theme='dark'] {
--ifm-color-primary: #adcbfa; --ifm-color-primary: #adcbfa;
@@ -71,15 +82,22 @@ div[class^='announcementBar_'] {
padding: 10px 10px 10px 16px; padding: 10px 10px 10px 16px;
border-radius: 24px; border-radius: 24px;
margin-right: 16px; margin-right: 16px;
font-weight: 500;
} }
.menu__list-item-collapsible { .menu__list-item-collapsible {
margin-right: 16px; margin-right: 16px;
border-radius: 24px; border-radius: 24px;
font-weight: 500;
} }
.menu__link--active { .menu__link--active {
font-weight: 500; font-weight: 600;
}
.table-of-contents__link {
font-size: 14px;
font-weight: 450;
} }
/* workaround for version switcher PR 15894 */ /* workaround for version switcher PR 15894 */
@@ -88,13 +106,14 @@ div[class*='navbar__items'] > li:has(a[class*='version-switcher-34ab39']) {
} }
code { code {
font-weight: 600; font-weight: 500;
font-family: 'GoogleSansCode';
} }
.buy-button { .buy-button {
padding: 8px 14px; padding: 8px 14px;
border: 1px solid transparent; border: 1px solid transparent;
font-family: 'Overpass', sans-serif; font-family: 'GoogleSans', sans-serif;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
box-shadow: 0 0 5px 2px rgba(181, 206, 254, 0.4); box-shadow: 0 0 5px 2px rgba(181, 206, 254, 0.4);

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,6 @@
FROM node:24.1.0-alpine3.20@sha256:8fe019e0d57dbdce5f5c27c0b63d2775cf34b00e3755a7dea969802d7e0c2b25
RUN corepack enable
ADD package.json *.ts ./
RUN pnpm install
EXPOSE 2286
CMD ["pnpm", "run", "start"]

View File

@@ -125,7 +125,7 @@ const setup = async () => {
], ],
}); });
const onStart = () => console.log(`[auth-server] http://${host}:${port}/.well-known/openid-configuration`); const onStart = () => console.log(`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`);
const app = oidc.listen(port, host, onStart); const app = oidc.listen(port, host, onStart);
return () => app.close(); return () => app.close();
}; };

View File

@@ -0,0 +1,15 @@
{
"name": "@immich/e2e-auth-server",
"version": "0.1.0",
"type": "module",
"main": "auth-server.ts",
"scripts": {
"start": "tsx startup.ts"
},
"devDependencies": {
"jose": "^5.6.3",
"@types/oidc-provider": "^9.0.0",
"oidc-provider": "^9.0.0",
"tsx": "^4.20.6"
}
}

View File

@@ -0,0 +1,8 @@
import setup from './auth-server'
const teardown = await setup()
process.on('exit', () => {
teardown()
console.log('[e2e-auth-server] stopped')
process.exit(0)
})

View File

@@ -1 +1 @@
24.11.1 24.13.0

View File

@@ -1,6 +1,12 @@
name: immich-e2e name: immich-e2e
services: services:
e2e-auth-server:
build:
context: ../e2e-auth-server
ports:
- 2286:2286
immich-server: immich-server:
container_name: immich-e2e-server container_name: immich-e2e-server
image: immich-server:latest image: immich-server:latest
@@ -27,8 +33,6 @@ services:
- IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true - IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true
volumes: volumes:
- ./test-assets:/test-assets - ./test-assets:/test-assets
extra_hosts:
- 'auth-server:host-gateway'
depends_on: depends_on:
redis: redis:
condition: service_started condition: service_started

View File

@@ -22,12 +22,12 @@
"@eslint/js": "^9.8.0", "@eslint/js": "^9.8.0",
"@faker-js/faker": "^10.1.0", "@faker-js/faker": "^10.1.0",
"@immich/cli": "file:../cli", "@immich/cli": "file:../cli",
"@immich/e2e-auth-server": "file:../e2e-auth-server",
"@immich/sdk": "file:../open-api/typescript-sdk", "@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2", "@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^24.10.3", "@types/node": "^24.10.8",
"@types/oidc-provider": "^9.0.0",
"@types/pg": "^8.15.1", "@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4", "@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
@@ -36,11 +36,9 @@
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^62.0.0", "eslint-plugin-unicorn": "^62.0.0",
"exiftool-vendored": "^34.0.0", "exiftool-vendored": "^34.3.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"jose": "^5.6.3",
"luxon": "^3.4.4", "luxon": "^3.4.4",
"oidc-provider": "^9.0.0",
"pg": "^8.11.3", "pg": "^8.11.3",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
"prettier": "^3.7.4", "prettier": "^3.7.4",
@@ -54,6 +52,6 @@
"vitest": "^3.0.0" "vitest": "^3.0.0"
}, },
"volta": { "volta": {
"node": "24.11.1" "node": "24.13.0"
} }
} }

View File

@@ -8,7 +8,7 @@ dotenv.config({ path: resolve(import.meta.dirname, '.env') });
export const playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1'; export const playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1';
export const playwrightDbHost = process.env.PLAYWRIGHT_DB_HOST ?? '127.0.0.1'; export const playwrightDbHost = process.env.PLAYWRIGHT_DB_HOST ?? '127.0.0.1';
export const playwriteBaseUrl = process.env.PLAYWRIGHT_BASE_URL ?? `http://${playwrightHost}:2285`; export const playwriteBaseUrl = process.env.PLAYWRIGHT_BASE_URL ?? `http://${playwrightHost}:2285`;
export const playwriteSlowMo = parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0'); export const playwriteSlowMo = Number.parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
export const playwrightDisableWebserver = process.env.PLAYWRIGHT_DISABLE_WEBSERVER; export const playwrightDisableWebserver = process.env.PLAYWRIGHT_DISABLE_WEBSERVER;
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1'; process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1';
@@ -39,13 +39,13 @@ const config: PlaywrightTestConfig = {
testMatch: /.*\.e2e-spec\.ts/, testMatch: /.*\.e2e-spec\.ts/,
workers: 1, workers: 1,
}, },
{ // {
name: 'parallel tests', // name: 'parallel tests',
use: { ...devices['Desktop Chrome'] }, // use: { ...devices['Desktop Chrome'] },
testMatch: /.*\.parallel-e2e-spec\.ts/, // testMatch: /.*\.parallel-e2e-spec\.ts/,
fullyParallel: true, // fullyParallel: true,
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1), // workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
}, // },
// { // {
// name: 'firefox', // name: 'firefox',

View File

@@ -0,0 +1,350 @@
import { LoginResponseDto, ManualJobName } from '@immich/sdk';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
describe('/admin/database-backups', () => {
let cookie: string | undefined;
let admin: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
await utils.resetBackups(admin.accessToken);
});
describe('GET /', async () => {
it('should succeed and be empty', async () => {
const { status, body } = await request(app)
.get('/admin/database-backups')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
backups: [],
});
});
it('should contain a created backup', async () => {
await utils.createJob(admin.accessToken, {
name: ManualJobName.BackupDatabase,
});
await utils.waitForQueueFinish(admin.accessToken, 'backupDatabase');
await expect
.poll(
async () => {
const { status, body } = await request(app)
.get('/admin/database-backups')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
return body;
},
{
interval: 500,
timeout: 10_000,
},
)
.toEqual(
expect.objectContaining({
backups: [
expect.objectContaining({
filename: expect.stringMatching(/immich-db-backup-\d{8}T\d{6}-v.*-pg.*\.sql\.gz$/),
filesize: expect.any(Number),
}),
],
}),
);
});
});
describe('DELETE /', async () => {
it('should delete backup', async () => {
const filename = await utils.createBackup(admin.accessToken);
const { status } = await request(app)
.delete(`/admin/database-backups`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ backups: [filename] });
expect(status).toBe(200);
const { status: listStatus, body } = await request(app)
.get('/admin/database-backups')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(listStatus).toBe(200);
expect(body).toEqual(
expect.objectContaining({
backups: [],
}),
);
});
});
// => action: restore database flow
describe.sequential('POST /start-restore', () => {
afterAll(async () => {
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({ action: 'end' });
await utils.poll(
() => request(app).get('/server/config'),
({ status, body }) => status === 200 && !body.maintenanceMode,
);
admin = await utils.adminSetup();
});
it.sequential('should not work when the server is configured', async () => {
const { status, body } = await request(app).post('/admin/database-backups/start-restore').send();
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('The server already has an admin'));
});
it.sequential('should enter maintenance mode in "database restore mode"', async () => {
await utils.resetDatabase(); // reset database before running this test
const { status, headers } = await request(app).post('/admin/database-backups/start-restore').send();
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';')[0];
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode;
},
{
interval: 500,
timeout: 10_000,
},
)
.toBeTruthy();
const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
expect(status2).toBe(200);
expect(body).toEqual({
active: true,
action: 'select_database_restore',
});
});
});
// => action: restore database
describe.sequential('POST /backups/restore', () => {
beforeAll(async () => {
await utils.disconnectDatabase();
});
afterAll(async () => {
await utils.connectDatabase();
});
it.sequential('should restore a backup', { timeout: 60_000 }, async () => {
let filename = await utils.createBackup(admin.accessToken);
// work-around until test is running on released version
await utils.move(
`/data/backups/${filename}`,
'/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
);
filename = 'immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz';
const { status } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
action: 'restore_database',
restoreBackupFilename: filename,
});
expect(status).toBe(201);
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode;
},
{
interval: 500,
timeout: 10_000,
},
)
.toBeTruthy();
const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
expect(status2).toBe(200);
expect(body).toEqual(
expect.objectContaining({
active: true,
action: 'restore_database',
}),
);
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode;
},
{
interval: 500,
timeout: 60_000,
},
)
.toBeFalsy();
});
it.sequential('fail to restore a corrupted backup', { timeout: 60_000 }, async () => {
await utils.prepareTestBackup('corrupted');
const { status, headers } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
action: 'restore_database',
restoreBackupFilename: 'development-corrupted.sql.gz',
});
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';')[0];
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode;
},
{
interval: 500,
timeout: 10_000,
},
)
.toBeTruthy();
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
expect(status).toBe(200);
return body;
},
{
interval: 500,
timeout: 10_000,
},
)
.toEqual(
expect.objectContaining({
active: true,
action: 'restore_database',
error: 'Something went wrong, see logs!',
}),
);
const { status: status2, body: body2 } = await request(app)
.get('/admin/maintenance/status')
.set('cookie', cookie!)
.send({ token: 'token' });
expect(status2).toBe(200);
expect(body2).toEqual(
expect.objectContaining({
active: true,
action: 'restore_database',
error: expect.stringContaining('IM CORRUPTED'),
}),
);
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
action: 'end',
});
await utils.poll(
() => request(app).get('/server/config'),
({ status, body }) => status === 200 && !body.maintenanceMode,
);
});
it.sequential('rollback to restore point if backup is missing admin', { timeout: 60_000 }, async () => {
await utils.prepareTestBackup('empty');
const { status, headers } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
action: 'restore_database',
restoreBackupFilename: 'development-empty.sql.gz',
});
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';')[0];
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode;
},
{
interval: 500,
timeout: 10_000,
},
)
.toBeTruthy();
await expect
.poll(
async () => {
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
expect(status).toBe(200);
return body;
},
{
interval: 500,
timeout: 30_000,
},
)
.toEqual(
expect.objectContaining({
active: true,
action: 'restore_database',
error: 'Something went wrong, see logs!',
}),
);
const { status: status2, body: body2 } = await request(app)
.get('/admin/maintenance/status')
.set('cookie', cookie!)
.send({ token: 'token' });
expect(status2).toBe(200);
expect(body2).toEqual(
expect.objectContaining({
active: true,
action: 'restore_database',
error: expect.stringContaining('Server health check failed, no admin exists.'),
}),
);
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
action: 'end',
});
await utils.poll(
() => request(app).get('/server/config'),
({ status, body }) => status === 200 && !body.maintenanceMode,
);
});
});
});

View File

@@ -14,6 +14,7 @@ describe('/admin/maintenance', () => {
await utils.resetDatabase(); await utils.resetDatabase();
admin = await utils.adminSetup(); admin = await utils.adminSetup();
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
await utils.resetBackups(admin.accessToken);
}); });
// => outside of maintenance mode // => outside of maintenance mode
@@ -26,6 +27,17 @@ describe('/admin/maintenance', () => {
}); });
}); });
describe('GET /status', async () => {
it('to always indicate we are not in maintenance mode', async () => {
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
expect(status).toBe(200);
expect(body).toEqual({
active: false,
action: 'end',
});
});
});
describe('POST /login', async () => { describe('POST /login', async () => {
it('should not work out of maintenance mode', async () => { it('should not work out of maintenance mode', async () => {
const { status, body } = await request(app).post('/admin/maintenance/login').send({ token: 'token' }); const { status, body } = await request(app).post('/admin/maintenance/login').send({ token: 'token' });
@@ -39,6 +51,7 @@ describe('/admin/maintenance', () => {
describe.sequential('POST /', () => { describe.sequential('POST /', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(app).post('/admin/maintenance').send({ const { status, body } = await request(app).post('/admin/maintenance').send({
active: false,
action: 'end', action: 'end',
}); });
expect(status).toBe(401); expect(status).toBe(401);
@@ -69,6 +82,7 @@ describe('/admin/maintenance', () => {
.send({ .send({
action: 'start', action: 'start',
}); });
expect(status).toBe(201); expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';')[0]; cookie = headers['set-cookie'][0].split(';')[0];
@@ -79,12 +93,13 @@ describe('/admin/maintenance', () => {
await expect await expect
.poll( .poll(
async () => { async () => {
const { body } = await request(app).get('/server/config'); const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode; return body.maintenanceMode;
}, },
{ {
interval: 5e2, interval: 500,
timeout: 1e4, timeout: 10_000,
}, },
) )
.toBeTruthy(); .toBeTruthy();
@@ -102,6 +117,17 @@ describe('/admin/maintenance', () => {
}); });
}); });
describe('GET /status', async () => {
it('to indicate we are in maintenance mode', async () => {
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
expect(status).toBe(200);
expect(body).toEqual({
active: true,
action: 'start',
});
});
});
describe('POST /login', async () => { describe('POST /login', async () => {
it('should fail without cookie or token in body', async () => { it('should fail without cookie or token in body', async () => {
const { status, body } = await request(app).post('/admin/maintenance/login').send({}); const { status, body } = await request(app).post('/admin/maintenance/login').send({});
@@ -158,12 +184,13 @@ describe('/admin/maintenance', () => {
await expect await expect
.poll( .poll(
async () => { async () => {
const { body } = await request(app).get('/server/config'); const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
return body.maintenanceMode; return body.maintenanceMode;
}, },
{ {
interval: 5e2, interval: 500,
timeout: 1e4, timeout: 10_000,
}, },
) )
.toBeFalsy(); .toBeFalsy();

View File

@@ -1,3 +1,4 @@
import { OAuthClient, OAuthUser } from '@immich/e2e-auth-server';
import { import {
LoginResponseDto, LoginResponseDto,
SystemConfigOAuthDto, SystemConfigOAuthDto,
@@ -8,13 +9,12 @@ import {
} from '@immich/sdk'; } from '@immich/sdk';
import { createHash, randomBytes } from 'node:crypto'; import { createHash, randomBytes } from 'node:crypto';
import { errorDto } from 'src/responses'; import { errorDto } from 'src/responses';
import { OAuthClient, OAuthUser } from 'src/setup/auth-server';
import { app, asBearerAuth, baseUrl, utils } from 'src/utils'; import { app, asBearerAuth, baseUrl, utils } from 'src/utils';
import request from 'supertest'; import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest'; import { beforeAll, describe, expect, it } from 'vitest';
const authServer = { const authServer = {
internal: 'http://auth-server:2286', internal: 'http://e2e-auth-server:2286',
external: 'http://127.0.0.1:2286', external: 'http://127.0.0.1:2286',
}; };

View File

@@ -20,7 +20,6 @@ describe('/shared-links', () => {
let user1: LoginResponseDto; let user1: LoginResponseDto;
let user2: LoginResponseDto; let user2: LoginResponseDto;
let album: AlbumResponseDto; let album: AlbumResponseDto;
let metadataAlbum: AlbumResponseDto;
let deletedAlbum: AlbumResponseDto; let deletedAlbum: AlbumResponseDto;
let linkWithDeletedAlbum: SharedLinkResponseDto; let linkWithDeletedAlbum: SharedLinkResponseDto;
let linkWithPassword: SharedLinkResponseDto; let linkWithPassword: SharedLinkResponseDto;
@@ -41,18 +40,9 @@ describe('/shared-links', () => {
[asset1, asset2] = await Promise.all([utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken)]); [asset1, asset2] = await Promise.all([utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken)]);
[album, deletedAlbum, metadataAlbum] = await Promise.all([ [album, deletedAlbum] = await Promise.all([
createAlbum({ createAlbumDto: { albumName: 'album' } }, { headers: asBearerAuth(user1.accessToken) }), createAlbum({ createAlbumDto: { albumName: 'album' } }, { headers: asBearerAuth(user1.accessToken) }),
createAlbum({ createAlbumDto: { albumName: 'deleted album' } }, { headers: asBearerAuth(user2.accessToken) }), createAlbum({ createAlbumDto: { albumName: 'deleted album' } }, { headers: asBearerAuth(user2.accessToken) }),
createAlbum(
{
createAlbumDto: {
albumName: 'metadata album',
assetIds: [asset1.id],
},
},
{ headers: asBearerAuth(user1.accessToken) },
),
]); ]);
[linkWithDeletedAlbum, linkWithAlbum, linkWithAssets, linkWithPassword, linkWithMetadata, linkWithoutMetadata] = [linkWithDeletedAlbum, linkWithAlbum, linkWithAssets, linkWithPassword, linkWithMetadata, linkWithoutMetadata] =
@@ -75,14 +65,14 @@ describe('/shared-links', () => {
password: 'foo', password: 'foo',
}), }),
utils.createSharedLink(user1.accessToken, { utils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album, type: SharedLinkType.Individual,
albumId: metadataAlbum.id, assetIds: [asset1.id],
showMetadata: true, showMetadata: true,
slug: 'metadata-album', slug: 'metadata-slug',
}), }),
utils.createSharedLink(user1.accessToken, { utils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album, type: SharedLinkType.Individual,
albumId: metadataAlbum.id, assetIds: [asset1.id],
showMetadata: false, showMetadata: false,
}), }),
]); ]);
@@ -95,9 +85,7 @@ describe('/shared-links', () => {
const resp = await request(shareUrl).get(`/${linkWithMetadata.key}`); const resp = await request(shareUrl).get(`/${linkWithMetadata.key}`);
expect(resp.status).toBe(200); expect(resp.status).toBe(200);
expect(resp.header['content-type']).toContain('text/html'); expect(resp.header['content-type']).toContain('text/html');
expect(resp.text).toContain( expect(resp.text).toContain(`<meta name="description" content="1 shared photos &amp; videos" />`);
`<meta name="description" content="${metadataAlbum.assets.length} shared photos &amp; videos" />`,
);
}); });
it('should have correct asset count in meta tag for empty album', async () => { it('should have correct asset count in meta tag for empty album', async () => {
@@ -144,9 +132,7 @@ describe('/shared-links', () => {
const resp = await request(baseUrl).get(`/s/${linkWithMetadata.slug}`); const resp = await request(baseUrl).get(`/s/${linkWithMetadata.slug}`);
expect(resp.status).toBe(200); expect(resp.status).toBe(200);
expect(resp.header['content-type']).toContain('text/html'); expect(resp.header['content-type']).toContain('text/html');
expect(resp.text).toContain( expect(resp.text).toContain(`<meta name="description" content="1 shared photos &amp; videos" />`);
`<meta name="description" content="${metadataAlbum.assets.length} shared photos &amp; videos" />`,
);
}); });
}); });
@@ -271,12 +257,12 @@ describe('/shared-links', () => {
); );
}); });
it('should return metadata for album shared link', async () => { it('should return metadata for individual shared link', async () => {
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithMetadata.key }); const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithMetadata.key });
expect(status).toBe(200); expect(status).toBe(200);
expect(body.assets).toHaveLength(0); expect(body.assets).toHaveLength(1);
expect(body.album).toBeDefined(); expect(body.album).not.toBeDefined();
}); });
it('should not return metadata for album shared link without metadata', async () => { it('should not return metadata for album shared link without metadata', async () => {
@@ -284,7 +270,7 @@ describe('/shared-links', () => {
expect(status).toBe(200); expect(status).toBe(200);
expect(body.assets).toHaveLength(1); expect(body.assets).toHaveLength(1);
expect(body.album).toBeDefined(); expect(body.album).not.toBeDefined();
const asset = body.assets[0]; const asset = body.assets[0];
expect(asset).not.toHaveProperty('exifInfo'); expect(asset).not.toHaveProperty('exifInfo');

View File

@@ -26,6 +26,5 @@ export const makeRandomImage = () => {
if (!value) { if (!value) {
throw new Error('Ran out of random asset data'); throw new Error('Ran out of random asset data');
} }
return value; return value;
}; };

View File

@@ -346,6 +346,9 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
duplicateId: null, duplicateId: null,
resized: true, resized: true,
checksum: asset.checksum, checksum: asset.checksum,
width: exifInfo.exifImageWidth ?? 1,
height: exifInfo.exifImageHeight ?? 1,
isEdited: false,
}; };
} }

View File

@@ -1,3 +1,4 @@
import { AssetResponseDto } from '@immich/sdk';
import { BrowserContext, Page, Request, Route } from '@playwright/test'; import { BrowserContext, Page, Request, Route } from '@playwright/test';
import { basename } from 'node:path'; import { basename } from 'node:path';
import { import {
@@ -63,15 +64,33 @@ export const setupTimelineMockApiRoutes = async (
}); });
await context.route('**/api/assets/*', async (route, request) => { await context.route('**/api/assets/*', async (route, request) => {
const url = new URL(request.url()); if (request.method() === 'GET') {
const pathname = url.pathname; const url = new URL(request.url());
const assetId = basename(pathname); const pathname = url.pathname;
const asset = getAsset(timelineRestData, assetId); const assetId = basename(pathname);
return route.fulfill({ let asset = getAsset(timelineRestData, assetId);
status: 200, if (changes.assetDeletions.includes(asset!.id)) {
contentType: 'application/json', asset = {
json: asset, ...asset,
}); isTrashed: true,
} as AssetResponseDto;
}
return route.fulfill({
status: 200,
contentType: 'application/json',
json: asset,
});
}
await route.fallback();
});
await context.route('**/api/assets', async (route, request) => {
if (request.method() === 'DELETE') {
return route.fulfill({
status: 204,
});
}
await route.fallback();
}); });
await context.route('**/api/assets/*/ocr', async (route) => { await context.route('**/api/assets/*/ocr', async (route) => {
@@ -117,17 +136,28 @@ export const setupTimelineMockApiRoutes = async (
}); });
await context.route('**/api/albums/**', async (route, request) => { await context.route('**/api/albums/**', async (route, request) => {
const pattern = /\/api\/albums\/(?<albumId>[^/?]+)/; const albumsMatch = request.url().match(/\/api\/albums\/(?<albumId>[^/?]+)/);
const match = request.url().match(pattern); if (albumsMatch) {
if (!match) { const album = getAlbum(timelineRestData, testContext.adminId, albumsMatch.groups?.albumId, changes);
return route.continue(); return route.fulfill({
status: 200,
contentType: 'application/json',
json: album,
});
} }
const album = getAlbum(timelineRestData, testContext.adminId, match.groups?.albumId, changes); return route.fallback();
return route.fulfill({ });
status: 200,
contentType: 'application/json', await context.route('**/api/albums**', async (route, request) => {
json: album, const allAlbums = request.url().match(/\/api\/albums\?assetId=(?<assetId>[^&]+)/);
}); if (allAlbums) {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: [],
});
}
return route.fallback();
}); });
}; };

View File

@@ -6,7 +6,9 @@ import {
CheckExistingAssetsDto, CheckExistingAssetsDto,
CreateAlbumDto, CreateAlbumDto,
CreateLibraryDto, CreateLibraryDto,
JobCreateDto,
MaintenanceAction, MaintenanceAction,
ManualJobName,
MetadataSearchDto, MetadataSearchDto,
Permission, Permission,
PersonCreateDto, PersonCreateDto,
@@ -21,6 +23,7 @@ import {
checkExistingAssets, checkExistingAssets,
createAlbum, createAlbum,
createApiKey, createApiKey,
createJob,
createLibrary, createLibrary,
createPartner, createPartner,
createPerson, createPerson,
@@ -28,10 +31,12 @@ import {
createStack, createStack,
createUserAdmin, createUserAdmin,
deleteAssets, deleteAssets,
deleteDatabaseBackup,
getAssetInfo, getAssetInfo,
getConfig, getConfig,
getConfigDefaults, getConfigDefaults,
getQueuesLegacy, getQueuesLegacy,
listDatabaseBackups,
login, login,
runQueueCommandLegacy, runQueueCommandLegacy,
scanLibrary, scanLibrary,
@@ -52,11 +57,15 @@ import {
import { BrowserContext } from '@playwright/test'; import { BrowserContext } from '@playwright/test';
import { exec, spawn } from 'node:child_process'; import { exec, spawn } from 'node:child_process';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { dirname, resolve } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { setTimeout as setAsyncTimeout } from 'node:timers/promises'; import { setTimeout as setAsyncTimeout } from 'node:timers/promises';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { createGzip } from 'node:zlib';
import pg from 'pg'; import pg from 'pg';
import { io, type Socket } from 'socket.io-client'; import { io, type Socket } from 'socket.io-client';
import { loginDto, signupDto } from 'src/fixtures'; import { loginDto, signupDto } from 'src/fixtures';
@@ -84,8 +93,9 @@ export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer $
export const asKeyAuth = (key: string) => ({ 'x-api-key': key }); export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
export const immichCli = (args: string[]) => export const immichCli = (args: string[]) =>
executeCommand('pnpm', ['exec', 'immich', '-d', `/${tempDir}/immich/`, ...args], { cwd: '../cli' }).promise; executeCommand('pnpm', ['exec', 'immich', '-d', `/${tempDir}/immich/`, ...args], { cwd: '../cli' }).promise;
export const immichAdmin = (args: string[]) => export const dockerExec = (args: string[]) =>
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]); executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', args.join(' ')]);
export const immichAdmin = (args: string[]) => dockerExec([`immich-admin ${args.join(' ')}`]);
export const specialCharStrings = ["'", '"', ',', '{', '}', '*']; export const specialCharStrings = ["'", '"', ',', '{', '}', '*'];
export const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; export const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
@@ -149,12 +159,26 @@ const onEvent = ({ event, id }: { event: EventType; id: string }) => {
}; };
export const utils = { export const utils = {
connectDatabase: async () => {
if (!client) {
client = new pg.Client(dbUrl);
client.on('end', () => (client = null));
client.on('error', () => (client = null));
await client.connect();
}
return client;
},
disconnectDatabase: async () => {
if (client) {
await client.end();
}
},
resetDatabase: async (tables?: string[]) => { resetDatabase: async (tables?: string[]) => {
try { try {
if (!client) { client = await utils.connectDatabase();
client = new pg.Client(dbUrl);
await client.connect();
}
tables = tables || [ tables = tables || [
// TODO e2e test for deleting a stack, since it is quite complex // TODO e2e test for deleting a stack, since it is quite complex
@@ -481,6 +505,9 @@ export const utils = {
tagAssets: (accessToken: string, tagId: string, assetIds: string[]) => tagAssets: (accessToken: string, tagId: string, assetIds: string[]) =>
tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }), tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }),
createJob: async (accessToken: string, jobCreateDto: JobCreateDto) =>
createJob({ jobCreateDto }, { headers: asBearerAuth(accessToken) }),
queueCommand: async (accessToken: string, name: QueueName, queueCommandDto: QueueCommandDto) => queueCommand: async (accessToken: string, name: QueueName, queueCommandDto: QueueCommandDto) =>
runQueueCommandLegacy({ name, queueCommandDto }, { headers: asBearerAuth(accessToken) }), runQueueCommandLegacy({ name, queueCommandDto }, { headers: asBearerAuth(accessToken) }),
@@ -559,6 +586,45 @@ export const utils = {
mkdirSync(`${testAssetDir}/temp`, { recursive: true }); mkdirSync(`${testAssetDir}/temp`, { recursive: true });
}, },
async move(source: string, dest: string) {
return executeCommand('docker', ['exec', 'immich-e2e-server', 'mv', source, dest]).promise;
},
createBackup: async (accessToken: string) => {
await utils.createJob(accessToken, {
name: ManualJobName.BackupDatabase,
});
return utils.poll(
() => request(app).get('/admin/database-backups').set('Authorization', `Bearer ${accessToken}`),
({ status, body }) => status === 200 && body.backups.length === 1,
({ body }) => body.backups[0].filename,
);
},
resetBackups: async (accessToken: string) => {
const { backups } = await listDatabaseBackups({ headers: asBearerAuth(accessToken) });
const backupFiles = backups.map((b) => b.filename);
await deleteDatabaseBackup(
{ databaseBackupDeleteDto: { backups: backupFiles } },
{ headers: asBearerAuth(accessToken) },
);
},
prepareTestBackup: async (generate: 'empty' | 'corrupted') => {
const dir = await mkdtemp(join(tmpdir(), 'test-'));
const fn = join(dir, 'file');
const sql = Readable.from(generate === 'corrupted' ? 'IM CORRUPTED;' : 'SELECT 1;');
const gzip = createGzip();
const writeStream = createWriteStream(fn);
await pipeline(sql, gzip, writeStream);
await executeCommand('docker', ['cp', fn, `immich-e2e-server:/data/backups/development-${generate}.sql.gz`])
.promise;
},
resetAdminConfig: async (accessToken: string) => { resetAdminConfig: async (accessToken: string) => {
const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) }); const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) });
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) }); await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
@@ -601,6 +667,25 @@ export const utils = {
await utils.waitForQueueFinish(accessToken, 'sidecar'); await utils.waitForQueueFinish(accessToken, 'sidecar');
await utils.waitForQueueFinish(accessToken, 'metadataExtraction'); await utils.waitForQueueFinish(accessToken, 'metadataExtraction');
}, },
async poll<T>(cb: () => Promise<T>, validate: (value: T) => boolean, map?: (value: T) => any) {
let timeout = 0;
while (true) {
try {
const data = await cb();
if (validate(data)) {
return map ? map(data) : data;
}
timeout++;
if (timeout >= 10) {
throw 'Could not clean up test.';
}
await new Promise((resolve) => setTimeout(resolve, 5e2));
} catch {
// no-op
}
}
},
}; };
utils.initSdk(); utils.initSdk();

View File

@@ -0,0 +1,269 @@
import { faker } from '@faker-js/faker';
import { expect, test } from '@playwright/test';
import {
Changes,
createDefaultTimelineConfig,
generateTimelineData,
SeededRandom,
selectRandom,
TimelineAssetConfig,
TimelineData,
} from 'src/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
import { utils } from 'src/utils';
import { assetViewerUtils } from 'src/web/specs/timeline/utils';
test.describe.configure({ mode: 'parallel' });
test.describe('asset-viewer', () => {
const rng = new SeededRandom(529);
let adminUserId: string;
let timelineRestData: TimelineData;
const assets: TimelineAssetConfig[] = [];
const yearMonths: string[] = [];
const testContext = new TimelineTestContext();
const changes: Changes = {
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
};
test.beforeAll(async () => {
utils.initSdk();
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
for (const timeBucket of timelineRestData.buckets.values()) {
assets.push(...timeBucket);
}
for (const yearMonth of timelineRestData.buckets.keys()) {
const [year, month] = yearMonth.split('-');
yearMonths.push(`${year}-${Number(month)}`);
}
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, adminUserId);
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
});
test.afterEach(() => {
testContext.slowBucket = false;
changes.albumAdditions = [];
changes.assetDeletions = [];
changes.assetArchivals = [];
changes.assetFavorites = [];
});
test.describe('/photos/:id', () => {
test('Navigate to next asset via button', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.getByLabel('View next asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
});
test('Navigate to previous asset via button', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.getByLabel('View previous asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
});
test('Navigate to next asset via keyboard (ArrowRight)', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.keyboard.press('ArrowRight');
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
});
test('Navigate to previous asset via keyboard (ArrowLeft)', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.keyboard.press('ArrowLeft');
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
});
test('Navigate forward 5 times via button', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
for (let i = 1; i <= 5; i++) {
await page.getByLabel('View next asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + i].id}`);
}
});
test('Navigate backward 5 times via button', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
for (let i = 1; i <= 5; i++) {
await page.getByLabel('View previous asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index - i]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - i].id}`);
}
});
test('Navigate forward then backward via keyboard', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
// Navigate forward 3 times
for (let i = 1; i <= 3; i++) {
await page.keyboard.press('ArrowRight');
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
}
// Navigate backward 3 times to return to original
for (let i = 2; i >= 0; i--) {
await page.keyboard.press('ArrowLeft');
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
}
// Verify we're back at the original asset
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
});
test('Verify no next button on last asset', async ({ page }) => {
const lastAsset = assets.at(-1)!;
await page.goto(`/photos/${lastAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
// Verify next button doesn't exist
await expect(page.getByLabel('View next asset')).toHaveCount(0);
});
test('Verify no previous button on first asset', async ({ page }) => {
const firstAsset = assets[0];
await page.goto(`/photos/${firstAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, firstAsset);
// Verify previous button doesn't exist
await expect(page.getByLabel('View previous asset')).toHaveCount(0);
});
test('Delete photo advances to next', async ({ page }) => {
const asset = selectRandom(assets, rng);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
const index = assets.indexOf(asset);
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
});
test('Delete photo advances to next (2x)', async ({ page }) => {
const asset = selectRandom(assets, rng);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
const index = assets.indexOf(asset);
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
});
test('Delete last photo advances to prev', async ({ page }) => {
const asset = assets.at(-1)!;
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
const index = assets.indexOf(asset);
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
});
test('Delete last photo advances to prev (2x)', async ({ page }) => {
const asset = assets.at(-1)!;
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
const index = assets.indexOf(asset);
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index - 2]);
});
});
test.describe('/trash/photos/:id', () => {
test('Delete trashed photo advances to next', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
changes.assetDeletions.push(...deletedAssets);
await page.goto(`/trash/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
});
test('Delete trashed photo advances to next 2x', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
changes.assetDeletions.push(...deletedAssets);
await page.goto(`/trash/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
});
test('Delete trashed photo advances to prev', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
changes.assetDeletions.push(...deletedAssets);
await page.goto(`/trash/photos/${assets[index + 9].id}`);
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
});
test('Delete trashed photo advances to prev 2x', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
changes.assetDeletions.push(...deletedAssets);
await page.goto(`/trash/photos/${assets[index + 9].id}`);
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 7]);
});
});
});

View File

@@ -0,0 +1,105 @@
import { LoginResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { utils } from 'src/utils';
test.describe.configure({ mode: 'serial' });
test.describe('Database Backups', () => {
let admin: LoginResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
});
test('restore a backup from settings', async ({ context, page }) => {
test.setTimeout(60_000);
await utils.resetBackups(admin.accessToken);
const filename = await utils.createBackup(admin.accessToken);
await utils.setAuthCookies(context, admin.accessToken);
// work-around until test is running on released version
await utils.move(
`/data/backups/${filename}`,
'/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
);
await page.goto('/admin/maintenance?isOpen=backups');
await page.getByRole('button', { name: 'Restore', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
await page.waitForURL('/maintenance?**');
await page.waitForURL('/admin/maintenance**', { timeout: 60_000 });
});
test('handle backup restore failure', async ({ context, page }) => {
test.setTimeout(60_000);
await utils.resetBackups(admin.accessToken);
await utils.prepareTestBackup('corrupted');
await utils.setAuthCookies(context, admin.accessToken);
await page.goto('/admin/maintenance?isOpen=backups');
await page.getByRole('button', { name: 'Restore', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
await page.waitForURL('/maintenance?**');
await expect(page.getByText('IM CORRUPTED')).toBeVisible({ timeout: 60_000 });
await page.getByRole('button', { name: 'End maintenance mode' }).click();
await page.waitForURL('/admin/maintenance**');
});
test('rollback to restore point if backup is missing admin', async ({ context, page }) => {
test.setTimeout(60_000);
await utils.resetBackups(admin.accessToken);
await utils.prepareTestBackup('empty');
await utils.setAuthCookies(context, admin.accessToken);
await page.goto('/admin/maintenance?isOpen=backups');
await page.getByRole('button', { name: 'Restore', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
await page.waitForURL('/maintenance?**');
await expect(page.getByText('Server health check failed, no admin exists.')).toBeVisible({ timeout: 60_000 });
await page.getByRole('button', { name: 'End maintenance mode' }).click();
await page.waitForURL('/admin/maintenance**');
});
test('restore a backup from onboarding', async ({ context, page }) => {
test.setTimeout(60_000);
await utils.resetBackups(admin.accessToken);
const filename = await utils.createBackup(admin.accessToken);
await utils.setAuthCookies(context, admin.accessToken);
// work-around until test is running on released version
await utils.move(
`/data/backups/${filename}`,
'/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
);
await utils.resetDatabase();
await page.goto('/');
await page.getByRole('button', { name: 'Restore from backup' }).click();
try {
await page.waitForURL('/maintenance**');
} catch {
// when chained with the rest of the tests
// this navigation may fail..? not sure why...
await page.goto('/maintenance');
await page.waitForURL('/maintenance**');
}
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('button', { name: 'Restore', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
await page.waitForURL('/maintenance?**');
await page.waitForURL('/photos', { timeout: 60_000 });
});
});

View File

@@ -16,12 +16,12 @@ test.describe('Maintenance', () => {
test('enter and exit maintenance mode', async ({ context, page }) => { test('enter and exit maintenance mode', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken); await utils.setAuthCookies(context, admin.accessToken);
await page.goto('/admin/system-settings?isOpen=maintenance'); await page.goto('/admin/maintenance');
await page.getByRole('button', { name: 'Start maintenance mode' }).click(); await page.getByRole('button', { name: 'Switch to maintenance mode' }).click();
await expect(page.getByText('Temporarily Unavailable')).toBeVisible({ timeout: 10_000 }); await expect(page.getByText('Temporarily Unavailable')).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'End maintenance mode' }).click(); await page.getByRole('button', { name: 'End maintenance mode' }).click();
await page.waitForURL('**/admin/system-settings*', { timeout: 10_000 }); await page.waitForURL('**/admin/maintenance*', { timeout: 10_000 });
}); });
test('maintenance shows no options to users until they authenticate', async ({ page }) => { test('maintenance shows no options to users until they authenticate', async ({ page }) => {

View File

@@ -3,7 +3,7 @@ import { Page, expect, test } from '@playwright/test';
import { utils } from 'src/utils'; import { utils } from 'src/utils';
function imageLocator(page: Page) { function imageLocator(page: Page) {
return page.getByAltText('Image taken on').locator('visible=true'); return page.getByAltText('Image taken').locator('visible=true');
} }
test.describe('Photo Viewer', () => { test.describe('Photo Viewer', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;

View File

@@ -18,7 +18,6 @@ import { pageRoutePromise, setupTimelineMockApiRoutes, TimelineTestContext } fro
import { utils } from 'src/utils'; import { utils } from 'src/utils';
import { import {
assetViewerUtils, assetViewerUtils,
cancelAllPollers,
padYearMonth, padYearMonth,
pageUtils, pageUtils,
poll, poll,
@@ -64,7 +63,6 @@ test.describe('Timeline', () => {
}); });
test.afterEach(() => { test.afterEach(() => {
cancelAllPollers();
testContext.slowBucket = false; testContext.slowBucket = false;
changes.albumAdditions = []; changes.albumAdditions = [];
changes.assetDeletions = []; changes.assetDeletions = [];
@@ -463,7 +461,7 @@ test.describe('Timeline', () => {
}); });
changes.albumAdditions.push(...requestJson.ids); changes.albumAdditions.push(...requestJson.ids);
}); });
await page.getByText('Done').click(); await page.getByText('Add assets').click();
await expect(put).resolves.toEqual({ await expect(put).resolves.toEqual({
ids: [ ids: [
'c077ea7b-cfa1-45e4-8554-f86c00ee5658', 'c077ea7b-cfa1-45e4-8554-f86c00ee5658',

View File

@@ -23,13 +23,6 @@ export async function throttlePage(context: BrowserContext, page: Page) {
await session.send('Emulation.setCPUThrottlingRate', { rate: 10 }); await session.send('Emulation.setCPUThrottlingRate', { rate: 10 });
} }
let activePollsAbortController = new AbortController();
export const cancelAllPollers = () => {
activePollsAbortController.abort();
activePollsAbortController = new AbortController();
};
export const poll = async <T>( export const poll = async <T>(
page: Page, page: Page,
query: () => Promise<T>, query: () => Promise<T>,
@@ -37,21 +30,14 @@ export const poll = async <T>(
) => { ) => {
let result; let result;
const timeout = Date.now() + 10_000; const timeout = Date.now() + 10_000;
const signal = activePollsAbortController.signal;
const terminate = callback || ((result: Awaited<T> | undefined) => !!result); const terminate = callback || ((result: Awaited<T> | undefined) => !!result);
while (!terminate(result) && Date.now() < timeout) { while (!terminate(result) && Date.now() < timeout) {
if (signal.aborted) {
return;
}
try { try {
result = await query(); result = await query();
} catch { } catch {
// ignore // ignore
} }
if (signal.aborted) {
return;
}
if (page.isClosed()) { if (page.isClosed()) {
return; return;
} }
@@ -181,8 +167,12 @@ export const assetViewerUtils = {
}, },
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) { async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
await page await page
.locator(`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}"]`) .locator(
.or(page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}"]`)) `img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`,
)
.or(
page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`),
)
.waitFor(); .waitFor();
}, },
async expectActiveAssetToBe(page: Page, assetId: string) { async expectActiveAssetToBe(page: Page, assetId: string) {

View File

@@ -56,7 +56,7 @@ test.describe('User Administration', () => {
await expect(page.getByLabel('Admin User')).not.toBeChecked(); await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByLabel('Admin User').click(); await page.getByLabel('Admin User').click();
await expect(page.getByLabel('Admin User')).toBeChecked(); await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await expect await expect
.poll(async () => { .poll(async () => {
@@ -85,7 +85,7 @@ test.describe('User Administration', () => {
await expect(page.getByLabel('Admin User')).toBeChecked(); await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByLabel('Admin User').click(); await page.getByLabel('Admin User').click();
await expect(page.getByLabel('Admin User')).not.toBeChecked(); await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await expect await expect
.poll(async () => { .poll(async () => {

View File

@@ -1,7 +1,7 @@
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
// skip `docker compose up` if `make e2e` was already run // skip `docker compose up` if `make e2e` was already run
const globalSetup: string[] = ['src/setup/auth-server.ts']; const globalSetup: string[] = [];
try { try {
await fetch('http://127.0.0.1:2285/api/server-info/ping'); await fetch('http://127.0.0.1:2285/api/server-info/ping');
} catch { } catch {

5
i18n/.prettierrc Normal file
View File

@@ -0,0 +1,5 @@
{
"jsonRecursiveSort": true,
"jsonSortOrder": "{\"/.*/\": \"lexical\"}",
"plugins": ["prettier-plugin-sort-json"]
}

View File

@@ -5,6 +5,7 @@
"acknowledge": "Acknowledge", "acknowledge": "Acknowledge",
"action": "Action", "action": "Action",
"action_common_update": "Update", "action_common_update": "Update",
"action_description": "A set of action to perform on the filtered assets",
"actions": "Actions", "actions": "Actions",
"active": "Active", "active": "Active",
"active_count": "Active: {count}", "active_count": "Active: {count}",
@@ -15,9 +16,14 @@
"add_a_location": "Add a location", "add_a_location": "Add a location",
"add_a_name": "Add a name", "add_a_name": "Add a name",
"add_a_title": "Add a title", "add_a_title": "Add a title",
"add_action": "Add action",
"add_action_description": "Click to add an action to perform",
"add_assets": "Add assets",
"add_birthday": "Add a birthday", "add_birthday": "Add a birthday",
"add_endpoint": "Add endpoint", "add_endpoint": "Add endpoint",
"add_exclusion_pattern": "Add exclusion pattern", "add_exclusion_pattern": "Add exclusion pattern",
"add_filter": "Add filter",
"add_filter_description": "Click to add a filter condition",
"add_location": "Add location", "add_location": "Add location",
"add_more_users": "Add more users", "add_more_users": "Add more users",
"add_partner": "Add partner", "add_partner": "Add partner",
@@ -36,6 +42,7 @@
"add_to_shared_album": "Add to shared album", "add_to_shared_album": "Add to shared album",
"add_upload_to_stack": "Add upload to stack", "add_upload_to_stack": "Add upload to stack",
"add_url": "Add URL", "add_url": "Add URL",
"add_workflow_step": "Add workflow step",
"added_to_archive": "Added to archive", "added_to_archive": "Added to archive",
"added_to_favorites": "Added to favorites", "added_to_favorites": "Added to favorites",
"added_to_favorites_count": "Added {count, number} to favorites", "added_to_favorites_count": "Added {count, number} to favorites",
@@ -181,10 +188,21 @@
"machine_learning_smart_search_enabled": "Enable smart search", "machine_learning_smart_search_enabled": "Enable smart search",
"machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.", "machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.",
"machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online.", "machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online.",
"maintenance_delete_backup": "Delete Backup",
"maintenance_delete_backup_description": "This file will be irrevocably deleted.",
"maintenance_delete_error": "Failed to delete backup.",
"maintenance_restore_backup": "Restore Backup",
"maintenance_restore_backup_description": "Immich will be wiped and restored from the chosen backup. A backup will be created before continuing.",
"maintenance_restore_backup_different_version": "This backup was created with a different version of Immich!",
"maintenance_restore_backup_unknown_version": "Couldn't determine backup version.",
"maintenance_restore_database_backup": "Restore database backup",
"maintenance_restore_database_backup_description": "Rollback to an earlier database state using a backup file",
"maintenance_settings": "Maintenance", "maintenance_settings": "Maintenance",
"maintenance_settings_description": "Put Immich into maintenance mode.", "maintenance_settings_description": "Put Immich into maintenance mode.",
"maintenance_start": "Start maintenance mode", "maintenance_start": "Switch to maintenance mode",
"maintenance_start_error": "Failed to start maintenance mode.", "maintenance_start_error": "Failed to start maintenance mode.",
"maintenance_upload_backup": "Upload database backup file",
"maintenance_upload_backup_error": "Could not upload backup, is it an .sql/.sql.gz file?",
"manage_concurrency": "Manage Concurrency", "manage_concurrency": "Manage Concurrency",
"manage_concurrency_description": "Navigate to the jobs page to manage job concurrency", "manage_concurrency_description": "Navigate to the jobs page to manage job concurrency",
"manage_log_settings": "Manage log settings", "manage_log_settings": "Manage log settings",
@@ -467,10 +485,12 @@
"album_remove_user": "Remove user?", "album_remove_user": "Remove user?",
"album_remove_user_confirmation": "Are you sure you want to remove {user}?", "album_remove_user_confirmation": "Are you sure you want to remove {user}?",
"album_search_not_found": "No albums found matching your search", "album_search_not_found": "No albums found matching your search",
"album_selected": "Album selected",
"album_share_no_users": "Looks like you have shared this album with all users or you don't have any user to share with.", "album_share_no_users": "Looks like you have shared this album with all users or you don't have any user to share with.",
"album_summary": "Album summary", "album_summary": "Album summary",
"album_updated": "Album updated", "album_updated": "Album updated",
"album_updated_setting_description": "Receive an email notification when a shared album has new assets", "album_updated_setting_description": "Receive an email notification when a shared album has new assets",
"album_upload_assets": "Upload assets from your computer and add to album",
"album_user_left": "Left {album}", "album_user_left": "Left {album}",
"album_user_removed": "Removed {user}", "album_user_removed": "Removed {user}",
"album_viewer_appbar_delete_confirm": "Are you sure you want to delete this album from your account?", "album_viewer_appbar_delete_confirm": "Are you sure you want to delete this album from your account?",
@@ -488,6 +508,7 @@
"albums_default_sort_order_description": "Initial asset sort order when creating new albums.", "albums_default_sort_order_description": "Initial asset sort order when creating new albums.",
"albums_feature_description": "Collections of assets that can be shared with other users.", "albums_feature_description": "Collections of assets that can be shared with other users.",
"albums_on_device_count": "Albums on device ({count})", "albums_on_device_count": "Albums on device ({count})",
"albums_selected": "{count, plural, one {# album selected} other {# albums selected}}",
"all": "All", "all": "All",
"all_albums": "All albums", "all_albums": "All albums",
"all_people": "All people", "all_people": "All people",
@@ -524,10 +545,12 @@
"archived_count": "{count, plural, other {Archived #}}", "archived_count": "{count, plural, other {Archived #}}",
"are_these_the_same_person": "Are these the same person?", "are_these_the_same_person": "Are these the same person?",
"are_you_sure_to_do_this": "Are you sure you want to do this?", "are_you_sure_to_do_this": "Are you sure you want to do this?",
"array_field_not_fully_supported": "Array fields require manual JSON editing",
"asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping",
"asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping", "asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping",
"asset_added_to_album": "Added to album", "asset_added_to_album": "Added to album",
"asset_adding_to_album": "Adding to album…", "asset_adding_to_album": "Adding to album…",
"asset_created": "Asset created",
"asset_description_updated": "Asset description has been updated", "asset_description_updated": "Asset description has been updated",
"asset_filename_is_offline": "Asset {filename} is offline", "asset_filename_is_offline": "Asset {filename} is offline",
"asset_has_unassigned_faces": "Asset has unassigned faces", "asset_has_unassigned_faces": "Asset has unassigned faces",
@@ -591,7 +614,7 @@
"backup_album_selection_page_select_albums": "Select albums", "backup_album_selection_page_select_albums": "Select albums",
"backup_album_selection_page_selection_info": "Selection Info", "backup_album_selection_page_selection_info": "Selection Info",
"backup_album_selection_page_total_assets": "Total unique assets", "backup_album_selection_page_total_assets": "Total unique assets",
"backup_albums_sync": "Backup albums synchronization", "backup_albums_sync": "Backup Albums Synchronization",
"backup_all": "All", "backup_all": "All",
"backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…", "backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…",
"backup_background_service_complete_notification": "Asset backup complete", "backup_background_service_complete_notification": "Asset backup complete",
@@ -711,6 +734,8 @@
"change_password_form_password_mismatch": "Passwords do not match", "change_password_form_password_mismatch": "Passwords do not match",
"change_password_form_reenter_new_password": "Re-enter New Password", "change_password_form_reenter_new_password": "Re-enter New Password",
"change_pin_code": "Change PIN code", "change_pin_code": "Change PIN code",
"change_trigger": "Change trigger",
"change_trigger_prompt": "Are you sure you want to change the trigger? This will remove all existing actions and filters.",
"change_your_password": "Change your password", "change_your_password": "Change your password",
"changed_visibility_successfully": "Changed visibility successfully", "changed_visibility_successfully": "Changed visibility successfully",
"charging": "Charging", "charging": "Charging",
@@ -722,6 +747,18 @@
"checksum": "Checksum", "checksum": "Checksum",
"choose_matching_people_to_merge": "Choose matching people to merge", "choose_matching_people_to_merge": "Choose matching people to merge",
"city": "City", "city": "City",
"cleanup_confirm_description": "Immich found {count} assets (created before {date}) safely backed up to the server. Remove the local copies from this device?",
"cleanup_confirm_prompt_title": "Remove from this device?",
"cleanup_deleted_assets": "Moved {count} assets to device trash",
"cleanup_deleting": "Moving to trash...",
"cleanup_filter_description": "Choose which types of assets to remove in the cleanup",
"cleanup_found_assets": "Found {count} backed up assets",
"cleanup_icloud_shared_albums_excluded": "iCloud Shared Albums are excluded from the scan",
"cleanup_no_assets_found": "No backed up assets found matching your criteria",
"cleanup_preview_title": "Assets to remove ({count})",
"cleanup_step3_description": "Scan for photos and videos that have been backed up to the server with the selected cutoff date and filter options",
"cleanup_step4_summary": "{count} assets created before {date} are queued for removal from your device",
"cleanup_trash_hint": "To fully reclaim storage space, open the system gallery app and empty the trash",
"clear": "Clear", "clear": "Clear",
"clear_all": "Clear all", "clear_all": "Clear all",
"clear_all_recent_searches": "Clear all recent searches", "clear_all_recent_searches": "Clear all recent searches",
@@ -787,6 +824,7 @@
"create_album": "Create album", "create_album": "Create album",
"create_album_page_untitled": "Untitled", "create_album_page_untitled": "Untitled",
"create_api_key": "Create API key", "create_api_key": "Create API key",
"create_first_workflow": "Create first workflow",
"create_library": "Create Library", "create_library": "Create Library",
"create_link": "Create link", "create_link": "Create link",
"create_link_to_share": "Create link to share", "create_link_to_share": "Create link to share",
@@ -801,17 +839,25 @@
"create_tag": "Create tag", "create_tag": "Create tag",
"create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.", "create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.",
"create_user": "Create user", "create_user": "Create user",
"create_workflow": "Create workflow",
"created": "Created", "created": "Created",
"created_at": "Created", "created_at": "Created",
"creating_linked_albums": "Creating linked albums...", "creating_linked_albums": "Creating linked albums...",
"crop": "Crop", "crop": "Crop",
"crop_aspect_ratio_fixed": "Fixed",
"crop_aspect_ratio_free": "Free",
"crop_aspect_ratio_original": "Original",
"curated_object_page_title": "Things", "curated_object_page_title": "Things",
"current_device": "Current device", "current_device": "Current device",
"current_pin_code": "Current PIN code", "current_pin_code": "Current PIN code",
"current_server_address": "Current server address", "current_server_address": "Current server address",
"custom_date": "Custom date",
"custom_locale": "Custom Locale", "custom_locale": "Custom Locale",
"custom_locale_description": "Format dates and numbers based on the language and the region", "custom_locale_description": "Format dates and numbers based on the language and the region",
"custom_url": "Custom URL", "custom_url": "Custom URL",
"cutoff_date_description": "Remove photos and videos older than",
"cutoff_day": "{count, plural, one {day} other {days}}",
"cutoff_year": "{count, plural, one {year} other {years}}",
"daily_title_text_date": "E, MMM dd", "daily_title_text_date": "E, MMM dd",
"daily_title_text_date_year": "E, MMM dd, yyyy", "daily_title_text_date_year": "E, MMM dd, yyyy",
"dark": "Dark", "dark": "Dark",
@@ -867,6 +913,7 @@
"deselect_all": "Deselect All", "deselect_all": "Deselect All",
"details": "Details", "details": "Details",
"direction": "Direction", "direction": "Direction",
"disable": "Disable",
"disabled": "Disabled", "disabled": "Disabled",
"disallow_edits": "Disallow edits", "disallow_edits": "Disallow edits",
"discord": "Discord", "discord": "Discord",
@@ -892,6 +939,7 @@
"download_include_embedded_motion_videos": "Embedded videos", "download_include_embedded_motion_videos": "Embedded videos",
"download_include_embedded_motion_videos_description": "Include videos embedded in motion photos as a separate file", "download_include_embedded_motion_videos_description": "Include videos embedded in motion photos as a separate file",
"download_notfound": "Download not found", "download_notfound": "Download not found",
"download_original": "Download original",
"download_paused": "Download paused", "download_paused": "Download paused",
"download_settings": "Download", "download_settings": "Download",
"download_settings_description": "Manage settings related to asset download", "download_settings_description": "Manage settings related to asset download",
@@ -901,6 +949,7 @@
"download_waiting_to_retry": "Waiting to retry", "download_waiting_to_retry": "Waiting to retry",
"downloading": "Downloading", "downloading": "Downloading",
"downloading_asset_filename": "Downloading asset {filename}", "downloading_asset_filename": "Downloading asset {filename}",
"downloading_from_icloud": "Downloading from iCloud",
"downloading_media": "Downloading media", "downloading_media": "Downloading media",
"drop_files_to_upload": "Drop files anywhere to upload", "drop_files_to_upload": "Drop files anywhere to upload",
"duplicates": "Duplicates", "duplicates": "Duplicates",
@@ -929,11 +978,17 @@
"edit_tag": "Edit tag", "edit_tag": "Edit tag",
"edit_title": "Edit Title", "edit_title": "Edit Title",
"edit_user": "Edit user", "edit_user": "Edit user",
"edit_workflow": "Edit workflow",
"editor": "Editor", "editor": "Editor",
"editor_close_without_save_prompt": "The changes will not be saved", "editor_close_without_save_prompt": "The changes will not be saved",
"editor_close_without_save_title": "Close editor?", "editor_close_without_save_title": "Close editor?",
"editor_crop_tool_h2_aspect_ratios": "Aspect ratios", "editor_confirm_reset_all_changes": "Are you sure you want to reset all changes?",
"editor_crop_tool_h2_rotation": "Rotation", "editor_flip_horizontal": "Flip horizontal",
"editor_flip_vertical": "Flip vertical",
"editor_orientation": "Orientation",
"editor_reset_all_changes": "Reset changes",
"editor_rotate_left": "Rotate 90° counterclockwise",
"editor_rotate_right": "Rotate 90° clockwise",
"email": "Email", "email": "Email",
"email_notifications": "Email notifications", "email_notifications": "Email notifications",
"empty_folder": "This folder is empty", "empty_folder": "This folder is empty",
@@ -954,9 +1009,11 @@
"error_getting_places": "Error getting places", "error_getting_places": "Error getting places",
"error_loading_image": "Error loading image", "error_loading_image": "Error loading image",
"error_loading_partners": "Error loading partners: {error}", "error_loading_partners": "Error loading partners: {error}",
"error_retrieving_asset_information": "Error retrieving asset information",
"error_saving_image": "Error: {error}", "error_saving_image": "Error: {error}",
"error_tag_face_bounding_box": "Error tagging face - cannot get bounding box coordinates", "error_tag_face_bounding_box": "Error tagging face - cannot get bounding box coordinates",
"error_title": "Error - Something went wrong", "error_title": "Error - Something went wrong",
"error_while_navigating": "Error while navigating to asset",
"errors": { "errors": {
"cannot_navigate_next_asset": "Cannot navigate to the next asset", "cannot_navigate_next_asset": "Cannot navigate to the next asset",
"cannot_navigate_previous_asset": "Cannot navigate to previous asset", "cannot_navigate_previous_asset": "Cannot navigate to previous asset",
@@ -1014,6 +1071,7 @@
"unable_to_complete_oauth_login": "Unable to complete OAuth login", "unable_to_complete_oauth_login": "Unable to complete OAuth login",
"unable_to_connect": "Unable to connect", "unable_to_connect": "Unable to connect",
"unable_to_copy_to_clipboard": "Cannot copy to clipboard, make sure you are accessing the page through https", "unable_to_copy_to_clipboard": "Cannot copy to clipboard, make sure you are accessing the page through https",
"unable_to_create": "Unable to create workflow",
"unable_to_create_admin_account": "Unable to create admin account", "unable_to_create_admin_account": "Unable to create admin account",
"unable_to_create_api_key": "Unable to create a new API Key", "unable_to_create_api_key": "Unable to create a new API Key",
"unable_to_create_library": "Unable to create library", "unable_to_create_library": "Unable to create library",
@@ -1024,6 +1082,7 @@
"unable_to_delete_exclusion_pattern": "Unable to delete exclusion pattern", "unable_to_delete_exclusion_pattern": "Unable to delete exclusion pattern",
"unable_to_delete_shared_link": "Unable to delete shared link", "unable_to_delete_shared_link": "Unable to delete shared link",
"unable_to_delete_user": "Unable to delete user", "unable_to_delete_user": "Unable to delete user",
"unable_to_delete_workflow": "Unable to delete workflow",
"unable_to_download_files": "Unable to download files", "unable_to_download_files": "Unable to download files",
"unable_to_edit_exclusion_pattern": "Unable to edit exclusion pattern", "unable_to_edit_exclusion_pattern": "Unable to edit exclusion pattern",
"unable_to_empty_trash": "Unable to empty trash", "unable_to_empty_trash": "Unable to empty trash",
@@ -1063,6 +1122,7 @@
"unable_to_scan_library": "Unable to scan library", "unable_to_scan_library": "Unable to scan library",
"unable_to_set_feature_photo": "Unable to set feature photo", "unable_to_set_feature_photo": "Unable to set feature photo",
"unable_to_set_profile_picture": "Unable to set profile picture", "unable_to_set_profile_picture": "Unable to set profile picture",
"unable_to_set_rating": "Unable to set rating",
"unable_to_submit_job": "Unable to submit job", "unable_to_submit_job": "Unable to submit job",
"unable_to_trash_asset": "Unable to trash asset", "unable_to_trash_asset": "Unable to trash asset",
"unable_to_unlink_account": "Unable to unlink account", "unable_to_unlink_account": "Unable to unlink account",
@@ -1074,8 +1134,10 @@
"unable_to_update_settings": "Unable to update settings", "unable_to_update_settings": "Unable to update settings",
"unable_to_update_timeline_display_status": "Unable to update timeline display status", "unable_to_update_timeline_display_status": "Unable to update timeline display status",
"unable_to_update_user": "Unable to update user", "unable_to_update_user": "Unable to update user",
"unable_to_update_workflow": "Unable to update workflow",
"unable_to_upload_file": "Unable to upload file" "unable_to_upload_file": "Unable to upload file"
}, },
"errors_text": "Errors",
"exclusion_pattern": "Exclusion pattern", "exclusion_pattern": "Exclusion pattern",
"exif": "Exif", "exif": "Exif",
"exif_bottom_sheet_description": "Add Description...", "exif_bottom_sheet_description": "Add Description...",
@@ -1120,14 +1182,17 @@
"features": "Features", "features": "Features",
"features_in_development": "Features in Development", "features_in_development": "Features in Development",
"features_setting_description": "Manage the app features", "features_setting_description": "Manage the app features",
"file_name": "File name", "file_name": "File name: {file_name}",
"file_name_or_extension": "File name or extension", "file_name_or_extension": "File name or extension",
"file_size": "File size", "file_size": "File size",
"filename": "Filename", "filename": "Filename",
"filetype": "Filetype", "filetype": "Filetype",
"filter": "Filter", "filter": "Filter",
"filter_description": "Conditions to filter the target assets",
"filter_options": "Filter options",
"filter_people": "Filter people", "filter_people": "Filter people",
"filter_places": "Filter places", "filter_places": "Filter places",
"filters": "Filters",
"find_them_fast": "Find them fast by name with search", "find_them_fast": "Find them fast by name with search",
"first": "First", "first": "First",
"fix_incorrect_match": "Fix incorrect match", "fix_incorrect_match": "Fix incorrect match",
@@ -1137,12 +1202,16 @@
"folders_feature_description": "Browsing the folder view for the photos and videos on the file system", "folders_feature_description": "Browsing the folder view for the photos and videos on the file system",
"forgot_pin_code_question": "Forgot your PIN?", "forgot_pin_code_question": "Forgot your PIN?",
"forward": "Forward", "forward": "Forward",
"free_up_space": "Free Up Space",
"free_up_space_description": "Move backed-up photos and videos to your device's trash to free up space. Your copies on the server remain safe",
"free_up_space_settings_subtitle": "Free up device storage",
"full_path": "Full path: {path}", "full_path": "Full path: {path}",
"gcast_enabled": "Google Cast", "gcast_enabled": "Google Cast",
"gcast_enabled_description": "This feature loads external resources from Google in order to work.", "gcast_enabled_description": "This feature loads external resources from Google in order to work.",
"general": "General", "general": "General",
"geolocation_instruction_location": "Click on an asset with GPS coordinates to use its location, or select a location directly from the map", "geolocation_instruction_location": "Click on an asset with GPS coordinates to use its location, or select a location directly from the map",
"get_help": "Get Help", "get_help": "Get Help",
"get_people_error": "Error getting people",
"get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network",
"getting_started": "Getting Started", "getting_started": "Getting Started",
"go_back": "Go back", "go_back": "Go back",
@@ -1175,6 +1244,7 @@
"hide_named_person": "Hide person {name}", "hide_named_person": "Hide person {name}",
"hide_password": "Hide password", "hide_password": "Hide password",
"hide_person": "Hide person", "hide_person": "Hide person",
"hide_schema": "Hide schema",
"hide_text_recognition": "Hide text recognition", "hide_text_recognition": "Hide text recognition",
"hide_unnamed_people": "Hide unnamed people", "hide_unnamed_people": "Hide unnamed people",
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
@@ -1247,8 +1317,12 @@
"ios_debug_info_processing_ran_at": "Processing ran {dateTime}", "ios_debug_info_processing_ran_at": "Processing ran {dateTime}",
"items_count": "{count, plural, one {# item} other {# items}}", "items_count": "{count, plural, one {# item} other {# items}}",
"jobs": "Jobs", "jobs": "Jobs",
"json_editor": "JSON editor",
"json_error": "JSON error",
"keep": "Keep", "keep": "Keep",
"keep_all": "Keep All", "keep_all": "Keep All",
"keep_favorites": "Keep favorites",
"keep_favorites_description": "Favorite assets will not be deleted from your device",
"keep_this_delete_others": "Keep this, delete others", "keep_this_delete_others": "Keep this, delete others",
"kept_this_deleted_others": "Kept this asset and deleted {count, plural, one {# asset} other {# assets}}", "kept_this_deleted_others": "Kept this asset and deleted {count, plural, one {# asset} other {# assets}}",
"keyboard_shortcuts": "Keyboard shortcuts", "keyboard_shortcuts": "Keyboard shortcuts",
@@ -1343,10 +1417,28 @@
"loop_videos_description": "Enable to automatically loop a video in the detail viewer.", "loop_videos_description": "Enable to automatically loop a video in the detail viewer.",
"main_branch_warning": "You're using a development version; we strongly recommend using a release version!", "main_branch_warning": "You're using a development version; we strongly recommend using a release version!",
"main_menu": "Main menu", "main_menu": "Main menu",
"maintenance_action_restore": "Restoring Database",
"maintenance_description": "Immich has been put into <link>maintenance mode</link>.", "maintenance_description": "Immich has been put into <link>maintenance mode</link>.",
"maintenance_end": "End maintenance mode", "maintenance_end": "End maintenance mode",
"maintenance_end_error": "Failed to end maintenance mode.", "maintenance_end_error": "Failed to end maintenance mode.",
"maintenance_logged_in_as": "Currently logged in as {user}", "maintenance_logged_in_as": "Currently logged in as {user}",
"maintenance_restore_from_backup": "Restore From Backup",
"maintenance_restore_library": "Restore Your Library",
"maintenance_restore_library_confirm": "If this looks correct, continue to restoring a backup!",
"maintenance_restore_library_description": "Restoring Database",
"maintenance_restore_library_folder_has_files": "{folder} has {count} folder(s)",
"maintenance_restore_library_folder_no_files": "{folder} is missing files!",
"maintenance_restore_library_folder_pass": "readable and writable",
"maintenance_restore_library_folder_read_fail": "not readable",
"maintenance_restore_library_folder_write_fail": "not writable",
"maintenance_restore_library_hint_missing_files": "You may be missing important files",
"maintenance_restore_library_hint_regenerate_later": "You can regenerate these later in settings",
"maintenance_restore_library_hint_storage_template_missing_files": "Using storage template? You may be missing files",
"maintenance_restore_library_loading": "Loading integrity checks and heuristics…",
"maintenance_task_backup": "Creating a backup of the existing database…",
"maintenance_task_migrations": "Running database migrations…",
"maintenance_task_restore": "Restoring the chosen backup…",
"maintenance_task_rollback": "Restore failed, rolling back to restore point…",
"maintenance_title": "Temporarily Unavailable", "maintenance_title": "Temporarily Unavailable",
"make": "Make", "make": "Make",
"manage_geolocation": "Manage location", "manage_geolocation": "Manage location",
@@ -1408,6 +1500,8 @@
"minimize": "Minimize", "minimize": "Minimize",
"minute": "Minute", "minute": "Minute",
"minutes": "Minutes", "minutes": "Minutes",
"mirror_horizontal": "Horizontal",
"mirror_vertical": "Vertical",
"missing": "Missing", "missing": "Missing",
"mobile_app": "Mobile App", "mobile_app": "Mobile App",
"mobile_app_download_onboarding_note": "Download the companion mobile app using the following options", "mobile_app_download_onboarding_note": "Download the companion mobile app using the following options",
@@ -1416,11 +1510,14 @@
"monthly_title_text_date_format": "MMMM y", "monthly_title_text_date_format": "MMMM y",
"more": "More", "more": "More",
"move": "Move", "move": "Move",
"move_down": "Move down",
"move_off_locked_folder": "Move out of locked folder", "move_off_locked_folder": "Move out of locked folder",
"move_to": "Move to", "move_to": "Move to",
"move_to_device_trash": "Move to device trash",
"move_to_lock_folder_action_prompt": "{count} added to the locked folder", "move_to_lock_folder_action_prompt": "{count} added to the locked folder",
"move_to_locked_folder": "Move to locked folder", "move_to_locked_folder": "Move to locked folder",
"move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder", "move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder",
"move_up": "Move up",
"moved_to_archive": "Moved {count, plural, one {# asset} other {# assets}} to archive", "moved_to_archive": "Moved {count, plural, one {# asset} other {# assets}} to archive",
"moved_to_library": "Moved {count, plural, one {# asset} other {# assets}} to library", "moved_to_library": "Moved {count, plural, one {# asset} other {# assets}} to library",
"moved_to_trash": "Moved to trash", "moved_to_trash": "Moved to trash",
@@ -1430,6 +1527,7 @@
"my_albums": "My albums", "my_albums": "My albums",
"name": "Name", "name": "Name",
"name_or_nickname": "Name or nickname", "name_or_nickname": "Name or nickname",
"name_required": "Name is required",
"navigate": "Navigate", "navigate": "Navigate",
"navigate_to_time": "Navigate to Time", "navigate_to_time": "Navigate to Time",
"network_requirement_photos_upload": "Use cellular data to backup photos", "network_requirement_photos_upload": "Use cellular data to backup photos",
@@ -1454,20 +1552,23 @@
"next": "Next", "next": "Next",
"next_memory": "Next memory", "next_memory": "Next memory",
"no": "No", "no": "No",
"no_actions_added": "No actions added yet",
"no_albums_message": "Create an album to organize your photos and videos", "no_albums_message": "Create an album to organize your photos and videos",
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.", "no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
"no_albums_yet": "It looks like you do not have any albums yet.", "no_albums_yet": "It looks like you do not have any albums yet.",
"no_archived_assets_message": "Archive photos and videos to hide them from your Photos view", "no_archived_assets_message": "Archive photos and videos to hide them from your Photos view",
"no_assets_message": "CLICK TO UPLOAD YOUR FIRST PHOTO", "no_assets_message": "Click to upload your first photo",
"no_assets_to_show": "No assets to show", "no_assets_to_show": "No assets to show",
"no_cast_devices_found": "No cast devices found", "no_cast_devices_found": "No cast devices found",
"no_checksum_local": "No checksum available - cannot fetch local assets", "no_checksum_local": "No checksum available - cannot fetch local assets",
"no_checksum_remote": "No checksum available - cannot fetch remote asset", "no_checksum_remote": "No checksum available - cannot fetch remote asset",
"no_configuration_needed": "No configuration needed",
"no_devices": "No authorized devices", "no_devices": "No authorized devices",
"no_duplicates_found": "No duplicates were found.", "no_duplicates_found": "No duplicates were found.",
"no_exif_info_available": "No exif info available", "no_exif_info_available": "No exif info available",
"no_explore_results_message": "Upload more photos to explore your collection.", "no_explore_results_message": "Upload more photos to explore your collection.",
"no_favorites_message": "Add favorites to quickly find your best pictures and videos", "no_favorites_message": "Add favorites to quickly find your best pictures and videos",
"no_filters_added": "No filters added yet",
"no_libraries_message": "Create an external library to view your photos and videos", "no_libraries_message": "Create an external library to view your photos and videos",
"no_local_assets_found": "No local assets found with this checksum", "no_local_assets_found": "No local assets found with this checksum",
"no_location_set": "No location set", "no_location_set": "No location set",
@@ -1563,6 +1664,7 @@
"people": "People", "people": "People",
"people_edits_count": "Edited {count, plural, one {# person} other {# people}}", "people_edits_count": "Edited {count, plural, one {# person} other {# people}}",
"people_feature_description": "Browsing photos and videos grouped by people", "people_feature_description": "Browsing photos and videos grouped by people",
"people_selected": "{count, plural, one {# person selected} other {# people selected}}",
"people_sidebar_description": "Display a link to People in the sidebar", "people_sidebar_description": "Display a link to People in the sidebar",
"permanent_deletion_warning": "Permanent deletion warning", "permanent_deletion_warning": "Permanent deletion warning",
"permanent_deletion_warning_setting_description": "Show a warning when permanently deleting assets", "permanent_deletion_warning_setting_description": "Show a warning when permanently deleting assets",
@@ -1587,11 +1689,14 @@
"person_age_years": "{years, plural, other {# years}} old", "person_age_years": "{years, plural, other {# years}} old",
"person_birthdate": "Born on {date}", "person_birthdate": "Born on {date}",
"person_hidden": "{name}{hidden, select, true { (hidden)} other {}}", "person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
"person_recognized": "Person recognized",
"person_selected": "Person selected",
"photo_shared_all_users": "Looks like you shared your photos with all users or you don't have any user to share with.", "photo_shared_all_users": "Looks like you shared your photos with all users or you don't have any user to share with.",
"photos": "Photos", "photos": "Photos",
"photos_and_videos": "Photos & Videos", "photos_and_videos": "Photos & Videos",
"photos_count": "{count, plural, one {{count, number} Photo} other {{count, number} Photos}}", "photos_count": "{count, plural, one {{count, number} Photo} other {{count, number} Photos}}",
"photos_from_previous_years": "Photos from previous years", "photos_from_previous_years": "Photos from previous years",
"photos_only": "Photos only",
"pick_a_location": "Pick a location", "pick_a_location": "Pick a location",
"pick_custom_range": "Custom range", "pick_custom_range": "Custom range",
"pick_date_range": "Select a date range", "pick_date_range": "Select a date range",
@@ -1667,10 +1772,12 @@
"purchase_settings_server_activated": "The server product key is managed by the admin", "purchase_settings_server_activated": "The server product key is managed by the admin",
"query_asset_id": "Query Asset ID", "query_asset_id": "Query Asset ID",
"queue_status": "Queuing {count}/{total}", "queue_status": "Queuing {count}/{total}",
"rate_asset": "Rate Asset",
"rating": "Star rating", "rating": "Star rating",
"rating_clear": "Clear rating", "rating_clear": "Clear rating",
"rating_count": "{count, plural, one {# star} other {# stars}}", "rating_count": "{count, plural, one {# star} other {# stars}}",
"rating_description": "Display the EXIF rating in the info panel", "rating_description": "Display the EXIF rating in the info panel",
"rating_set": "Rating set to {rating, plural, one {# star} other {# stars}}",
"reaction_options": "Reaction options", "reaction_options": "Reaction options",
"read_changelog": "Read Changelog", "read_changelog": "Read Changelog",
"readonly_mode_disabled": "Read-only mode disabled", "readonly_mode_disabled": "Read-only mode disabled",
@@ -1770,9 +1877,11 @@
"saved_settings": "Saved settings", "saved_settings": "Saved settings",
"say_something": "Say something", "say_something": "Say something",
"scaffold_body_error_occurred": "Error occurred", "scaffold_body_error_occurred": "Error occurred",
"scan": "Scan",
"scan_all_libraries": "Scan All Libraries", "scan_all_libraries": "Scan All Libraries",
"scan_library": "Scan", "scan_library": "Scan",
"scan_settings": "Scan Settings", "scan_settings": "Scan Settings",
"scanning": "Scanning",
"scanning_for_album": "Scanning for album...", "scanning_for_album": "Scanning for album...",
"search": "Search", "search": "Search",
"search_albums": "Search albums", "search_albums": "Search albums",
@@ -1836,17 +1945,23 @@
"second": "Second", "second": "Second",
"see_all_people": "See all people", "see_all_people": "See all people",
"select": "Select", "select": "Select",
"select_album": "Select album",
"select_album_cover": "Select album cover", "select_album_cover": "Select album cover",
"select_albums": "Select albums",
"select_all": "Select all", "select_all": "Select all",
"select_all_duplicates": "Select all duplicates", "select_all_duplicates": "Select all duplicates",
"select_all_in": "Select all in {group}", "select_all_in": "Select all in {group}",
"select_avatar_color": "Select avatar color", "select_avatar_color": "Select avatar color",
"select_count": "{count, plural, one {Select #} other {Select #}}",
"select_cutoff_date": "Select cutoff date",
"select_face": "Select face", "select_face": "Select face",
"select_featured_photo": "Select featured photo", "select_featured_photo": "Select featured photo",
"select_from_computer": "Select from computer", "select_from_computer": "Select from computer",
"select_keep_all": "Select keep all", "select_keep_all": "Select keep all",
"select_library_owner": "Select library owner", "select_library_owner": "Select library owner",
"select_new_face": "Select new face", "select_new_face": "Select new face",
"select_people": "Select people",
"select_person": "Select person",
"select_person_to_tag": "Select a person to tag", "select_person_to_tag": "Select a person to tag",
"select_photos": "Select photos", "select_photos": "Select photos",
"select_trash_all": "Select trash all", "select_trash_all": "Select trash all",
@@ -1982,6 +2097,7 @@
"show_password": "Show password", "show_password": "Show password",
"show_person_options": "Show person options", "show_person_options": "Show person options",
"show_progress_bar": "Show Progress Bar", "show_progress_bar": "Show Progress Bar",
"show_schema": "Show schema",
"show_search_options": "Show search options", "show_search_options": "Show search options",
"show_shared_links": "Show shared links", "show_shared_links": "Show shared links",
"show_slideshow_transition": "Show slideshow transition", "show_slideshow_transition": "Show slideshow transition",
@@ -2075,6 +2191,7 @@
"theme_setting_theme_subtitle": "Choose the app's theme setting", "theme_setting_theme_subtitle": "Choose the app's theme setting",
"theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load",
"theme_setting_three_stage_loading_title": "Enable three-stage loading", "theme_setting_three_stage_loading_title": "Enable three-stage loading",
"then": "Then",
"they_will_be_merged_together": "They will be merged together", "they_will_be_merged_together": "They will be merged together",
"third_party_resources": "Third-Party Resources", "third_party_resources": "Third-Party Resources",
"time": "Time", "time": "Time",
@@ -2109,6 +2226,13 @@
"trash_page_select_assets_btn": "Select assets", "trash_page_select_assets_btn": "Select assets",
"trash_page_title": "Trash ({count})", "trash_page_title": "Trash ({count})",
"trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.", "trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.",
"trigger": "Trigger",
"trigger_asset_uploaded": "Asset Uploaded",
"trigger_asset_uploaded_description": "Triggered when a new asset is uploaded",
"trigger_description": "An event that kicks off the workflow",
"trigger_person_recognized": "Person Recognized",
"trigger_person_recognized_description": "Triggered when a person is detected",
"trigger_type": "Trigger type",
"troubleshoot": "Troubleshoot", "troubleshoot": "Troubleshoot",
"type": "Type", "type": "Type",
"unable_to_change_pin_code": "Unable to change PIN code", "unable_to_change_pin_code": "Unable to change PIN code",
@@ -2123,6 +2247,7 @@
"unhide_person": "Unhide person", "unhide_person": "Unhide person",
"unknown": "Unknown", "unknown": "Unknown",
"unknown_country": "Unknown Country", "unknown_country": "Unknown Country",
"unknown_date": "Unknown date",
"unknown_year": "Unknown Year", "unknown_year": "Unknown Year",
"unlimited": "Unlimited", "unlimited": "Unlimited",
"unlink_motion_video": "Unlink motion video", "unlink_motion_video": "Unlink motion video",
@@ -2139,13 +2264,14 @@
"unstack": "Un-stack", "unstack": "Un-stack",
"unstack_action_prompt": "{count} unstacked", "unstack_action_prompt": "{count} unstacked",
"unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}", "unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}",
"unsupported_field_type": "Unsupported field type",
"untagged": "Untagged", "untagged": "Untagged",
"untitled_workflow": "Untitled workflow",
"up_next": "Up next", "up_next": "Up next",
"update_location_action_prompt": "Update the location of {count} selected assets with:", "update_location_action_prompt": "Update the location of {count} selected assets with:",
"updated_at": "Updated", "updated_at": "Updated",
"updated_password": "Updated password", "updated_password": "Updated password",
"upload": "Upload", "upload": "Upload",
"upload_action_prompt": "{count} queued for upload",
"upload_concurrency": "Upload concurrency", "upload_concurrency": "Upload concurrency",
"upload_details": "Upload Details", "upload_details": "Upload Details",
"upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?",
@@ -2164,7 +2290,7 @@
"url": "URL", "url": "URL",
"usage": "Usage", "usage": "Usage",
"use_biometric": "Use biometric", "use_biometric": "Use biometric",
"use_current_connection": "use current connection", "use_current_connection": "Use current connection",
"use_custom_date_range": "Use custom date range instead", "use_custom_date_range": "Use custom date range instead",
"user": "User", "user": "User",
"user_has_been_deleted": "This user has been deleted.", "user_has_been_deleted": "This user has been deleted.",
@@ -2185,6 +2311,7 @@
"utilities": "Utilities", "utilities": "Utilities",
"validate": "Validate", "validate": "Validate",
"validate_endpoint_error": "Please enter a valid URL", "validate_endpoint_error": "Please enter a valid URL",
"validation_error": "Validation error",
"variables": "Variables", "variables": "Variables",
"version": "Version", "version": "Version",
"version_announcement_closing": "Your friend, Alex", "version_announcement_closing": "Your friend, Alex",
@@ -2196,6 +2323,7 @@
"video_hover_setting_description": "Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon.", "video_hover_setting_description": "Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon.",
"videos": "Videos", "videos": "Videos",
"videos_count": "{count, plural, one {# Video} other {# Videos}}", "videos_count": "{count, plural, one {# Video} other {# Videos}}",
"videos_only": "Videos only",
"view": "View", "view": "View",
"view_album": "View Album", "view_album": "View Album",
"view_all": "View All", "view_all": "View All",
@@ -2216,6 +2344,8 @@
"viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_stack_use_as_main_asset": "Use as Main Asset",
"viewer_unstack": "Un-Stack", "viewer_unstack": "Un-Stack",
"visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}", "visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}",
"visual": "Visual",
"visual_builder": "Visual builder",
"waiting": "Waiting", "waiting": "Waiting",
"waiting_count": "Waiting: {count}", "waiting_count": "Waiting: {count}",
"warning": "Warning", "warning": "Warning",
@@ -2224,13 +2354,26 @@
"welcome_to_immich": "Welcome to Immich", "welcome_to_immich": "Welcome to Immich",
"width": "Width", "width": "Width",
"wifi_name": "Wi-Fi Name", "wifi_name": "Wi-Fi Name",
"workflow": "Workflow", "workflow_delete_prompt": "Are you sure you want to delete this workflow?",
"workflow_deleted": "Workflow deleted",
"workflow_description": "Workflow description",
"workflow_info": "Workflow info",
"workflow_json": "Workflow JSON",
"workflow_json_help": "Edit the workflow configuration in JSON format. Changes will sync to the visual builder.",
"workflow_name": "Workflow name",
"workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?",
"workflow_summary": "Workflow summary",
"workflow_update_success": "Workflow updated successfully",
"workflow_updated": "Workflow updated",
"workflows": "Workflows",
"workflows_help_text": "Workflows automate actions on your assets based on triggers and filters",
"wrong_pin_code": "Wrong PIN code", "wrong_pin_code": "Wrong PIN code",
"year": "Year", "year": "Year",
"years_ago": "{years, plural, one {# year} other {# years}} ago", "years_ago": "{years, plural, one {# year} other {# years}} ago",
"yes": "Yes", "yes": "Yes",
"you_dont_have_any_shared_links": "You don't have any shared links", "you_dont_have_any_shared_links": "You don't have any shared links",
"your_wifi_name": "Your Wi-Fi name", "your_wifi_name": "Your Wi-Fi name",
"zero_to_clear_rating": "press 0 to clear asset rating",
"zoom_image": "Zoom Image", "zoom_image": "Zoom Image",
"zoom_to_bounds": "Zoom to bounds" "zoom_to_bounds": "Zoom to bounds"
} }

13
i18n/package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "immich-i18n",
"version": "1.0.0",
"private": true,
"scripts": {
"format": "prettier --check .",
"format:fix": "prettier --write ."
},
"devDependencies": {
"prettier": "^3.7.4",
"prettier-plugin-sort-json": "^4.1.1"
}
}

View File

@@ -1,8 +1,8 @@
ARG DEVICE=cpu ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:e39286476f84ffedf7c3564b0b74e32c9e1193ec9ca32ee8a11f8c09dbf6aafe AS builder-cpu FROM python:3.11-bookworm@sha256:667cf70698924920f29ebdb8d749ab665811503b87093d4f11826d114fd7255e AS builder-cpu
FROM builder-cpu AS builder-openvino FROM python:3.13-slim-trixie@sha256:0222b795db95bf7412cede36ab46a266cfb31f632e64051aac9806dabf840a61 AS builder-openvino
FROM builder-cpu AS builder-cuda FROM builder-cpu AS builder-cuda
@@ -22,20 +22,18 @@ FROM builder-cpu AS builder-rknn
# Warning: 25GiB+ disk space required to pull this image # Warning: 25GiB+ disk space required to pull this image
# TODO: find a way to reduce the image size # TODO: find a way to reduce the image size
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:6cda50e312f3aac068cea9ec06c560ca1f522ad546bc8b3d2cf06da0fe8e8a76 AS builder-rocm FROM rocm/dev-ubuntu-24.04:6.4.4-complete@sha256:31418ac10a3769a71eaef330c07280d1d999d7074621339b8f93c484c35f6078 AS builder-rocm
# renovate: datasource=github-releases depName=Microsoft/onnxruntime # renovate: datasource=github-releases depName=Microsoft/onnxruntime
ARG ONNXRUNTIME_VERSION="v1.22.1" ARG ONNXRUNTIME_VERSION="v1.22.1"
WORKDIR /code WORKDIR /code
RUN apt-get update && apt-get install -y --no-install-recommends wget git python3.10-venv RUN apt-get update && apt-get install -y --no-install-recommends wget git
RUN wget -nv https://github.com/Kitware/CMake/releases/download/v3.30.1/cmake-3.30.1-linux-x86_64.sh && \ RUN wget -nv https://github.com/Kitware/CMake/releases/download/v3.31.9/cmake-3.31.9-linux-x86_64.sh && \
chmod +x cmake-3.30.1-linux-x86_64.sh && \ chmod +x cmake-3.31.9-linux-x86_64.sh && \
mkdir -p /code/cmake-3.30.1-linux-x86_64 && \ mkdir -p /code/cmake-3.31.9-linux-x86_64 && \
./cmake-3.30.1-linux-x86_64.sh --skip-license --prefix=/code/cmake-3.30.1-linux-x86_64 && \ ./cmake-3.31.9-linux-x86_64.sh --skip-license --prefix=/code/cmake-3.31.9-linux-x86_64 && \
rm cmake-3.30.1-linux-x86_64.sh rm cmake-3.31.9-linux-x86_64.sh
ENV PATH=/code/cmake-3.30.1-linux-x86_64/bin:${PATH}
RUN git clone --single-branch --branch "${ONNXRUNTIME_VERSION}" --recursive "https://github.com/Microsoft/onnxruntime" onnxruntime RUN git clone --single-branch --branch "${ONNXRUNTIME_VERSION}" --recursive "https://github.com/Microsoft/onnxruntime" onnxruntime
WORKDIR /code/onnxruntime WORKDIR /code/onnxruntime
@@ -45,9 +43,26 @@ COPY ./patches/* /tmp/
RUN git apply /tmp/*.patch RUN git apply /tmp/*.patch
RUN /bin/sh ./dockerfiles/scripts/install_common_deps.sh RUN /bin/sh ./dockerfiles/scripts/install_common_deps.sh
ENV PATH=/opt/rocm-venv/bin:/code/cmake-3.31.9-linux-x86_64/bin:${PATH}
ENV CCACHE_DIR="/ccache"
# Note: the `parallel` setting uses a substantial amount of RAM # Note: the `parallel` setting uses a substantial amount of RAM
RUN ./build.sh --allow_running_as_root --config Release --build_wheel --update --build --parallel 17 --cmake_extra_defines\ RUN --mount=type=cache,target=/ccache \
ONNXRUNTIME_VERSION="${ONNXRUNTIME_VERSION}" --skip_tests --use_rocm --rocm_home=/opt/rocm ./build.sh \
--allow_running_as_root \
--config Release \
--build_wheel \
--update \
--build \
--parallel 17 \
--cmake_extra_defines \
ONNXRUNTIME_VERSION="${ONNXRUNTIME_VERSION}" \
CMAKE_HIP_ARCHITECTURES="gfx900;gfx906;gfx908;gfx90a;gfx940;gfx941;gfx942;gfx1030;gfx1100;gfx1101;gfx1102;gfx1200;gfx1201" \
--skip_tests \
--use_rocm \
--rocm_home=/opt/rocm \
--use_cache \
--compile_no_warning_as_error
RUN mv /code/onnxruntime/build/Linux/Release/dist/*.whl /opt/ RUN mv /code/onnxruntime/build/Linux/Release/dist/*.whl /opt/
FROM builder-${DEVICE} AS builder FROM builder-${DEVICE} AS builder
@@ -68,20 +83,23 @@ RUN if [ "$DEVICE" = "rocm" ]; then \
uv pip install /opt/onnxruntime_rocm-*.whl; \ uv pip install /opt/onnxruntime_rocm-*.whl; \
fi fi
FROM python:3.11-slim-bookworm@sha256:2c5bc243b1cc47985ee4fb768bb0bbd4490481c5d0897a62da31b7f30b7304a7 AS prod-cpu FROM python:3.11-slim-bookworm@sha256:917ec0e42cd6af87657a768449c2f604a6b67c7ab8e10ff917b8724799f816d3 AS prod-cpu
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \ ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
MACHINE_LEARNING_MODEL_ARENA=false MACHINE_LEARNING_MODEL_ARENA=false
FROM python:3.11-slim-bookworm@sha256:2c5bc243b1cc47985ee4fb768bb0bbd4490481c5d0897a62da31b7f30b7304a7 AS prod-openvino FROM python:3.13-slim-trixie@sha256:0222b795db95bf7412cede36ab46a266cfb31f632e64051aac9806dabf840a61 AS prod-openvino
RUN apt-get update && \ RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \ apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-core_1.0.17384.11_amd64.deb && \ wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.27.10/intel-igc-core-2_2.27.10+20617_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-opencl_1.0.17384.11_amd64.deb && \ wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.27.10/intel-igc-opencl-2_2.27.10+20617_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/intel-opencl-icd_24.31.30508.7_amd64.deb && \ wget -nv https://github.com/intel/compute-runtime/releases/download/26.01.36711.4/intel-opencl-icd_26.01.36711.4-0_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file # TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
wget -nv https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/libigdgmm12_22.4.1_amd64.deb && \ wget -nv https://github.com/intel/compute-runtime/releases/download/26.01.36711.4/libigdgmm12_22.9.0_amd64.deb && \
dpkg -i *.deb && \ dpkg -i *.deb && \
rm *.deb && \ rm *.deb && \
apt-get remove wget -yqq && \ apt-get remove wget -yqq && \
@@ -102,7 +120,7 @@ COPY --from=builder-cuda /usr/local/bin/python3 /usr/local/bin/python3
COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11 COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11
COPY --from=builder-cuda /usr/local/lib/libpython3.11.so /usr/local/lib/libpython3.11.so COPY --from=builder-cuda /usr/local/lib/libpython3.11.so /usr/local/lib/libpython3.11.so
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:6cda50e312f3aac068cea9ec06c560ca1f522ad546bc8b3d2cf06da0fe8e8a76 AS prod-rocm FROM rocm/dev-ubuntu-24.04:6.4.4-complete@sha256:31418ac10a3769a71eaef330c07280d1d999d7074621339b8f93c484c35f6078 AS prod-rocm
FROM prod-cpu AS prod-armnn FROM prod-cpu AS prod-armnn

View File

@@ -36,7 +36,7 @@ from .schemas import (
T, T,
) )
MultiPartParser.max_file_size = 2**26 # spools to disk if payload is 64 MiB or larger MultiPartParser.spool_max_size = 2**26 # spools to disk if payload is 64 MiB or larger
model_cache = ModelCache(revalidate=settings.model_ttl > 0) model_cache = ModelCache(revalidate=settings.model_ttl > 0)
thread_pool: ThreadPoolExecutor | None = None thread_pool: ThreadPoolExecutor | None = None

View File

@@ -0,0 +1,33 @@
diff --git a/dockerfiles/scripts/install_common_deps.sh b/dockerfiles/scripts/install_common_deps.sh
index bbb672a99e..0dc652fbda 100644
--- a/dockerfiles/scripts/install_common_deps.sh
+++ b/dockerfiles/scripts/install_common_deps.sh
@@ -8,16 +8,23 @@ apt-get update && apt-get install -y --no-install-recommends \
curl \
libcurl4-openssl-dev \
libssl-dev \
- python3-dev
+ python3-dev \
+ ccache
# Dependencies: conda
-wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-4.5.11-Linux-x86_64.sh -O ~/miniconda.sh --no-check-certificate && /bin/bash ~/miniconda.sh -b -p /opt/miniconda
+wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-py312_25.9.1-1-Linux-x86_64.sh -O ~/miniconda.sh && /bin/bash ~/miniconda.sh -b -p /opt/miniconda
rm ~/miniconda.sh
/opt/miniconda/bin/conda clean -ya
-pip install numpy
-pip install packaging
-pip install "wheel>=0.35.1"
+# Dependencies: venv and packages
+/opt/miniconda/bin/python3 -m venv /opt/rocm-venv
+/opt/rocm-venv/bin/pip install --no-cache-dir --upgrade pip
+/opt/rocm-venv/bin/pip install --no-cache-dir \
+ "numpy==2.3.4" \
+ "packaging==25.0" \
+ "wheel==0.45.1" \
+ "setuptools==80.9.0"
+
rm -rf /opt/miniconda/pkgs
# Dependencies: cmake

View File

@@ -1,13 +0,0 @@
diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt
index 2714e6f59..a69da76b4 100644
--- a/cmake/CMakeLists.txt
+++ b/cmake/CMakeLists.txt
@@ -338,7 +338,7 @@ if (onnxruntime_USE_ROCM)
if (ROCM_VERSION_DEV VERSION_LESS "6.2")
message(FATAL_ERROR "CMAKE_HIP_ARCHITECTURES is not set when ROCm version < 6.2")
else()
- set(CMAKE_HIP_ARCHITECTURES "gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx940;gfx941;gfx942;gfx1200;gfx1201")
+ set(CMAKE_HIP_ARCHITECTURES "gfx900;gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx1102;gfx940;gfx941;gfx942;gfx1200;gfx1201")
endif()
endif()

View File

@@ -3,7 +3,7 @@ name = "immich-ml"
version = "2.4.1" version = "2.4.1"
description = "" description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }] authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.10,<4.0" requires-python = ">=3.11,<4.0"
readme = "README.md" readme = "README.md"
dependencies = [ dependencies = [
"aiocache>=0.12.1,<1.0", "aiocache>=0.12.1,<1.0",
@@ -12,7 +12,7 @@ dependencies = [
"gunicorn>=21.1.0", "gunicorn>=21.1.0",
"huggingface-hub>=0.20.1,<1.0", "huggingface-hub>=0.20.1,<1.0",
"insightface>=0.7.3,<1.0", "insightface>=0.7.3,<1.0",
"numpy<2", "numpy>=2.3.4",
"opencv-python-headless>=4.7.0.72,<5.0", "opencv-python-headless>=4.7.0.72,<5.0",
"orjson>=3.9.5", "orjson>=3.9.5",
"pillow>=9.5.0,<11.0", "pillow>=9.5.0,<11.0",
@@ -49,24 +49,16 @@ lint = [
dev = ["locust>=2.15.1", { include-group = "test" }, { include-group = "lint" }] dev = ["locust>=2.15.1", { include-group = "test" }, { include-group = "lint" }]
[project.optional-dependencies] [project.optional-dependencies]
cpu = ["onnxruntime>=1.15.0,<2"] cpu = ["onnxruntime>=1.23.2,<2"]
cuda = ["onnxruntime-gpu>=1.17.0,<2"] cuda = ["onnxruntime-gpu>=1.23.2,<2"]
openvino = ["onnxruntime-openvino>=1.17.1,<1.19.0"] openvino = ["onnxruntime-openvino>=1.23.0,<2"]
armnn = ["onnxruntime>=1.15.0,<2"] armnn = ["onnxruntime>=1.23.2,<2"]
rknn = ["onnxruntime>=1.15.0,<2", "rknn-toolkit-lite2>=2.3.0,<3"] rknn = ["onnxruntime>=1.23.2,<2", "rknn-toolkit-lite2>=2.3.0,<3"]
rocm = [] rocm = []
[tool.uv] [tool.uv]
compile-bytecode = true compile-bytecode = true
[[tool.uv.index]]
name = "cuda12"
url = "https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple/"
explicit = true
[tool.uv.sources]
onnxruntime-gpu = { index = "cuda12" }
[tool.hatch.build.targets.sdist] [tool.hatch.build.targets.sdist]
include = ["immich_ml"] include = ["immich_ml"]

1975
machine-learning/uv.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -90,6 +90,7 @@ fi
sed -i "s/\"android\.injected\.version\.name\" => \"$CURRENT_SERVER\",/\"android\.injected\.version\.name\" => \"$NEXT_SERVER\",/" mobile/android/fastlane/Fastfile sed -i "s/\"android\.injected\.version\.name\" => \"$CURRENT_SERVER\",/\"android\.injected\.version\.name\" => \"$NEXT_SERVER\",/" mobile/android/fastlane/Fastfile
sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.injected\.version\.code\" => $NEXT_MOBILE,/" mobile/android/fastlane/Fastfile sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.injected\.version\.code\" => $NEXT_MOBILE,/" mobile/android/fastlane/Fastfile
sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml
perl -i -p0e "s/(<key>CFBundleShortVersionString<\/key>\s*<string>)$CURRENT_SERVER(<\/string>)/\${1}$NEXT_SERVER\${2}/s" mobile/ios/Runner/Info.plist
./misc/release/archive-version.js "$NEXT_SERVER" ./misc/release/archive-version.js "$NEXT_SERVER"

View File

@@ -1,9 +1,9 @@
experimental_monorepo_root = true experimental_monorepo_root = true
[tools] [tools]
node = "24.11.1" node = "24.13.0"
flutter = "3.35.7" flutter = "3.35.7"
pnpm = "10.24.0" pnpm = "10.28.0"
terragrunt = "0.93.10" terragrunt = "0.93.10"
opentofu = "1.10.7" opentofu = "1.10.7"
java = "25.0.1" java = "25.0.1"
@@ -34,4 +34,4 @@ run = { task = ":i18n:format-fix" }
[tasks."i18n:format-fix"] [tasks."i18n:format-fix"]
dir = "i18n" dir = "i18n"
run = "pnpm dlx sort-json *.json" run = "pnpm run format:fix"

View File

@@ -117,6 +117,9 @@
<data <data
android:host="my.immich.app" android:host="my.immich.app"
android:pathPrefix="/memories/" /> android:pathPrefix="/memories/" />
<data
android:host="my.immich.app"
android:path="/memory" />
<data <data
android:host="my.immich.app" android:host="my.immich.app"
android:pathPrefix="/photos/" /> android:pathPrefix="/photos/" />

View File

@@ -252,6 +252,40 @@ data class HashResult (
override fun hashCode(): Int = toList().hashCode() override fun hashCode(): Int = toList().hashCode()
} }
/** Generated class from Pigeon that represents data sent in messages. */
data class CloudIdResult (
val assetId: String,
val error: String? = null,
val cloudId: String? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): CloudIdResult {
val assetId = pigeonVar_list[0] as String
val error = pigeonVar_list[1] as String?
val cloudId = pigeonVar_list[2] as String?
return CloudIdResult(assetId, error, cloudId)
}
}
fun toList(): List<Any?> {
return listOf(
assetId,
error,
cloudId,
)
}
override fun equals(other: Any?): Boolean {
if (other !is CloudIdResult) {
return false
}
if (this === other) {
return true
}
return MessagesPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
private open class MessagesPigeonCodec : StandardMessageCodec() { private open class MessagesPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) { return when (type) {
@@ -275,6 +309,11 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
HashResult.fromList(it) HashResult.fromList(it)
} }
} }
133.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
CloudIdResult.fromList(it)
}
}
else -> super.readValueOfType(type, buffer) else -> super.readValueOfType(type, buffer)
} }
} }
@@ -296,6 +335,10 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
stream.write(132) stream.write(132)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is CloudIdResult -> {
stream.write(133)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value) else -> super.writeValue(stream, value)
} }
} }
@@ -315,6 +358,7 @@ interface NativeSyncApi {
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit) fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
fun cancelHashing() fun cancelHashing()
fun getTrashedAssets(): Map<String, List<PlatformAsset>> fun getTrashedAssets(): Map<String, List<PlatformAsset>>
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
companion object { companion object {
/** The codec used by NativeSyncApi. */ /** The codec used by NativeSyncApi. */
@@ -508,6 +552,23 @@ interface NativeSyncApi {
channel.setMessageHandler(null) channel.setMessageHandler(null)
} }
} }
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val assetIdsArg = args[0] as List<String>
val wrapped: List<Any?> = try {
listOf(api.getCloudIdForAssetIds(assetIdsArg))
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
} }
} }
} }

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
@@ -21,7 +22,6 @@ import kotlinx.coroutines.sync.withPermit
import java.io.File import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.coroutineContext
sealed class AssetResult { sealed class AssetResult {
data class ValidAsset(val asset: PlatformAsset, val albumId: String) : AssetResult() data class ValidAsset(val asset: PlatformAsset, val albumId: String) : AssetResult()
@@ -298,7 +298,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
var bytesRead: Int var bytesRead: Int
val buffer = ByteArray(HASH_BUFFER_SIZE) val buffer = ByteArray(HASH_BUFFER_SIZE)
while (inputStream.read(buffer).also { bytesRead = it } > 0) { while (inputStream.read(buffer).also { bytesRead = it } > 0) {
coroutineContext.ensureActive() currentCoroutineContext().ensureActive()
digest.update(buffer, 0, bytesRead) digest.update(buffer, 0, bytesRead)
} }
} ?: return HashResult(assetId, "Cannot open input stream for asset", null) } ?: return HashResult(assetId, "Cannot open input stream for asset", null)
@@ -316,4 +316,10 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
hashTask?.cancel() hashTask?.cancel()
hashTask = null hashTask = null
} }
// This method is only implemented on iOS; on Android, we do not have a concept of cloud IDs
@Suppress("unused", "UNUSED_PARAMETER")
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
return emptyList()
}
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -33,4 +33,5 @@ Runner/GeneratedPluginRegistrant.*
!default.perspectivev3 !default.perspectivev3
fastlane/report.xml fastlane/report.xml
Gemfile.lock Gemfile.lock
certs/

View File

@@ -55,6 +55,7 @@ import UIKit
NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!) NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!)
ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl()) ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl())
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl()) BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl())
ConnectivityApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ConnectivityApiImpl())
} }
public static func cancelPlugins(with engine: FlutterEngine) { public static func cancelPlugins(with engine: FlutterEngine) {

View File

@@ -1,6 +1,60 @@
import Network
class ConnectivityApiImpl: ConnectivityApi { class ConnectivityApiImpl: ConnectivityApi {
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "ConnectivityMonitor")
private var currentPath: NWPath?
init() {
monitor.pathUpdateHandler = { [weak self] path in
self?.currentPath = path
}
monitor.start(queue: queue)
// Get initial state synchronously
currentPath = monitor.currentPath
}
deinit {
monitor.cancel()
}
func getCapabilities() throws -> [NetworkCapability] { func getCapabilities() throws -> [NetworkCapability] {
[] guard let path = currentPath else {
return []
}
guard path.status == .satisfied else {
return []
}
var capabilities: [NetworkCapability] = []
if path.usesInterfaceType(.wifi) {
capabilities.append(.wifi)
}
if path.usesInterfaceType(.cellular) {
capabilities.append(.cellular)
}
// Check for VPN - iOS reports VPN as .other interface type in many cases
// or through the path's expensive property when on cellular with VPN
if path.usesInterfaceType(.other) {
capabilities.append(.vpn)
}
// Determine if connection is unmetered:
// - Must be on WiFi (not cellular)
// - Must not be expensive (rules out personal hotspot)
// - Must not be constrained (Low Data Mode)
// Note: VPN over cellular should still be considered metered
let isOnCellular = path.usesInterfaceType(.cellular)
let isOnWifi = path.usesInterfaceType(.wifi)
if isOnWifi && !isOnCellular && !path.isExpensive && !path.isConstrained {
capabilities.append(.unmetered)
}
return capabilities
} }
} }

View File

@@ -80,7 +80,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2.2.1</string> <string>2.4.1</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>

View File

@@ -312,6 +312,39 @@ struct HashResult: Hashable {
} }
} }
/// Generated class from Pigeon that represents data sent in messages.
struct CloudIdResult: Hashable {
var assetId: String
var error: String? = nil
var cloudId: String? = nil
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> CloudIdResult? {
let assetId = pigeonVar_list[0] as! String
let error: String? = nilOrValue(pigeonVar_list[1])
let cloudId: String? = nilOrValue(pigeonVar_list[2])
return CloudIdResult(
assetId: assetId,
error: error,
cloudId: cloudId
)
}
func toList() -> [Any?] {
return [
assetId,
error,
cloudId,
]
}
static func == (lhs: CloudIdResult, rhs: CloudIdResult) -> Bool {
return deepEqualsMessages(lhs.toList(), rhs.toList()) }
func hash(into hasher: inout Hasher) {
deepHashMessages(value: toList(), hasher: &hasher)
}
}
private class MessagesPigeonCodecReader: FlutterStandardReader { private class MessagesPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? { override func readValue(ofType type: UInt8) -> Any? {
switch type { switch type {
@@ -323,6 +356,8 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
return SyncDelta.fromList(self.readValue() as! [Any?]) return SyncDelta.fromList(self.readValue() as! [Any?])
case 132: case 132:
return HashResult.fromList(self.readValue() as! [Any?]) return HashResult.fromList(self.readValue() as! [Any?])
case 133:
return CloudIdResult.fromList(self.readValue() as! [Any?])
default: default:
return super.readValue(ofType: type) return super.readValue(ofType: type)
} }
@@ -343,6 +378,9 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter {
} else if let value = value as? HashResult { } else if let value = value as? HashResult {
super.writeByte(132) super.writeByte(132)
super.writeValue(value.toList()) super.writeValue(value.toList())
} else if let value = value as? CloudIdResult {
super.writeByte(133)
super.writeValue(value.toList())
} else { } else {
super.writeValue(value) super.writeValue(value)
} }
@@ -377,6 +415,7 @@ protocol NativeSyncApi {
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
func cancelHashing() throws func cancelHashing() throws
func getTrashedAssets() throws -> [String: [PlatformAsset]] func getTrashedAssets() throws -> [String: [PlatformAsset]]
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
} }
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -560,5 +599,22 @@ class NativeSyncApiSetup {
} else { } else {
getTrashedAssetsChannel.setMessageHandler(nil) getTrashedAssetsChannel.setMessageHandler(nil)
} }
let getCloudIdForAssetIdsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getCloudIdForAssetIdsChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let assetIdsArg = args[0] as! [String]
do {
let result = try api.getCloudIdForAssetIds(assetIds: assetIdsArg)
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
getCloudIdForAssetIdsChannel.setMessageHandler(nil)
}
} }
} }

View File

@@ -19,31 +19,31 @@ struct AssetWrapper: Hashable, Equatable {
class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
static let name = "NativeSyncApi" static let name = "NativeSyncApi"
static func register(with registrar: any FlutterPluginRegistrar) { static func register(with registrar: any FlutterPluginRegistrar) {
let instance = NativeSyncApiImpl() let instance = NativeSyncApiImpl()
NativeSyncApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance) NativeSyncApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance)
registrar.publish(instance) registrar.publish(instance)
} }
func detachFromEngine(for registrar: any FlutterPluginRegistrar) { func detachFromEngine(for registrar: any FlutterPluginRegistrar) {
super.detachFromEngine() super.detachFromEngine()
} }
private let defaults: UserDefaults private let defaults: UserDefaults
private let changeTokenKey = "immich:changeToken" private let changeTokenKey = "immich:changeToken"
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum] private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
private let recoveredAlbumSubType = 1000000219 private let recoveredAlbumSubType = 1000000219
private var hashTask: Task<Void?, Error>? private var hashTask: Task<Void?, Error>?
private static let hashCancelledCode = "HASH_CANCELLED" private static let hashCancelledCode = "HASH_CANCELLED"
private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil)) private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil))
init(with defaults: UserDefaults = .standard) { init(with defaults: UserDefaults = .standard) {
self.defaults = defaults self.defaults = defaults
} }
@available(iOS 16, *) @available(iOS 16, *)
private func getChangeToken() -> PHPersistentChangeToken? { private func getChangeToken() -> PHPersistentChangeToken? {
guard let data = defaults.data(forKey: changeTokenKey) else { guard let data = defaults.data(forKey: changeTokenKey) else {
@@ -51,7 +51,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
} }
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: PHPersistentChangeToken.self, from: data) return try? NSKeyedUnarchiver.unarchivedObject(ofClass: PHPersistentChangeToken.self, from: data)
} }
@available(iOS 16, *) @available(iOS 16, *)
private func saveChangeToken(token: PHPersistentChangeToken) -> Void { private func saveChangeToken(token: PHPersistentChangeToken) -> Void {
guard let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) else { guard let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) else {
@@ -59,18 +59,18 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
} }
defaults.set(data, forKey: changeTokenKey) defaults.set(data, forKey: changeTokenKey)
} }
func clearSyncCheckpoint() -> Void { func clearSyncCheckpoint() -> Void {
defaults.removeObject(forKey: changeTokenKey) defaults.removeObject(forKey: changeTokenKey)
} }
func checkpointSync() { func checkpointSync() {
guard #available(iOS 16, *) else { guard #available(iOS 16, *) else {
return return
} }
saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken) saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken)
} }
func shouldFullSync() -> Bool { func shouldFullSync() -> Bool {
guard #available(iOS 16, *), guard #available(iOS 16, *),
PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized, PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized,
@@ -78,36 +78,36 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
// When we do not have access to photo library, older iOS version or No token available, fallback to full sync // When we do not have access to photo library, older iOS version or No token available, fallback to full sync
return true return true
} }
guard let _ = try? PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) else { guard let _ = try? PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) else {
// Cannot fetch persistent changes // Cannot fetch persistent changes
return true return true
} }
return false return false
} }
func getAlbums() throws -> [PlatformAlbum] { func getAlbums() throws -> [PlatformAlbum] {
var albums: [PlatformAlbum] = [] var albums: [PlatformAlbum] = []
albumTypes.forEach { type in albumTypes.forEach { type in
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil) let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
for i in 0..<collections.count { for i in 0..<collections.count {
let album = collections.object(at: i) let album = collections.object(at: i)
// Ignore recovered album // Ignore recovered album
if(album.assetCollectionSubtype.rawValue == self.recoveredAlbumSubType) { if(album.assetCollectionSubtype.rawValue == self.recoveredAlbumSubType) {
continue; continue;
} }
let options = PHFetchOptions() let options = PHFetchOptions()
options.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)] options.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)]
options.includeHiddenAssets = false options.includeHiddenAssets = false
let assets = getAssetsFromAlbum(in: album, options: options) let assets = getAssetsFromAlbum(in: album, options: options)
let isCloud = album.assetCollectionSubtype == .albumCloudShared || album.assetCollectionSubtype == .albumMyPhotoStream let isCloud = album.assetCollectionSubtype == .albumCloudShared || album.assetCollectionSubtype == .albumMyPhotoStream
var domainAlbum = PlatformAlbum( var domainAlbum = PlatformAlbum(
id: album.localIdentifier, id: album.localIdentifier,
name: album.localizedTitle!, name: album.localizedTitle!,
@@ -115,57 +115,57 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
isCloud: isCloud, isCloud: isCloud,
assetCount: Int64(assets.count) assetCount: Int64(assets.count)
) )
if let firstAsset = assets.firstObject { if let firstAsset = assets.firstObject {
domainAlbum.updatedAt = firstAsset.modificationDate.map { Int64($0.timeIntervalSince1970) } domainAlbum.updatedAt = firstAsset.modificationDate.map { Int64($0.timeIntervalSince1970) }
} }
albums.append(domainAlbum) albums.append(domainAlbum)
} }
} }
return albums.sorted { $0.id < $1.id } return albums.sorted { $0.id < $1.id }
} }
func getMediaChanges() throws -> SyncDelta { func getMediaChanges() throws -> SyncDelta {
guard #available(iOS 16, *) else { guard #available(iOS 16, *) else {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil) throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil)
} }
guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized else { guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized else {
throw PigeonError(code: "NO_AUTH", message: "No photo library access", details: nil) throw PigeonError(code: "NO_AUTH", message: "No photo library access", details: nil)
} }
guard let storedToken = getChangeToken() else { guard let storedToken = getChangeToken() else {
// No token exists, definitely need a full sync // No token exists, definitely need a full sync
print("MediaManager::getMediaChanges: No token found") print("MediaManager::getMediaChanges: No token found")
throw PigeonError(code: "NO_TOKEN", message: "No stored change token", details: nil) throw PigeonError(code: "NO_TOKEN", message: "No stored change token", details: nil)
} }
let currentToken = PHPhotoLibrary.shared().currentChangeToken let currentToken = PHPhotoLibrary.shared().currentChangeToken
if storedToken == currentToken { if storedToken == currentToken {
return SyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:]) return SyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:])
} }
do { do {
let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken)
var updatedAssets: Set<AssetWrapper> = [] var updatedAssets: Set<AssetWrapper> = []
var deletedAssets: Set<String> = [] var deletedAssets: Set<String> = []
for change in changes { for change in changes {
guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue } guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue }
let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers) let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers)
deletedAssets.formUnion(details.deletedLocalIdentifiers) deletedAssets.formUnion(details.deletedLocalIdentifiers)
if (updated.isEmpty) { continue } if (updated.isEmpty) { continue }
let options = PHFetchOptions() let options = PHFetchOptions()
options.includeHiddenAssets = false options.includeHiddenAssets = false
let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options) let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options)
for i in 0..<result.count { for i in 0..<result.count {
let asset = result.object(at: i) let asset = result.object(at: i)
// Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes // Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes
let predicate = PlatformAsset( let predicate = PlatformAsset(
id: asset.localIdentifier, id: asset.localIdentifier,
@@ -178,25 +178,25 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
if (updatedAssets.contains(AssetWrapper(with: predicate))) { if (updatedAssets.contains(AssetWrapper(with: predicate))) {
continue continue
} }
let domainAsset = AssetWrapper(with: asset.toPlatformAsset()) let domainAsset = AssetWrapper(with: asset.toPlatformAsset())
updatedAssets.insert(domainAsset) updatedAssets.insert(domainAsset)
} }
} }
let updates = Array(updatedAssets.map { $0.asset }) let updates = Array(updatedAssets.map { $0.asset })
return SyncDelta(hasChanges: true, updates: updates, deletes: Array(deletedAssets), assetAlbums: buildAssetAlbumsMap(assets: updates)) return SyncDelta(hasChanges: true, updates: updates, deletes: Array(deletedAssets), assetAlbums: buildAssetAlbumsMap(assets: updates))
} }
} }
private func buildAssetAlbumsMap(assets: Array<PlatformAsset>) -> [String: [String]] { private func buildAssetAlbumsMap(assets: Array<PlatformAsset>) -> [String: [String]] {
guard !assets.isEmpty else { guard !assets.isEmpty else {
return [:] return [:]
} }
var albumAssets: [String: [String]] = [:] var albumAssets: [String: [String]] = [:]
for type in albumTypes { for type in albumTypes {
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil) let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
collections.enumerateObjects { (album, _, _) in collections.enumerateObjects { (album, _, _) in
@@ -211,13 +211,13 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
} }
return albumAssets return albumAssets
} }
func getAssetIdsForAlbum(albumId: String) throws -> [String] { func getAssetIdsForAlbum(albumId: String) throws -> [String] {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil) let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else { guard let album = collections.firstObject else {
return [] return []
} }
var ids: [String] = [] var ids: [String] = []
let options = PHFetchOptions() let options = PHFetchOptions()
options.includeHiddenAssets = false options.includeHiddenAssets = false
@@ -227,13 +227,13 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
} }
return ids return ids
} }
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 { func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil) let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else { guard let album = collections.firstObject else {
return 0 return 0
} }
let date = NSDate(timeIntervalSince1970: TimeInterval(timestamp)) let date = NSDate(timeIntervalSince1970: TimeInterval(timestamp))
let options = PHFetchOptions() let options = PHFetchOptions()
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date) options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
@@ -241,32 +241,32 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
let assets = getAssetsFromAlbum(in: album, options: options) let assets = getAssetsFromAlbum(in: album, options: options)
return Int64(assets.count) return Int64(assets.count)
} }
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] { func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil) let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
guard let album = collections.firstObject else { guard let album = collections.firstObject else {
return [] return []
} }
let options = PHFetchOptions() let options = PHFetchOptions()
options.includeHiddenAssets = false options.includeHiddenAssets = false
if(updatedTimeCond != nil) { if(updatedTimeCond != nil) {
let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!)) let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!))
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date) options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
} }
let result = getAssetsFromAlbum(in: album, options: options) let result = getAssetsFromAlbum(in: album, options: options)
if(result.count == 0) { if(result.count == 0) {
return [] return []
} }
var assets: [PlatformAsset] = [] var assets: [PlatformAsset] = []
result.enumerateObjects { (asset, _, _) in result.enumerateObjects { (asset, _, _) in
assets.append(asset.toPlatformAsset()) assets.append(asset.toPlatformAsset())
} }
return assets return assets
} }
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) { func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) {
if let prevTask = hashTask { if let prevTask = hashTask {
prevTask.cancel() prevTask.cancel()
@@ -284,11 +284,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
missingAssetIds.remove(asset.localIdentifier) missingAssetIds.remove(asset.localIdentifier)
assets.append(asset) assets.append(asset)
} }
if Task.isCancelled { if Task.isCancelled {
return self?.completeWhenActive(for: completion, with: Self.hashCancelled) return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
} }
await withTaskGroup(of: HashResult?.self) { taskGroup in await withTaskGroup(of: HashResult?.self) { taskGroup in
var results = [HashResult]() var results = [HashResult]()
results.reserveCapacity(assets.count) results.reserveCapacity(assets.count)
@@ -301,28 +301,28 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return await self.hashAsset(asset, allowNetworkAccess: allowNetworkAccess) return await self.hashAsset(asset, allowNetworkAccess: allowNetworkAccess)
} }
} }
for await result in taskGroup { for await result in taskGroup {
guard let result = result else { guard let result = result else {
return self?.completeWhenActive(for: completion, with: Self.hashCancelled) return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
} }
results.append(result) results.append(result)
} }
for missing in missingAssetIds { for missing in missingAssetIds {
results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil)) results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil))
} }
return self?.completeWhenActive(for: completion, with: .success(results)) return self?.completeWhenActive(for: completion, with: .success(results))
} }
} }
} }
func cancelHashing() { func cancelHashing() {
hashTask?.cancel() hashTask?.cancel()
hashTask = nil hashTask = nil
} }
private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? { private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? {
class RequestRef { class RequestRef {
var id: PHAssetResourceDataRequestID? var id: PHAssetResourceDataRequestID?
@@ -332,21 +332,21 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
if Task.isCancelled { if Task.isCancelled {
return nil return nil
} }
guard let resource = asset.getResource() else { guard let resource = asset.getResource() else {
return HashResult(assetId: asset.localIdentifier, error: "Cannot get asset resource", hash: nil) return HashResult(assetId: asset.localIdentifier, error: "Cannot get asset resource", hash: nil)
} }
if Task.isCancelled { if Task.isCancelled {
return nil return nil
} }
let options = PHAssetResourceRequestOptions() let options = PHAssetResourceRequestOptions()
options.isNetworkAccessAllowed = allowNetworkAccess options.isNetworkAccessAllowed = allowNetworkAccess
return await withCheckedContinuation { continuation in return await withCheckedContinuation { continuation in
var hasher = Insecure.SHA1() var hasher = Insecure.SHA1()
requestRef.id = PHAssetResourceManager.default().requestData( requestRef.id = PHAssetResourceManager.default().requestData(
for: resource, for: resource,
options: options, options: options,
@@ -377,11 +377,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
PHAssetResourceManager.default().cancelDataRequest(requestId) PHAssetResourceManager.default().cancelDataRequest(requestId)
}) })
} }
func getTrashedAssets() throws -> [String: [PlatformAsset]] { func getTrashedAssets() throws -> [String: [PlatformAsset]] {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil) throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
} }
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> { private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
// Ensure to actually getting all assets for the Recents album // Ensure to actually getting all assets for the Recents album
if (album.assetCollectionSubtype == .smartAlbumUserLibrary) { if (album.assetCollectionSubtype == .smartAlbumUserLibrary) {
@@ -390,4 +390,28 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return PHAsset.fetchAssets(in: album, options: options) return PHAsset.fetchAssets(in: album, options: options)
} }
} }
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult] {
guard #available(iOS 16, *) else {
return assetIds.map { CloudIdResult(assetId: $0) }
}
var mappings: [CloudIdResult] = []
let result = PHPhotoLibrary.shared().cloudIdentifierMappings(forLocalIdentifiers: assetIds)
for (key, value) in result {
switch value {
case .success(let cloudIdentifier):
let cloudId = cloudIdentifier.stringValue
// Ignores invalid cloud ids of the format "GUID:ID:". Valid Ids are of the form "GUID:ID:HASH"
if !cloudId.hasSuffix(":") {
mappings.append(CloudIdResult(assetId: key, cloudId: cloudId))
} else {
mappings.append(CloudIdResult(assetId: key, error: "Incomplete Cloud Id: \(cloudId)"))
}
case .failure(let error):
mappings.append(CloudIdResult(assetId: key, error: "Error getting Cloud Id: \(error.localizedDescription)"))
}
}
return mappings;
}
} }

View File

@@ -44,7 +44,7 @@ def get_version_from_pubspec
end end
# Helper method to configure code signing for all targets # Helper method to configure code signing for all targets
def configure_code_signing(bundle_id_suffix: "") def configure_code_signing(bundle_id_suffix: "", profile_name_main:, profile_name_share:, profile_name_widget:)
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}" bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
# Runner (main app) # Runner (main app)
@@ -54,7 +54,7 @@ end
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID, team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY, code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}", bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}",
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix} AppStore", profile_name: profile_name_main,
targets: ["Runner"] targets: ["Runner"]
) )
@@ -65,7 +65,7 @@ end
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID, team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY, code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension", bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension",
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension AppStore", profile_name: profile_name_share,
targets: ["ShareExtension"] targets: ["ShareExtension"]
) )
@@ -76,7 +76,7 @@ end
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID, team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY, code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget", bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget",
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget AppStore", profile_name: profile_name_widget,
targets: ["WidgetExtension"] targets: ["WidgetExtension"]
) )
end end
@@ -87,7 +87,10 @@ end
bundle_id_suffix: "", bundle_id_suffix: "",
configuration: "Release", configuration: "Release",
distribute_external: true, distribute_external: true,
version_number: nil version_number: nil,
profile_name_main:,
profile_name_share:,
profile_name_widget:
) )
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}" bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
app_identifier = "#{BASE_BUNDLE_ID}#{bundle_suffix}" app_identifier = "#{BASE_BUNDLE_ID}#{bundle_suffix}"
@@ -115,9 +118,9 @@ end
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual", xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: { export_options: {
provisioningProfiles: { provisioningProfiles: {
"#{app_identifier}" => "#{app_identifier} AppStore", "#{app_identifier}" => profile_name_main,
"#{app_identifier}.ShareExtension" => "#{app_identifier}.ShareExtension AppStore", "#{app_identifier}.ShareExtension" => profile_name_share,
"#{app_identifier}.Widget" => "#{app_identifier}.Widget AppStore" "#{app_identifier}.Widget" => profile_name_widget
}, },
signingStyle: "manual", signingStyle: "manual",
signingCertificate: CODE_SIGN_IDENTITY signingCertificate: CODE_SIGN_IDENTITY
@@ -136,20 +139,35 @@ end
lane :gha_testflight_dev do lane :gha_testflight_dev do
api_key = get_api_key api_key = get_api_key
# Install development provisioning profiles # Download and install provisioning profiles from App Store Connect
install_provisioning_profile(path: "profile_dev.mobileprovision") # Certificate is imported by GHA workflow into build.keychain
install_provisioning_profile(path: "profile_dev_share.mobileprovision") # Capture profile names after each sigh call
install_provisioning_profile(path: "profile_dev_widget.mobileprovision") sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
configure_code_signing(bundle_id_suffix: "development") share_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs using the downloaded profile names
configure_code_signing(
bundle_id_suffix: "development",
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
# Build and upload # Build and upload
build_and_upload( build_and_upload(
api_key: api_key, api_key: api_key,
bundle_id_suffix: "development", bundle_id_suffix: "development",
configuration: "Profile", configuration: "Profile",
distribute_external: false distribute_external: false,
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
) )
end end
@@ -157,20 +175,33 @@ end
lane :gha_release_prod do lane :gha_release_prod do
api_key = get_api_key api_key = get_api_key
# Install provisioning profiles # Download and install provisioning profiles from App Store Connect
install_provisioning_profile(path: "profile.mobileprovision") # Certificate is imported by GHA workflow into build.keychain
install_provisioning_profile(path: "profile_share.mobileprovision") sigh(api_key: api_key, app_identifier: BASE_BUNDLE_ID, force: true)
install_provisioning_profile(path: "profile_widget.mobileprovision") main_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for production bundle IDs # Configure code signing for production bundle IDs
configure_code_signing configure_code_signing(
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
# Build and upload with version number # Build and upload with version number
build_and_upload( build_and_upload(
api_key: api_key, api_key: api_key,
version_number: get_version_from_pubspec, version_number: get_version_from_pubspec,
distribute_external: false, distribute_external: false,
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
) )
end end
@@ -215,13 +246,26 @@ end
# Use the same build process as production, just skip the upload # Use the same build process as production, just skip the upload
# This ensures PR builds validate the same way as production builds # This ensures PR builds validate the same way as production builds
# Install provisioning profiles (use development profiles for PR builds) api_key = get_api_key
install_provisioning_profile(path: "profile_dev.mobileprovision")
install_provisioning_profile(path: "profile_dev_share.mobileprovision") # Download and install provisioning profiles from App Store Connect
install_provisioning_profile(path: "profile_dev_widget.mobileprovision") # Certificate is imported by GHA workflow into build.keychain
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs # Configure code signing for dev bundle IDs
configure_code_signing(bundle_id_suffix: "development") configure_code_signing(
bundle_id_suffix: "development",
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
# Build the app (same as gha_testflight_dev but without upload) # Build the app (same as gha_testflight_dev but without upload)
build_app( build_app(
@@ -233,9 +277,9 @@ end
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual", xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: { export_options: {
provisioningProfiles: { provisioningProfiles: {
"#{BASE_BUNDLE_ID}.development" => "#{BASE_BUNDLE_ID}.development AppStore", "#{BASE_BUNDLE_ID}.development" => main_profile_name,
"#{BASE_BUNDLE_ID}.development.ShareExtension" => "#{BASE_BUNDLE_ID}.development.ShareExtension AppStore", "#{BASE_BUNDLE_ID}.development.ShareExtension" => share_profile_name,
"#{BASE_BUNDLE_ID}.development.Widget" => "#{BASE_BUNDLE_ID}.development.Widget AppStore" "#{BASE_BUNDLE_ID}.development.Widget" => widget_profile_name
}, },
signingStyle: "manual", signingStyle: "manual",
signingCertificate: CODE_SIGN_IDENTITY signingCertificate: CODE_SIGN_IDENTITY

View File

@@ -4,6 +4,8 @@ const int noDbId = -9223372036854775808; // from Isar
const double downloadCompleted = -1; const double downloadCompleted = -1;
const double downloadFailed = -2; const double downloadFailed = -2;
const String kMobileMetadataKey = "mobile-app";
// Number of log entries to retain on app start // Number of log entries to retain on app start
const int kLogTruncateLimit = 2000; const int kLogTruncateLimit = 2000;

View File

@@ -7,3 +7,7 @@ enum AssetVisibilityEnum { timeline, hidden, archive, locked }
enum SortUserBy { id } enum SortUserBy { id }
enum ActionSource { timeline, viewer } enum ActionSource { timeline, viewer }
enum CleanupStep { selectDate, filterOptions, scan, delete }
enum AssetFilterType { all, photosOnly, videosOnly }

View File

@@ -51,4 +51,4 @@ const Map<String, Locale> locales = {
const String translationsPath = 'assets/i18n'; const String translationsPath = 'assets/i18n';
const List<Locale> localesNotSupportedByOverpass = [Locale('el', 'GR'), Locale('sr', 'Cyrl')]; const List<Locale> localesNotSupportedByAppFont = [Locale('el', 'GR'), Locale('sr', 'Cyrl')];

View File

@@ -0,0 +1,62 @@
enum RemoteAssetMetadataKey {
mobileApp("mobile-app");
final String key;
const RemoteAssetMetadataKey(this.key);
}
abstract class RemoteAssetMetadataValue {
const RemoteAssetMetadataValue();
Map<String, dynamic> toJson();
}
class RemoteAssetMetadataItem {
final RemoteAssetMetadataKey key;
final RemoteAssetMetadataValue value;
const RemoteAssetMetadataItem({required this.key, required this.value});
Map<String, Object?> toJson() {
return {'key': key.key, 'value': value};
}
}
class RemoteAssetMobileAppMetadata extends RemoteAssetMetadataValue {
final String? cloudId;
final String? createdAt;
final String? adjustmentTime;
final String? latitude;
final String? longitude;
const RemoteAssetMobileAppMetadata({
this.cloudId,
this.createdAt,
this.adjustmentTime,
this.latitude,
this.longitude,
});
@override
Map<String, dynamic> toJson() {
final map = <String, Object?>{};
if (cloudId != null) {
map["iCloudId"] = cloudId;
}
if (createdAt != null) {
map["createdAt"] = createdAt;
}
if (adjustmentTime != null) {
map["adjustmentTime"] = adjustmentTime;
}
if (latitude != null) {
map["latitude"] = latitude;
}
if (longitude != null) {
map["longitude"] = longitude;
}
return map;
}
}

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