mirror of
https://github.com/immich-app/immich.git
synced 2026-01-26 11:24:44 -08:00
Compare commits
7 Commits
refactor/a
...
release/ne
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab80cb7544 | ||
|
|
b5c3d87290 | ||
|
|
97220102e4 | ||
|
|
6430c88b84 | ||
|
|
df7efc4945 | ||
|
|
646bb372ab | ||
|
|
836d22570f |
404
CHANGELOG.md
Normal file
404
CHANGELOG.md
Normal file
@@ -0,0 +1,404 @@
|
||||
|
||||
# v2.5.0
|
||||
|
||||
# v2.5.0
|
||||
|
||||
## Highlights
|
||||
|
||||
Happy New Year! Welcome to Immich `v2.5.0`. This release is our fireworks to celebrate both the new year and reaching *90,000* stars on GitHub. It is packed with major features that have been in the works for quite some time, and the team has kicked off the year with incredible momentum that we're excited to carry forward. We couldn't wait to share this with you. Let's dive right in:
|
||||
|
||||
* Free Up Space
|
||||
* Non-destructive editing
|
||||
* Database backup and restore (web)
|
||||
* Foreground upload improvements
|
||||
* Visual refresh across all platforms
|
||||
|
||||
### Free Up Spac*e*
|
||||
|
||||
*This feature was requested ages ago. So long in fact, that it has a 3-digit ID (#165)! Given the rapid iteration and development pace of the pre-stable era, it was risky to include it in the app due to its bulk-delete nature. But it is now 2026* :smile:*, so here we are.*
|
||||
|
||||
|
||||
**Free Up Space** 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 Immich trash). This helps reclaim storage on your mobile device without losing your memories. The feature can be accessed from the user profile panel or from the Settings page in the mobile app.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
There are configuration options and steps to make sure that everything is verified before deleting from the app.
|
||||
|
||||
|
||||
1. **Configuration:**
|
||||
* **Cutoff date:** Free Up Space will only look for photos and videos **on or before** this date.
|
||||
* **Keep albums:** Hold all photos and videos in the selected albums on your device, regardless of other settings. By default, `**WhatsApp**` related albums are selected to be kept on the device. Assets that are not already on the device will not be redownloaded.
|
||||
* **Keep favorites:** This works the same way `**Keep albums**` . By default, favorited assets are preserved on your device.
|
||||
* **Keep on device:** You can choose to restrict removal to `**Always keep**` **All photos** or **All videos**, regardless of other settings. This setting can hamper freeing up space significantly — with 80 GB of videos and 40 GB of photos, selecting `**Always keep photos**` retains thousands of photos on your device.
|
||||
2. **Scan & Review:** Before any files are removed, you are presented with a review screen to confirm which items will be deleted and how much storage will be reclaimed.
|
||||
3. **Deletion:** Confirmed items are moved to your device's native Trash/Recycle Bin.
|
||||
|
||||
|
||||
:::info
|
||||
**Reclaim storage**
|
||||
|
||||
To use the reclaimed space right away, you must manually empty the system/gallery trash outside Immich.
|
||||
|
||||
:::
|
||||
|
||||
For more information about this feature, please read it [here](https://immich.app/features/mobile-app#free-up-space)
|
||||
|
||||
|
||||
### Non-destructive editing
|
||||
|
||||
Immich now supports non-destructive photo editing. This means that any edits you make to an asset do not modify the original file; instead, the modifications are stored in the database, and new thumbnails are generated corresponding to the changes. This means you can always revert to the original asset if needed.
|
||||
|
||||
When you download an edited asset, Immich provides the edited version by default. However, you can choose to download the original version if needed. Immich always generates an edited full-size version based on your full-size quality settings. This occurs regardless if you have the "Enable full-size image generation" setting enabled or disabled.
|
||||
|
||||
|
||||
:::info
|
||||
**Limitations:**
|
||||
|
||||
* Mobile clients must be updated to v2.5.0 in order view the edited version of an asset. Clients will continue to see the original asset if on a mobile app version <2.5.0
|
||||
* As of this version, the edited download won't include the EXIF metadata of the original asset. This feature will come in future releases.
|
||||
|
||||
|
||||
* Mobile editing still uses the old edit system (saving a new version of the photo). The mobile editor will be upgraded to use the new non-destructive editing system in future releases.
|
||||
|
||||
:::
|
||||
|
||||
You can click on the following icon to enter edit mode
|
||||
|
||||

|
||||
|
||||
Currently, Immich supports the following types of edits:
|
||||
|
||||
* Cropping
|
||||
* Rotation
|
||||
* Mirroring
|
||||
|
||||

|
||||
|
||||
Opening the editor on an edited asset will load the existing edits back in so you can make adjustments and changes.
|
||||
|
||||

|
||||
|
||||
### Backup and restore from the web UI
|
||||
|
||||
Backup and restore are an important part of any self-hosted application; this feature helps you maintain reliable access to your instance during unexpected events, such as database corruption caused by system failure or power loss.
|
||||
|
||||
Historically, restoring an Immich instance to a specific point required the user to have access to the command line, which proved challenging for many users, especially those new to self-hosting and software maintenance.
|
||||
|
||||
Now, we have the entire backup and restore pipeline built into Immich, which allows you to quickly restore a database backup directly from the web UI. You can perform the steps either from the `Administration > Maintenance` page, or from a brand new instance at the Onboarding step.
|
||||
|
||||
 
|
||||
|
||||
For more detailed steps, please read them in [our documentation](immich.app/administration/backup-and-restore)
|
||||
|
||||
### Foreground upload improvement
|
||||
|
||||
This release also improves foreground upload in the mobile app. The previous implementation improves background uploads but leaves foreground uploads less reliable by leveraging the queue system to offload upload handling to the OS, which can be throttled under specific criteria.
|
||||
|
||||
We are taking back more control over handling uploads with the *try-and-true* method used in the old timeline, but this time it is supercharged with concurrent uploads and also correctly handles assets with missing file extensions from software such as DJI or Fusion Camera.
|
||||
|
||||

|
||||
|
||||
|
||||
### Visual refresh across all platforms
|
||||
|
||||
This release also brings you a refreshed look and feel across the web, mobile app, and the documentation site, with a new font face ("The-name-must-not-be-named" Sans) that improves reading legibility, especially for numbers and smaller text.
|
||||
|
||||

|
||||
|
||||
The UI library (https://ui.immich.app) components have also been added to the web app in more places. You should see a more standardized, coherent, and better hierarchy for UI components across the app.
|
||||
|
||||

|
||||
|
||||
All icon buttons now come with a tooltip, so you don't have to guess what function the button serves
|
||||
|
||||

|
||||
|
||||
<!-- Release notes generated using configuration in .github/release.yml at main -->
|
||||
|
||||
## What's Changed
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
* feat: workflow ui by @alextran1502 in <https://github.com/immich-app/immich/pull/24190>
|
||||
* feat: disable admin setup by @jrasm91 in <https://github.com/immich-app/immich/pull/24628>
|
||||
* feat: free up space by @alextran1502 in <https://github.com/immich-app/immich/pull/24999>
|
||||
* feat: use fastlane sigh to manage signing profiles by @alextran1502 in <https://github.com/immich-app/immich/pull/25089>
|
||||
* feat: image editing by @bwees in <https://github.com/immich-app/immich/pull/24155>
|
||||
* feat: add cloud id during native sync by @shenlong-tanwen in <https://github.com/immich-app/immich/pull/20418>
|
||||
* feat: restore database backups by @insertish in <https://github.com/immich-app/immich/pull/23978>
|
||||
* feat(mobile): star rating by @YarosMallorca in <https://github.com/immich-app/immich/pull/24457>
|
||||
* feat(mobile): scrollbar for album page by @alextran1502 in <https://github.com/immich-app/immich/pull/25507>
|
||||
|
||||
### 🌟 Enhancements
|
||||
|
||||
* feat: focus jumped-to item in timeline by @bo0tzz in <https://github.com/immich-app/immich/pull/24738>
|
||||
* chore: web editor improvements by @bwees in <https://github.com/immich-app/immich/pull/25169>
|
||||
* feat: modal routes by @jrasm91 in <https://github.com/immich-app/immich/pull/24726>
|
||||
* feat: prefer admin settings page over users page by @jrasm91 in <https://github.com/immich-app/immich/pull/24780>
|
||||
* feat: shared link edit by @jrasm91 in <https://github.com/immich-app/immich/pull/24783>
|
||||
* feat(mobile): use tabular figures in backup info card by @wrbl606 in <https://github.com/immich-app/immich/pull/24820>
|
||||
* feat(mobile): album options to kebab menu by @idubnori in <https://github.com/immich-app/immich/pull/24204>
|
||||
* feat: Hide/show controls when zoom state changes by @Lauritz-Tieste in <https://github.com/immich-app/immich/pull/24784>
|
||||
* feat(server): Support camera `make`, `model`, and `lensModel` in Storage Template by @rahul-kumar-saini in <https://github.com/immich-app/immich/pull/24650>
|
||||
* feat(ml): update ONNX Runtime, OpenVINO and ROCm stack by @savely-krasovsky in <https://github.com/immich-app/immich/pull/23458>
|
||||
* chore(server): Vchord 1.0 support by @mmomjian in <https://github.com/immich-app/immich/pull/23845>
|
||||
* feat(web): Add coordinate pair location searching. by @GustavJones in <https://github.com/immich-app/immich/pull/24799>
|
||||
* feat: show asset owners for editors in shared albums by @ama156 in <https://github.com/immich-app/immich/pull/24890>
|
||||
* feat(web): undo delete single asset by @YarosMallorca in <https://github.com/immich-app/immich/pull/24439>
|
||||
* feat(server): implement switchable logging formats (console/json) by @DanielRamosAcosta in <https://github.com/immich-app/immich/pull/24791>
|
||||
* chore(web): bump immich/ui for tooltips by @jrasm91 in <https://github.com/immich-app/immich/pull/24632>
|
||||
* feat(web): star rating keyboard shortcut by @cbochs in <https://github.com/immich-app/immich/pull/24620>
|
||||
* feat: bulk asset metadata endpoints by @jrasm91 in <https://github.com/immich-app/immich/pull/25133>
|
||||
* feat(mobile): 2026 font by @alextran1502 in <https://github.com/immich-app/immich/pull/25213>
|
||||
* feat(web): search albums by description by @YarosMallorca in <https://github.com/immich-app/immich/pull/25244>
|
||||
* feat(web): 2026 font by @alextran1502 in <https://github.com/immich-app/immich/pull/25174>
|
||||
* chore: dart http foreground upload by @alextran1502 in <https://github.com/immich-app/immich/pull/24883>
|
||||
* feat: update intel compute driver by @savely-krasovsky in <https://github.com/immich-app/immich/pull/25259>
|
||||
* feat: download original asset by @danieldietzler in <https://github.com/immich-app/immich/pull/25302>
|
||||
* feat: allow /memory?id= in AndroidManifest by @arne182 in <https://github.com/immich-app/immich/pull/25373>
|
||||
* fix: add scoped API permissions to map endpoints by @meesfrensel in <https://github.com/immich-app/immich/pull/25423>
|
||||
* fix(server): scoped permissions for more endpoints by @meesfrensel in <https://github.com/immich-app/immich/pull/25452>
|
||||
* feat: generate progressive JPEGs for thumbnails by @midzelis in <https://github.com/immich-app/immich/pull/25463>
|
||||
* feat: loop slideshows by @GeneralZero in <https://github.com/immich-app/immich/pull/25462>
|
||||
* feat(mobile): native clients by @mertalev in <https://github.com/immich-app/immich/pull/21459>
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
* fix(maintenance): prevent enable/disable maintenance CLI hanging on occasion by @insertish in <https://github.com/immich-app/immich/pull/24713>
|
||||
* fix(web): display jxl original by @mertalev in <https://github.com/immich-app/immich/pull/24766>
|
||||
* fix(web): stale album info by @jrasm91 in <https://github.com/immich-app/immich/pull/24787>
|
||||
* fix: album card timezone by @danieldietzler in <https://github.com/immich-app/immich/pull/24855>
|
||||
* fix(web): let slideshow videos play (#19601) by @keanucz in <https://github.com/immich-app/immich/pull/24914>
|
||||
* fix(server): update exiftool-vendored to v34.3 for correct colon-less timezone parsing by @dosten in <https://github.com/immich-app/immich/pull/24979>
|
||||
* fix(mobile): hide delete action for remote-only assets by @skrmc in <https://github.com/immich-app/immich/pull/25010>
|
||||
* fix: import config from json by @MontejoJorge in <https://github.com/immich-app/immich/pull/25030>
|
||||
* fix: search input has incorrect focus state after closing the search filter modal by @alextran1502 in <https://github.com/immich-app/immich/pull/24886>
|
||||
* fix(web): duplicate key error and enable expiration editing for expired shared links by @timonrieger in <https://github.com/immich-app/immich/pull/24686>
|
||||
* fix: shared-link-mapper by @jrasm91 in <https://github.com/immich-app/immich/pull/24794>
|
||||
* fix(server): migrate motion part of live photo by @NikhilAlapati in <https://github.com/immich-app/immich/pull/24688>
|
||||
* fix(web): use asset date for change date popup when single asset selected by @majiayu000 in <https://github.com/immich-app/immich/pull/25076>
|
||||
* fix(web): long text taking more width than expected in duplicate manager by @HemendraSinghShekhawat in <https://github.com/immich-app/immich/pull/24547>
|
||||
* fix(web): broken asset urls if shared link has photos in name by @YarosMallorca in <https://github.com/immich-app/immich/pull/24451>
|
||||
* fix(server): search statistics with personIds returns 500 by @majiayu000 in <https://github.com/immich-app/immich/pull/25074>
|
||||
* fix(web): server stats layout by @meesfrensel in <https://github.com/immich-app/immich/pull/25085>
|
||||
* fix: enter now submits the date modals by @fabb in <https://github.com/immich-app/immich/pull/25053>
|
||||
* fix(web): improve text contrast in minimized upload panel by @majiayu000 in <https://github.com/immich-app/immich/pull/25075>
|
||||
* fix: propagate iCloud Shared Album flag by @alextran1502 in <https://github.com/immich-app/immich/pull/25060>
|
||||
* fix: description does not rerender when navigating between assets by @alextran1502 in <https://github.com/immich-app/immich/pull/25137>
|
||||
* fix(server): avoid upserting empty metadata array by @timonrieger in <https://github.com/immich-app/immich/pull/25143>
|
||||
* fix(server): Document HTTP 200 response for duplicate uploads in OpenAPI by @timonrieger in <https://github.com/immich-app/immich/pull/25148>
|
||||
* fix(web): person asset count doesn't update when navigating by @YarosMallorca in <https://github.com/immich-app/immich/pull/24438>
|
||||
* fix(mobile): remove weird zooming behaviour on videos and play/pause button delay by @goalie2002 in <https://github.com/immich-app/immich/pull/24006>
|
||||
* fix: unlock properties after successful sidecar write by @danieldietzler in <https://github.com/immich-app/immich/pull/25168>
|
||||
* fix(web): show relevant navbar options for partner assets by @YarosMallorca in <https://github.com/immich-app/immich/pull/24832>
|
||||
* fix(web): added background gradient for video time visibility by @HemendraSinghShekhawat in <https://github.com/immich-app/immich/pull/25138>
|
||||
* feat(mobile): do not restore locally deleted assets during trash sync (Android) by @PeterOmbodi in <https://github.com/immich-app/immich/pull/24218>
|
||||
* fix: asset local type casting by @alextran1502 in <https://github.com/immich-app/immich/pull/25214>
|
||||
* fix(web): ocr button not clickable for stacked assets by @YarosMallorca in <https://github.com/immich-app/immich/pull/25210>
|
||||
* fix(web): Handle upload failures from public users by @juliancarrivick in <https://github.com/immich-app/immich/pull/24826>
|
||||
* fix(mobile): prevent system UI from hiding on drag down gesture by @goalie2002 in <https://github.com/immich-app/immich/pull/25240>
|
||||
* fix: migration order by @jrasm91 in <https://github.com/immich-app/immich/pull/25249>
|
||||
* fix(web): redirect to login by @jrasm91 in <https://github.com/immich-app/immich/pull/25254>
|
||||
* fix(mobile): improve asset transition back to timeline by @goalie2002 in <https://github.com/immich-app/immich/pull/24485>
|
||||
* fix: dark mode appbar color by @akashKarmakar02 in <https://github.com/immich-app/immich/pull/24976>
|
||||
* fix(web): add min-width to setting input field by @K0lin in <https://github.com/immich-app/immich/pull/25317>
|
||||
* fix(server): api key update checks by @jrasm91 in <https://github.com/immich-app/immich/pull/25363>
|
||||
* fix(mobile): album selector icon visibility by @ByteSizedMarius in <https://github.com/immich-app/immich/pull/25311>
|
||||
* fix(mobile): indicators not showing on thumbnail tile after asset change in viewer by @goalie2002 in <https://github.com/immich-app/immich/pull/25297>
|
||||
* fix(web): handle deletion from asset viewer on map page by @meesfrensel in <https://github.com/immich-app/immich/pull/25393>
|
||||
* fix: tag update race condition by @danieldietzler in <https://github.com/immich-app/immich/pull/25371>
|
||||
* fix(web): allow exiting pin setup flow by @meesfrensel in <https://github.com/immich-app/immich/pull/25413>
|
||||
* fix: upload file without extension by @alextran1502 in <https://github.com/immich-app/immich/pull/25419>
|
||||
* fix: incorrect asset viewer scale on image frame update by @shenlong-tanwen in <https://github.com/immich-app/immich/pull/25430>
|
||||
* fix(mobile): bring back map settings by @shenlong-tanwen in <https://github.com/immich-app/immich/pull/25448>
|
||||
* fix(web): fix badge value in queues page by @beinganukul in <https://github.com/immich-app/immich/pull/25445>
|
||||
* fix(mobile): backfill asset dimensions to exif table by @bwees in <https://github.com/immich-app/immich/pull/25483>
|
||||
* fix(mobile): do not try to load video as image by @mertalev in <https://github.com/immich-app/immich/pull/25495>
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
* fix: product keys wording in commercial guidelines faq by @bo0tzz in <https://github.com/immich-app/immich/pull/24765>
|
||||
* docs: config options for hardware transcoding by @Javex in <https://github.com/immich-app/immich/pull/24853>
|
||||
* fix: use my.immich.app as url placeholder in docs by @bo0tzz in <https://github.com/immich-app/immich/pull/25153>
|
||||
* chore: update Thai README (remove "under active development" lines) by @ppnplus in <https://github.com/immich-app/immich/pull/25208>
|
||||
* fix(docs): add missing mermaid dependency and configuration by @bdoerfchen in <https://github.com/immich-app/immich/pull/25247>
|
||||
* chore(docs): update RAM req by @mmomjian in <https://github.com/immich-app/immich/pull/25344>
|
||||
* feat(docs): add Free Up Space section by @aviv926 in <https://github.com/immich-app/immich/pull/25253>
|
||||
* docs: update README_de_DE.md by @solluh in <https://github.com/immich-app/immich/pull/25443>
|
||||
* fix(docs): document that fullsize thumbnail might redirect to original by @meesfrensel in <https://github.com/immich-app/immich/pull/25416>
|
||||
* docs: update documentation by @alextran1502 in <https://github.com/immich-app/immich/pull/25440>
|
||||
|
||||
## New Contributors
|
||||
|
||||
* @wrbl606 made their first contribution in <https://github.com/immich-app/immich/pull/24820>
|
||||
* @keanucz made their first contribution in <https://github.com/immich-app/immich/pull/24914>
|
||||
* @rahul-kumar-saini made their first contribution in <https://github.com/immich-app/immich/pull/24650>
|
||||
* @dosten made their first contribution in <https://github.com/immich-app/immich/pull/24979>
|
||||
* @GustavJones made their first contribution in <https://github.com/immich-app/immich/pull/24799>
|
||||
* @skrmc made their first contribution in <https://github.com/immich-app/immich/pull/25010>
|
||||
* @ama156 made their first contribution in <https://github.com/immich-app/immich/pull/24890>
|
||||
* @DanielRamosAcosta made their first contribution in <https://github.com/immich-app/immich/pull/24791>
|
||||
* @NikhilAlapati made their first contribution in <https://github.com/immich-app/immich/pull/24688>
|
||||
* @flpcury made their first contribution in <https://github.com/immich-app/immich/pull/24867>
|
||||
* @Javex made their first contribution in <https://github.com/immich-app/immich/pull/24853>
|
||||
* @majiayu000 made their first contribution in <https://github.com/immich-app/immich/pull/25076>
|
||||
* @HemendraSinghShekhawat made their first contribution in <https://github.com/immich-app/immich/pull/24547>
|
||||
* @cbochs made their first contribution in <https://github.com/immich-app/immich/pull/24620>
|
||||
* @fabb made their first contribution in <https://github.com/immich-app/immich/pull/25053>
|
||||
* @ppnplus made their first contribution in <https://github.com/immich-app/immich/pull/25208>
|
||||
* @juliancarrivick made their first contribution in <https://github.com/immich-app/immich/pull/24826>
|
||||
* @bdoerfchen made their first contribution in <https://github.com/immich-app/immich/pull/25247>
|
||||
* @akashKarmakar02 made their first contribution in <https://github.com/immich-app/immich/pull/24976>
|
||||
* @K0lin made their first contribution in <https://github.com/immich-app/immich/pull/25317>
|
||||
* @NAM-MAN made their first contribution in <https://github.com/immich-app/immich/pull/25320>
|
||||
* @ByteSizedMarius made their first contribution in <https://github.com/immich-app/immich/pull/25311>
|
||||
* @arne182 made their first contribution in <https://github.com/immich-app/immich/pull/25373>
|
||||
* @solluh made their first contribution in <https://github.com/immich-app/immich/pull/25443>
|
||||
* @beinganukul made their first contribution in <https://github.com/immich-app/immich/pull/25445>
|
||||
* @GeneralZero made their first contribution in <https://github.com/immich-app/immich/pull/25462>
|
||||
|
||||
**Full Changelog**: <https://github.com/immich-app/immich/compare/v2.4.1...v2.5.0>
|
||||
|
||||
<!-- Release notes generated using configuration in .github/release.yml at main -->
|
||||
|
||||
## What's Changed
|
||||
### 🚀 Features
|
||||
* feat: workflow ui by @alextran1502 in https://github.com/immich-app/immich/pull/24190
|
||||
* feat: disable admin setup by @jrasm91 in https://github.com/immich-app/immich/pull/24628
|
||||
* feat: free up space by @alextran1502 in https://github.com/immich-app/immich/pull/24999
|
||||
* feat: use fastlane sigh to manage signing profiles by @alextran1502 in https://github.com/immich-app/immich/pull/25089
|
||||
* feat: image editing by @bwees in https://github.com/immich-app/immich/pull/24155
|
||||
* feat: add cloud id during native sync by @shenlong-tanwen in https://github.com/immich-app/immich/pull/20418
|
||||
* chore: web editor improvements by @bwees in https://github.com/immich-app/immich/pull/25169
|
||||
* feat: restore database backups by @insertish in https://github.com/immich-app/immich/pull/23978
|
||||
* feat(mobile): star rating by @YarosMallorca in https://github.com/immich-app/immich/pull/24457
|
||||
* feat(mobile): scrollbar for album page by @alextran1502 in https://github.com/immich-app/immich/pull/25507
|
||||
### 🌟 Enhancements
|
||||
* feat: focus jumped-to item in timeline by @bo0tzz in https://github.com/immich-app/immich/pull/24738
|
||||
* feat: modal routes by @jrasm91 in https://github.com/immich-app/immich/pull/24726
|
||||
* feat: prefer admin settings page over users page by @jrasm91 in https://github.com/immich-app/immich/pull/24780
|
||||
* feat: shared link edit by @jrasm91 in https://github.com/immich-app/immich/pull/24783
|
||||
* feat(mobile): use tabular figures in backup info card by @wrbl606 in https://github.com/immich-app/immich/pull/24820
|
||||
* feat(mobile): album options to kebab menu by @idubnori in https://github.com/immich-app/immich/pull/24204
|
||||
* feat: Hide/show controls when zoom state changes by @Lauritz-Tieste in https://github.com/immich-app/immich/pull/24784
|
||||
* feat(server): Support camera `make`, `model`, and `lensModel` in Storage Template by @rahul-kumar-saini in https://github.com/immich-app/immich/pull/24650
|
||||
* feat(ml): update ONNX Runtime, OpenVINO and ROCm stack by @savely-krasovsky in https://github.com/immich-app/immich/pull/23458
|
||||
* chore(server): Vchord 1.0 support by @mmomjian in https://github.com/immich-app/immich/pull/23845
|
||||
* feat(web): Add coordinate pair location searching. by @GustavJones in https://github.com/immich-app/immich/pull/24799
|
||||
* feat: show asset owners for editors in shared albums by @ama156 in https://github.com/immich-app/immich/pull/24890
|
||||
* feat(web): undo delete single asset by @YarosMallorca in https://github.com/immich-app/immich/pull/24439
|
||||
* feat(server): implement switchable logging formats (console/json) by @DanielRamosAcosta in https://github.com/immich-app/immich/pull/24791
|
||||
* chore(web): bump immich/ui for tooltips by @jrasm91 in https://github.com/immich-app/immich/pull/24632
|
||||
* feat(web): star rating keyboard shortcut by @cbochs in https://github.com/immich-app/immich/pull/24620
|
||||
* feat: bulk asset metadata endpoints by @jrasm91 in https://github.com/immich-app/immich/pull/25133
|
||||
* feat(mobile): 2026 font by @alextran1502 in https://github.com/immich-app/immich/pull/25213
|
||||
* feat(web): search albums by description by @YarosMallorca in https://github.com/immich-app/immich/pull/25244
|
||||
* feat(web): 2026 font by @alextran1502 in https://github.com/immich-app/immich/pull/25174
|
||||
* chore: dart http foreground upload by @alextran1502 in https://github.com/immich-app/immich/pull/24883
|
||||
* feat: update intel compute driver by @savely-krasovsky in https://github.com/immich-app/immich/pull/25259
|
||||
* feat: download original asset by @danieldietzler in https://github.com/immich-app/immich/pull/25302
|
||||
* feat: allow /memory?id= in AndroidManifest by @arne182 in https://github.com/immich-app/immich/pull/25373
|
||||
* fix: add scoped API permissions to map endpoints by @meesfrensel in https://github.com/immich-app/immich/pull/25423
|
||||
* fix(server): scoped permissions for more endpoints by @meesfrensel in https://github.com/immich-app/immich/pull/25452
|
||||
* feat: generate progressive JPEGs for thumbnails by @midzelis in https://github.com/immich-app/immich/pull/25463
|
||||
* feat: loop slideshows by @GeneralZero in https://github.com/immich-app/immich/pull/25462
|
||||
* feat(mobile): native clients by @mertalev in https://github.com/immich-app/immich/pull/21459
|
||||
### 🐛 Bug fixes
|
||||
* fix(maintenance): prevent enable/disable maintenance CLI hanging on occasion by @insertish in https://github.com/immich-app/immich/pull/24713
|
||||
* fix(web): display jxl original by @mertalev in https://github.com/immich-app/immich/pull/24766
|
||||
* fix(web): stale album info by @jrasm91 in https://github.com/immich-app/immich/pull/24787
|
||||
* fix: album card timezone by @danieldietzler in https://github.com/immich-app/immich/pull/24855
|
||||
* fix(web): let slideshow videos play (#19601) by @keanucz in https://github.com/immich-app/immich/pull/24914
|
||||
* fix(server): update exiftool-vendored to v34.3 for correct colon-less timezone parsing by @dosten in https://github.com/immich-app/immich/pull/24979
|
||||
* fix(mobile): hide delete action for remote-only assets by @skrmc in https://github.com/immich-app/immich/pull/25010
|
||||
* fix: import config from json by @MontejoJorge in https://github.com/immich-app/immich/pull/25030
|
||||
* fix: search input has incorrect focus state after closing the search filter modal by @alextran1502 in https://github.com/immich-app/immich/pull/24886
|
||||
* fix(web): duplicate key error and enable expiration editing for expired shared links by @timonrieger in https://github.com/immich-app/immich/pull/24686
|
||||
* fix: shared-link-mapper by @jrasm91 in https://github.com/immich-app/immich/pull/24794
|
||||
* fix(server): migrate motion part of live photo by @NikhilAlapati in https://github.com/immich-app/immich/pull/24688
|
||||
* fix(web): use asset date for change date popup when single asset selected by @majiayu000 in https://github.com/immich-app/immich/pull/25076
|
||||
* fix(web): long text taking more width than expected in duplicate manager by @HemendraSinghShekhawat in https://github.com/immich-app/immich/pull/24547
|
||||
* fix(web): broken asset urls if shared link has photos in name by @YarosMallorca in https://github.com/immich-app/immich/pull/24451
|
||||
* fix(server): search statistics with personIds returns 500 by @majiayu000 in https://github.com/immich-app/immich/pull/25074
|
||||
* fix(web): server stats layout by @meesfrensel in https://github.com/immich-app/immich/pull/25085
|
||||
* fix: enter now submits the date modals by @fabb in https://github.com/immich-app/immich/pull/25053
|
||||
* fix(web): improve text contrast in minimized upload panel by @majiayu000 in https://github.com/immich-app/immich/pull/25075
|
||||
* fix: propagate iCloud Shared Album flag by @alextran1502 in https://github.com/immich-app/immich/pull/25060
|
||||
* fix: description does not rerender when navigating between assets by @alextran1502 in https://github.com/immich-app/immich/pull/25137
|
||||
* fix(server): avoid upserting empty metadata array by @timonrieger in https://github.com/immich-app/immich/pull/25143
|
||||
* fix(server): Document HTTP 200 response for duplicate uploads in OpenAPI by @timonrieger in https://github.com/immich-app/immich/pull/25148
|
||||
* fix(web): person asset count doesn't update when navigating by @YarosMallorca in https://github.com/immich-app/immich/pull/24438
|
||||
* fix(mobile): remove weird zooming behaviour on videos and play/pause button delay by @goalie2002 in https://github.com/immich-app/immich/pull/24006
|
||||
* fix: unlock properties after successful sidecar write by @danieldietzler in https://github.com/immich-app/immich/pull/25168
|
||||
* fix(web): show relevant navbar options for partner assets by @YarosMallorca in https://github.com/immich-app/immich/pull/24832
|
||||
* fix(web): added background gradient for video time visibility by @HemendraSinghShekhawat in https://github.com/immich-app/immich/pull/25138
|
||||
* feat(mobile): do not restore locally deleted assets during trash sync (Android) by @PeterOmbodi in https://github.com/immich-app/immich/pull/24218
|
||||
* fix: asset local type casting by @alextran1502 in https://github.com/immich-app/immich/pull/25214
|
||||
* fix(web): ocr button not clickable for stacked assets by @YarosMallorca in https://github.com/immich-app/immich/pull/25210
|
||||
* fix(web): Handle upload failures from public users by @juliancarrivick in https://github.com/immich-app/immich/pull/24826
|
||||
* fix(mobile): prevent system UI from hiding on drag down gesture by @goalie2002 in https://github.com/immich-app/immich/pull/25240
|
||||
* fix: migration order by @jrasm91 in https://github.com/immich-app/immich/pull/25249
|
||||
* fix(web): redirect to login by @jrasm91 in https://github.com/immich-app/immich/pull/25254
|
||||
* fix(mobile): improve asset transition back to timeline by @goalie2002 in https://github.com/immich-app/immich/pull/24485
|
||||
* fix: dark mode appbar color by @akashKarmakar02 in https://github.com/immich-app/immich/pull/24976
|
||||
* fix(web): add min-width to setting input field by @K0lin in https://github.com/immich-app/immich/pull/25317
|
||||
* fix(server): api key update checks by @jrasm91 in https://github.com/immich-app/immich/pull/25363
|
||||
* fix(mobile): album selector icon visibility by @ByteSizedMarius in https://github.com/immich-app/immich/pull/25311
|
||||
* fix(mobile): indicators not showing on thumbnail tile after asset change in viewer by @goalie2002 in https://github.com/immich-app/immich/pull/25297
|
||||
* fix(web): handle deletion from asset viewer on map page by @meesfrensel in https://github.com/immich-app/immich/pull/25393
|
||||
* fix: tag update race condition by @danieldietzler in https://github.com/immich-app/immich/pull/25371
|
||||
* fix(web): allow exiting pin setup flow by @meesfrensel in https://github.com/immich-app/immich/pull/25413
|
||||
* fix: upload file without extension by @alextran1502 in https://github.com/immich-app/immich/pull/25419
|
||||
* fix: incorrect asset viewer scale on image frame update by @shenlong-tanwen in https://github.com/immich-app/immich/pull/25430
|
||||
* fix(mobile): bring back map settings by @shenlong-tanwen in https://github.com/immich-app/immich/pull/25448
|
||||
* fix(web): fix badge value in queues page by @beinganukul in https://github.com/immich-app/immich/pull/25445
|
||||
* fix(mobile): backfill asset dimensions to exif table by @bwees in https://github.com/immich-app/immich/pull/25483
|
||||
* fix(mobile): do not try to load video as image by @mertalev in https://github.com/immich-app/immich/pull/25495
|
||||
* fix: deep link service when user is null by @bwees in https://github.com/immich-app/immich/pull/25530
|
||||
### 📚 Documentation
|
||||
* fix: product keys wording in commercial guidelines faq by @bo0tzz in https://github.com/immich-app/immich/pull/24765
|
||||
* docs: config options for hardware transcoding by @Javex in https://github.com/immich-app/immich/pull/24853
|
||||
* fix: use my.immich.app as url placeholder in docs by @bo0tzz in https://github.com/immich-app/immich/pull/25153
|
||||
* chore: update Thai README (remove "under active development" lines) by @ppnplus in https://github.com/immich-app/immich/pull/25208
|
||||
* fix(docs): add missing mermaid dependency and configuration by @bdoerfchen in https://github.com/immich-app/immich/pull/25247
|
||||
* chore(docs): update RAM req by @mmomjian in https://github.com/immich-app/immich/pull/25344
|
||||
* feat(docs): add Free Up Space section by @aviv926 in https://github.com/immich-app/immich/pull/25253
|
||||
* docs: update README_de_DE.md by @solluh in https://github.com/immich-app/immich/pull/25443
|
||||
* fix(docs): document that fullsize thumbnail might redirect to original by @meesfrensel in https://github.com/immich-app/immich/pull/25416
|
||||
* docs: update documentation by @alextran1502 in https://github.com/immich-app/immich/pull/25440
|
||||
|
||||
## New Contributors
|
||||
* @wrbl606 made their first contribution in https://github.com/immich-app/immich/pull/24820
|
||||
* @keanucz made their first contribution in https://github.com/immich-app/immich/pull/24914
|
||||
* @rahul-kumar-saini made their first contribution in https://github.com/immich-app/immich/pull/24650
|
||||
* @dosten made their first contribution in https://github.com/immich-app/immich/pull/24979
|
||||
* @GustavJones made their first contribution in https://github.com/immich-app/immich/pull/24799
|
||||
* @skrmc made their first contribution in https://github.com/immich-app/immich/pull/25010
|
||||
* @ama156 made their first contribution in https://github.com/immich-app/immich/pull/24890
|
||||
* @DanielRamosAcosta made their first contribution in https://github.com/immich-app/immich/pull/24791
|
||||
* @NikhilAlapati made their first contribution in https://github.com/immich-app/immich/pull/24688
|
||||
* @flpcury made their first contribution in https://github.com/immich-app/immich/pull/24867
|
||||
* @Javex made their first contribution in https://github.com/immich-app/immich/pull/24853
|
||||
* @majiayu000 made their first contribution in https://github.com/immich-app/immich/pull/25076
|
||||
* @HemendraSinghShekhawat made their first contribution in https://github.com/immich-app/immich/pull/24547
|
||||
* @cbochs made their first contribution in https://github.com/immich-app/immich/pull/24620
|
||||
* @fabb made their first contribution in https://github.com/immich-app/immich/pull/25053
|
||||
* @ppnplus made their first contribution in https://github.com/immich-app/immich/pull/25208
|
||||
* @juliancarrivick made their first contribution in https://github.com/immich-app/immich/pull/24826
|
||||
* @bdoerfchen made their first contribution in https://github.com/immich-app/immich/pull/25247
|
||||
* @akashKarmakar02 made their first contribution in https://github.com/immich-app/immich/pull/24976
|
||||
* @K0lin made their first contribution in https://github.com/immich-app/immich/pull/25317
|
||||
* @NAM-MAN made their first contribution in https://github.com/immich-app/immich/pull/25320
|
||||
* @ByteSizedMarius made their first contribution in https://github.com/immich-app/immich/pull/25311
|
||||
* @arne182 made their first contribution in https://github.com/immich-app/immich/pull/25373
|
||||
* @solluh made their first contribution in https://github.com/immich-app/immich/pull/25443
|
||||
* @beinganukul made their first contribution in https://github.com/immich-app/immich/pull/25445
|
||||
* @GeneralZero made their first contribution in https://github.com/immich-app/immich/pull/25462
|
||||
* @ianmuninio made their first contribution in https://github.com/immich-app/immich/pull/25468
|
||||
|
||||
**Full Changelog**: https://github.com/immich-app/immich/compare/v2.4.1...v2.5.0
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.105",
|
||||
"version": "2.2.106",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"type": "module",
|
||||
"exports": "./dist/index.js",
|
||||
|
||||
4
docs/static/archived-versions.json
vendored
4
docs/static/archived-versions.json
vendored
@@ -1,4 +1,8 @@
|
||||
[
|
||||
{
|
||||
"label": "v2.5.0",
|
||||
"url": "https://docs.v2.5.0.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.4.1",
|
||||
"url": "https://docs.v2.4.1.archive.immich.app"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "2.4.1",
|
||||
"version": "2.5.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -272,7 +272,7 @@
|
||||
"oauth_auto_register": "Auto register",
|
||||
"oauth_auto_register_description": "Automatically register new users after signing in with OAuth",
|
||||
"oauth_button_text": "Button text",
|
||||
"oauth_client_secret_description": "Required if PKCE (Proof Key for Code Exchange) is not supported by the OAuth provider",
|
||||
"oauth_client_secret_description": "Required for confidential client, or if PKCE (Proof Key for Code Exchange) is not supported for public client.",
|
||||
"oauth_enable_description": "Login with OAuth",
|
||||
"oauth_mobile_redirect_uri": "Mobile redirect URI",
|
||||
"oauth_mobile_redirect_uri_override": "Mobile redirect URI override",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "immich-ml"
|
||||
version = "2.4.1"
|
||||
version = "2.5.0"
|
||||
description = ""
|
||||
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
||||
requires-python = ">=3.11,<4.0"
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3030,
|
||||
"android.injected.version.name" => "2.4.1",
|
||||
"android.injected.version.code" => 3031,
|
||||
"android.injected.version.name" => "2.5.0",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
1
mobile/drift_schemas/main/drift_schema_v18.json
generated
Normal file
1
mobile/drift_schemas/main/drift_schema_v18.json
generated
Normal file
File diff suppressed because one or more lines are too long
@@ -23,6 +23,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
||||
static let session = {
|
||||
let cacheDir = FileManager.default.temporaryDirectory.appendingPathComponent("thumbnails", isDirectory: true)
|
||||
let config = URLSessionConfiguration.default
|
||||
config.requestCachePolicy = .returnCacheDataElseLoad
|
||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
|
||||
config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"]
|
||||
try! FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.4.1</string>
|
||||
<string>2.5.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -41,7 +41,7 @@ class HashService {
|
||||
final Stopwatch stopwatch = Stopwatch()..start();
|
||||
try {
|
||||
// Migrate hashes from cloud ID to local ID so we don't have to re-hash them
|
||||
await _migrateHashes();
|
||||
await _localAssetRepository.reconcileHashesFromCloudId();
|
||||
|
||||
// Sorted by backupSelection followed by isCloud
|
||||
final localAlbums = await _localAlbumRepository.getBackupAlbums();
|
||||
@@ -78,15 +78,6 @@ class HashService {
|
||||
_log.info("Hashing took - ${stopwatch.elapsedMilliseconds}ms");
|
||||
}
|
||||
|
||||
Future<void> _migrateHashes() async {
|
||||
final hashMappings = await _localAssetRepository.getHashMappingFromCloudId();
|
||||
if (hashMappings.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _localAssetRepository.updateHashes(hashMappings);
|
||||
}
|
||||
|
||||
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
|
||||
/// with hash for those that were successfully hashed. Hashes are looked up in a table
|
||||
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
|
||||
|
||||
@@ -50,75 +50,84 @@ Future<void> syncCloudIds(ProviderContainer ref) async {
|
||||
return;
|
||||
}
|
||||
|
||||
final mappingsToUpdate = await _fetchCloudIdMappings(db, currentUser.id);
|
||||
// Deduplicate mappings as a single remote asset ID can match multiple local assets
|
||||
final seenRemoteAssetIds = <String>{};
|
||||
final uniqueMapping = mappingsToUpdate.where((mapping) {
|
||||
if (!seenRemoteAssetIds.add(mapping.remoteAssetId)) {
|
||||
logger.fine('Duplicate remote asset ID found: ${mapping.remoteAssetId}. Skipping duplicate entry.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
final assetApi = ref.read(apiServiceProvider).assetsApi;
|
||||
|
||||
if (canBulkUpdateMetadata) {
|
||||
await _bulkUpdateCloudIds(assetApi, uniqueMapping);
|
||||
return;
|
||||
}
|
||||
await _sequentialUpdateCloudIds(assetApi, uniqueMapping);
|
||||
// Process cloud IDs in paginated batches
|
||||
await _processCloudIdMappingsInBatches(db, currentUser.id, assetApi, canBulkUpdateMetadata, logger);
|
||||
}
|
||||
|
||||
Future<void> _sequentialUpdateCloudIds(AssetsApi assetsApi, List<_CloudIdMapping> mappings) async {
|
||||
for (final mapping in mappings) {
|
||||
final item = AssetMetadataUpsertItemDto(
|
||||
key: kMobileMetadataKey,
|
||||
value: RemoteAssetMobileAppMetadata(
|
||||
cloudId: mapping.localAsset.cloudId,
|
||||
createdAt: mapping.localAsset.createdAt.toIso8601String(),
|
||||
adjustmentTime: mapping.localAsset.adjustmentTime?.toIso8601String(),
|
||||
latitude: mapping.localAsset.latitude?.toString(),
|
||||
longitude: mapping.localAsset.longitude?.toString(),
|
||||
),
|
||||
);
|
||||
try {
|
||||
await assetsApi.updateAssetMetadata(mapping.remoteAssetId, AssetMetadataUpsertDto(items: [item]));
|
||||
} catch (error, stack) {
|
||||
Logger('migrateCloudIds').warning('Failed to update metadata for asset ${mapping.remoteAssetId}', error, stack);
|
||||
Future<void> _processCloudIdMappingsInBatches(
|
||||
Drift drift,
|
||||
String userId,
|
||||
AssetsApi assetsApi,
|
||||
bool canBulkUpdate,
|
||||
Logger logger,
|
||||
) async {
|
||||
const pageSize = 20000;
|
||||
String? lastLocalId;
|
||||
final seenRemoteAssetIds = <String>{};
|
||||
|
||||
while (true) {
|
||||
final mappings = await _fetchCloudIdMappings(drift, userId, pageSize, lastLocalId);
|
||||
if (mappings.isEmpty) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _bulkUpdateCloudIds(AssetsApi assetsApi, List<_CloudIdMapping> mappings) async {
|
||||
const batchSize = 10000;
|
||||
for (int i = 0; i < mappings.length; i += batchSize) {
|
||||
final endIndex = (i + batchSize > mappings.length) ? mappings.length : i + batchSize;
|
||||
final batch = mappings.sublist(i, endIndex);
|
||||
final items = <AssetMetadataBulkUpsertItemDto>[];
|
||||
for (final mapping in batch) {
|
||||
items.add(
|
||||
AssetMetadataBulkUpsertItemDto(
|
||||
assetId: mapping.remoteAssetId,
|
||||
key: kMobileMetadataKey,
|
||||
value: RemoteAssetMobileAppMetadata(
|
||||
cloudId: mapping.localAsset.cloudId,
|
||||
createdAt: mapping.localAsset.createdAt.toIso8601String(),
|
||||
adjustmentTime: mapping.localAsset.adjustmentTime?.toIso8601String(),
|
||||
latitude: mapping.localAsset.latitude?.toString(),
|
||||
longitude: mapping.localAsset.longitude?.toString(),
|
||||
for (final mapping in mappings) {
|
||||
if (seenRemoteAssetIds.add(mapping.remoteAssetId)) {
|
||||
items.add(
|
||||
AssetMetadataBulkUpsertItemDto(
|
||||
assetId: mapping.remoteAssetId,
|
||||
key: kMobileMetadataKey,
|
||||
value: RemoteAssetMobileAppMetadata(
|
||||
cloudId: mapping.localAsset.cloudId,
|
||||
createdAt: mapping.localAsset.createdAt.toIso8601String(),
|
||||
adjustmentTime: mapping.localAsset.adjustmentTime?.toIso8601String(),
|
||||
latitude: mapping.localAsset.latitude?.toString(),
|
||||
longitude: mapping.localAsset.longitude?.toString(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
} else {
|
||||
logger.fine('Duplicate remote asset ID found: ${mapping.remoteAssetId}. Skipping duplicate entry.');
|
||||
}
|
||||
}
|
||||
|
||||
if (items.isNotEmpty) {
|
||||
if (canBulkUpdate) {
|
||||
await _bulkUpdateCloudIds(assetsApi, items);
|
||||
} else {
|
||||
await _sequentialUpdateCloudIds(assetsApi, items);
|
||||
}
|
||||
}
|
||||
|
||||
lastLocalId = mappings.last.localAsset.id;
|
||||
if (mappings.length < pageSize) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sequentialUpdateCloudIds(AssetsApi assetsApi, List<AssetMetadataBulkUpsertItemDto> items) async {
|
||||
for (final item in items) {
|
||||
final upsertItem = AssetMetadataUpsertItemDto(key: item.key, value: item.value);
|
||||
try {
|
||||
await assetsApi.updateBulkAssetMetadata(AssetMetadataBulkUpsertDto(items: items));
|
||||
await assetsApi.updateAssetMetadata(item.assetId, AssetMetadataUpsertDto(items: [upsertItem]));
|
||||
} catch (error, stack) {
|
||||
Logger('migrateCloudIds').warning('Failed to bulk update metadata', error, stack);
|
||||
Logger('migrateCloudIds').warning('Failed to update metadata for asset ${item.assetId}', error, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _bulkUpdateCloudIds(AssetsApi assetsApi, List<AssetMetadataBulkUpsertItemDto> items) async {
|
||||
try {
|
||||
await assetsApi.updateBulkAssetMetadata(AssetMetadataBulkUpsertDto(items: items));
|
||||
} catch (error, stack) {
|
||||
Logger('migrateCloudIds').warning('Failed to bulk update metadata', error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _populateCloudIds(Drift drift) async {
|
||||
final query = drift.localAssetEntity.selectOnly()
|
||||
..addColumns([drift.localAssetEntity.id])
|
||||
@@ -141,31 +150,38 @@ Future<void> _populateCloudIds(Drift drift) async {
|
||||
|
||||
typedef _CloudIdMapping = ({String remoteAssetId, LocalAsset localAsset});
|
||||
|
||||
Future<List<_CloudIdMapping>> _fetchCloudIdMappings(Drift drift, String userId) async {
|
||||
Future<List<_CloudIdMapping>> _fetchCloudIdMappings(Drift drift, String userId, int limit, String? lastLocalId) async {
|
||||
final query =
|
||||
drift.remoteAssetEntity.select().join([
|
||||
leftOuterJoin(
|
||||
drift.localAssetEntity,
|
||||
drift.localAssetEntity.checksum.equalsExp(drift.remoteAssetEntity.checksum),
|
||||
),
|
||||
leftOuterJoin(
|
||||
drift.remoteAssetCloudIdEntity,
|
||||
drift.remoteAssetEntity.id.equalsExp(drift.remoteAssetCloudIdEntity.assetId),
|
||||
useColumns: false,
|
||||
),
|
||||
])..where(
|
||||
// Only select assets that have a local cloud ID but either no remote cloud ID or a mismatched eTag
|
||||
drift.localAssetEntity.id.isNotNull() &
|
||||
drift.localAssetEntity.iCloudId.isNotNull() &
|
||||
drift.remoteAssetEntity.ownerId.equals(userId) &
|
||||
// Skip locked assets as we cannot update them without unlocking first
|
||||
drift.remoteAssetEntity.visibility.isNotValue(AssetVisibility.locked.index) &
|
||||
(drift.remoteAssetCloudIdEntity.cloudId.isNull() |
|
||||
drift.remoteAssetCloudIdEntity.adjustmentTime.isNotExp(drift.localAssetEntity.adjustmentTime) |
|
||||
drift.remoteAssetCloudIdEntity.latitude.isNotExp(drift.localAssetEntity.latitude) |
|
||||
drift.remoteAssetCloudIdEntity.longitude.isNotExp(drift.localAssetEntity.longitude) |
|
||||
drift.remoteAssetCloudIdEntity.createdAt.isNotExp(drift.localAssetEntity.createdAt)),
|
||||
);
|
||||
drift.localAssetEntity.select().join([
|
||||
innerJoin(
|
||||
drift.remoteAssetEntity,
|
||||
drift.localAssetEntity.checksum.equalsExp(drift.remoteAssetEntity.checksum),
|
||||
),
|
||||
leftOuterJoin(
|
||||
drift.remoteAssetCloudIdEntity,
|
||||
drift.remoteAssetEntity.id.equalsExp(drift.remoteAssetCloudIdEntity.assetId),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..where(
|
||||
// Only select assets that have a local cloud ID but either no remote cloud ID or a mismatched eTag
|
||||
drift.localAssetEntity.iCloudId.isNotNull() &
|
||||
drift.remoteAssetEntity.ownerId.equals(userId) &
|
||||
// Skip locked assets as we cannot update them without unlocking first
|
||||
drift.remoteAssetEntity.visibility.isNotValue(AssetVisibility.locked.index) &
|
||||
(drift.remoteAssetCloudIdEntity.cloudId.isNull() |
|
||||
drift.remoteAssetCloudIdEntity.adjustmentTime.isNotExp(drift.localAssetEntity.adjustmentTime) |
|
||||
drift.remoteAssetCloudIdEntity.latitude.isNotExp(drift.localAssetEntity.latitude) |
|
||||
drift.remoteAssetCloudIdEntity.longitude.isNotExp(drift.localAssetEntity.longitude) |
|
||||
drift.remoteAssetCloudIdEntity.createdAt.isNotExp(drift.localAssetEntity.createdAt)),
|
||||
)
|
||||
..orderBy([OrderingTerm.asc(drift.localAssetEntity.id)])
|
||||
..limit(limit);
|
||||
|
||||
if (lastLocalId != null) {
|
||||
query.where(drift.localAssetEntity.id.isBiggerThanValue(lastLocalId));
|
||||
}
|
||||
|
||||
return query.map((row) {
|
||||
return (
|
||||
remoteAssetId: row.read(drift.remoteAssetEntity.id)!,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)')
|
||||
class RemoteAssetCloudIdEntity extends Table with DriftDefaultsMixin {
|
||||
TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
|
||||
@@ -403,6 +403,10 @@ typedef $$RemoteAssetCloudIdEntityTableProcessedTableManager =
|
||||
i1.RemoteAssetCloudIdEntityData,
|
||||
i0.PrefetchHooks Function({bool assetId})
|
||||
>;
|
||||
i0.Index get idxRemoteAssetCloudId => i0.Index(
|
||||
'idx_remote_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
|
||||
);
|
||||
|
||||
class $RemoteAssetCloudIdEntityTable extends i2.RemoteAssetCloudIdEntity
|
||||
with
|
||||
|
||||
@@ -97,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 17;
|
||||
int get schemaVersion => 18;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -204,6 +204,9 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||
from16To17: (m, v17) async {
|
||||
await m.addColumn(v17.remoteAssetEntity, v17.remoteAssetEntity.isEdited);
|
||||
},
|
||||
from17To18: (m, v18) async {
|
||||
await m.createIndex(v18.idxRemoteAssetCloudId);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
i11.idxLatLng,
|
||||
i14.idxRemoteAssetCloudId,
|
||||
i20.idxTrashedLocalAssetChecksum,
|
||||
i20.idxTrashedLocalAssetAlbum,
|
||||
];
|
||||
|
||||
@@ -7408,6 +7408,455 @@ i1.GeneratedColumn<bool> _column_101(String aliasedName) =>
|
||||
),
|
||||
defaultValue: const CustomExpression('0'),
|
||||
);
|
||||
|
||||
final class Schema18 extends i0.VersionedSchema {
|
||||
Schema18({required super.database}) : super(version: 18);
|
||||
@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,
|
||||
idxRemoteAssetCloudId,
|
||||
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 idxRemoteAssetCloudId = i1.Index(
|
||||
'idx_remote_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
|
||||
);
|
||||
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)',
|
||||
);
|
||||
}
|
||||
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
@@ -7425,6 +7874,7 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
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,
|
||||
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
@@ -7508,6 +7958,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from16To17(migrator, schema);
|
||||
return 17;
|
||||
case 17:
|
||||
final schema = Schema18(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from17To18(migrator, schema);
|
||||
return 18;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
@@ -7531,6 +7986,7 @@ i1.OnUpgrade stepByStep({
|
||||
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,
|
||||
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
|
||||
}) => i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
@@ -7549,5 +8005,6 @@ i1.OnUpgrade stepByStep({
|
||||
from14To15: from14To15,
|
||||
from15To16: from15To16,
|
||||
from16To17: from16To17,
|
||||
from17To18: from17To18,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -204,34 +204,23 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||
return query.map((row) => row.toDto()).get();
|
||||
}
|
||||
|
||||
Future<Map<String, String>> getHashMappingFromCloudId() async {
|
||||
final query =
|
||||
_db.localAssetEntity.selectOnly().join([
|
||||
leftOuterJoin(
|
||||
_db.remoteAssetCloudIdEntity,
|
||||
_db.localAssetEntity.iCloudId.equalsExp(_db.remoteAssetCloudIdEntity.cloudId),
|
||||
useColumns: false,
|
||||
),
|
||||
leftOuterJoin(
|
||||
_db.remoteAssetEntity,
|
||||
_db.remoteAssetCloudIdEntity.assetId.equalsExp(_db.remoteAssetEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..addColumns([_db.localAssetEntity.id, _db.remoteAssetEntity.checksum])
|
||||
..where(
|
||||
_db.remoteAssetCloudIdEntity.cloudId.isNotNull() &
|
||||
_db.localAssetEntity.checksum.isNull() &
|
||||
((_db.remoteAssetCloudIdEntity.adjustmentTime.isExp(_db.localAssetEntity.adjustmentTime)) &
|
||||
(_db.remoteAssetCloudIdEntity.latitude.isExp(_db.localAssetEntity.latitude)) &
|
||||
(_db.remoteAssetCloudIdEntity.longitude.isExp(_db.localAssetEntity.longitude)) &
|
||||
(_db.remoteAssetCloudIdEntity.createdAt.isExp(_db.localAssetEntity.createdAt))),
|
||||
);
|
||||
final mapping = await query
|
||||
.map(
|
||||
(row) => (assetId: row.read(_db.localAssetEntity.id)!, checksum: row.read(_db.remoteAssetEntity.checksum)!),
|
||||
)
|
||||
.get();
|
||||
return {for (final entry in mapping) entry.assetId: entry.checksum};
|
||||
Future<void> reconcileHashesFromCloudId() async {
|
||||
await _db.customUpdate(
|
||||
'''
|
||||
UPDATE local_asset_entity
|
||||
SET checksum = remote_asset_entity.checksum
|
||||
FROM remote_asset_cloud_id_entity
|
||||
INNER JOIN remote_asset_entity
|
||||
ON remote_asset_cloud_id_entity.asset_id = remote_asset_entity.id
|
||||
WHERE local_asset_entity.i_cloud_id = remote_asset_cloud_id_entity.cloud_id
|
||||
AND local_asset_entity.checksum IS NULL
|
||||
AND remote_asset_cloud_id_entity.adjustment_time IS local_asset_entity.adjustment_time
|
||||
AND remote_asset_cloud_id_entity.latitude IS local_asset_entity.latitude
|
||||
AND remote_asset_cloud_id_entity.longitude IS local_asset_entity.longitude
|
||||
AND remote_asset_cloud_id_entity.created_at IS local_asset_entity.created_at
|
||||
''',
|
||||
updates: {_db.localAssetEntity},
|
||||
updateKind: UpdateKind.update,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,8 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
_resumeBackup(backupProvider);
|
||||
}),
|
||||
_resumeBackup(backupProvider),
|
||||
backgroundManager.syncCloudIds(),
|
||||
// TODO: Bring back when the soft freeze issue is addressed
|
||||
// backgroundManager.syncCloudIds(),
|
||||
]);
|
||||
} else {
|
||||
await backgroundManager.hashAssets();
|
||||
|
||||
@@ -160,7 +160,8 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
_resumeBackup();
|
||||
}),
|
||||
_resumeBackup(),
|
||||
_safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"),
|
||||
// TODO: Bring back when the soft freeze issue is addressed
|
||||
// _safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"),
|
||||
]);
|
||||
} else {
|
||||
await _safeRun(backgroundManager.hashAssets(), "hashAssets");
|
||||
|
||||
@@ -33,7 +33,7 @@ final deepLinkServiceProvider = Provider(
|
||||
ref.watch(beta_asset_provider.assetServiceProvider),
|
||||
ref.watch(remoteAlbumServiceProvider),
|
||||
ref.watch(driftMemoryServiceProvider),
|
||||
ref.watch(currentUserProvider.select((user) => user!)),
|
||||
ref.watch(currentUserProvider),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -51,7 +51,7 @@ class DeepLinkService {
|
||||
final RemoteAlbumService _betaRemoteAlbumService;
|
||||
final DriftMemoryService _betaMemoryServiceProvider;
|
||||
|
||||
final UserDto _currentUser;
|
||||
final UserDto? _currentUser;
|
||||
|
||||
const DeepLinkService(
|
||||
this._memoryService,
|
||||
@@ -131,9 +131,18 @@ class DeepLinkService {
|
||||
if (Store.isBetaTimelineEnabled) {
|
||||
List<DriftMemory> memories = [];
|
||||
|
||||
memories = memoryId == null
|
||||
? await _betaMemoryServiceProvider.getMemoryLane(_currentUser.id)
|
||||
: [await _betaMemoryServiceProvider.get(memoryId)].whereType<DriftMemory>().toList();
|
||||
if (memoryId == null) {
|
||||
if (_currentUser == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
memories = await _betaMemoryServiceProvider.getMemoryLane(_currentUser.id);
|
||||
} else {
|
||||
final memory = await _betaMemoryServiceProvider.get(memoryId);
|
||||
if (memory != null) {
|
||||
memories = [memory];
|
||||
}
|
||||
}
|
||||
|
||||
if (memories.isEmpty) {
|
||||
return null;
|
||||
|
||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 2.4.1
|
||||
- API version: 2.5.0
|
||||
- Generator version: 7.8.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 2.4.1+3030
|
||||
version: 2.5.0+3031
|
||||
|
||||
environment:
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
|
||||
@@ -33,7 +33,7 @@ void main() {
|
||||
registerFallbackValue(LocalAssetStub.image1);
|
||||
registerFallbackValue(<String, String>{});
|
||||
|
||||
when(() => mockAssetRepo.getHashMappingFromCloudId()).thenAnswer((_) async => {});
|
||||
when(() => mockAssetRepo.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
|
||||
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
||||
});
|
||||
|
||||
@@ -191,5 +191,4 @@ void main() {
|
||||
verify(() => mockNativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
4
mobile/test/drift/main/generated/schema.dart
generated
4
mobile/test/drift/main/generated/schema.dart
generated
@@ -20,6 +20,7 @@ import 'schema_v14.dart' as v14;
|
||||
import 'schema_v15.dart' as v15;
|
||||
import 'schema_v16.dart' as v16;
|
||||
import 'schema_v17.dart' as v17;
|
||||
import 'schema_v18.dart' as v18;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
@@ -59,6 +60,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
return v16.DatabaseAtV16(db);
|
||||
case 17:
|
||||
return v17.DatabaseAtV17(db);
|
||||
case 18:
|
||||
return v18.DatabaseAtV18(db);
|
||||
default:
|
||||
throw MissingSchemaException(version, versions);
|
||||
}
|
||||
@@ -82,5 +85,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
15,
|
||||
16,
|
||||
17,
|
||||
18,
|
||||
];
|
||||
}
|
||||
|
||||
8342
mobile/test/drift/main/generated/schema_v18.dart
generated
Normal file
8342
mobile/test/drift/main/generated/schema_v18.dart
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/drift.dart' hide isNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
@@ -8,11 +8,13 @@ import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.d
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
|
||||
void main() {
|
||||
final now = DateTime(2024, 1, 15);
|
||||
late Drift db;
|
||||
late DriftLocalAssetRepository repository;
|
||||
|
||||
@@ -25,68 +27,98 @@ void main() {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
Future<void> insertLocalAsset({
|
||||
required String id,
|
||||
String? checksum,
|
||||
DateTime? createdAt,
|
||||
AssetType type = AssetType.image,
|
||||
bool isFavorite = false,
|
||||
String? iCloudId,
|
||||
DateTime? adjustmentTime,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
}) async {
|
||||
final created = createdAt ?? now;
|
||||
await db
|
||||
.into(db.localAssetEntity)
|
||||
.insert(
|
||||
LocalAssetEntityCompanion.insert(
|
||||
id: id,
|
||||
name: 'asset_$id.jpg',
|
||||
checksum: Value(checksum),
|
||||
type: type,
|
||||
createdAt: Value(created),
|
||||
updatedAt: Value(created),
|
||||
isFavorite: Value(isFavorite),
|
||||
iCloudId: Value(iCloudId),
|
||||
adjustmentTime: Value(adjustmentTime),
|
||||
latitude: Value(latitude),
|
||||
longitude: Value(longitude),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> insertRemoteAsset({
|
||||
required String id,
|
||||
required String checksum,
|
||||
required String ownerId,
|
||||
DateTime? deletedAt,
|
||||
}) async {
|
||||
await db
|
||||
.into(db.remoteAssetEntity)
|
||||
.insert(
|
||||
RemoteAssetEntityCompanion.insert(
|
||||
id: id,
|
||||
name: 'remote_$id.jpg',
|
||||
checksum: checksum,
|
||||
type: AssetType.image,
|
||||
createdAt: Value(now),
|
||||
updatedAt: Value(now),
|
||||
ownerId: ownerId,
|
||||
visibility: AssetVisibility.timeline,
|
||||
deletedAt: Value(deletedAt),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> insertRemoteAssetCloudId({
|
||||
required String assetId,
|
||||
required String? cloudId,
|
||||
DateTime? createdAt,
|
||||
DateTime? adjustmentTime,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
}) async {
|
||||
await db
|
||||
.into(db.remoteAssetCloudIdEntity)
|
||||
.insert(
|
||||
RemoteAssetCloudIdEntityCompanion.insert(
|
||||
assetId: assetId,
|
||||
cloudId: Value(cloudId),
|
||||
createdAt: Value(createdAt),
|
||||
adjustmentTime: Value(adjustmentTime),
|
||||
latitude: Value(latitude),
|
||||
longitude: Value(longitude),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> insertUser(String id, String email) async {
|
||||
await db.into(db.userEntity).insert(UserEntityCompanion.insert(id: id, email: email, name: email));
|
||||
}
|
||||
|
||||
group('getRemovalCandidates', () {
|
||||
final userId = 'user-123';
|
||||
final otherUserId = 'user-456';
|
||||
final now = DateTime(2024, 1, 15);
|
||||
final cutoffDate = DateTime(2024, 1, 10);
|
||||
final beforeCutoff = DateTime(2024, 1, 5);
|
||||
final afterCutoff = DateTime(2024, 1, 12);
|
||||
|
||||
Future<void> insertUser(String id, String email) async {
|
||||
await db.into(db.userEntity).insert(UserEntityCompanion.insert(id: id, email: email, name: email));
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
await insertUser(userId, 'user@test.com');
|
||||
await insertUser(otherUserId, 'other@test.com');
|
||||
});
|
||||
|
||||
Future<void> insertLocalAsset({
|
||||
required String id,
|
||||
required String checksum,
|
||||
required DateTime createdAt,
|
||||
required AssetType type,
|
||||
required bool isFavorite,
|
||||
}) async {
|
||||
await db
|
||||
.into(db.localAssetEntity)
|
||||
.insert(
|
||||
LocalAssetEntityCompanion.insert(
|
||||
id: id,
|
||||
name: 'asset_$id.jpg',
|
||||
checksum: Value(checksum),
|
||||
type: type,
|
||||
createdAt: Value(createdAt),
|
||||
updatedAt: Value(createdAt),
|
||||
isFavorite: Value(isFavorite),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> insertRemoteAsset({
|
||||
required String id,
|
||||
required String checksum,
|
||||
required String ownerId,
|
||||
DateTime? deletedAt,
|
||||
}) async {
|
||||
await db
|
||||
.into(db.remoteAssetEntity)
|
||||
.insert(
|
||||
RemoteAssetEntityCompanion.insert(
|
||||
id: id,
|
||||
name: 'remote_$id.jpg',
|
||||
checksum: checksum,
|
||||
type: AssetType.image,
|
||||
createdAt: Value(now),
|
||||
updatedAt: Value(now),
|
||||
ownerId: ownerId,
|
||||
visibility: AssetVisibility.timeline,
|
||||
deletedAt: Value(deletedAt),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> insertLocalAlbum({required String id, required String name, required bool isIosSharedAlbum}) async {
|
||||
await db
|
||||
.into(db.localAlbumEntity)
|
||||
@@ -211,11 +243,7 @@ void main() {
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
|
||||
|
||||
final result = await repository.getRemovalCandidates(
|
||||
userId,
|
||||
cutoffDate,
|
||||
keepMediaType: AssetKeepType.photosOnly,
|
||||
);
|
||||
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepMediaType: AssetKeepType.photosOnly);
|
||||
|
||||
expect(result.assets.length, 1);
|
||||
expect(result.assets[0].id, 'local-video');
|
||||
@@ -243,11 +271,7 @@ void main() {
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
|
||||
|
||||
final result = await repository.getRemovalCandidates(
|
||||
userId,
|
||||
cutoffDate,
|
||||
keepMediaType: AssetKeepType.videosOnly,
|
||||
);
|
||||
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepMediaType: AssetKeepType.videosOnly);
|
||||
|
||||
expect(result.assets.length, 1);
|
||||
expect(result.assets[0].id, 'local-photo');
|
||||
@@ -507,11 +531,7 @@ void main() {
|
||||
await insertRemoteAsset(id: 'remote-3', checksum: 'checksum-3', ownerId: userId);
|
||||
await insertLocalAlbumAsset(albumId: 'album-3', assetId: 'local-3');
|
||||
|
||||
final result = await repository.getRemovalCandidates(
|
||||
userId,
|
||||
cutoffDate,
|
||||
keepAlbumIds: {'album-1', 'album-2'},
|
||||
);
|
||||
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {'album-1', 'album-2'});
|
||||
|
||||
expect(result.assets.length, 1);
|
||||
expect(result.assets[0].id, 'local-3');
|
||||
@@ -644,4 +664,313 @@ void main() {
|
||||
expect(result.assets[0].id, 'local-video');
|
||||
});
|
||||
});
|
||||
|
||||
group('reconcileHashesFromCloudId', () {
|
||||
final userId = 'user-123';
|
||||
final createdAt = DateTime(2024, 1, 10);
|
||||
final adjustmentTime = DateTime(2024, 1, 11);
|
||||
const latitude = 37.7749;
|
||||
const longitude = -122.4194;
|
||||
|
||||
setUp(() async {
|
||||
await insertUser(userId, 'user@test.com');
|
||||
});
|
||||
|
||||
test('updates local asset checksum when all metadata matches', () async {
|
||||
await insertLocalAsset(
|
||||
id: 'local-1',
|
||||
checksum: null,
|
||||
iCloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||
|
||||
await insertRemoteAssetCloudId(
|
||||
assetId: 'remote-1',
|
||||
cloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await repository.reconcileHashesFromCloudId();
|
||||
|
||||
final updated = await repository.getById('local-1');
|
||||
expect(updated?.checksum, 'hash-abc123');
|
||||
});
|
||||
|
||||
test('does not update when local asset already has checksum', () async {
|
||||
await insertLocalAsset(
|
||||
id: 'local-1',
|
||||
checksum: 'existing-checksum',
|
||||
iCloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||
|
||||
await insertRemoteAssetCloudId(
|
||||
assetId: 'remote-1',
|
||||
cloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await repository.reconcileHashesFromCloudId();
|
||||
|
||||
final updated = await repository.getById('local-1');
|
||||
expect(updated?.checksum, 'existing-checksum');
|
||||
});
|
||||
|
||||
test('does not update when adjustment_time does not match', () async {
|
||||
await insertLocalAsset(
|
||||
id: 'local-1',
|
||||
checksum: null,
|
||||
iCloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||
|
||||
await insertRemoteAssetCloudId(
|
||||
assetId: 'remote-1',
|
||||
cloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: DateTime(2024, 1, 12),
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await repository.reconcileHashesFromCloudId();
|
||||
|
||||
final updated = await repository.getById('local-1');
|
||||
expect(updated?.checksum, isNull);
|
||||
});
|
||||
|
||||
test('does not update when latitude does not match', () async {
|
||||
await insertLocalAsset(
|
||||
id: 'local-1',
|
||||
checksum: null,
|
||||
iCloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||
|
||||
await insertRemoteAssetCloudId(
|
||||
assetId: 'remote-1',
|
||||
cloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: 40.7128,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await repository.reconcileHashesFromCloudId();
|
||||
|
||||
final updated = await repository.getById('local-1');
|
||||
expect(updated?.checksum, isNull);
|
||||
});
|
||||
|
||||
test('does not update when longitude does not match', () async {
|
||||
await insertLocalAsset(
|
||||
id: 'local-1',
|
||||
checksum: null,
|
||||
iCloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||
|
||||
await insertRemoteAssetCloudId(
|
||||
assetId: 'remote-1',
|
||||
cloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: -74.0060,
|
||||
);
|
||||
|
||||
await repository.reconcileHashesFromCloudId();
|
||||
|
||||
final updated = await repository.getById('local-1');
|
||||
expect(updated?.checksum, isNull);
|
||||
});
|
||||
|
||||
test('does not update when createdAt does not match', () async {
|
||||
await insertLocalAsset(
|
||||
id: 'local-1',
|
||||
checksum: null,
|
||||
iCloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||
|
||||
await insertRemoteAssetCloudId(
|
||||
assetId: 'remote-1',
|
||||
cloudId: 'cloud-123',
|
||||
createdAt: DateTime(2024, 1, 5),
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await repository.reconcileHashesFromCloudId();
|
||||
|
||||
final updated = await repository.getById('local-1');
|
||||
expect(updated?.checksum, isNull);
|
||||
});
|
||||
|
||||
test('does not update when iCloudId is null', () async {
|
||||
await insertLocalAsset(
|
||||
id: 'local-1',
|
||||
checksum: null,
|
||||
iCloudId: null,
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||
|
||||
await insertRemoteAssetCloudId(
|
||||
assetId: 'remote-1',
|
||||
cloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await repository.reconcileHashesFromCloudId();
|
||||
|
||||
final updated = await repository.getById('local-1');
|
||||
expect(updated?.checksum, isNull);
|
||||
});
|
||||
|
||||
test('does not update when cloudId does not match iCloudId', () async {
|
||||
await insertLocalAsset(
|
||||
id: 'local-1',
|
||||
checksum: null,
|
||||
iCloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||
|
||||
await insertRemoteAssetCloudId(
|
||||
assetId: 'remote-1',
|
||||
cloudId: 'cloud-456',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await repository.reconcileHashesFromCloudId();
|
||||
|
||||
final updated = await repository.getById('local-1');
|
||||
expect(updated?.checksum, isNull);
|
||||
});
|
||||
|
||||
test('handles partial null metadata fields matching correctly', () async {
|
||||
await insertLocalAsset(
|
||||
id: 'local-1',
|
||||
checksum: null,
|
||||
iCloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: null,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||
|
||||
await insertRemoteAssetCloudId(
|
||||
assetId: 'remote-1',
|
||||
cloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: null,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await repository.reconcileHashesFromCloudId();
|
||||
|
||||
final updated = await repository.getById('local-1');
|
||||
expect(updated?.checksum, 'hash-abc123');
|
||||
});
|
||||
|
||||
test('does not update when one has null and other has value', () async {
|
||||
await insertLocalAsset(
|
||||
id: 'local-1',
|
||||
checksum: null,
|
||||
iCloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: null,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||
|
||||
await insertRemoteAssetCloudId(
|
||||
assetId: 'remote-1',
|
||||
cloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await repository.reconcileHashesFromCloudId();
|
||||
|
||||
final updated = await repository.getById('local-1');
|
||||
expect(updated?.checksum, isNull);
|
||||
});
|
||||
|
||||
test('handles no matching assets gracefully', () async {
|
||||
await insertLocalAsset(
|
||||
id: 'local-1',
|
||||
checksum: null,
|
||||
iCloudId: 'cloud-999',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await repository.reconcileHashesFromCloudId();
|
||||
|
||||
final updated = await repository.getById('local-1');
|
||||
expect(updated?.checksum, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14951,7 +14951,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "2.4.1",
|
||||
"version": "2.5.0",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "2.4.1",
|
||||
"version": "2.5.0",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"type": "module",
|
||||
"main": "./build/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 2.4.1
|
||||
* 2.5.0
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "2.4.1",
|
||||
"version": "2.5.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "2.4.1",
|
||||
"version": "2.5.0",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { getAssetInfo, getAssetOcr, type AssetOcrResponseDto, type AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
const defaultSerializer = <K>(params: K) => JSON.stringify(params);
|
||||
@@ -35,6 +36,13 @@ class AssetCacheManager {
|
||||
#assetCache = new AsyncCache<AssetResponseDto>();
|
||||
#ocrCache = new AsyncCache<AssetOcrResponseDto[]>();
|
||||
|
||||
constructor() {
|
||||
eventManager.on('AssetEditsApplied', () => {
|
||||
this.#assetCache.clear();
|
||||
this.#ocrCache.clear();
|
||||
});
|
||||
}
|
||||
|
||||
async getAsset(assetIdentifier: { key?: string; slug?: string; id: string }, updateCache = true) {
|
||||
return this.#assetCache.getOrFetch(assetIdentifier, getAssetInfo, defaultSerializer, updateCache);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import TransformTool from '$lib/components/asset-viewer/editor/transform-tool/transform-tool.svelte';
|
||||
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { waitForWebsocketEvent } from '$lib/stores/websocket';
|
||||
import { editAsset, removeAssetEdits, type AssetEditsDto, type AssetResponseDto } from '@immich/sdk';
|
||||
import { ConfirmModal, modalManager, toastManager } from '@immich/ui';
|
||||
@@ -110,25 +111,29 @@ export class EditManager {
|
||||
this.isApplyingEdits = true;
|
||||
|
||||
const edits = this.tools.flatMap((tool) => tool.manager.edits);
|
||||
if (!this.currentAsset) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const assetId = this.currentAsset.id;
|
||||
|
||||
try {
|
||||
// Setup the websocket listener before sending the edit request
|
||||
const editCompleted = waitForWebsocketEvent(
|
||||
'AssetEditReadyV1',
|
||||
(event) => event.asset.id === this.currentAsset!.id,
|
||||
10_000,
|
||||
);
|
||||
const editCompleted = waitForWebsocketEvent('AssetEditReadyV1', (event) => event.asset.id === assetId, 10_000);
|
||||
|
||||
await (edits.length === 0
|
||||
? removeAssetEdits({ id: this.currentAsset!.id })
|
||||
? removeAssetEdits({ id: assetId })
|
||||
: editAsset({
|
||||
id: this.currentAsset!.id,
|
||||
id: assetId,
|
||||
assetEditActionListDto: {
|
||||
edits,
|
||||
},
|
||||
}));
|
||||
|
||||
await editCompleted;
|
||||
|
||||
eventManager.emit('AssetEditsApplied', assetId);
|
||||
|
||||
toastManager.success('Edits applied successfully');
|
||||
this.hasAppliedEdits = true;
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export type Events = {
|
||||
AssetReplace: [{ oldAssetId: string; newAssetId: string }];
|
||||
AssetsArchive: [string[]];
|
||||
AssetsDelete: [string[]];
|
||||
AssetEditsApplied: [string];
|
||||
|
||||
AlbumAddAssets: [];
|
||||
AlbumUpdate: [AlbumResponseDto];
|
||||
|
||||
@@ -6,8 +6,10 @@ class UploadManager {
|
||||
mediaTypes = $state<ServerMediaTypesResponseDto>({ image: [], sidecar: [], video: [] });
|
||||
|
||||
constructor() {
|
||||
eventManager.on('AppInit', () => this.#loadExtensions());
|
||||
eventManager.on('AuthLogout', () => this.reset());
|
||||
eventManager.onMany({
|
||||
AppInit: () => this.#loadExtensions(),
|
||||
AuthLogout: () => this.reset(),
|
||||
});
|
||||
}
|
||||
|
||||
reset() {
|
||||
|
||||
@@ -23,8 +23,10 @@ class MemoryStoreSvelte {
|
||||
#loading: Promise<void> | undefined;
|
||||
|
||||
constructor() {
|
||||
eventManager.on('AuthLogout', () => this.clearCache());
|
||||
eventManager.on('AuthUserLoaded', () => this.initialize());
|
||||
eventManager.onMany({
|
||||
AuthLogout: () => this.clearCache(),
|
||||
AuthUserLoaded: () => this.initialize(),
|
||||
});
|
||||
}
|
||||
|
||||
ready() {
|
||||
|
||||
@@ -8,8 +8,10 @@ class NotificationStore {
|
||||
notifications = $state<NotificationDto[]>([]);
|
||||
|
||||
constructor() {
|
||||
eventManager.on('AuthLogin', () => this.refresh());
|
||||
eventManager.on('AuthLogout', () => this.clear());
|
||||
eventManager.onMany({
|
||||
AuthLogin: () => this.refresh(),
|
||||
AuthLogout: () => this.clear(),
|
||||
});
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
|
||||
@@ -30,6 +30,17 @@ export class BaseEventManager<Events extends EventMap> {
|
||||
};
|
||||
}
|
||||
|
||||
onMany(subscriptions: { [T in keyof Events]?: EventCallback<Events, T> }) {
|
||||
const cleanups = Object.entries(subscriptions).map(([event, callback]) =>
|
||||
this.on(event as keyof Events, callback as EventCallback<Events, keyof Events>),
|
||||
);
|
||||
return () => {
|
||||
for (const cleanup of cleanups) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
emit<T extends keyof Events>(event: T, ...params: Events[T]) {
|
||||
const listeners = this.getListeners(event);
|
||||
for (const listener of listeners) {
|
||||
|
||||
Reference in New Issue
Block a user