Compare commits

..

33 Commits

Author SHA1 Message Date
Alex
2a046ec896 docs: beginning of the year tune up and updates 2026-01-21 16:21:53 -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
320 changed files with 18394 additions and 3442 deletions

View File

@@ -20,7 +20,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^24.10.4",
"@types/node": "^24.10.8",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",

View File

@@ -5,9 +5,7 @@ import TabItem from '@theme/TabItem';
A [3-2-1 backup strategy](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) is recommended to protect your data. You should keep copies of your uploaded photos/videos as well as the Immich database for a comprehensive backup solution. This page provides an overview on how to backup the database and the location of user-uploaded pictures and videos. A template bash script that can be run as a cron job is provided [here](/guides/template-backup-script.md)
:::danger
The instructions on this page show you how to prepare your Immich instance to be backed up, and which files to take a backup of. You still need to take care of using an actual backup tool to make a backup yourself.
:::
The instructions on this page show you how to prepare your Immich instance to be backed up, and which files to take a backup of. _You still need to take care of using an actual backup tool to make a backup yourself._
## Database

View File

@@ -1,37 +1,42 @@
# Automatic Backup
## Overview
Immich supports uploading photos and videos from your mobile device to the server automatically.
---
By enable the backup button, Immich will upload new photos and videos from selected albums when you open or resume the app, as well as periodically in the background (iOS), or when the a new photos or videos are taken (Android).
You can enable the settings by accessing the upload options from the upload page
<img
src={require('./img/enable-backup-button.webp').default}
width="300px"
title="Upload button"
/>
<img src={require('./img/backup-settings-access.webp').default} width="50%" title="Backup option selection" />
## Platform Specific Features
<img src={require('./img/background-foreground-backup.webp').default} width="50%" title="Foreground&Background Backup" />
### General
## Foreground backup
By default, Immich will only upload photos and videos when connected to Wi-Fi. You can change this behavior in the backup settings page.
If foreground backup is enabled: whenever the app is opened or resumed, it will check if any photos or videos in the selected album(s) have yet to be uploaded to the cloud (the remainder count). If there are any, they will be uploaded.
<img
src={require('./img/backup-options.webp').default}
width="500px"
title="Upload button"
/>
## Background backup
### Android
This feature is intended for everyday use. For initial bulk uploading, please use the foreground upload feature. For more information on why background upload is not working as expected, please refer to the [FAQ](/FAQ#why-does-foreground-backup-stop-when-i-navigate-away-from-the-app-shouldnt-it-transfer-the-job-to-background-backup).
If background backup is enabled. The app will periodically check if there are any new photos or videos in the selected album(s) to be uploaded to the server. If there are, it will upload them to the cloud in the background.
:::info Note
#### General
- The app must be in the background for the backup worker to start running.
- If you reopen the app and the first page you see is the backup page, the counts will not reflect the background uploaded result. You have to navigate out of the page and come back to see the updated counts.
#### Android
<img
src={require('./img/android-backup-options.webp').default}
width="500px"
title="Upload button"
/>
- It is a well-known problem that some Android models are very strict with battery optimization settings, which can cause a problem with the background worker. Please visit [Don't kill my app](https://dontkillmyapp.com/) for a guide on disabling this setting on your phone.
- You can allow the background task to run when the device is charging.
- You can set the minimum delay from the time a photo is taken to when the background upload task will run.
#### iOS
### iOS
- You must enable **Background App Refresh** for the app to work in the background. You can enable it in the Settings app under General > Background App Refresh.
@@ -39,4 +44,4 @@ If background backup is enabled. The app will periodically check if there are an
<img src={require('./img/background-app-refresh.webp').default} width="30%" title="background-app-refresh" />
</div>
:::
- iOS automatically manages background tasks, the app cannot control when the background upload task will run. It is known that the more frequently you open the app, the more often the background task will run.

View File

@@ -188,6 +188,8 @@ immich upload --dry-run . | tail -n +6 | jq .newFiles[]
### Obtain the API Key
The API key can be obtained in the user setting panel on the web interface.
The API key can be obtained in the user setting panel on the web interface. You can also specify permissions for the key to limit its access.
![Obtain Api Key](./img/obtain-api-key.webp)
![Specify permission for the key](./img/obtain-api-key-2.webp)

View File

@@ -21,14 +21,14 @@ The asset detail view will also show the faces that are recognized in the asset.
Additional actions you can do include:
- Changing the feature photo of the person
- Setting a person's date of birth
- Merging two or more detected faces into one person
- Hiding the faces of a person from the Explore page and detail view
- Assigning an unrecognized face to a person
- Setting a person's date of birth, so that the age of the person can be shown at the time the photo was taken
- Merging two or more detected faces into one person
- Favoriting a person to pin them to the top of the list
It can be found from the app bar when you access the detail view of a person.
<img src={require('./img/facial-recognition-4.webp').default} title='Facial Recognition 4' width="70%"/>
<img src={require('./img/facial-recognition-4.webp').default} title='Facial Recognition 4' />
## How Face Detection Works

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 112 KiB

View File

@@ -20,14 +20,6 @@ Below are the SHA-256 fingerprints for the certificates signing the android appl
:::
:::info Beta Program
The beta release channel allows users to test upcoming changes before they are officially released. To join the channel use the links below.
- Android: Invitation link from [web](https://play.google.com/store/apps/details?id=app.alextran.immich) or from [mobile](https://play.google.com/store/apps/details?id=app.alextran.immich)
- iOS: [TestFlight invitation link](https://testflight.apple.com/join/1vYsAa8P)
:::
## Login
<MobileAppLogin />
@@ -36,10 +28,6 @@ The beta release channel allows users to test upcoming changes before they are o
<MobileAppBackup />
:::info
You can enable automatic backup on supported devices. For more information see [Automatic Backup](/features/automatic-backup.md).
:::
## Sync only selected photos
If you have a large number of photos on the device, and you would prefer not to backup all the photos, then it might be prudent to only backup selected photos from device to the Immich server.
@@ -57,17 +45,45 @@ This will enable a small cloud icon on the bottom right corner of the asset tile
Now make sure that the local album is selected in the backup screen (steps 1-2 above). You can find these albums listed in **<ins>Library -> On this device</ins>**. To selectively upload photos from these albums, simply select the local-only photos and tap on "Upload" button in the dynamic bottom menu.
<img
src={require('./img/mobile-upload-open-photo.webp').default}
width="50%"
title="Upload button on local asset preview"
/>
<img
src={require('./img/mobile-upload-selected-photos.webp').default}
width="40%"
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
<img src={require('./img/free-up-space.webp').default} title="Free up space" />
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.
:::info reclaim storage
To permanently free up space, you must manually empty the system/gallery trash.
:::
### iCloud Photos
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**.
Assets that are part of an **iCloud Shared Album** are automatically excluded from the cleanup scan because iCloud does not allow removing the items from the device.
### External App Dependencies (WhatsApp, etc.)
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.
## 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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -17,11 +17,11 @@ If this does not work, try running `docker compose up -d --force-recreate`.
## Docker Compose
| Variable | Description | Default | Containers |
| :----------------- | :------------------------------ | :-------: | :----------------------- |
| `IMMICH_VERSION` | Image tags | `release` | server, machine learning |
| `UPLOAD_LOCATION` | Host path for uploads | | server |
| `DB_DATA_LOCATION` | Host path for Postgres database | | database |
| Variable | Description | Default | Containers |
| :----------------- | :------------------------------ | :-----: | :----------------------- |
| `IMMICH_VERSION` | Image tags | `v2` | server, machine learning |
| `UPLOAD_LOCATION` | Host path for uploads | | server |
| `DB_DATA_LOCATION` | Host path for Postgres database | | database |
:::tip
These environment variables are used by the `docker-compose.yml` file and do **NOT** affect the containers directly.

View File

@@ -10,7 +10,7 @@ to install and use it.
## Requirements
- A system with at least 4GB of RAM and 2 CPU cores.
- A system with at least 6GB of RAM and 2 CPU cores.
- [Docker](https://docs.docker.com/engine/install/)
> For a more detailed list of requirements, see the [requirements page](/install/requirements).
@@ -63,9 +63,9 @@ The backup time differs depending on how many photos are on your mobile device.
take quite a while.
To quickly get going, you can selectively upload few photos first, by following this [guide](/features/mobile-app#sync-only-selected-photos).
You can select the **Jobs** tab to see Immich processing your photos.
You can select the **Job Queues** tab to see Immich processing your photos.
<img src={require('/docs/guides/img/jobs-tab.webp').default} title="Jobs tab" width={300} />
<img src={require('/docs/guides/img/jobs-tab.webp').default} title="Job Queues Tah" width={300} />
---

View File

@@ -6,4 +6,4 @@
<img src={require('./img/album-selection.webp').default} width='50%' title='Backup button' />
3. Scroll down to the bottom and press "**Start Backup**" to start the backup process. This will upload all the assets in the selected albums.
3. Scroll down to the bottom and press "**Enable Backup**" to start the backup process. This will upload all the assets in the selected albums.

View File

@@ -2,6 +2,6 @@ If you have friends or family members who want to use the application as well, y
<img src={require('./img/create-new-user.webp').default} width="90%" title='New User Registration' />
In the Administration panel, you can click on the **Create user** button, and you'll be presented with the following dialog:
In the **Administration > Users** page, you can click on the **Create user** button, and you'll be presented with the following dialog:
<img src={require('./img/create-new-user-dialog.webp').default} width="90%" title='New User Registration Dialog' />
<img src={require('./img/create-new-user-dialog.webp').default} width="40%" title='New User Registration Dialog' />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 260 KiB

View File

@@ -27,7 +27,7 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^24.10.4",
"@types/node": "^24.10.8",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2",

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 playwrightDbHost = process.env.PLAYWRIGHT_DB_HOST ?? '127.0.0.1';
export const playwriteBaseUrl = process.env.PLAYWRIGHT_BASE_URL ?? `http://${playwrightHost}:2285`;
export const playwriteSlowMo = parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
export const playwriteSlowMo = Number.parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
export const playwrightDisableWebserver = process.env.PLAYWRIGHT_DISABLE_WEBSERVER;
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1';
@@ -39,13 +39,13 @@ const config: PlaywrightTestConfig = {
testMatch: /.*\.e2e-spec\.ts/,
workers: 1,
},
{
name: 'parallel tests',
use: { ...devices['Desktop Chrome'] },
testMatch: /.*\.parallel-e2e-spec\.ts/,
fullyParallel: true,
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
},
// {
// name: 'parallel tests',
// use: { ...devices['Desktop Chrome'] },
// testMatch: /.*\.parallel-e2e-spec\.ts/,
// fullyParallel: true,
// workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
// },
// {
// 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();
admin = await utils.adminSetup();
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
await utils.resetBackups(admin.accessToken);
});
// => 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 () => {
it('should not work out of maintenance mode', async () => {
const { status, body } = await request(app).post('/admin/maintenance/login').send({ token: 'token' });
@@ -39,6 +51,7 @@ describe('/admin/maintenance', () => {
describe.sequential('POST /', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/admin/maintenance').send({
active: false,
action: 'end',
});
expect(status).toBe(401);
@@ -69,6 +82,7 @@ describe('/admin/maintenance', () => {
.send({
action: 'start',
});
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';')[0];
@@ -79,12 +93,13 @@ describe('/admin/maintenance', () => {
await expect
.poll(
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;
},
{
interval: 5e2,
timeout: 1e4,
interval: 500,
timeout: 10_000,
},
)
.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 () => {
it('should fail without cookie or token in body', async () => {
const { status, body } = await request(app).post('/admin/maintenance/login').send({});
@@ -158,12 +184,13 @@ describe('/admin/maintenance', () => {
await expect
.poll(
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;
},
{
interval: 5e2,
timeout: 1e4,
interval: 500,
timeout: 10_000,
},
)
.toBeFalsy();

View File

@@ -348,6 +348,7 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
checksum: asset.checksum,
width: exifInfo.exifImageWidth ?? 1,
height: exifInfo.exifImageHeight ?? 1,
isEdited: false,
};
}

View File

@@ -6,7 +6,9 @@ import {
CheckExistingAssetsDto,
CreateAlbumDto,
CreateLibraryDto,
JobCreateDto,
MaintenanceAction,
ManualJobName,
MetadataSearchDto,
Permission,
PersonCreateDto,
@@ -21,6 +23,7 @@ import {
checkExistingAssets,
createAlbum,
createApiKey,
createJob,
createLibrary,
createPartner,
createPerson,
@@ -28,10 +31,12 @@ import {
createStack,
createUserAdmin,
deleteAssets,
deleteDatabaseBackup,
getAssetInfo,
getConfig,
getConfigDefaults,
getQueuesLegacy,
listDatabaseBackups,
login,
runQueueCommandLegacy,
scanLibrary,
@@ -52,11 +57,15 @@ import {
import { BrowserContext } from '@playwright/test';
import { exec, spawn } from 'node:child_process';
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 { 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 { promisify } from 'node:util';
import { createGzip } from 'node:zlib';
import pg from 'pg';
import { io, type Socket } from 'socket.io-client';
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 immichCli = (args: string[]) =>
executeCommand('pnpm', ['exec', 'immich', '-d', `/${tempDir}/immich/`, ...args], { cwd: '../cli' }).promise;
export const immichAdmin = (args: string[]) =>
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]);
export const dockerExec = (args: string[]) =>
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 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 = {
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[]) => {
try {
if (!client) {
client = new pg.Client(dbUrl);
await client.connect();
}
client = await utils.connectDatabase();
tables = tables || [
// 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({ 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) =>
runQueueCommandLegacy({ name, queueCommandDto }, { headers: asBearerAuth(accessToken) }),
@@ -559,6 +586,45 @@ export const utils = {
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) => {
const defaultConfig = await getConfigDefaults({ 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, '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();

View File

@@ -12,7 +12,7 @@ import {
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
import { utils } from 'src/utils';
import { assetViewerUtils, cancelAllPollers } from 'src/web/specs/timeline/utils';
import { assetViewerUtils } from 'src/web/specs/timeline/utils';
test.describe.configure({ mode: 'parallel' });
test.describe('asset-viewer', () => {
@@ -49,7 +49,6 @@ test.describe('asset-viewer', () => {
});
test.afterEach(() => {
cancelAllPollers();
testContext.slowBucket = false;
changes.albumAdditions = [];
changes.assetDeletions = [];

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 }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto('/admin/system-settings?isOpen=maintenance');
await page.getByRole('button', { name: 'Start maintenance mode' }).click();
await page.goto('/admin/maintenance');
await page.getByRole('button', { name: 'Switch to maintenance mode' }).click();
await expect(page.getByText('Temporarily Unavailable')).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'End maintenance mode' }).click();
await page.waitForURL('**/admin/system-settings*', { timeout: 10_000 });
await page.waitForURL('**/admin/maintenance*', { timeout: 10_000 });
});
test('maintenance shows no options to users until they authenticate', async ({ page }) => {

View File

@@ -18,7 +18,6 @@ import { pageRoutePromise, setupTimelineMockApiRoutes, TimelineTestContext } fro
import { utils } from 'src/utils';
import {
assetViewerUtils,
cancelAllPollers,
padYearMonth,
pageUtils,
poll,
@@ -64,7 +63,6 @@ test.describe('Timeline', () => {
});
test.afterEach(() => {
cancelAllPollers();
testContext.slowBucket = false;
changes.albumAdditions = [];
changes.assetDeletions = [];

View File

@@ -23,13 +23,6 @@ export async function throttlePage(context: BrowserContext, page: Page) {
await session.send('Emulation.setCPUThrottlingRate', { rate: 10 });
}
let activePollsAbortController = new AbortController();
export const cancelAllPollers = () => {
activePollsAbortController.abort();
activePollsAbortController = new AbortController();
};
export const poll = async <T>(
page: Page,
query: () => Promise<T>,
@@ -37,21 +30,14 @@ export const poll = async <T>(
) => {
let result;
const timeout = Date.now() + 10_000;
const signal = activePollsAbortController.signal;
const terminate = callback || ((result: Awaited<T> | undefined) => !!result);
while (!terminate(result) && Date.now() < timeout) {
if (signal.aborted) {
return;
}
try {
result = await query();
} catch {
// ignore
}
if (signal.aborted) {
return;
}
if (page.isClosed()) {
return;
}

View File

@@ -188,10 +188,21 @@
"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_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_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_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_description": "Navigate to the jobs page to manage job concurrency",
"manage_log_settings": "Manage log settings",
@@ -603,7 +614,7 @@
"backup_album_selection_page_select_albums": "Select albums",
"backup_album_selection_page_selection_info": "Selection Info",
"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_background_service_backup_failed_message": "Failed to backup assets. Retrying…",
"backup_background_service_complete_notification": "Asset backup complete",
@@ -998,9 +1009,11 @@
"error_getting_places": "Error getting places",
"error_loading_image": "Error loading image",
"error_loading_partners": "Error loading partners: {error}",
"error_retrieving_asset_information": "Error retrieving asset information",
"error_saving_image": "Error: {error}",
"error_tag_face_bounding_box": "Error tagging face - cannot get bounding box coordinates",
"error_title": "Error - Something went wrong",
"error_while_navigating": "Error while navigating to asset",
"errors": {
"cannot_navigate_next_asset": "Cannot navigate to the next asset",
"cannot_navigate_previous_asset": "Cannot navigate to previous asset",
@@ -1404,10 +1417,28 @@
"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_menu": "Main menu",
"maintenance_action_restore": "Restoring Database",
"maintenance_description": "Immich has been put into <link>maintenance mode</link>.",
"maintenance_end": "End maintenance mode",
"maintenance_end_error": "Failed to end maintenance mode.",
"maintenance_logged_in_as": "Currently logged in as {user}",
"maintenance_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",
"make": "Make",
"manage_geolocation": "Manage location",
@@ -1526,7 +1557,7 @@
"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_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_cast_devices_found": "No cast devices found",
"no_checksum_local": "No checksum available - cannot fetch local assets",
@@ -2160,6 +2191,7 @@
"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_title": "Enable three-stage loading",
"then": "Then",
"they_will_be_merged_together": "They will be merged together",
"third_party_resources": "Third-Party Resources",
"time": "Time",
@@ -2215,6 +2247,7 @@
"unhide_person": "Unhide person",
"unknown": "Unknown",
"unknown_country": "Unknown Country",
"unknown_date": "Unknown date",
"unknown_year": "Unknown Year",
"unlimited": "Unlimited",
"unlink_motion_video": "Unlink motion video",
@@ -2257,7 +2290,7 @@
"url": "URL",
"usage": "Usage",
"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",
"user": "User",
"user_has_been_deleted": "This user has been deleted.",

View File

@@ -3,7 +3,7 @@ experimental_monorepo_root = true
[tools]
node = "24.13.0"
flutter = "3.35.7"
pnpm = "10.27.0"
pnpm = "10.28.0"
terragrunt = "0.93.10"
opentofu = "1.10.7"
java = "25.0.1"

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -22,6 +22,7 @@ sealed class BaseAsset {
final int? durationInSeconds;
final bool isFavorite;
final String? livePhotoVideoId;
final bool isEdited;
const BaseAsset({
required this.name,
@@ -34,6 +35,7 @@ sealed class BaseAsset {
this.durationInSeconds,
this.isFavorite = false,
this.livePhotoVideoId,
required this.isEdited,
});
bool get isImage => type == AssetType.image;
@@ -71,6 +73,7 @@ sealed class BaseAsset {
height: ${height ?? "<NA>"},
durationInSeconds: ${durationInSeconds ?? "<NA>"},
isFavorite: $isFavorite,
isEdited: $isEdited,
}''';
}
@@ -85,7 +88,8 @@ sealed class BaseAsset {
width == other.width &&
height == other.height &&
durationInSeconds == other.durationInSeconds &&
isFavorite == other.isFavorite;
isFavorite == other.isFavorite &&
isEdited == other.isEdited;
}
return false;
}
@@ -99,6 +103,7 @@ sealed class BaseAsset {
width.hashCode ^
height.hashCode ^
durationInSeconds.hashCode ^
isFavorite.hashCode;
isFavorite.hashCode ^
isEdited.hashCode;
}
}

View File

@@ -28,6 +28,7 @@ class LocalAsset extends BaseAsset {
this.adjustmentTime,
this.latitude,
this.longitude,
required super.isEdited,
}) : remoteAssetId = remoteId;
@override
@@ -107,6 +108,7 @@ class LocalAsset extends BaseAsset {
DateTime? adjustmentTime,
double? latitude,
double? longitude,
bool? isEdited,
}) {
return LocalAsset(
id: id ?? this.id,
@@ -125,6 +127,7 @@ class LocalAsset extends BaseAsset {
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
isEdited: isEdited ?? this.isEdited,
);
}
}

View File

@@ -28,6 +28,7 @@ class RemoteAsset extends BaseAsset {
this.visibility = AssetVisibility.timeline,
super.livePhotoVideoId,
this.stackId,
required super.isEdited,
}) : localAssetId = localId;
@override
@@ -104,6 +105,7 @@ class RemoteAsset extends BaseAsset {
AssetVisibility? visibility,
String? livePhotoVideoId,
String? stackId,
bool? isEdited,
}) {
return RemoteAsset(
id: id ?? this.id,
@@ -122,6 +124,7 @@ class RemoteAsset extends BaseAsset {
visibility: visibility ?? this.visibility,
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
stackId: stackId ?? this.stackId,
isEdited: isEdited ?? this.isEdited,
);
}
}

View File

@@ -436,5 +436,6 @@ extension PlatformToLocalAsset on PlatformAsset {
adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true),
latitude: latitude,
longitude: longitude,
isEdited: false,
);
}

View File

@@ -77,6 +77,7 @@ extension on AssetResponseDto {
thumbHash: thumbhash,
localId: null,
type: type.toAssetType(),
isEdited: isEdited,
);
}
}

View File

@@ -247,6 +247,42 @@ class SyncStreamService {
}
}
Future<void> handleWsAssetEditReadyV1Batch(List<dynamic> batchData) async {
if (batchData.isEmpty) return;
_logger.info('Processing batch of ${batchData.length} AssetEditReadyV1 events');
final List<SyncAssetV1> assets = [];
try {
for (final data in batchData) {
if (data is! Map<String, dynamic>) {
continue;
}
final payload = data;
final assetData = payload['asset'];
if (assetData == null) {
continue;
}
final asset = SyncAssetV1.fromJson(assetData);
if (asset != null) {
assets.add(asset);
}
}
if (assets.isNotEmpty) {
await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-edit');
_logger.info('Successfully processed ${assets.length} edited assets');
}
} catch (error, stackTrace) {
_logger.severe("Error processing AssetEditReadyV1 websocket batch events", error, stackTrace);
}
}
Future<void> _handleRemoteTrashed(Iterable<String> checksums) async {
if (checksums.isEmpty) {
return Future.value();

View File

@@ -196,6 +196,16 @@ class BackgroundSyncManager {
});
}
Future<void> syncWebsocketEditBatch(List<dynamic> batchData) {
if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future;
}
_syncWebsocketTask = _handleWsAssetEditReadyV1Batch(batchData);
return _syncWebsocketTask!.whenComplete(() {
_syncWebsocketTask = null;
});
}
Future<void> syncLinkedAlbum() {
if (_linkedAlbumSyncTask != null) {
return _linkedAlbumSyncTask!.future;
@@ -231,3 +241,8 @@ Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => ru
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetUploadReadyV1Batch(batchData),
debugLabel: 'websocket-batch',
);
Cancelable<void> _handleWsAssetEditReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1Batch(batchData),
debugLabel: 'websocket-edit',
);

View File

@@ -47,5 +47,6 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
latitude: latitude,
longitude: longitude,
cloudId: iCloudId,
isEdited: false,
);
}

View File

@@ -25,7 +25,8 @@ SELECT
NULL as i_cloud_id,
NULL as latitude,
NULL as longitude,
NULL as adjustmentTime
NULL as adjustmentTime,
rae.is_edited
FROM
remote_asset_entity rae
LEFT JOIN
@@ -61,7 +62,8 @@ SELECT
lae.i_cloud_id,
lae.latitude,
lae.longitude,
lae.adjustment_time
lae.adjustment_time,
0 as is_edited
FROM
local_asset_entity lae
WHERE NOT EXISTS (

View File

@@ -29,7 +29,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
);
$arrayStartIndex += generatedlimit.amountOfVariables;
return customSelect(
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
variables: [
for (var $ in userIds) i0.Variable<String>($),
...generatedlimit.introducedVariables,
@@ -66,6 +66,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
latitude: row.readNullable<double>('latitude'),
longitude: row.readNullable<double>('longitude'),
adjustmentTime: row.readNullable<DateTime>('adjustmentTime'),
isEdited: row.read<bool>('is_edited'),
),
);
}
@@ -137,6 +138,7 @@ class MergedAssetResult {
final double? latitude;
final double? longitude;
final DateTime? adjustmentTime;
final bool isEdited;
MergedAssetResult({
this.remoteId,
this.localId,
@@ -158,6 +160,7 @@ class MergedAssetResult {
this.latitude,
this.longitude,
this.adjustmentTime,
required this.isEdited,
});
}

View File

@@ -44,6 +44,8 @@ class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin
TextColumn get libraryId => text().nullable()();
BoolColumn get isEdited => boolean().withDefault(const Constant(false))();
@override
Set<Column> get primaryKey => {id};
}
@@ -66,5 +68,6 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
livePhotoVideoId: livePhotoVideoId,
localId: localId,
stackId: stackId,
isEdited: isEdited,
);
}

View File

@@ -31,6 +31,7 @@ typedef $$RemoteAssetEntityTableCreateCompanionBuilder =
required i2.AssetVisibility visibility,
i0.Value<String?> stackId,
i0.Value<String?> libraryId,
i0.Value<bool> isEdited,
});
typedef $$RemoteAssetEntityTableUpdateCompanionBuilder =
i1.RemoteAssetEntityCompanion Function({
@@ -52,6 +53,7 @@ typedef $$RemoteAssetEntityTableUpdateCompanionBuilder =
i0.Value<i2.AssetVisibility> visibility,
i0.Value<String?> stackId,
i0.Value<String?> libraryId,
i0.Value<bool> isEdited,
});
final class $$RemoteAssetEntityTableReferences
@@ -196,6 +198,11 @@ class $$RemoteAssetEntityTableFilterComposer
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<bool> get isEdited => $composableBuilder(
column: $table.isEdited,
builder: (column) => i0.ColumnFilters(column),
);
i5.$$UserEntityTableFilterComposer get ownerId {
final i5.$$UserEntityTableFilterComposer composer = $composerBuilder(
composer: this,
@@ -318,6 +325,11 @@ class $$RemoteAssetEntityTableOrderingComposer
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<bool> get isEdited => $composableBuilder(
column: $table.isEdited,
builder: (column) => i0.ColumnOrderings(column),
);
i5.$$UserEntityTableOrderingComposer get ownerId {
final i5.$$UserEntityTableOrderingComposer composer = $composerBuilder(
composer: this,
@@ -417,6 +429,9 @@ class $$RemoteAssetEntityTableAnnotationComposer
i0.GeneratedColumn<String> get libraryId =>
$composableBuilder(column: $table.libraryId, builder: (column) => column);
i0.GeneratedColumn<bool> get isEdited =>
$composableBuilder(column: $table.isEdited, builder: (column) => column);
i5.$$UserEntityTableAnnotationComposer get ownerId {
final i5.$$UserEntityTableAnnotationComposer composer = $composerBuilder(
composer: this,
@@ -497,6 +512,7 @@ class $$RemoteAssetEntityTableTableManager
const i0.Value.absent(),
i0.Value<String?> stackId = const i0.Value.absent(),
i0.Value<String?> libraryId = const i0.Value.absent(),
i0.Value<bool> isEdited = const i0.Value.absent(),
}) => i1.RemoteAssetEntityCompanion(
name: name,
type: type,
@@ -516,6 +532,7 @@ class $$RemoteAssetEntityTableTableManager
visibility: visibility,
stackId: stackId,
libraryId: libraryId,
isEdited: isEdited,
),
createCompanionCallback:
({
@@ -537,6 +554,7 @@ class $$RemoteAssetEntityTableTableManager
required i2.AssetVisibility visibility,
i0.Value<String?> stackId = const i0.Value.absent(),
i0.Value<String?> libraryId = const i0.Value.absent(),
i0.Value<bool> isEdited = const i0.Value.absent(),
}) => i1.RemoteAssetEntityCompanion.insert(
name: name,
type: type,
@@ -556,6 +574,7 @@ class $$RemoteAssetEntityTableTableManager
visibility: visibility,
stackId: stackId,
libraryId: libraryId,
isEdited: isEdited,
),
withReferenceMapper: (p0) => p0
.map(
@@ -844,6 +863,21 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _isEditedMeta = const i0.VerificationMeta(
'isEdited',
);
@override
late final i0.GeneratedColumn<bool> isEdited = i0.GeneratedColumn<bool>(
'is_edited',
aliasedName,
false,
type: i0.DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'CHECK ("is_edited" IN (0, 1))',
),
defaultValue: const i4.Constant(false),
);
@override
List<i0.GeneratedColumn> get $columns => [
name,
@@ -864,6 +898,7 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
visibility,
stackId,
libraryId,
isEdited,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -987,6 +1022,12 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
libraryId.isAcceptableOrUnknown(data['library_id']!, _libraryIdMeta),
);
}
if (data.containsKey('is_edited')) {
context.handle(
_isEditedMeta,
isEdited.isAcceptableOrUnknown(data['is_edited']!, _isEditedMeta),
);
}
return context;
}
@@ -1075,6 +1116,10 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
i0.DriftSqlType.string,
data['${effectivePrefix}library_id'],
),
isEdited: attachedDatabase.typeMapping.read(
i0.DriftSqlType.bool,
data['${effectivePrefix}is_edited'],
)!,
);
}
@@ -1115,6 +1160,7 @@ class RemoteAssetEntityData extends i0.DataClass
final i2.AssetVisibility visibility;
final String? stackId;
final String? libraryId;
final bool isEdited;
const RemoteAssetEntityData({
required this.name,
required this.type,
@@ -1134,6 +1180,7 @@ class RemoteAssetEntityData extends i0.DataClass
required this.visibility,
this.stackId,
this.libraryId,
required this.isEdited,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
@@ -1182,6 +1229,7 @@ class RemoteAssetEntityData extends i0.DataClass
if (!nullToAbsent || libraryId != null) {
map['library_id'] = i0.Variable<String>(libraryId);
}
map['is_edited'] = i0.Variable<bool>(isEdited);
return map;
}
@@ -1213,6 +1261,7 @@ class RemoteAssetEntityData extends i0.DataClass
),
stackId: serializer.fromJson<String?>(json['stackId']),
libraryId: serializer.fromJson<String?>(json['libraryId']),
isEdited: serializer.fromJson<bool>(json['isEdited']),
);
}
@override
@@ -1241,6 +1290,7 @@ class RemoteAssetEntityData extends i0.DataClass
),
'stackId': serializer.toJson<String?>(stackId),
'libraryId': serializer.toJson<String?>(libraryId),
'isEdited': serializer.toJson<bool>(isEdited),
};
}
@@ -1263,6 +1313,7 @@ class RemoteAssetEntityData extends i0.DataClass
i2.AssetVisibility? visibility,
i0.Value<String?> stackId = const i0.Value.absent(),
i0.Value<String?> libraryId = const i0.Value.absent(),
bool? isEdited,
}) => i1.RemoteAssetEntityData(
name: name ?? this.name,
type: type ?? this.type,
@@ -1288,6 +1339,7 @@ class RemoteAssetEntityData extends i0.DataClass
visibility: visibility ?? this.visibility,
stackId: stackId.present ? stackId.value : this.stackId,
libraryId: libraryId.present ? libraryId.value : this.libraryId,
isEdited: isEdited ?? this.isEdited,
);
RemoteAssetEntityData copyWithCompanion(i1.RemoteAssetEntityCompanion data) {
return RemoteAssetEntityData(
@@ -1319,6 +1371,7 @@ class RemoteAssetEntityData extends i0.DataClass
: this.visibility,
stackId: data.stackId.present ? data.stackId.value : this.stackId,
libraryId: data.libraryId.present ? data.libraryId.value : this.libraryId,
isEdited: data.isEdited.present ? data.isEdited.value : this.isEdited,
);
}
@@ -1342,7 +1395,8 @@ class RemoteAssetEntityData extends i0.DataClass
..write('livePhotoVideoId: $livePhotoVideoId, ')
..write('visibility: $visibility, ')
..write('stackId: $stackId, ')
..write('libraryId: $libraryId')
..write('libraryId: $libraryId, ')
..write('isEdited: $isEdited')
..write(')'))
.toString();
}
@@ -1367,6 +1421,7 @@ class RemoteAssetEntityData extends i0.DataClass
visibility,
stackId,
libraryId,
isEdited,
);
@override
bool operator ==(Object other) =>
@@ -1389,7 +1444,8 @@ class RemoteAssetEntityData extends i0.DataClass
other.livePhotoVideoId == this.livePhotoVideoId &&
other.visibility == this.visibility &&
other.stackId == this.stackId &&
other.libraryId == this.libraryId);
other.libraryId == this.libraryId &&
other.isEdited == this.isEdited);
}
class RemoteAssetEntityCompanion
@@ -1412,6 +1468,7 @@ class RemoteAssetEntityCompanion
final i0.Value<i2.AssetVisibility> visibility;
final i0.Value<String?> stackId;
final i0.Value<String?> libraryId;
final i0.Value<bool> isEdited;
const RemoteAssetEntityCompanion({
this.name = const i0.Value.absent(),
this.type = const i0.Value.absent(),
@@ -1431,6 +1488,7 @@ class RemoteAssetEntityCompanion
this.visibility = const i0.Value.absent(),
this.stackId = const i0.Value.absent(),
this.libraryId = const i0.Value.absent(),
this.isEdited = const i0.Value.absent(),
});
RemoteAssetEntityCompanion.insert({
required String name,
@@ -1451,6 +1509,7 @@ class RemoteAssetEntityCompanion
required i2.AssetVisibility visibility,
this.stackId = const i0.Value.absent(),
this.libraryId = const i0.Value.absent(),
this.isEdited = const i0.Value.absent(),
}) : name = i0.Value(name),
type = i0.Value(type),
id = i0.Value(id),
@@ -1476,6 +1535,7 @@ class RemoteAssetEntityCompanion
i0.Expression<int>? visibility,
i0.Expression<String>? stackId,
i0.Expression<String>? libraryId,
i0.Expression<bool>? isEdited,
}) {
return i0.RawValuesInsertable({
if (name != null) 'name': name,
@@ -1496,6 +1556,7 @@ class RemoteAssetEntityCompanion
if (visibility != null) 'visibility': visibility,
if (stackId != null) 'stack_id': stackId,
if (libraryId != null) 'library_id': libraryId,
if (isEdited != null) 'is_edited': isEdited,
});
}
@@ -1518,6 +1579,7 @@ class RemoteAssetEntityCompanion
i0.Value<i2.AssetVisibility>? visibility,
i0.Value<String?>? stackId,
i0.Value<String?>? libraryId,
i0.Value<bool>? isEdited,
}) {
return i1.RemoteAssetEntityCompanion(
name: name ?? this.name,
@@ -1538,6 +1600,7 @@ class RemoteAssetEntityCompanion
visibility: visibility ?? this.visibility,
stackId: stackId ?? this.stackId,
libraryId: libraryId ?? this.libraryId,
isEdited: isEdited ?? this.isEdited,
);
}
@@ -1602,6 +1665,9 @@ class RemoteAssetEntityCompanion
if (libraryId.present) {
map['library_id'] = i0.Variable<String>(libraryId.value);
}
if (isEdited.present) {
map['is_edited'] = i0.Variable<bool>(isEdited.value);
}
return map;
}
@@ -1625,7 +1691,8 @@ class RemoteAssetEntityCompanion
..write('livePhotoVideoId: $livePhotoVideoId, ')
..write('visibility: $visibility, ')
..write('stackId: $stackId, ')
..write('libraryId: $libraryId')
..write('libraryId: $libraryId, ')
..write('isEdited: $isEdited')
..write(')'))
.toString();
}

View File

@@ -45,5 +45,6 @@ extension TrashedLocalAssetEntityDataDomainExtension on TrashedLocalAssetEntityD
height: height,
width: width,
orientation: orientation,
isEdited: false,
);
}

View File

@@ -97,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository {
}
@override
int get schemaVersion => 16;
int get schemaVersion => 17;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -201,6 +201,9 @@ class Drift extends $Drift implements IDatabaseRepository {
await m.createIndex(v16.idxLocalAssetCloudId);
await m.createTable(v16.remoteAssetCloudIdEntity);
},
from16To17: (m, v17) async {
await m.addColumn(v17.remoteAssetEntity, v17.remoteAssetEntity.isEdited);
},
),
);

View File

@@ -6911,6 +6911,503 @@ i1.GeneratedColumn<DateTime> _column_100(String aliasedName) =>
true,
type: i1.DriftSqlType.dateTime,
);
final class Schema17 extends i0.VersionedSchema {
Schema17({required super.database}) : super(version: 17);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
idxLatLng,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
];
late final Shape20 userEntity = Shape20(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_84,
_column_85,
_column_91,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape28 remoteAssetEntity = Shape28(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_13,
_column_14,
_column_15,
_column_16,
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
_column_86,
_column_101,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape3 stackEntity = Shape3(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
attachedDatabase: database,
),
alias: null,
);
late final Shape26 localAssetEntity = Shape26(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_22,
_column_14,
_column_23,
_column_98,
_column_96,
_column_46,
_column_47,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape9 remoteAlbumEntity = Shape9(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_56,
_column_9,
_column_5,
_column_15,
_column_57,
_column_58,
_column_59,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape19 localAlbumEntity = Shape19(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_5,
_column_31,
_column_32,
_column_90,
_column_33,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape22 localAlbumAssetEntity = Shape22(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_34, _column_35, _column_33],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
'idx_remote_asset_owner_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
late final Shape21 authUserEntity = Shape21(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_2,
_column_84,
_column_85,
_column_92,
_column_93,
_column_7,
_column_94,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_25, _column_26, _column_27],
attachedDatabase: database,
),
alias: null,
);
late final Shape5 partnerEntity = Shape5(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_28, _column_29, _column_30],
attachedDatabase: database,
),
alias: null,
);
late final Shape8 remoteExifEntity = Shape8(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_37,
_column_38,
_column_39,
_column_40,
_column_41,
_column_11,
_column_10,
_column_42,
_column_43,
_column_44,
_column_45,
_column_46,
_column_47,
_column_48,
_column_49,
_column_50,
_column_51,
_column_52,
_column_53,
_column_54,
_column_55,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_36, _column_60],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_60, _column_25, _column_61],
attachedDatabase: database,
),
alias: null,
);
late final Shape27 remoteAssetCloudIdEntity = Shape27(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_99,
_column_100,
_column_96,
_column_46,
_column_47,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape11 memoryEntity = Shape11(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_18,
_column_15,
_column_8,
_column_62,
_column_63,
_column_64,
_column_65,
_column_66,
_column_67,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_36, _column_68],
attachedDatabase: database,
),
alias: null,
);
late final Shape14 personEntity = Shape14(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_15,
_column_1,
_column_69,
_column_71,
_column_72,
_column_73,
_column_74,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape15 assetFaceEntity = Shape15(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_36,
_column_76,
_column_77,
_column_78,
_column_79,
_column_80,
_column_81,
_column_82,
_column_83,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_87, _column_88, _column_89],
attachedDatabase: database,
),
alias: null,
);
late final Shape25 trashedLocalAssetEntity = Shape25(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_95,
_column_22,
_column_14,
_column_23,
_column_97,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
}
class Shape28 extends i0.VersionedTable {
Shape28({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get type =>
columnsByName['type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<int> get width =>
columnsByName['width']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get height =>
columnsByName['height']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get durationInSeconds =>
columnsByName['duration_in_seconds']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<String> get ownerId =>
columnsByName['owner_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get localDateTime =>
columnsByName['local_date_time']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<String> get thumbHash =>
columnsByName['thumb_hash']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get deletedAt =>
columnsByName['deleted_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<String> get livePhotoVideoId =>
columnsByName['live_photo_video_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get visibility =>
columnsByName['visibility']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get stackId =>
columnsByName['stack_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get libraryId =>
columnsByName['library_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isEdited =>
columnsByName['is_edited']! as i1.GeneratedColumn<bool>;
}
i1.GeneratedColumn<bool> _column_101(String aliasedName) =>
i1.GeneratedColumn<bool>(
'is_edited',
aliasedName,
false,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("is_edited" IN (0, 1))',
),
defaultValue: const CustomExpression('0'),
);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -6927,6 +7424,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16,
required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -7005,6 +7503,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from15To16(migrator, schema);
return 16;
case 16:
final schema = Schema17(database: database);
final migrator = i1.Migrator(database, schema);
await from16To17(migrator, schema);
return 17;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -7027,6 +7530,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16,
required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -7044,5 +7548,6 @@ i1.OnUpgrade stepByStep({
from13To14: from13To14,
from14To15: from14To15,
from15To16: from15To16,
from16To17: from16To17,
),
);

View File

@@ -200,6 +200,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
libraryId: Value(asset.libraryId),
width: Value(asset.width),
height: Value(asset.height),
isEdited: Value(asset.isEdited),
);
batch.insert(

View File

@@ -70,6 +70,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
durationInSeconds: row.durationInSeconds,
livePhotoVideoId: row.livePhotoVideoId,
stackId: row.stackId,
isEdited: row.isEdited,
)
: LocalAsset(
id: row.localId!,
@@ -88,6 +89,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
latitude: row.latitude,
longitude: row.longitude,
adjustmentTime: row.adjustmentTime,
isEdited: row.isEdited,
),
)
.get();

View File

@@ -92,7 +92,7 @@ class _MobileLayout extends StatelessWidget {
],
)
.toList();
return ListView(padding: const EdgeInsets.only(top: 10.0, bottom: 16), children: [...settings]);
return ListView(padding: const EdgeInsets.only(top: 10.0, bottom: 60), children: [...settings]);
}
}
@@ -142,7 +142,7 @@ class SettingsSubPage extends StatelessWidget {
context.locale;
return Scaffold(
appBar: AppBar(centerTitle: false, title: Text(section.title).tr()),
body: section.widget,
body: Padding(padding: const EdgeInsets.only(bottom: 60.0), child: section.widget),
);
}
}

View File

@@ -10,6 +10,7 @@ import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:logging/logging.dart';
@@ -49,6 +50,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
final accessToken = Store.tryGet(StoreKey.accessToken);
if (accessToken != null && serverUrl != null && endpoint != null) {
final infoProvider = ref.read(serverInfoProvider.notifier);
final wsProvider = ref.read(websocketProvider.notifier);
final backgroundManager = ref.read(backgroundSyncProvider);
final backupProvider = ref.read(driftBackupProvider.notifier);
@@ -58,6 +60,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
(_) async {
try {
wsProvider.connect();
unawaited(infoProvider.getServerInfo());
if (Store.isBetaTimelineEnabled) {
bool syncSuccess = false;

View File

@@ -18,6 +18,7 @@ class SyncStatusPage extends StatelessWidget {
splashRadius: 24,
icon: const Icon(Icons.arrow_back_ios_rounded),
),
centerTitle: false,
),
body: const SyncStatusAndActions(),
);

View File

@@ -118,6 +118,7 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
),
_PropertyItem(label: 'Is Favorite', value: asset.isFavorite.toString()),
_PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId),
_PropertyItem(label: 'Is Edited', value: asset.isEdited.toString()),
]);
}

View File

@@ -167,7 +167,7 @@ class _PlaceTile extends StatelessWidget {
child: SizedBox(
width: 80,
height: 80,
child: Thumbnail.remote(remoteId: place.$2, fit: BoxFit.cover),
child: Thumbnail.remote(remoteId: place.$2, fit: BoxFit.cover, thumbhash: ""),
),
),
);

View File

@@ -14,14 +14,15 @@ import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/album/new_album_name_modal.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/album_filter.utils.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -310,18 +311,17 @@ class _SortButtonState extends ConsumerState<_SortButton> {
: const Icon(Icons.abc, color: Colors.transparent),
onPressed: () => onMenuTapped(sortMode),
style: ButtonStyle(
padding: WidgetStateProperty.all(const EdgeInsets.fromLTRB(16, 16, 32, 16)),
padding: WidgetStateProperty.all(const EdgeInsets.fromLTRB(12, 12, 24, 12)),
backgroundColor: WidgetStateProperty.all(
albumSortOption == sortMode ? context.colorScheme.primary : Colors.transparent,
),
shape: WidgetStateProperty.all(
const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(24))),
const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
),
),
child: Text(
sortMode.label.t(context: context),
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
style: context.textTheme.labelLarge?.copyWith(
color: albumSortOption == sortMode
? context.colorScheme.onPrimary
: context.colorScheme.onSurface.withAlpha(185),
@@ -344,15 +344,12 @@ class _SortButtonState extends ConsumerState<_SortButton> {
Padding(
padding: const EdgeInsets.only(right: 5),
child: albumSortIsReverse
? const Icon(Icons.keyboard_arrow_down)
: const Icon(Icons.keyboard_arrow_up_rounded),
? Icon(Icons.keyboard_arrow_down, color: context.colorScheme.onSurface)
: Icon(Icons.keyboard_arrow_up_rounded, color: context.colorScheme.onSurface),
),
Text(
albumSortOption.label.t(context: context),
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color: context.colorScheme.onSurface.withAlpha(225),
),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurface.withAlpha(225)),
),
isSorting
? SizedBox(
@@ -542,7 +539,11 @@ class _QuickSortAndViewMode extends StatelessWidget {
initialIsReverse: currentIsReverse,
),
IconButton(
icon: Icon(isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, size: 24),
icon: Icon(
isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined,
size: 24,
color: context.colorScheme.onSurface,
),
onPressed: onToggleViewMode,
),
],
@@ -662,6 +663,8 @@ class _GridAlbumCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final albumThumbnailAsset = ref.read(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? "");
return GestureDetector(
onTap: () => onAlbumSelected(album),
child: Card(
@@ -680,12 +683,22 @@ class _GridAlbumCard extends ConsumerWidget {
borderRadius: const BorderRadius.vertical(top: Radius.circular(15)),
child: SizedBox(
width: double.infinity,
child: album.thumbnailAssetId != null
? Thumbnail.remote(remoteId: album.thumbnailAssetId!)
: Container(
color: context.colorScheme.surfaceContainerHighest,
child: const Icon(Icons.photo_album_rounded, size: 40, color: Colors.grey),
),
child: FutureBuilder(
future: albumThumbnailAsset,
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data != null) {
return Thumbnail.remote(
remoteId: album.thumbnailAssetId!,
thumbhash: snapshot.data!.thumbHash ?? "",
);
}
return Container(
color: context.colorScheme.surfaceContainerHighest,
child: const Icon(Icons.photo_album_rounded, size: 40, color: Colors.grey),
);
},
),
),
),
),

View File

@@ -1,12 +1,14 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
class AlbumTile extends StatelessWidget {
class AlbumTile extends ConsumerWidget {
const AlbumTile({super.key, required this.album, required this.isOwner, this.onAlbumSelected});
final RemoteAlbum album;
@@ -14,7 +16,9 @@ class AlbumTile extends StatelessWidget {
final Function(RemoteAlbum)? onAlbumSelected;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final albumThumbnailAsset = ref.read(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? "");
return LargeLeadingTile(
title: Text(
album.name,
@@ -29,23 +33,35 @@ class AlbumTile extends StatelessWidget {
),
onTap: () => onAlbumSelected?.call(album),
leadingPadding: const EdgeInsets.only(right: 16),
leading: album.thumbnailAssetId != null
? ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: SizedBox(width: 80, height: 80, child: Thumbnail.remote(remoteId: album.thumbnailAssetId!)),
)
: SizedBox(
width: 80,
height: 80,
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainer,
borderRadius: const BorderRadius.all(Radius.circular(16)),
border: Border.all(color: context.colorScheme.outline.withAlpha(50), width: 1),
),
child: const Icon(Icons.photo_album_rounded, size: 24, color: Colors.grey),
),
),
leading: FutureBuilder(
future: albumThumbnailAsset,
builder: (context, snapshot) {
return snapshot.hasData && snapshot.data != null
? ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: SizedBox(
width: 80,
height: 80,
child: Thumbnail.remote(
remoteId: album.thumbnailAssetId!,
thumbhash: snapshot.data!.thumbHash ?? "",
),
),
)
: SizedBox(
width: 80,
height: 80,
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainer,
borderRadius: const BorderRadius.all(Radius.circular(16)),
border: Border.all(color: context.colorScheme.outline.withAlpha(50), width: 1),
),
child: const Icon(Icons.photo_album_rounded, size: 24, color: Colors.grey),
),
);
},
),
);
}
}

View File

@@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
@@ -164,11 +165,8 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
children: [
if (albums.isNotEmpty)
SheetTile(
title: 'appears_in'.t(context: context).toUpperCase(),
titleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
title: 'appears_in'.t(context: context),
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
Padding(
padding: const EdgeInsets.only(left: 24),
@@ -224,9 +222,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
color: context.textTheme.labelLarge?.color,
),
subtitle: _getFileInfo(asset, exifInfo),
subtitleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
);
},
);
@@ -241,9 +237,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
color: context.textTheme.labelLarge?.color,
),
subtitle: _getFileInfo(asset, exifInfo),
subtitleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
);
}
}
@@ -262,11 +256,8 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
const SheetLocationDetails(),
// Details header
SheetTile(
title: 'details'.t(context: context).toUpperCase(),
titleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
title: 'details'.t(context: context),
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
// File info
buildFileInfoTile(),
@@ -278,9 +269,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color),
subtitle: _getCameraInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
],
// Lens info
@@ -291,15 +280,13 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
subtitle: _getLensInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
],
// Appears in (Albums)
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
// padding at the bottom to avoid cut-off
const SizedBox(height: 30),
const SizedBox(height: 60),
],
);
}

View File

@@ -4,6 +4,7 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
@@ -77,11 +78,8 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SheetTile(
title: 'location'.t(context: context).toUpperCase(),
titleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
title: 'location'.t(context: context),
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
trailing: hasCoordinates ? const Icon(Icons.edit_location_alt, size: 20) : null,
onTap: editLocation,
),
@@ -105,9 +103,7 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
),
Text(
coordinates,
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
),
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
],
),

View File

@@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
@@ -53,11 +54,8 @@ class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
Padding(
padding: const EdgeInsets.only(left: 16, top: 16, bottom: 16),
child: Text(
"people".t(context: context).toUpperCase(),
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
"people".t(context: context),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
),
SizedBox(

View File

@@ -112,14 +112,17 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type);
} else {
final String assetId;
final String thumbhash;
if (asset is LocalAsset && asset.hasRemote) {
assetId = asset.remoteId!;
thumbhash = "";
} else if (asset is RemoteAsset) {
assetId = asset.id;
thumbhash = asset.thumbHash ?? "";
} else {
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
}
provider = RemoteFullImageProvider(assetId: assetId);
provider = RemoteFullImageProvider(assetId: assetId, thumbhash: thumbhash);
}
return provider;
@@ -132,8 +135,9 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai
}
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
return assetId != null ? RemoteThumbProvider(assetId: assetId) : null;
final thumbhash = asset is RemoteAsset ? asset.thumbHash ?? "" : "";
return assetId != null ? RemoteThumbProvider(assetId: assetId, thumbhash: thumbhash) : null;
}
bool _shouldUseLocalAsset(BaseAsset asset) =>
asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage));
asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage)) && !asset.isEdited;

View File

@@ -16,8 +16,9 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
with CancellableImageProviderMixin<RemoteThumbProvider> {
static final cacheManager = RemoteThumbnailCacheManager();
final String assetId;
final String thumbhash;
RemoteThumbProvider({required this.assetId});
RemoteThumbProvider({required this.assetId, required this.thumbhash});
@override
Future<RemoteThumbProvider> obtainKey(ImageConfiguration configuration) {
@@ -38,7 +39,7 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
Stream<ImageInfo> _codec(RemoteThumbProvider key, ImageDecoderCallback decode) {
final request = this.request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId),
uri: getThumbnailUrlForRemoteId(key.assetId, thumbhash: key.thumbhash),
headers: ApiService.getRequestHeaders(),
cacheManager: cacheManager,
);
@@ -49,22 +50,23 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is RemoteThumbProvider) {
return assetId == other.assetId;
return assetId == other.assetId && thumbhash == other.thumbhash;
}
return false;
}
@override
int get hashCode => assetId.hashCode;
int get hashCode => assetId.hashCode ^ thumbhash.hashCode;
}
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
with CancellableImageProviderMixin<RemoteFullImageProvider> {
static final cacheManager = RemoteThumbnailCacheManager();
final String assetId;
final String thumbhash;
RemoteFullImageProvider({required this.assetId});
RemoteFullImageProvider({required this.assetId, required this.thumbhash});
@override
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
@@ -75,7 +77,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getInitialImage(RemoteThumbProvider(assetId: key.assetId)),
initialImage: getInitialImage(RemoteThumbProvider(assetId: key.assetId, thumbhash: key.thumbhash)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
@@ -94,7 +96,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
final headers = ApiService.getRequestHeaders();
final request = this.request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview),
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
headers: headers,
cacheManager: cacheManager,
);
@@ -115,12 +117,12 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is RemoteFullImageProvider) {
return assetId == other.assetId;
return assetId == other.assetId && thumbhash == other.thumbhash;
}
return false;
}
@override
int get hashCode => assetId.hashCode;
int get hashCode => assetId.hashCode ^ thumbhash.hashCode;
}

View File

@@ -21,9 +21,14 @@ class Thumbnail extends StatefulWidget {
const Thumbnail({this.imageProvider, this.fit = BoxFit.cover, this.thumbhashProvider, super.key});
Thumbnail.remote({required String remoteId, this.fit = BoxFit.cover, Size size = kThumbnailResolution, super.key})
: imageProvider = RemoteThumbProvider(assetId: remoteId),
thumbhashProvider = null;
Thumbnail.remote({
required String remoteId,
required String thumbhash,
this.fit = BoxFit.cover,
Size size = kThumbnailResolution,
super.key,
}) : imageProvider = RemoteThumbProvider(assetId: remoteId, thumbhash: thumbhash),
thumbhashProvider = null;
Thumbnail.fromAsset({
required BaseAsset? asset,

View File

@@ -6,6 +6,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
@@ -47,6 +48,7 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
Widget build(BuildContext context) {
final asset = widget.asset;
final heroIndex = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
final isCurrentAsset = ref.watch(assetViewerProvider.select((current) => current.currentAsset == asset));
final assetContainerColor = context.isDarkTheme
? context.primaryColor.darken(amount: 0.4)
@@ -59,6 +61,10 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
final bool storageIndicator =
ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator))) && widget.showStorageIndicator;
if (!isCurrentAsset) {
_hideIndicators = false;
}
if (isSelected) {
_showSelectionContainer = true;
}
@@ -96,7 +102,11 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
children: [
Positioned.fill(
child: Hero(
tag: '${asset?.heroTag ?? ''}_$heroIndex',
// This key resets the hero animation when the asset is changed in the asset viewer.
// It doesn't seem like the best solution, and only works to reset the hero, not prime the hero of the new active asset for animation,
// but other solutions have failed thus far.
key: ValueKey(isCurrentAsset),
tag: '${asset?.heroTag}_$heroIndex',
child: Thumbnail.fromAsset(asset: asset, size: widget.size),
// Placeholderbuilder used to hide indicators on first hero animation, since flightShuttleBuilder isn't called until both source and destination hero exist in widget tree.
placeholderBuilder: (context, heroSize, child) {

View File

@@ -60,7 +60,11 @@ class DriftMemoryCard extends ConsumerWidget {
child: SizedBox(
width: 205,
height: 200,
child: Thumbnail.remote(remoteId: memory.assets[0].id, fit: BoxFit.cover),
child: Thumbnail.remote(
remoteId: memory.assets[0].id,
thumbhash: memory.assets[0].thumbHash ?? "",
fit: BoxFit.cover,
),
),
),
Positioned(

View File

@@ -160,7 +160,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
_resumeBackup();
}),
_resumeBackup(),
backgroundManager.syncCloudIds(),
_safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"),
]);
} else {
await _safeRun(backgroundManager.hashAssets(), "hashAssets");

View File

@@ -69,6 +69,7 @@ class CastNotifier extends StateNotifier<CastManagerState> {
: AssetType.other,
createdAt: asset.fileCreatedAt,
updatedAt: asset.updatedAt,
isEdited: false,
);
_gCastService.loadMedia(remoteAsset, reload);

View File

@@ -144,6 +144,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.on('on_asset_hidden', _handleOnAssetHidden);
} else {
socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReady);
socket.on('AssetEditReadyV1', _handleSyncAssetEditReady);
}
socket.on('on_config_update', _handleOnConfigUpdate);
@@ -192,10 +193,12 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
void stopListeningToBetaEvents() {
state.socket?.off('AssetUploadReadyV1');
state.socket?.off('AssetEditReadyV1');
}
void startListeningToBetaEvents() {
state.socket?.on('AssetUploadReadyV1', _handleSyncAssetUploadReady);
state.socket?.on('AssetEditReadyV1', _handleSyncAssetEditReady);
}
void listenUploadEvent() {
@@ -315,6 +318,10 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
_batchDebouncer.run(_processBatchedAssetUploadReady);
}
void _handleSyncAssetEditReady(dynamic data) {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditBatch([data]));
}
void _processBatchedAssetUploadReady() {
if (_batchedAssetUploadReady.isEmpty) {
return;

View File

@@ -25,6 +25,7 @@ class FileMediaRepository {
type: AssetType.image,
createdAt: entity.createDateTime,
updatedAt: entity.modifiedDateTime,
isEdited: false,
);
}

View File

@@ -285,7 +285,12 @@ class BackgroundUploadService {
return null;
}
final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
final hasExtension = p.extension(fileName).isNotEmpty;
if (!hasExtension) {
fileName = p.setExtension(fileName, p.extension(asset.name));
}
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
String metadata = UploadTaskMetadata(
@@ -307,10 +312,10 @@ class BackgroundUploadService {
priority: priority,
isFavorite: asset.isFavorite,
requiresWiFi: requiresWiFi,
cloudId: asset.cloudId,
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
cloudId: entity.isLivePhoto ? null : asset.cloudId,
adjustmentTime: entity.isLivePhoto ? null : asset.adjustmentTime?.toIso8601String(),
latitude: entity.isLivePhoto ? null : asset.latitude?.toString(),
longitude: entity.isLivePhoto ? null : asset.longitude?.toString(),
);
}

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