Compare commits

..

150 Commits

Author SHA1 Message Date
idubnori
ebed026d5a Merge branch 'main' into feature/bottom-buttons-order 2025-12-18 01:38:03 +09:00
Daniel Dietzler
c15998e805 fix: asset update race condition (#24384)
* fix: asset update race condition

* fix: asset update race condition

* single statement

* update sql

* missed one

* fix `none` handling

* fix: tests

* chore: simplify update all assets

* fix: updating lockable properties

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2025-12-17 09:23:13 -06:00
idubnori
e966ee5544 refactor: replace copyWith method with withButtonPosition for ActionButtonContext 2025-12-17 23:37:36 +09:00
idubnori
de36f9a215 refactor: update bottom bar button type assertions for consistency and clarity 2025-12-17 21:52:28 +09:00
idubnori
8ceb68e240 test: verify kebab menu does not contain bottom bar buttons in action button types 2025-12-17 21:52:28 +09:00
idubnori
75734b45a0 fix: pass iconOnly and menuItem parameters to EditImageActionButton for enhanced customization 2025-12-17 21:52:28 +09:00
idubnori
a9bee498c4 fix: add copyWith method to ActionButtonContext for improved context management 2025-12-17 21:52:28 +09:00
idubnori
6b4cc4e65e fix: adjust action button layout for better alignment 2025-12-17 21:52:28 +09:00
idubnori
e49239e7e9 refactor: clean up 2025-12-17 21:52:28 +09:00
idubnori
939c222728 feat(mobile): add ButtonPosition enum and update action button context 2025-12-17 21:52:28 +09:00
idubnori
c38ecab1a6 refactor: action button visibility logic and kebab menu handling 2025-12-17 21:52:28 +09:00
idubnori
76fd68957c feat(mobile): improve action button order 2025-12-17 21:52:28 +09:00
Alex
f0b069adb9 fix: shared link expiration and small styling (#24566)
* fix: shared link expiration and small styling

* Use text color of enable/disable shared link properties
2025-12-16 16:41:12 +00:00
Hai Sullivan
276d02e12b fix(mobile): better UI for metadata panel (#24428)
* change drag bar and animation position

* ensure bottom bar is below the metadata panel - move the bottom bar from bottomNavigationBar into the Stack

* change some parameters

* add background color for night mode

* background color

* change default position

* minor changes

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-16 16:25:01 +00:00
Yaros
ded9535434 fix(mobile): local delete missing from sheet on some routes (#24505)
* fix(mobile): local delete missing from album sheet

* chore: remove hasLocal
2025-12-16 09:54:53 -06:00
idubnori
997aec2441 feat: replace heart icons to thumbs-up across activity (#24590)
* feat: replace heart icons to thumbs-up across activity

* fix: update thumb_up icon color to use primaryColor in activity components

* chore: web colors

* chore: modify colors

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-16 15:27:09 +00:00
Ben
cb2bd47816 fix(web): immich logo in shared links (#24618)
* fix(web): immich logo in shared links

* chore: apply changes for individual shared link as well

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2025-12-16 14:59:17 +00:00
renovate[bot]
f1c8377ca0 chore(deps): update dependency @types/node to ^24.10.3 (#24605)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-16 12:23:52 +01:00
idubnori
7d4de5f2e2 feat(mobile): move activity button to action button menu 2025-12-16 16:58:06 +09:00
Alex
8416397589 chore: revert Svelte 5.43.3 (#24509) 2025-12-16 04:03:53 +00:00
Yaros
dc29635b67 chore(mobile): changed default album sort to match with web (#24526)
chore(mobile): matched default album sort with web
2025-12-15 21:18:45 -06:00
Min Idzelis
00290e1e71 feat: make OCR store reentrant-safe (#24419) 2025-12-15 21:06:04 -06:00
Yaros
3ef4c4f315 feat(web): slideshow feature on shared albums (#24598) 2025-12-15 20:49:50 -06:00
idubnori
b10a8baf53 feat(mobile): move buttons in the bottom sheet to the kebabu menu (#24175)
* refactor: remove bottom sheet buttons

* feat: add iconOnly and menuItem parameters to action buttons

* feat: enhance action button context and kebab menu integration

* feat: use ActionButtonContext

* fix: add missing options in some cases

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-15 16:44:27 -06:00
Mees Frensel
77926383db fix(server): only extract image's duration if format supports animation (#24587) 2025-12-15 12:36:46 -05:00
Yaros
35eda735c8 fix(web): recent search doesn't use search type (#24578)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-12-15 12:44:00 +01:00
Diogo Correia
8f7a71d1cf fix(web): download panel being hidden by admin sidebar (#24583) 2025-12-15 12:29:18 +01:00
Yaros
33cdea88aa fix(mobile): birthday off by one day on remote (#24527) 2025-12-11 21:23:11 -06:00
Alex
4b345e02ff fix: refresh appear in list after asset is added to a current or new album (#24523) 2025-12-11 11:06:53 -06:00
Yaros
8cf900bafa fix(mobile): local videos with '#' don't play on android (#24373)
* fix(mobile): videos with '#' don't play on android

* refactor: one line

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>

* fix: depend on platform

---------

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
2025-12-11 10:57:37 -06:00
Yaros
59a3f0f455 feat(mobile): create new album from add to modal (#24431)
* feat(mobile): create new album from add to modal

* refactor: use statefulwidget instead of hook

* chore: rename createalbumbutton
2025-12-11 09:47:31 -06:00
Sergey Katsubo
c5d99711f7 fix(web): show inferred timezone in date editor (#24513)
fix(web): show inferred timezone of asset in date editor
2025-12-11 09:20:51 -06:00
Yaros
4c0a41723f feat(web): asset selection bar in tags view (#24522)
* feat(web): asset selection tab in tags view

* chore: remove unused imports
2025-12-11 15:20:29 +00:00
Bart van Velden
f73511a754 fix(docs): typo in maintenance mode command (#24518) 2025-12-11 09:19:33 -06:00
hubert-taieb
e637387082 fix(server): prevent metadata extraction failures on large video files (#24094)
* prevent  metadata extraction failures on large video files

Increases ExifTool timeout from 20s to 120s to prevent GPS metadata
extraction failures on large video files (>2GB, 10+ minutes).

Issue: Large videos timeout during metadata extraction, causing GPS
coordinates to be lost even though ExifTool can extract them given
enough time.

Testing: 2.6GB, 10:52min video that previously timed out now
successfully extracts GPS metadata.

* redundant comment

Increased task timeout for processing large videos.

* chore: lint

---------

Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2025-12-11 15:18:19 +00:00
renovate[bot]
baad38f0e6 fix(deps): update typescript-projects (#24476)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-12-11 00:13:06 +00:00
Min Idzelis
161147af51 feat: timeline-manager improvement to use AssetResponseDto efficiently (#24421) 2025-12-11 01:07:31 +01:00
renovate[bot]
cbdf5011f9 chore(deps): update docker.io/valkey/valkey:9 docker digest to fb8d272 (#24474)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 00:59:39 +01:00
renovate[bot]
f0f1d279c4 chore(deps): update prom/prometheus docker digest to d936808 (#24475)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 00:59:20 +01:00
renovate[bot]
5821f2fe61 chore(deps): update github-actions (#24477)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 00:59:03 +01:00
Noel S
4cbce072be fix(docs): slow upload speed with example nginx reverse proxy config (#24490)
* increase buffer size

* increase further

* increase buffer further
2025-12-10 22:29:36 +00:00
idubnori
5e5bb7e87d fix(mobile): versionStatus.message text overflow (#24504) 2025-12-10 16:18:55 -06:00
shenlong
b052893a1e feat(mobile): immich-ui icon button (#24502)
* feat(mobile): immich-ui icon button

* fix lint

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-12-10 16:18:01 -06:00
Kurt McKee
15e58595fd fix(mobile): iOS local permission dialog extra whitespace (#24491)
Fix a iOS rendering issue caused by extra whitespace
2025-12-10 16:17:08 -06:00
Alex
6d499c782a chore: update ui lib (#24483) 2025-12-09 17:27:01 -06:00
idubnori
7af99b8606 feat(mobile): move top bar buttons into kebabu menu in AssetViewer (#24461)
* chore(mobile):  i18n: "open_asset_info" in viewer kebab menu

* feat(mobile): move some top buttons into kebabu menu

* refactor(mobile): viewer kebab menu to use context-based button generation

* feat(mobile): refactor action button and kebab menu to use ConsumerWidget for improved state management

* feat(mobile): pass original theme to ViewerKebabMenu for consistent styling

* chore: styling

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-09 18:26:28 +00:00
Arnau Mora
01e39277e0 feat(mobile): Localized backup upload details page (#21136)
* Localized backup details page

# Conflicts:
#	i18n/en.json

* Format

* format fix

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-12-09 11:23:01 -06:00
Yaros
06e79703da fix(mobile): timeline bottom padding on selection (#24480) 2025-12-09 09:19:41 -06:00
Yaros
c360781565 fix(mobile): fix overflow text in backup card (#24448)
* fix(mobile): fix overflow text in backup card

* refactor: use intrinsicheight

* chore: fix spelling of entitycounttile
2025-12-09 09:03:29 -06:00
idubnori
287f6d5c94 fix(mobile): buttons inside AddActionButton color is the same as background color (#24460)
* fix: icon & text color in AddActionButton

* fix: use Divider
2025-12-08 14:29:31 -06:00
Simon Kubiak
fe9125a3d1 fix(web): [album table view] long album title overflows table row (#24450)
fix(web): long album title overflows vertically on album page in table view
2025-12-08 15:35:58 +00:00
Yaros
8b31936bb6 fix(mobile): cannot create album while name field is focused (#24449)
fix(mobile): create album disabled when focused
2025-12-08 09:33:01 -06:00
Sergey Katsubo
19958dfd83 fix(server): building docker image for different platforms on the same host (#24459)
Fix building docker image for different platforms on the same host

Use per-platform mise cache to avoid 'sh: 1: extism-js: not found'
This happens due to re-using cached installed binary for another platform
2025-12-08 09:15:43 -06:00
Alex
1e1cf0d1fe fix: build iOS fastlane installation (#24408) 2025-12-06 14:55:53 -06:00
Min Idzelis
879e0ea131 fix: thumbnail doesnt send mouseLeave events properly (#24423) 2025-12-06 21:52:06 +01:00
Sergey Katsubo
42136f9091 fix(server): update exiftool-vendored to v34 for more robust metadata extraction (#24424) 2025-12-06 14:45:59 -06:00
Harrison
1109c32891 fix(docs): websockets in nginx example (#24411)
Co-authored-by: Harrison <frith.harry@gmail.com>
2025-12-06 16:28:12 +00:00
idubnori
3c80049192 chore(mobile): add kebabu menu in asset viewer (#24387)
* feat(mobile): implement viewer kebab menu with about option

* feat: revert exisitng buttons, adjust label name

* unify MenuAnchor usage

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-05 19:51:59 +00:00
Hai Sullivan
8f1669efbe chore(mobile): smoother UI experience for iOS devices (#24397)
allows the tab pages to use the standard Material page transition during push/pop navigation
2025-12-05 11:02:04 -06:00
Robert Schäfer
146bf65d02 refactor(dev): remove ulimits for rootless docker (#24393)
Description
-----------

When I follow the [developer setup](https://docs.immich.app/developer/setup) I run into a permission error using rootless docker. A while ago I asked on Discord in [#contributing](https://discord.com/channels/979116623879368755/1071165397228855327/1442974448776122592) about these ulimits.

I suggest to remove the `ulimits` altogether. It seems that @ItalyPaleAle has left the setting just hoping that it could help somebody in the future. See the [PR description](https://github.com/immich-app/immich/pull/4556).

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

Using rootless docker:

```
$ docker context ls
NAME         DESCRIPTION                               DOCKER ENDPOINT                     ERROR
default                                                unix:///var/run/docker.sock
rootless *                                             unix:///run/user/1000/docker.sock
```

Running `make` will fail because of permission errors:
```
$  docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
...
Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: error setting rlimits for ready process: error setting rlimit type 7: operation not permitted
```

On my machine I have the following hard limit for "Maximum number of open file descriptors":
```
$ ulimit -nH
524288
```

I can confirm that the permission error is caused by the security restrictions of the operating system mentioned above:

Changing `docker/docker-compose.dev.yml` like ..

```
    ulimits:
      nofile:
        soft: 524289
        hard: 524289
```

.. will lead to a permission error whereas this ..

```
    ulimits:
      nofile:
        soft: 524288
        hard: 524288
```

.. starts fine.

Apparently the defaults for these limits are coming from [systemd](26b2085d54/man/systemd.exec.xml (L1122)) which is used on nearly every linux distribution. So my assumption is that almost any linux user who uses rootless docker will run into a permission error when starting the development setup.

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

- [x] I have performed a self-review of my own code
- [x] I have made corresponding changes to the documentation if applicable
- [x] I have no unrelated changes in the PR.
- [ ] I have confirmed that any new dependencies are strictly necessary.
- [ ] I have written tests for new code (if applicable)
- [ ] I have followed naming conventions/patterns in the surrounding code
- [ ] All code in `src/services/` uses repositories implementations for database calls, filesystem operations, etc.
- [ ] All code in `src/repositories/` is pretty basic/simple and does not have any immich specific logic (that belongs in `src/services/`)
2025-12-05 09:26:20 -05:00
Daniel Dietzler
75a7c9c06c feat: sql tools array as default value (#24389) 2025-12-04 12:54:20 -05:00
Daniel Dietzler
ae8f5a6673 fix: prettier (#24386) 2025-12-04 16:10:42 +00:00
Jason Rasmussen
31f2c7b505 feat: header context menu (#24374) 2025-12-04 11:09:38 -05:00
Yaros
ba6687dde9 feat(web): search type selection dropdown (#24091)
* feat(web): search type selection dropdown

* chore: implement suggestions

* lint

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-04 04:10:12 +00:00
shenlong
bbba1bfe8c fix: use adjustment time in iOS for hash reset (#24047)
* use adjustment time in iOS for hash reset

* migration

* fix equals check

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-12-03 21:15:58 -06:00
Robert Schäfer
4be9a5ebf8 fix(docs): obsolete docs about rootless docker (#24376)
Description
-----------

The documentation lies about comments in `docker/docker-compose.dev.yml`.

Reason: in 689c6aa276 these docs were added
but the comments in this file are removed in
b9e2590752 and the docs weren't updated.

How Has This Been Tested?
-------------------------
```
$ git log -S rootless

commit b9e2590752
Author: Jason Rasmussen <jason@rasm.me>
Date:   Tue Sep 16 12:48:44 2025 -0400

    chore: simplify (#22082)

commit 689c6aa276
Author: Rudolf Horváth <R-Rudolf@users.noreply.github.com>
Date:   Thu Nov 21 13:25:45 2024 +0100

    docs: add developer notes about rootless docker setup (#13250)
```

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

- [x] I have performed a self-review of my own code
- [x] I have made corresponding changes to the documentation if applicable
- [x] I have no unrelated changes in the PR.
- [ ] I have confirmed that any new dependencies are strictly necessary.
- [ ] I have written tests for new code (if applicable)
- [ ] I have followed naming conventions/patterns in the surrounding code
- [ ] All code in `src/services/` uses repositories implementations for database calls, filesystem operations, etc.
- [ ] All code in `src/repositories/` is pretty basic/simple and does not have any immich specific logic (that belongs in `src/services/`)
2025-12-03 18:34:08 -06:00
Omar I
d41921247b fix(web): Add minimum content size to logo for consistent visual on small screens (#24372) 2025-12-03 21:35:48 +00:00
Nicholas
853a024f0f fix: prevent OOM on nginx reverse proxy servers (#24351)
Prevent OOM on reverse proxy servers

Added configuration to disable buffering for uploads.
2025-12-03 14:30:28 -06:00
Alex
4fe494776e fix: local full sync on Android on resume (#24348) 2025-12-03 20:22:07 +00:00
Justin Forseth
76b4adf276 fix: Adjust the zoom level (#24353)
Adjust the zoom level
2025-12-03 14:19:57 -06:00
Alex
75dde0d076 fix: exposure info and better readability (#24344)
fix: exposure info and better readabilit
2025-12-03 20:19:45 +00:00
Mert
cffb68d1c4 fix(server): do not delete offline assets (#24355)
* do not delete isOffline assets

* update sql

* add medium test

* add normal delete test

* formatting
2025-12-03 14:19:26 -06:00
Jason Rasmussen
45f68f73a9 feat: queue detail page (#24352) 2025-12-03 13:39:32 -05:00
renovate[bot]
4f93eda8d8 fix(deps): update typescript-projects (#24329)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-12-02 23:28:12 +01:00
Alex
f5df5fa98d chore: change workflow column name (#24349)
chore-change-workflow-column-name
2025-12-02 14:40:17 -06:00
renovate[bot]
f07d1441ea chore(deps): update github-actions (#24331)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 20:13:02 +01:00
Jonathan Jogenfors
1bcf28c062 chore(server): sidecars in asset_files (#21199)
* fix: sidecar check job

* feat: move sidecars to asset_files

* feat: combine with handleSidecarCheck

* fix(server): improved method signatures for stack and sidecar copying

* fix(server): improved method signatures for stack and sidecar copying

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2025-12-02 13:31:43 -05:00
Jonathan Jogenfors
62628dfcfa fix(web): folder view sort oder (#24337)
fix: folder view sort oder
2025-12-02 11:48:12 -06:00
Hai Sullivan
b11aecd184 fix(mobile): use correct timezone displayed in the info sheet (#24310)
* fixed the timezone issue in the Immich mobile app's metadata sheet to match the web app's behavior

* format dart

* now uses the shared applyTimezoneOffset() utility function from mobile/lib/utils/timezone.dart

* add tests

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-02 16:37:19 +00:00
Jason Rasmussen
116012f6f8 feat: less asset-metadata validation (#24342) 2025-12-02 10:56:31 -05:00
renovate[bot]
7594136050 chore(deps): update dependency express to v5.2.0 [security] (#24323)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 09:27:12 -05:00
renovate[bot]
bb341cc774 chore(deps): update docker.io/valkey/valkey docker tag to v9 (#24336)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 09:26:39 -05:00
Dionysius
af1d4afb95 fix(docs): server and machine-learning use IMMICH_HOST and IMMICH_PORT (#24335) 2025-12-02 09:25:39 -05:00
renovate[bot]
75b1ef2c57 chore(deps): update machine-learning (#24334)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 01:01:17 -05:00
renovate[bot]
1e37f7c8c8 chore(deps): update dependency nodemailer to v7.0.11 [security] (#24330)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-01 23:16:59 -05:00
Yaros
a32f450059 feat(mobile): persist album sorting & layout in settings (#22133)
* fix(mobile): persist album sorting in settings

* fix(mobile): persist album layout

* fix: fixed store model id

* fix: corrupted AppSettingsEnum

* chore: refactor to remove RemoteAlbumSortMode

* refactor: use t instead of tr
2025-12-01 20:51:35 -06:00
carbonemys
b452ab463b fix(web): open onboarding documentation link in new tab (#24289)
* fix(web): open onboarding documentation link in new tab

* Update web/src/lib/components/onboarding-page/onboarding-storage-template.svelte

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2025-12-01 20:49:31 -06:00
Sergey Katsubo
79bed80226 feat(server): log all thumbnail generation attempts at verbose level (#24324)
Log thumbnail generation at verbose level
2025-12-01 20:26:13 -06:00
Mert
6249996cdb fix(ml): do not upscale preview (#24322)
do not upscale
2025-12-01 20:26:01 -06:00
Jonathan Jogenfors
a3f281caa3 docs(faq): add more info on archiving (#24326)
docs: add more info on archive to faq
2025-12-01 20:25:31 -06:00
Mert
7c19b0591f fix(server): cjk migration (#24320)
* join string

* use pagination instead
2025-12-01 15:41:19 -06:00
Mert
95c29a8aea fix(server): use bigrams for cjk (#24285)
* use bigrams for cjk

* update sql

* linting

* actually migrate ocr

* fix backwards test

* use array

* tweaks
2025-12-01 17:24:37 +00:00
idubnori
d8ca210641 chore(web): minor UX improvements of "view asset owners" feature (#24319)
* feat: toggle in options modal

* feat(i18n): add labels to display who uploaded each asset and show asset owners

* feat: migrate asset owner settings to TimelineManager and update AlbumOptionsModal

* Revert "feat(i18n): add labels to display who uploaded each asset and show asset owners"

This reverts commit cf8f4eb135.

* fix: simplify AlbumOptionsModal invocation and update aria-label for asset owners

* feat(i18n): add label for viewing asset owners in the interface

* feat: add tests for showAssetOwners functionality in TimelineManager

* chore: move asset owner visibility toggle to kebabu menu
2025-12-01 10:25:12 -06:00
Min Idzelis
ab35afd3b1 refactor(web): reimplement operation-support as part of timeline-manager (#24056)
* refactor(web): reimplement operation-support as part of timeline-manager

Improve clarity of methods. 
Add inline method documentation.  
Make return type of AssetOperation optional.

* Review comments - self document code. remove optional return from callback
2025-12-01 09:04:39 -06:00
idubnori
65e4fdf98d refactor(web): i18n-ize "view asset owners" (#24317) 2025-12-01 15:01:57 +00:00
Matthew Momjian
fa43fae2a5 fix(mobile): docs link (#24277)
update docs link
2025-11-30 13:01:33 -06:00
Alex
46afd6a101 fix: only generate memory based on users assets (#24151) 2025-11-30 13:01:12 -06:00
Hai Sullivan
46e1967760 chore: optimisation of several UI components of the mobile app (#24098)
* fix(mobile): normalize scrolling behavior in networking settings

Remove ClampingScrollPhysics from networking settings page to match
the scrolling behavior of other settings pages. This restores the
standard iOS bounce/elastic scrolling effect.

* fix(mobile): use consistent native transitions for Library pages

Change Trash, Shared Links, and Folders routes from CustomRoute to AutoRoute to enable native iOS transitions with swipe-back gesture support.

* fix(mobile): remove SafeArea wrapper and ClampingScrollPhysics from Settings

Remove SafeArea wrapper (Scaffold handles safe areas automatically) and ClampingScrollPhysics to enable native iOS bounce scrolling.

* fix(mobile): remove bottom white space in Sync Status page

Replace Padding wrapper with ListView padding to match other Settings pages and eliminate bottom white space.

* chore: fix Dart formatting

Run dart format to fix formatting issues in settings.page.dart and sync_status_and_actions.dart

* Format Dart files

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: kao-byte <benjaminliu@MacBook-Air.local>
2025-11-30 13:01:01 -06:00
Chris Peckover
922282b2b4 feat(web): Shared album owner labels (#21171)
* - pass available album users along to the thumbnail through the asset-date-group
- show a small user-avatar in bottom right of thumbnail

* - change owner to their name in white text instead of the avatar

* cleanup

* - cleanup albumUsers creation
- use font-light for the user's name

* fix lint

* format

* - add toggle to show/hide asset owner names

* update new Timeline with albumUsers

* add @idubnori suggestion for the name font

* Don't show 'view owners' button if the album doesn't have editors

* add missing import

* format

* fix(web): #21171 (#24298)

fix: Bind timelineManager to Timeline component

---------

Co-authored-by: idubnori <i.dub.nori@gmail.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-11-30 18:56:03 +00:00
Alex
e3ab16a5bd chore: refactor mobile events (#24263)
chore: refactor mobile evets
2025-11-30 12:43:33 -06:00
Niklas von Moers
08f320c801 fix(web): use full tag path when creating nested subtags (#24249) 2025-11-29 12:09:32 +00:00
Mees Frensel
e36261b552 fix(web): integrate zoom toggle button into panorama photo viewer (#24189) 2025-11-28 18:50:16 +01:00
Daniel Dietzler
c0a3b58bba fix: rare cases of assets not loading in when scrolling backwards (#24245) 2025-11-28 10:18:49 -06:00
Yaros
f12f609038 fix(mobile): enable backup text overflows (#24227) 2025-11-28 10:18:44 -06:00
renovate[bot]
1f6eb662e5 chore(deps): update dependency opentofu to v1.10.7 (#23964)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-28 14:41:23 +00:00
renovate[bot]
0c1fe35f2f chore(deps): update dependency terragrunt to v0.93.10 (#24149)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-28 15:17:25 +01:00
Robert Schäfer
e98a33cf9d fix(docs): build cli for e2e tests (#24184) 2025-11-28 15:11:17 +01:00
Dionysius
d38305360c docs: DB_STORAGE_TYPE is only used by the database container (#24215)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-11-28 12:43:48 +00:00
Tijs
3e3ca4c104 feat(server): exclude syncthing folders from external libraries (#24240)
* Add SyncThing folders to External library exclusion

SyncThing is a popular library for syncing files (like pictures) between systems. It can really mess up your library if an external library, which is also used by SyncThing, is added and these folders are not excluded.

* Plural

* fix formatting

---------

Co-authored-by: Jonathan Jogenfors <jonathan@jogenfors.se>
2025-11-28 11:40:33 +00:00
Jacob Bundgaard
81edf0749f fix: label 'for' attributes in user-api-key-grid (#24232) 2025-11-27 23:28:38 +00:00
renovate[bot]
01f83ae964 fix(deps): update dependency exiftool-vendored to v33 (#24172)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-11-27 21:45:35 +00:00
renovate[bot]
5eec0dc981 chore(deps): update github-actions (#24038)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-27 21:41:41 +00:00
renovate[bot]
ca4fd07656 chore(deps): update dependency eslint-plugin-unicorn to v62 (#24167)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-11-27 21:31:16 +00:00
renovate[bot]
7ce43b3824 chore(deps): update dependency node-gyp to v12 (#24168)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-27 16:13:57 +01:00
Daniel Dietzler
ce00119926 chore: update sharp to 0.34.5 (#24170) 2025-11-27 15:13:16 +00:00
Daniel Dietzler
fffee80e2f feat: command palette (#23693) 2025-11-26 22:18:50 +01:00
Jason Rasmussen
64cd4e96e3 fix: theme switcher (#24209) 2025-11-26 21:17:26 +00:00
renovate[bot]
955a3bfaa6 chore(deps): update base-image to v202511261514 (major) (#24165)
chore(deps): update base-image to v202511261514

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-26 15:10:48 -05:00
renovate[bot]
e699d8f170 chore(deps): update machine-learning (#23352) 2025-11-26 19:09:39 +00:00
Jason Rasmussen
13104d49cd feat(web): shared link card tweaks (#24192) 2025-11-25 19:35:21 -06:00
Jason Rasmussen
2d5ec528d5 fix(web): user admin pages (#24185)
fix: user admin pages
2025-11-25 16:35:37 -05:00
Min Idzelis
5226898184 fix: update timeline-manager after archive actions (#24010)
* fix: update timeline-manager after archive actions

* Add locators to thumb icons
2025-11-25 15:06:29 -05:00
Luka Prebil Grintal
dd4169876c fix(ml): Upgrade ONNX Runtime to v1.22.1 to fix ROCm build failures (#24045)
* fix: update ONNX runtime version to 1.21.0 to fix the failing checksum of 1.20.1

* update patch

* update to 1.22.1

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2025-11-25 13:27:21 -05:00
renovate[bot]
8321c275b8 chore(deps): update dependency body-parser to v2.2.1 [security] (#24179)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 18:29:13 +01:00
renovate[bot]
3d6c26350a fix(deps): update typescript-projects (#24163)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-11-25 17:26:36 +00:00
Jason Rasmussen
db15e5e423 fix: duration extraction (#24178) 2025-11-25 10:26:25 -05:00
renovate[bot]
35d18da14a chore(deps): update node to v24 (major) (#24169)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 15:40:48 +01:00
renovate[bot]
cb56a11f0b chore(deps): update dependency @types/archiver to v7 (#24166)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 15:40:12 +01:00
Jason Rasmussen
104fa09f69 feat: queues (#24142) 2025-11-25 08:19:40 -05:00
Alex
66ae07ee39 fix: don't get OCR data in shared link (#24152) 2025-11-25 07:58:27 -05:00
Daniel Dietzler
939d2c8b27 chore: minor admin pages refactorings (#24160) 2025-11-25 07:57:30 -05:00
renovate[bot]
2801a6e672 chore(deps): update actions/download-artifact action to v6 (#24164)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 13:46:34 +01:00
renovate[bot]
4742360469 chore(deps): update grafana/grafana docker tag to v12.3.0 (#24162)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 13:45:50 +01:00
Daniel Dietzler
b56fa62b32 fix: revert "chore(deps): update dependency sharp to v0.34.5" (#24173) 2025-11-25 12:37:08 +00:00
renovate[bot]
ddbe485074 chore(deps): update dependency sharp to v0.34.5 (#24146)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 11:56:11 +01:00
renovate[bot]
01310c6d86 chore(deps): update node.js to v24.11.1 (#24147)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 11:55:36 +01:00
Brandon Wees
512327ef69 feat: java in mise (#24154) 2025-11-24 23:18:44 -06:00
Daniel Dietzler
8755cd59fd chore: refactor svelte reactivity (#24072) 2025-11-24 18:57:46 -05:00
Min Idzelis
7694b342ed refactor(web): Extract asset grid layout component from TimelineDateGroup and split into AssetLayout and Month components (#23338)
* refactor(web): Extract asset grid layout component from TimelineDateGroup and split into AssetLayout and Month components

* chore: cleanup

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2025-11-24 23:09:46 +00:00
fabianbees
78553a0258 feat: separate camera and lens info in detail panel (#23670)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-11-24 16:30:15 +00:00
renovate[bot]
c1198b99b7 chore(deps): update dependency js-yaml to v4.1.1 [security] (#23901)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-24 17:28:18 +01:00
renovate[bot]
8b7b9ee394 chore(deps): update dependency esbuild to ^0.25.0 [security] (#23903)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-24 17:27:46 +01:00
Min Idzelis
d6b39a464d feat: improve performance: don't sort timeline buckets from server (#24032) 2025-11-24 17:26:52 +01:00
Snowknight26
75d23fe135 fix(web): fix support & feedback modal wrapping (#24018)
* fix(web): fix support & feedback modal wrapping

* Fix reference
2025-11-24 10:24:02 -06:00
shenlong
c860809aa1 fix: getAspectRatio fallback to db width and height (#24131)
fix: getExif fallback to db width and height

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-11-24 10:23:17 -06:00
Daniel Dietzler
0498f6cb9d fix: albums page reactivity loops (#24046) 2025-11-24 17:14:24 +01:00
shenlong
24e5dabb51 fix: use proper updatedAt value in local assets (#24137)
* fix: incorrect updatedAt value in local assets

* add test

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-11-24 09:49:27 -06:00
Greg Lutostanski
aecf064ec9 fix(server): sanitize DB_URL for pg_dumpall to remove unknown query params (#23333)
Co-authored-by: Greg Lutostanski <greg.lutostanski@mobilityhouse.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-11-24 16:34:21 +01:00
Daniel Dietzler
57be3ff8c7 fix: add users to album (#24133) 2025-11-24 07:52:36 -05:00
Ujjwal Goel
99505f987e fix: use npm instead of pnpm and fix check:all (#24101)
* fix: use npm instead of pnpm and fix `check:all`

* fix: remove `--` from pnpm commands

* Remove `check:all` from the documentation section
2025-11-23 21:04:43 -06:00
460 changed files with 16339 additions and 18503 deletions

2
.github/.nvmrc vendored
View File

@@ -1 +1 @@
24.11.0
24.11.1

View File

@@ -4,6 +4,6 @@
"format:fix": "prettier --write ."
},
"devDependencies": {
"prettier": "^3.5.3"
"prettier": "^3.7.4"
}
}

View File

@@ -108,7 +108,7 @@ jobs:
working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
- uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0
with:
distribution: 'zulu'
java-version: '17'
@@ -222,6 +222,7 @@ jobs:
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
working-directory: ./mobile/ios
- name: Install CocoaPods dependencies
@@ -229,13 +230,6 @@ jobs:
run: |
pod install
- name: Install Fastlane
working-directory: ./mobile/ios
run: |
gem install bundler
bundle config set --local path 'vendor/bundle'
bundle install
- name: Create API Key
env:
API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}

View File

@@ -44,7 +44,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './cli/.nvmrc'
registry-url: 'https://registry.npmjs.org'
@@ -105,7 +105,7 @@ jobs:
- name: Generate docker image tags
id: metadata
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
flavor: |
latest=false

View File

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

View File

@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/autobuild@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
with:
category: '/language:${{matrix.language}}'

View File

@@ -132,7 +132,7 @@ jobs:
suffixes: '-rocm'
platforms: linux/amd64
runner-mapping: '{"linux/amd64": "mich"}'
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@47a2ee86898ccff51592d6572391fb1abcd7f782 # multi-runner-build-workflow-v2.0.1
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
permissions:
contents: read
actions: read
@@ -155,7 +155,7 @@ jobs:
name: Build and Push Server
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@47a2ee86898ccff51592d6572391fb1abcd7f782 # multi-runner-build-workflow-v2.0.1
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
permissions:
contents: read
actions: read

View File

@@ -69,7 +69,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './docs/.nvmrc'
cache: 'pnpm'

View File

@@ -16,7 +16,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -32,7 +32,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'

View File

@@ -31,7 +31,7 @@ jobs:
- name: Generate a token
id: generate_token
if: ${{ inputs.skip != true }}
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

@@ -49,7 +49,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -62,13 +62,13 @@ jobs:
ref: main
- name: Install uv
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -126,7 +126,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -144,7 +144,7 @@ jobs:
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
draft: true
tag_name: ${{ env.IMMICH_VERSION }}

View File

@@ -17,7 +17,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -30,13 +30,13 @@ jobs:
ref: main
- name: Install uv
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -159,7 +159,7 @@ jobs:
- name: Create PR
id: create-pr
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
with:
token: ${{ steps.generate-token.outputs.token }}
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'

View File

@@ -52,7 +52,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -74,13 +74,13 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Download APK
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
tag_name: ${{ steps.version.outputs.result }}
token: ${{ steps.generate-token.outputs.token }}

View File

@@ -31,7 +31,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './open-api/typescript-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org'

View File

@@ -77,7 +77,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -121,7 +121,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
@@ -168,7 +168,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
@@ -210,7 +210,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -254,7 +254,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -292,7 +292,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -340,7 +340,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -387,7 +387,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -426,7 +426,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -481,7 +481,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -571,8 +571,8 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
# with:
# python-version: 3.11
@@ -617,7 +617,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './.github/.nvmrc'
cache: 'pnpm'
@@ -668,7 +668,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -730,7 +730,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'

View File

@@ -52,7 +52,7 @@
},
"cSpell.words": ["immich"],
"editor.formatOnSave": true,
"eslint.validate": ["javascript", "svelte"],
"eslint.validate": ["javascript", "typescript", "svelte"],
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",

View File

@@ -1 +1 @@
24.11.0
24.11.1

View File

@@ -1,4 +1,4 @@
FROM node:22.16.0-alpine3.20@sha256:2289fb1fba0f4633b08ec47b94a89c7e20b829fc5679f9b7b298eaa2f1ed8b7e AS core
FROM node:24.1.0-alpine3.20@sha256:8fe019e0d57dbdce5f5c27c0b63d2775cf34b00e3755a7dea969802d7e0c2b25 AS core
WORKDIR /usr/src/app
COPY package* pnpm* .pnpmfile.cjs ./

View File

@@ -20,7 +20,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^22.19.1",
"@types/node": "^24.10.3",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
@@ -28,10 +28,10 @@
"eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^60.0.0",
"eslint-plugin-unicorn": "^62.0.0",
"globals": "^16.0.0",
"mock-fs": "^5.2.0",
"prettier": "^3.2.5",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.0.0",
"typescript": "^5.3.3",
"typescript-eslint": "^8.28.0",
@@ -69,6 +69,6 @@
"micromatch": "^4.0.8"
},
"volta": {
"node": "24.11.0"
"node": "24.11.1"
}
}

View File

@@ -299,7 +299,7 @@ describe('crawl', () => {
.map(([file]) => file);
// Compare file's content instead of path since a file can be represent in multiple ways.
expect(actual.map((path) => readContent(path)).sort()).toEqual(expected.sort());
expect(actual.map((path) => readContent(path)).toSorted()).toEqual(expected.toSorted());
});
}
});

View File

@@ -160,7 +160,7 @@ export const crawl = async (options: CrawlOptions): Promise<string[]> => {
ignore: [`**/${exclusionPattern}`],
});
globbedFiles.push(...crawledFiles);
return globbedFiles.sort();
return globbedFiles.toSorted();
};
export const sha1 = (filepath: string) => {

View File

@@ -9,7 +9,7 @@
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"target": "es2022",
"target": "es2023",
"sourceMap": true,
"outDir": "./dist",
"incremental": true,

View File

@@ -1,6 +1,6 @@
[tools]
terragrunt = "0.91.2"
opentofu = "1.10.6"
terragrunt = "0.93.10"
opentofu = "1.10.7"
[tasks."tg:fmt"]
run = "terragrunt hclfmt"

View File

@@ -58,10 +58,6 @@ services:
IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues
IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://docs.immich.app
IMMICH_THIRD_PARTY_SUPPORT_URL: https://docs.immich.app/community-guides
ulimits:
nofile:
soft: 1048576
hard: 1048576
ports:
- 9230:9230
- 9231:9231
@@ -100,10 +96,6 @@ services:
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
ulimits:
nofile:
soft: 1048576
hard: 1048576
restart: unless-stopped
depends_on:
immich-server:
@@ -135,7 +127,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
healthcheck:
test: redis-cli ping || exit 1

View File

@@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
healthcheck:
test: redis-cli ping || exit 1
restart: always
@@ -83,7 +83,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:49214755b6153f90a597adcbff0252cc61069f8ab69ce8411285cd4a560e8038
image: prom/prometheus@sha256:d936808bdea528155c0154a922cd42fd75716b8bb7ba302641350f9f3eaeba09
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
@@ -95,7 +95,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:12.2.1-ubuntu@sha256:797530c642f7b41ba7848c44cfda5e361ef1f3391a98bed1e5d448c472b6826a
image: grafana/grafana:12.3.0-ubuntu@sha256:cee936306135e1925ab21dffa16f8a411535d16ab086bef2309339a8e74d62df
volumes:
- grafana-data:/var/lib/grafana

View File

@@ -49,7 +49,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
healthcheck:
test: redis-cli ping || exit 1
restart: always

View File

@@ -1 +1 @@
24.11.0
24.11.1

View File

@@ -133,9 +133,9 @@ There are a few different scenarios that can lead to this situation. The solutio
The job is only automatically run once per asset after upload. If metadata extraction originally failed, the jobs were cleared/canceled, etc.,
the job may not have run automatically the first time.
### How can I hide photos from the timeline?
### How can I hide a photo or video from the timeline?
You can _archive_ them.
You can _archive_ them. This will hide the asset from the main timeline and folder view, but it will still show up in searches. All archived assets can be found in the _Archive_ view
### How can I backup data from Immich?

View File

@@ -41,7 +41,7 @@ By default, Immich will keep the last 14 database dumps and create a new dump ev
#### Trigger Dump
You are able to trigger a database dump in the [admin job status page](http://my.immich.app/admin/jobs-status).
You are able to trigger a database dump in the [admin job status page](http://my.immich.app/admin/queues).
Visit the page, open the "Create job" modal from the top right, select "Create Database Dump" and click "Confirm".
A job will run and trigger a dump, you can verify this worked correctly by checking the logs or the `backups/` folder.
This dumps will count towards the last `X` dumps that will be kept based on your settings.

View File

@@ -21,6 +21,12 @@ server {
# allow large file uploads
client_max_body_size 50000M;
# disable buffering uploads to prevent OOM on reverse proxy server and make uploads twice as fast (no pause)
proxy_request_buffering off;
# increase body buffer to avoid limiting upload speed
client_body_buffer_size 1024k;
# Set headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -29,8 +35,6 @@ server {
# enable websockets: http://nginx.org/en/docs/http/websocket.html
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_redirect off;
# set timeout
@@ -40,6 +44,8 @@ server {
location / {
proxy_pass http://<backend_url>:2283;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# useful when using Let's Encrypt http-01 challenge

View File

@@ -52,7 +52,7 @@ Password login has been enabled.
Disable Maintenance Mode
```
immich-admin disable-maintenace-mode
immich-admin disable-maintenance-mode
Maintenance mode has been disabled.
```

View File

@@ -14,15 +14,15 @@ When contributing code through a pull request, please check the following:
- [ ] `pnpm run check:typescript` (check typescript)
- [ ] `pnpm test` (unit tests)
:::tip AIO
Run all web checks with `pnpm run check:all`
:::
## Documentation
- [ ] `pnpm run format` (formatting via Prettier)
- [ ] Update the `_redirects` file if you have renamed a page or removed it from the documentation.
:::tip AIO
Run all web checks with `pnpm run check:all`
:::
## Server Checks
- [ ] `pnpm run lint` (linting via ESLint)

View File

@@ -48,7 +48,6 @@ You can access the web from `http://your-machine-ip:3000` or `http://localhost:3
**Notes:**
- The "web" development container runs with uid 1000. If that uid does not have read/write permissions on the mounted volumes, you may encounter errors
- In case of rootless docker setup, you need to use root within the container, otherwise you will encounter read/write permission related errors, see comments in `docker/docker-compose.dev.yml`.
#### Connect web to a remote backend

View File

@@ -18,6 +18,7 @@ make e2e
Before you can run the tests, you need to run the following commands _once_:
- `pnpm install` (in `e2e/`)
- `pnpm run build` (in `cli/`)
- `make open-api` (in the project root `/`)
Once the test environment is running, the e2e tests can be run via:

View File

@@ -1222,4 +1222,4 @@ Feel free to make a feature request if there's a model you want to use that we d
[huggingface-clip]: https://huggingface.co/collections/immich-app/clip-654eaefb077425890874cd07
[huggingface-multilingual-clip]: https://huggingface.co/collections/immich-app/multilingual-clip-654eb08c2382f591eeb8c2a7
[smart-search-settings]: https://my.immich.app/admin/system-settings?isOpen=machine-learning+smart-search
[job-status-page]: https://my.immich.app/admin/jobs-status
[job-status-page]: https://my.immich.app/admin/queues

View File

@@ -53,7 +53,7 @@ Version mismatches between both hosts may cause bugs and instability, so remembe
Adding a new URL to the settings is recommended over replacing the existing URL. This is because it will allow machine learning tasks to be processed successfully when the remote server is down by falling back to the local machine learning container. If you do not want machine learning tasks to be processed locally when the remote server is not available, you can instead replace the existing URL and only provide the remote container's URL. If doing this, you can remove the `immich-machine-learning` section of the local `docker-compose.yml` file to save resources, as this service will never be used.
Do note that this will mean that Smart Search and Face Detection jobs will fail to be processed when the remote instance is not available. This in turn means that tasks dependent on these features—Duplicate Detection and Facial Recognition—will not run for affected assets. If this occurs, you must manually click the _Missing_ button next to Smart Search and Face Detection in the [Job Status](http://my.immich.app/admin/jobs-status) page for the jobs to be retried.
Do note that this will mean that Smart Search and Face Detection jobs will fail to be processed when the remote instance is not available. This in turn means that tasks dependent on these features—Duplicate Detection and Facial Recognition—will not run for affected assets. If this occurs, you must manually click the _Missing_ button next to Smart Search and Face Detection in the [Job Status](http://my.immich.app/admin/queues) page for the jobs to be retried.
## Load balancing

View File

@@ -62,10 +62,10 @@ Information on the current workers can be found [here](/administration/jobs-work
## Ports
| Variable | Description | Default |
| :------------ | :------------- | :----------------------------------------: |
| `IMMICH_HOST` | Listening host | `0.0.0.0` |
| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) |
| Variable | Description | Default | Containers |
| :------------ | :------------- | :----------------------------------------: | :----------------------- |
| `IMMICH_HOST` | Listening host | `0.0.0.0` | server, machine learning |
| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) | server, machine learning |
## Database
@@ -80,7 +80,7 @@ Information on the current workers can be found [here](/administration/jobs-work
| `DB_SSL_MODE` | Database SSL mode | | server |
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server |
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | server |
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | database |
\*1: The values of `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` are passed to the Postgres container as the variables `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in `docker-compose.yml`.
@@ -93,7 +93,7 @@ Information on the current workers can be found [here](/administration/jobs-work
All `DB_` variables must be provided to all Immich workers, including `api` and `microservices`.
`DB_URL` must be in the format `postgresql://immichdbusername:immichdbpassword@postgreshost:postgresport/immichdatabasename`.
You can require SSL by adding `?sslmode=require` to the end of the `DB_URL` string, or require SSL and skip certificate verification by adding `?sslmode=require&sslmode=no-verify`.
You can require SSL by adding `?sslmode=require` to the end of the `DB_URL` string, or require SSL and skip certificate verification by adding `?sslmode=require&uselibpqcompat=true`. This allows both immich and `pg_dumpall` (the utility used for database backups) to [properly connect](https://github.com/brianc/node-postgres/tree/master/packages/pg-connection-string#tcp-connections) to your database.
When `DB_URL` is defined, the `DB_HOSTNAME`, `DB_PORT`, `DB_USERNAME`, `DB_PASSWORD` and `DB_DATABASE_NAME` database variables are ignored.

View File

@@ -38,7 +38,7 @@
"@docusaurus/module-type-aliases": "~3.9.0",
"@docusaurus/tsconfig": "^3.7.0",
"@docusaurus/types": "^3.7.0",
"prettier": "^3.2.4",
"prettier": "^3.7.4",
"typescript": "^5.1.6"
},
"browserslist": {
@@ -57,6 +57,6 @@
"node": ">=20"
},
"volta": {
"node": "24.11.0"
"node": "24.11.1"
}
}

View File

@@ -1 +1 @@
24.11.0
24.11.1

View File

@@ -26,7 +26,7 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^22.19.1",
"@types/node": "^24.10.3",
"@types/oidc-provider": "^9.0.0",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
@@ -35,17 +35,17 @@
"eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^60.0.0",
"exiftool-vendored": "^31.1.0",
"eslint-plugin-unicorn": "^62.0.0",
"exiftool-vendored": "^34.0.0",
"globals": "^16.0.0",
"jose": "^5.6.3",
"luxon": "^3.4.4",
"oidc-provider": "^9.0.0",
"pg": "^8.11.3",
"pngjs": "^7.0.0",
"prettier": "^3.2.5",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.0.0",
"sharp": "^0.34.4",
"sharp": "^0.34.5",
"socket.io-client": "^4.7.4",
"supertest": "^7.0.0",
"typescript": "^5.3.3",
@@ -54,6 +54,6 @@
"vitest": "^3.0.0"
},
"volta": {
"node": "24.11.0"
"node": "24.11.1"
}
}

View File

@@ -1006,7 +1006,7 @@ describe('/libraries', () => {
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
});
it('should switch from using file metadata to file.xmp metadata when asset refreshes', async () => {
it('should switch from using file metadata to file.ext.xmp metadata when asset refreshes', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/xmp`],

View File

@@ -61,7 +61,7 @@ export function selectRandomDays(daysInMonth: number, numDays: number, rng: Seed
}
}
return [...selectedDays].sort((a, b) => b - a);
return [...selectedDays].toSorted((a, b) => b - a);
}
/**

View File

@@ -62,50 +62,60 @@ export const setupTimelineMockApiRoutes = async (
return route.continue();
});
await context.route('**/api/assets/**', async (route, request) => {
await context.route('**/api/assets/*', async (route, request) => {
const url = new URL(request.url());
const pathname = url.pathname;
const assetId = basename(pathname);
const asset = getAsset(timelineRestData, assetId);
return route.fulfill({
status: 200,
contentType: 'application/json',
json: asset,
});
});
await context.route('**/api/assets/*/ocr', async (route) => {
return route.fulfill({ status: 200, contentType: 'application/json', json: [] });
});
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
const match = request.url().match(pattern);
if (!match) {
const url = new URL(request.url());
const pathname = url.pathname;
const assetId = basename(pathname);
const asset = getAsset(timelineRestData, assetId);
return route.fulfill({
status: 200,
contentType: 'application/json',
json: asset,
});
if (!match?.groups) {
throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`);
}
if (match.groups?.size === 'preview') {
if (match.groups.size === 'preview') {
if (!route.request().serviceWorker()) {
return route.continue();
}
const asset = getAsset(timelineRestData, match.groups?.assetId);
const asset = getAsset(timelineRestData, match.groups.assetId);
return route.fulfill({
status: 200,
headers: { 'content-type': 'image/jpeg', ETag: 'abc123', 'Cache-Control': 'public, max-age=3600' },
body: await randomPreview(
match.groups?.assetId,
match.groups.assetId,
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
),
});
}
if (match.groups?.size === 'thumbnail') {
if (match.groups.size === 'thumbnail') {
if (!route.request().serviceWorker()) {
return route.continue();
}
const asset = getAsset(timelineRestData, match.groups?.assetId);
const asset = getAsset(timelineRestData, match.groups.assetId);
return route.fulfill({
status: 200,
headers: { 'content-type': 'image/jpeg' },
body: await randomThumbnail(
match.groups?.assetId,
match.groups.assetId,
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
),
});
}
return route.continue();
});
await context.route('**/api/albums/**', async (route, request) => {
const pattern = /\/api\/albums\/(?<albumId>[^/?]+)/;
const match = request.url().match(pattern);

View File

@@ -12,7 +12,7 @@ import {
PersonCreateDto,
QueueCommandDto,
QueueName,
QueuesResponseDto,
QueuesResponseLegacyDto,
SharedLinkCreateDto,
UpdateLibraryDto,
UserAdminCreateDto,
@@ -564,13 +564,13 @@ export const utils = {
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
},
isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseDto) => {
isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseLegacyDto) => {
const queues = await getQueuesLegacy({ headers: asBearerAuth(accessToken) });
const jobCounts = queues[queue].jobCounts;
return !jobCounts.active && !jobCounts.waiting;
},
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseDto, ms?: number) => {
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseLegacyDto, ms?: number) => {
// eslint-disable-next-line no-async-promise-executor
return new Promise<void>(async (resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms || 10_000);

View File

@@ -611,6 +611,53 @@ test.describe('Timeline', () => {
await page.getByText('Photos', { exact: true }).click();
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
});
test('open /archive, favorite photo, unfavorite', async ({ page }) => {
const assetToFavorite = assets[0];
changes.assetArchivals.push(assetToFavorite.id);
await pageUtils.openArchivePage(page);
const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.isFavorite === undefined) {
return await route.continue();
}
const isFavorite = requestJson.isFavorite;
if (isFavorite) {
changes.assetFavorites.push(...requestJson.ids);
}
await route.fulfill({
status: 204,
});
});
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
await page.getByLabel('Favorite').click();
await expect(favorite).resolves.toEqual({
isFavorite: true,
ids: [assetToFavorite.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(1);
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.isFavorite === undefined) {
return await route.continue();
}
changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Remove from favorites').click();
await expect(unFavoriteRequest).resolves.toEqual({
isFavorite: false,
ids: [assetToFavorite.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(1);
await thumbnailUtils.expectThumbnailIsNotFavorite(page, assetToFavorite.id);
});
test('open album, archive photo, open album, unarchive', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
@@ -633,8 +680,7 @@ test.describe('Timeline', () => {
visibility: 'archive',
ids: [assetToArchive.id],
});
console.log('Skipping assertion - TODO - fix that archiving in album doesnt add icon');
// await thumbnail.expectThumbnailIsArchive(page, assetToArchive.id);
await thumbnailUtils.expectThumbnailIsArchive(page, assetToArchive.id);
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
await page.getByRole('link').getByText('Archive').click();
await timelineUtils.waitForTimelineLoad(page);
@@ -656,8 +702,7 @@ test.describe('Timeline', () => {
visibility: 'timeline',
ids: [assetToArchive.id],
});
console.log('Skipping assertion - TODO - fix bug with not removing asset from timeline-manager after unarchive');
// await expect(thumbnail.withAssetId(page, assetToArchive.id)).toHaveCount(0);
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
await pageUtils.openAlbumPage(page, album.id);
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
});
@@ -712,6 +757,50 @@ test.describe('Timeline', () => {
await page.getByText('Photos', { exact: true }).click();
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
});
test('open /favorites, archive photo, unarchive photo', async ({ page }) => {
await pageUtils.openFavorites(page);
const assetToArchive = getAsset(timelineRestData, 'ad31e29f-2069-4574-b9a9-ad86523c92cb')!;
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
await page.getByLabel('Menu').click();
const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.visibility !== 'archive') {
return await route.continue();
}
await route.fulfill({
status: 204,
});
changes.assetArchivals.push(...requestJson.ids);
});
await page.getByRole('menuitem').getByText('Archive').click();
await expect(archive).resolves.toEqual({
visibility: 'archive',
ids: [assetToArchive.id],
});
await page.getByRole('link').getByText('Archive').click();
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
await thumbnailUtils.expectThumbnailIsNotArchive(page, assetToArchive.id);
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.visibility !== 'timeline') {
return await route.continue();
}
changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Unarchive').click();
await expect(unarchiveRequest).resolves.toEqual({
visibility: 'timeline',
ids: [assetToArchive.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
await thumbnailUtils.expectThumbnailIsNotArchive(page, assetToArchive.id);
});
test('Open album, favorite photo, open /favorites, remove favorite, Open album', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);

View File

@@ -105,20 +105,16 @@ export const thumbnailUtils = {
return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, collector));
},
async expectThumbnailIsFavorite(page: Page, assetId: string) {
await expect(
thumbnailUtils
.withAssetId(page, assetId)
.locator(
'path[d="M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z"]',
),
).toHaveCount(1);
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(1);
},
async expectThumbnailIsNotFavorite(page: Page, assetId: string) {
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(0);
},
async expectThumbnailIsArchive(page: Page, assetId: string) {
await expect(
thumbnailUtils
.withAssetId(page, assetId)
.locator('path[d="M20 21H4V10H6V19H18V10H20V21M3 3H21V9H3V3M5 5V7H19V5M10.5 11V14H8L12 18L16 14H13.5V11"]'),
).toHaveCount(1);
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(1);
},
async expectThumbnailIsNotArchive(page: Page, assetId: string) {
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
},
async expectSelectedReadonly(page: Page, assetId: string) {
// todo - need a data attribute for selected
@@ -208,10 +204,18 @@ export const pageUtils = {
await page.goto(`/photos`);
await timelineUtils.waitForTimelineLoad(page);
},
async openFavorites(page: Page) {
await page.goto(`/favorites`);
await timelineUtils.waitForTimelineLoad(page);
},
async openAlbumPage(page: Page, albumId: string) {
await page.goto(`/albums/${albumId}`);
await timelineUtils.waitForTimelineLoad(page);
},
async openArchivePage(page: Page) {
await page.goto(`/archive`);
await timelineUtils.waitForTimelineLoad(page);
},
async deepLinkAlbumPage(page: Page, albumId: string, assetId: string) {
await page.goto(`/albums/${albumId}?at=${assetId}`);
await timelineUtils.waitForTimelineLoad(page);

View File

@@ -54,7 +54,7 @@ test.describe('User Administration', () => {
await page.getByRole('button', { name: 'Edit' }).click();
await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByText('Admin User').click();
await page.getByLabel('Admin User').click();
await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click();
@@ -83,7 +83,7 @@ test.describe('User Administration', () => {
await page.getByRole('button', { name: 'Edit' }).click();
await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByText('Admin User').click();
await page.getByLabel('Admin User').click();
await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click();

View File

@@ -9,7 +9,7 @@
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"target": "es2022",
"target": "es2023",
"sourceMap": true,
"outDir": "./dist",
"incremental": true,

View File

@@ -7,6 +7,7 @@
"action_common_update": "Update",
"actions": "Actions",
"active": "Active",
"active_count": "Active: {count}",
"activity": "Activity",
"activity_changed": "Activity is {enabled, select, true {enabled} other {disabled}}",
"add": "Add",
@@ -67,6 +68,7 @@
"confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.",
"confirm_user_password_reset": "Are you sure you want to reset {user}'s password?",
"confirm_user_pin_code_reset": "Are you sure you want to reset {user}'s PIN code?",
"copy_config_to_clipboard_description": "Copy the current system config as a JSON object to the clipboard",
"create_job": "Create job",
"cron_expression": "Cron expression",
"cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. <link>Crontab Guru</link>",
@@ -74,7 +76,8 @@
"disable_login": "Disable login",
"duplicate_detection_job_description": "Run machine learning on assets to detect similar images. Relies on Smart Search",
"exclusion_pattern_description": "Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have folders that contain files you don't want to import, such as RAW files.",
"external_library_management": "External Library Management",
"export_config_as_json_description": "Download the current system config as a JSON file",
"external_libraries_page_description": "Admin external library page",
"face_detection": "Face detection",
"face_detection_description": "Detect the faces in assets using machine learning. For videos, only the thumbnail is considered. \"Refresh\" (re-)processes all assets. \"Reset\" additionally clears all current face data. \"Missing\" queues assets that haven't been processed yet. Detected faces will be queued for Facial Recognition after Face Detection is complete, grouping them into existing or new people.",
"facial_recognition_job_description": "Group detected faces into people. This step runs after Face Detection is complete. \"Reset\" (re-)clusters all faces. \"Missing\" queues faces that don't have a person assigned.",
@@ -102,14 +105,15 @@
"image_thumbnail_description": "Small thumbnail with stripped metadata, used when viewing groups of photos like the main timeline",
"image_thumbnail_quality_description": "Thumbnail quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness.",
"image_thumbnail_title": "Thumbnail Settings",
"import_config_from_json_description": "Import system config by uploading a JSON config file",
"job_concurrency": "{job} concurrency",
"job_created": "Job created",
"job_not_concurrency_safe": "This job is not concurrency-safe.",
"job_settings": "Job Settings",
"job_settings_description": "Manage job concurrency",
"job_status": "Job Status",
"jobs_delayed": "{jobCount, plural, other {# delayed}}",
"jobs_failed": "{jobCount, plural, other {# failed}}",
"jobs_over_time": "Jobs over time",
"library_created": "Created library: {library}",
"library_deleted": "Library deleted",
"library_details": "Library details",
@@ -182,6 +186,7 @@
"maintenance_start": "Start maintenance mode",
"maintenance_start_error": "Failed to start maintenance mode.",
"manage_concurrency": "Manage Concurrency",
"manage_concurrency_description": "Navigate to the jobs page to manage job concurrency",
"manage_log_settings": "Manage log settings",
"map_dark_style": "Dark style",
"map_enable_description": "Enable map features",
@@ -271,10 +276,14 @@
"password_settings_description": "Manage password login settings",
"paths_validated_successfully": "All paths validated successfully",
"person_cleanup_job": "Person cleanup",
"queue_details": "Queue Details",
"queues": "Job Queues",
"queues_page_description": "Admin job queues page",
"quota_size_gib": "Quota Size (GiB)",
"refreshing_all_libraries": "Refreshing all libraries",
"registration": "Admin Registration",
"registration_description": "Since you are the first user on the system, you will be assigned as the Admin and are responsible for administrative tasks, and additional users will be created by you.",
"remove_failed_jobs": "Remove failed jobs",
"require_password_change_on_login": "Require user to change password on first login",
"reset_settings_to_default": "Reset settings to default",
"reset_settings_to_recent_saved": "Reset settings to the recent saved settings",
@@ -287,8 +296,10 @@
"server_public_users_description": "All users (name and email) are listed when adding a user to shared albums. When disabled, the user list will only be available to admin users.",
"server_settings": "Server Settings",
"server_settings_description": "Manage server settings",
"server_stats_page_description": "Admin server statistics page",
"server_welcome_message": "Welcome message",
"server_welcome_message_description": "A message that is displayed on the login page.",
"settings_page_description": "Admin settings page",
"sidecar_job": "Sidecar metadata",
"sidecar_job_description": "Discover or synchronize sidecar metadata from the filesystem",
"slideshow_duration_description": "Number of seconds to display each image",
@@ -407,6 +418,8 @@
"user_restore_scheduled_removal": "Restore user - scheduled removal on {date, date, long}",
"user_settings": "User Settings",
"user_settings_description": "Manage user settings",
"user_successfully_removed": "User {email} has been successfully removed.",
"users_page_description": "Admin users page",
"version_check_enabled_description": "Enable version check",
"version_check_implications": "The version check feature relies on periodic communication with github.com",
"version_check_settings": "Version Check",
@@ -639,6 +652,7 @@
"backup_options_page_title": "Backup options",
"backup_setting_subtitle": "Manage background and foreground upload settings",
"backup_settings_subtitle": "Manage upload settings",
"backup_upload_details_page_more_details": "Tap for more details",
"backward": "Backward",
"biometric_auth_enabled": "Biometric authentication enabled",
"biometric_locked_out": "You are locked out of biometric authentication",
@@ -705,6 +719,7 @@
"check_corrupt_asset_backup_button": "Perform check",
"check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.",
"check_logs": "Check Logs",
"checksum": "Checksum",
"choose_matching_people_to_merge": "Choose matching people to merge",
"city": "City",
"clear": "Clear",
@@ -727,6 +742,7 @@
"collapse_all": "Collapse all",
"color": "Color",
"color_theme": "Color theme",
"command": "Command",
"comment_deleted": "Comment deleted",
"comment_options": "Comment options",
"comments_and_likes": "Comments & likes",
@@ -1091,6 +1107,7 @@
"external_network_sheet_info": "When not on the preferred Wi-Fi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom",
"face_unassigned": "Unassigned",
"failed": "Failed",
"failed_count": "Failed: {count}",
"failed_to_authenticate": "Failed to authenticate",
"failed_to_load_assets": "Failed to load assets",
"failed_to_load_folder": "Failed to load folder",
@@ -1151,6 +1168,7 @@
"header_settings_header_name_input": "Header name",
"header_settings_header_value_input": "Header value",
"headers_settings_tile_title": "Custom proxy headers",
"height": "Height",
"hi_user": "Hi {name} ({email})",
"hide_all_people": "Hide all people",
"hide_gallery": "Hide gallery",
@@ -1273,6 +1291,7 @@
"local": "Local",
"local_asset_cast_failed": "Unable to cast an asset that is not uploaded to the server",
"local_assets": "Local Assets",
"local_id": "Local ID",
"local_media_summary": "Local Media Summary",
"local_network": "Local network",
"local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network",
@@ -1511,6 +1530,7 @@
"other_variables": "Other variables",
"owned": "Owned",
"owner": "Owner",
"page": "Page",
"partner": "Partner",
"partner_can_access": "{partner} can access",
"partner_can_access_assets": "All your photos and videos except those in Archived and Deleted",
@@ -2071,6 +2091,7 @@
"to_select": "to select",
"to_trash": "Trash",
"toggle_settings": "Toggle settings",
"toggle_theme_description": "Toggle theme",
"total": "Total",
"total_usage": "Total usage",
"trash": "Trash",
@@ -2179,6 +2200,7 @@
"view_album": "View Album",
"view_all": "View All",
"view_all_users": "View all users",
"view_asset_owners": "View asset owners",
"view_details": "View Details",
"view_in_timeline": "View in timeline",
"view_link": "View link",
@@ -2195,10 +2217,12 @@
"viewer_unstack": "Un-Stack",
"visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}",
"waiting": "Waiting",
"waiting_count": "Waiting: {count}",
"warning": "Warning",
"week": "Week",
"welcome": "Welcome",
"welcome_to_immich": "Welcome to Immich",
"width": "Width",
"wifi_name": "Wi-Fi Name",
"workflow": "Workflow",
"wrong_pin_code": "Wrong PIN code",

View File

@@ -1,6 +1,6 @@
ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:fc1f2e357c307c4044133952b203e66a47e7726821a664f603a180a0c5823844 AS builder-cpu
FROM python:3.11-bookworm@sha256:e39286476f84ffedf7c3564b0b74e32c9e1193ec9ca32ee8a11f8c09dbf6aafe AS builder-cpu
FROM builder-cpu AS builder-openvino
@@ -22,10 +22,10 @@ FROM builder-cpu AS builder-rknn
# Warning: 25GiB+ disk space required to pull this image
# TODO: find a way to reduce the image size
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS builder-rocm
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:6cda50e312f3aac068cea9ec06c560ca1f522ad546bc8b3d2cf06da0fe8e8a76 AS builder-rocm
# renovate: datasource=github-releases depName=Microsoft/onnxruntime
ARG ONNXRUNTIME_VERSION="v1.20.1"
ARG ONNXRUNTIME_VERSION="v1.22.1"
WORKDIR /code
RUN apt-get update && apt-get install -y --no-install-recommends wget git python3.10-venv
@@ -68,12 +68,12 @@ RUN if [ "$DEVICE" = "rocm" ]; then \
uv pip install /opt/onnxruntime_rocm-*.whl; \
fi
FROM python:3.11-slim-bookworm@sha256:873f91540d53b36327ed4fb018c9669107a4e2a676719720edb4209c4b15d029 AS prod-cpu
FROM python:3.11-slim-bookworm@sha256:2c5bc243b1cc47985ee4fb768bb0bbd4490481c5d0897a62da31b7f30b7304a7 AS prod-cpu
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
MACHINE_LEARNING_MODEL_ARENA=false
FROM python:3.11-slim-bookworm@sha256:873f91540d53b36327ed4fb018c9669107a4e2a676719720edb4209c4b15d029 AS prod-openvino
FROM python:3.11-slim-bookworm@sha256:2c5bc243b1cc47985ee4fb768bb0bbd4490481c5d0897a62da31b7f30b7304a7 AS prod-openvino
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
@@ -102,7 +102,7 @@ COPY --from=builder-cuda /usr/local/bin/python3 /usr/local/bin/python3
COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11
COPY --from=builder-cuda /usr/local/lib/libpython3.11.so /usr/local/lib/libpython3.11.so
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS prod-rocm
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:6cda50e312f3aac068cea9ec06c560ca1f522ad546bc8b3d2cf06da0fe8e8a76 AS prod-rocm
FROM prod-cpu AS prod-armnn

View File

@@ -82,6 +82,7 @@ class TextDetector(InferenceModel):
ratio = float(self.max_resolution) / img.height
else:
ratio = float(self.max_resolution) / img.width
ratio = min(ratio, 1.0)
resize_h = int(img.height * ratio)
resize_w = int(img.width * ratio)

View File

@@ -1,13 +1,13 @@
diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt
index d90a2a355..bb1a7de12 100644
index 2714e6f59..a69da76b4 100644
--- a/cmake/CMakeLists.txt
+++ b/cmake/CMakeLists.txt
@@ -295,7 +295,7 @@ if (onnxruntime_USE_ROCM)
@@ -338,7 +338,7 @@ if (onnxruntime_USE_ROCM)
if (ROCM_VERSION_DEV VERSION_LESS "6.2")
message(FATAL_ERROR "CMAKE_HIP_ARCHITECTURES is not set when ROCm version < 6.2")
else()
- set(CMAKE_HIP_ARCHITECTURES "gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx940;gfx941;gfx942;gfx1200;gfx1201")
+ set(CMAKE_HIP_ARCHITECTURES "gfx900;gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx1102;gfx940;gfx941;gfx942;gfx1200;gfx1201")
endif()
endif()
if (NOT CMAKE_HIP_ARCHITECTURES)
- set(CMAKE_HIP_ARCHITECTURES "gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx940;gfx941;gfx942;gfx1200;gfx1201")
+ set(CMAKE_HIP_ARCHITECTURES "gfx900;gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx1102;gfx940;gfx941;gfx942;gfx1200;gfx1201")
endif()
file(GLOB rocm_cmake_components ${onnxruntime_ROCM_HOME}/lib/cmake/*)

3598
machine-learning/uv.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,12 @@
experimental_monorepo_root = true
[tools]
node = "24.11.0"
node = "24.11.1"
flutter = "3.35.7"
pnpm = "10.20.0"
terragrunt = "0.91.2"
opentofu = "1.10.6"
pnpm = "10.24.0"
terragrunt = "0.93.10"
opentofu = "1.10.7"
java = "25.0.1"
[tools."github:CQLabs/homebrew-dcm"]
version = "1.30.0"

View File

@@ -105,7 +105,6 @@ dependencies {
def serialization_version = '1.8.1'
def compose_version = '1.1.1'
def gson_version = '2.10.1'
def room_version = "2.8.3"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
@@ -114,8 +113,6 @@ dependencies {
implementation "com.google.guava:guava:$guava_version"
implementation "com.github.bumptech.glide:glide:$glide_version"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$serialization_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.10.2"
implementation "com.squareup.okhttp3:okhttp:5.3.1"
ksp "com.github.bumptech.glide:ksp:$glide_version"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2'
@@ -130,10 +127,6 @@ dependencies {
implementation "androidx.compose.ui:ui-tooling:$compose_version"
implementation "androidx.compose.material3:material3:1.2.1"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.2"
// Room Database
implementation "androidx.room:room-runtime:$room_version"
ksp "androidx.room:room-compiler:$room_version"
}
// This is uncommented in F-Droid build script

View File

@@ -7,13 +7,11 @@ import androidx.work.Configuration
import androidx.work.WorkManager
import app.alextran.immich.background.BackgroundEngineLock
import app.alextran.immich.background.BackgroundWorkerApiImpl
import app.alextran.immich.upload.NetworkMonitor
class ImmichApp : Application() {
override fun onCreate() {
super.onCreate()
val config = Configuration.Builder().build()
NetworkMonitor.initialize(this)
WorkManager.initialize(this, config)
// always start BackupWorker after WorkManager init; this fixes the following bug:
// After the process is killed (by user or system), the first trigger (taking a new picture) is lost.

View File

@@ -15,8 +15,6 @@ import app.alextran.immich.images.ThumbnailsImpl
import app.alextran.immich.sync.NativeSyncApi
import app.alextran.immich.sync.NativeSyncApiImpl26
import app.alextran.immich.sync.NativeSyncApiImpl30
import app.alextran.immich.upload.UploadApi
import app.alextran.immich.upload.UploadTaskImpl
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
@@ -41,7 +39,6 @@ class MainActivity : FlutterFragmentActivity() {
ThumbnailApi.setUp(messenger, ThumbnailsImpl(ctx))
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
UploadApi.setUp(messenger, UploadTaskImpl(ctx))
flutterEngine.plugins.add(BackgroundServicePlugin())
flutterEngine.plugins.add(HttpSSLOptionsPlugin())

View File

@@ -1,114 +0,0 @@
package app.alextran.immich.schema
import androidx.room.TypeConverter
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.net.URL
import java.util.Date
class Converters {
private val gson = Gson()
@TypeConverter
fun fromTimestamp(value: Long?): Date? = value?.let { Date(it * 1000) }
@TypeConverter
fun dateToTimestamp(date: Date?): Long? = date?.let { it.time / 1000 }
@TypeConverter
fun fromUrl(value: String?): URL? = value?.let { URL(it) }
@TypeConverter
fun urlToString(url: URL?): String? = url?.toString()
@TypeConverter
fun fromStoreKey(value: Int?): StoreKey? = value?.let { StoreKey.fromInt(it) }
@TypeConverter
fun storeKeyToInt(storeKey: StoreKey?): Int? = storeKey?.rawValue
@TypeConverter
fun fromTaskStatus(value: Int?): TaskStatus? = value?.let { TaskStatus.entries[it] }
@TypeConverter
fun taskStatusToInt(status: TaskStatus?): Int? = status?.ordinal
@TypeConverter
fun fromBackupSelection(value: Int?): BackupSelection? = value?.let { BackupSelection.entries[it] }
@TypeConverter
fun backupSelectionToInt(selection: BackupSelection?): Int? = selection?.ordinal
@TypeConverter
fun fromAvatarColor(value: Int?): AvatarColor? = value?.let { AvatarColor.entries[it] }
@TypeConverter
fun avatarColorToInt(color: AvatarColor?): Int? = color?.ordinal
@TypeConverter
fun fromAlbumUserRole(value: Int?): AlbumUserRole? = value?.let { AlbumUserRole.entries[it] }
@TypeConverter
fun albumUserRoleToInt(role: AlbumUserRole?): Int? = role?.ordinal
@TypeConverter
fun fromMemoryType(value: Int?): MemoryType? = value?.let { MemoryType.entries[it] }
@TypeConverter
fun memoryTypeToInt(type: MemoryType?): Int? = type?.ordinal
@TypeConverter
fun fromAssetVisibility(value: Int?): AssetVisibility? = value?.let { AssetVisibility.entries[it] }
@TypeConverter
fun assetVisibilityToInt(visibility: AssetVisibility?): Int? = visibility?.ordinal
@TypeConverter
fun fromSourceType(value: String?): SourceType? = value?.let { SourceType.fromString(it) }
@TypeConverter
fun sourceTypeToString(type: SourceType?): String? = type?.value
@TypeConverter
fun fromUploadMethod(value: Int?): UploadMethod? = value?.let { UploadMethod.entries[it] }
@TypeConverter
fun uploadMethodToInt(method: UploadMethod?): Int? = method?.ordinal
@TypeConverter
fun fromUploadErrorCode(value: Int?): UploadErrorCode? = value?.let { UploadErrorCode.entries[it] }
@TypeConverter
fun uploadErrorCodeToInt(code: UploadErrorCode?): Int? = code?.ordinal
@TypeConverter
fun fromAssetType(value: Int?): AssetType? = value?.let { AssetType.entries[it] }
@TypeConverter
fun assetTypeToInt(type: AssetType?): Int? = type?.ordinal
@TypeConverter
fun fromStringMap(value: String?): Map<String, String>? {
val type = object : TypeToken<Map<String, String>>() {}.type
return gson.fromJson(value, type)
}
@TypeConverter
fun stringMapToString(map: Map<String, String>?): String? = gson.toJson(map)
@TypeConverter
fun fromEndpointStatus(value: String?): EndpointStatus? = value?.let { EndpointStatus.fromString(it) }
@TypeConverter
fun endpointStatusToString(status: EndpointStatus?): String? = status?.value
@TypeConverter
fun fromEndpointList(value: String?): List<Endpoint>? {
val type = object : TypeToken<List<Endpoint>>() {}.type
return gson.fromJson(value, type)
}
@TypeConverter
fun endpointListToString(list: List<Endpoint>?): String? = gson.toJson(list)
}

View File

@@ -1,59 +0,0 @@
package app.alextran.immich.schema
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@Database(
entities = [
AssetFace::class,
AuthUser::class,
LocalAlbum::class,
LocalAlbumAsset::class,
LocalAsset::class,
MemoryAsset::class,
Memory::class,
Partner::class,
Person::class,
RemoteAlbum::class,
RemoteAlbumAsset::class,
RemoteAlbumUser::class,
RemoteAsset::class,
RemoteExif::class,
Stack::class,
Store::class,
UploadTask::class,
UploadTaskStat::class,
User::class,
UserMetadata::class
],
version = 1,
exportSchema = false
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun localAssetDao(): LocalAssetDao
abstract fun storeDao(): StoreDao
abstract fun uploadTaskDao(): UploadTaskDao
abstract fun uploadTaskStatDao(): UploadTaskStatDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app_database"
).build()
INSTANCE = instance
instance
}
}
}
}

View File

@@ -1,267 +0,0 @@
package app.alextran.immich.schema
import java.net.URL
import java.util.Date
enum class StoreKey(val rawValue: Int) {
VERSION(0),
DEVICE_ID_HASH(3),
BACKUP_TRIGGER_DELAY(8),
TILES_PER_ROW(103),
GROUP_ASSETS_BY(105),
UPLOAD_ERROR_NOTIFICATION_GRACE_PERIOD(106),
THUMBNAIL_CACHE_SIZE(110),
IMAGE_CACHE_SIZE(111),
ALBUM_THUMBNAIL_CACHE_SIZE(112),
SELECTED_ALBUM_SORT_ORDER(113),
LOG_LEVEL(115),
MAP_RELATIVE_DATE(119),
MAP_THEME_MODE(124),
ASSET_ETAG(1),
CURRENT_USER(2),
DEVICE_ID(4),
ACCESS_TOKEN(11),
SERVER_ENDPOINT(12),
SSL_CLIENT_CERT_DATA(15),
SSL_CLIENT_PASSWD(16),
THEME_MODE(102),
CUSTOM_HEADERS(127),
PRIMARY_COLOR(128),
PREFERRED_WIFI_NAME(133),
EXTERNAL_ENDPOINT_LIST(135),
LOCAL_ENDPOINT(134),
SERVER_URL(10),
BACKUP_FAILED_SINCE(5),
BACKUP_REQUIRE_WIFI(6),
BACKUP_REQUIRE_CHARGING(7),
AUTO_BACKUP(13),
BACKGROUND_BACKUP(14),
LOAD_PREVIEW(100),
LOAD_ORIGINAL(101),
DYNAMIC_LAYOUT(104),
BACKGROUND_BACKUP_TOTAL_PROGRESS(107),
BACKGROUND_BACKUP_SINGLE_PROGRESS(108),
STORAGE_INDICATOR(109),
ADVANCED_TROUBLESHOOTING(114),
PREFER_REMOTE_IMAGE(116),
LOOP_VIDEO(117),
MAP_SHOW_FAVORITE_ONLY(118),
SELF_SIGNED_CERT(120),
MAP_INCLUDE_ARCHIVED(121),
IGNORE_ICLOUD_ASSETS(122),
SELECTED_ALBUM_SORT_REVERSE(123),
MAP_WITH_PARTNERS(125),
ENABLE_HAPTIC_FEEDBACK(126),
DYNAMIC_THEME(129),
COLORFUL_INTERFACE(130),
SYNC_ALBUMS(131),
AUTO_ENDPOINT_SWITCHING(132),
LOAD_ORIGINAL_VIDEO(136),
MANAGE_LOCAL_MEDIA_ANDROID(137),
READONLY_MODE_ENABLED(138),
AUTO_PLAY_VIDEO(139),
PHOTO_MANAGER_CUSTOM_FILTER(1000),
BETA_PROMPT_SHOWN(1001),
BETA_TIMELINE(1002),
ENABLE_BACKUP(1003),
USE_WIFI_FOR_UPLOAD_VIDEOS(1004),
USE_WIFI_FOR_UPLOAD_PHOTOS(1005),
NEED_BETA_MIGRATION(1006),
SHOULD_RESET_SYNC(1007);
companion object {
fun fromInt(value: Int): StoreKey? = entries.find { it.rawValue == value }
// Int keys
val version = TypedStoreKey<Int>(VERSION)
val deviceIdHash = TypedStoreKey<Int>(DEVICE_ID_HASH)
val backupTriggerDelay = TypedStoreKey<Int>(BACKUP_TRIGGER_DELAY)
val tilesPerRow = TypedStoreKey<Int>(TILES_PER_ROW)
val groupAssetsBy = TypedStoreKey<Int>(GROUP_ASSETS_BY)
val uploadErrorNotificationGracePeriod = TypedStoreKey<Int>(UPLOAD_ERROR_NOTIFICATION_GRACE_PERIOD)
val thumbnailCacheSize = TypedStoreKey<Int>(THUMBNAIL_CACHE_SIZE)
val imageCacheSize = TypedStoreKey<Int>(IMAGE_CACHE_SIZE)
val albumThumbnailCacheSize = TypedStoreKey<Int>(ALBUM_THUMBNAIL_CACHE_SIZE)
val selectedAlbumSortOrder = TypedStoreKey<Int>(SELECTED_ALBUM_SORT_ORDER)
val logLevel = TypedStoreKey<Int>(LOG_LEVEL)
val mapRelativeDate = TypedStoreKey<Int>(MAP_RELATIVE_DATE)
val mapThemeMode = TypedStoreKey<Int>(MAP_THEME_MODE)
// String keys
val assetETag = TypedStoreKey<String>(ASSET_ETAG)
val currentUser = TypedStoreKey<String>(CURRENT_USER)
val deviceId = TypedStoreKey<String>(DEVICE_ID)
val accessToken = TypedStoreKey<String>(ACCESS_TOKEN)
val sslClientCertData = TypedStoreKey<String>(SSL_CLIENT_CERT_DATA)
val sslClientPasswd = TypedStoreKey<String>(SSL_CLIENT_PASSWD)
val themeMode = TypedStoreKey<String>(THEME_MODE)
val customHeaders = TypedStoreKey<Map<String, String>>(CUSTOM_HEADERS)
val primaryColor = TypedStoreKey<String>(PRIMARY_COLOR)
val preferredWifiName = TypedStoreKey<String>(PREFERRED_WIFI_NAME)
// Endpoint keys
val externalEndpointList = TypedStoreKey<List<Endpoint>>(EXTERNAL_ENDPOINT_LIST)
// URL keys
val localEndpoint = TypedStoreKey<URL>(LOCAL_ENDPOINT)
val serverEndpoint = TypedStoreKey<URL>(SERVER_ENDPOINT)
val serverUrl = TypedStoreKey<URL>(SERVER_URL)
// Date keys
val backupFailedSince = TypedStoreKey<Date>(BACKUP_FAILED_SINCE)
// Bool keys
val backupRequireWifi = TypedStoreKey<Boolean>(BACKUP_REQUIRE_WIFI)
val backupRequireCharging = TypedStoreKey<Boolean>(BACKUP_REQUIRE_CHARGING)
val autoBackup = TypedStoreKey<Boolean>(AUTO_BACKUP)
val backgroundBackup = TypedStoreKey<Boolean>(BACKGROUND_BACKUP)
val loadPreview = TypedStoreKey<Boolean>(LOAD_PREVIEW)
val loadOriginal = TypedStoreKey<Boolean>(LOAD_ORIGINAL)
val dynamicLayout = TypedStoreKey<Boolean>(DYNAMIC_LAYOUT)
val backgroundBackupTotalProgress = TypedStoreKey<Boolean>(BACKGROUND_BACKUP_TOTAL_PROGRESS)
val backgroundBackupSingleProgress = TypedStoreKey<Boolean>(BACKGROUND_BACKUP_SINGLE_PROGRESS)
val storageIndicator = TypedStoreKey<Boolean>(STORAGE_INDICATOR)
val advancedTroubleshooting = TypedStoreKey<Boolean>(ADVANCED_TROUBLESHOOTING)
val preferRemoteImage = TypedStoreKey<Boolean>(PREFER_REMOTE_IMAGE)
val loopVideo = TypedStoreKey<Boolean>(LOOP_VIDEO)
val mapShowFavoriteOnly = TypedStoreKey<Boolean>(MAP_SHOW_FAVORITE_ONLY)
val selfSignedCert = TypedStoreKey<Boolean>(SELF_SIGNED_CERT)
val mapIncludeArchived = TypedStoreKey<Boolean>(MAP_INCLUDE_ARCHIVED)
val ignoreIcloudAssets = TypedStoreKey<Boolean>(IGNORE_ICLOUD_ASSETS)
val selectedAlbumSortReverse = TypedStoreKey<Boolean>(SELECTED_ALBUM_SORT_REVERSE)
val mapwithPartners = TypedStoreKey<Boolean>(MAP_WITH_PARTNERS)
val enableHapticFeedback = TypedStoreKey<Boolean>(ENABLE_HAPTIC_FEEDBACK)
val dynamicTheme = TypedStoreKey<Boolean>(DYNAMIC_THEME)
val colorfulInterface = TypedStoreKey<Boolean>(COLORFUL_INTERFACE)
val syncAlbums = TypedStoreKey<Boolean>(SYNC_ALBUMS)
val autoEndpointSwitching = TypedStoreKey<Boolean>(AUTO_ENDPOINT_SWITCHING)
val loadOriginalVideo = TypedStoreKey<Boolean>(LOAD_ORIGINAL_VIDEO)
val manageLocalMediaAndroid = TypedStoreKey<Boolean>(MANAGE_LOCAL_MEDIA_ANDROID)
val readonlyModeEnabled = TypedStoreKey<Boolean>(READONLY_MODE_ENABLED)
val autoPlayVideo = TypedStoreKey<Boolean>(AUTO_PLAY_VIDEO)
val photoManagerCustomFilter = TypedStoreKey<Boolean>(PHOTO_MANAGER_CUSTOM_FILTER)
val betaPromptShown = TypedStoreKey<Boolean>(BETA_PROMPT_SHOWN)
val betaTimeline = TypedStoreKey<Boolean>(BETA_TIMELINE)
val enableBackup = TypedStoreKey<Boolean>(ENABLE_BACKUP)
val useWifiForUploadVideos = TypedStoreKey<Boolean>(USE_WIFI_FOR_UPLOAD_VIDEOS)
val useWifiForUploadPhotos = TypedStoreKey<Boolean>(USE_WIFI_FOR_UPLOAD_PHOTOS)
val needBetaMigration = TypedStoreKey<Boolean>(NEED_BETA_MIGRATION)
val shouldResetSync = TypedStoreKey<Boolean>(SHOULD_RESET_SYNC)
}
}
enum class TaskStatus {
DOWNLOAD_PENDING,
DOWNLOAD_QUEUED,
DOWNLOAD_FAILED,
UPLOAD_PENDING,
UPLOAD_QUEUED,
UPLOAD_FAILED,
UPLOAD_COMPLETE
}
enum class BackupSelection {
SELECTED,
NONE,
EXCLUDED
}
enum class AvatarColor {
PRIMARY,
PINK,
RED,
YELLOW,
BLUE,
GREEN,
PURPLE,
ORANGE,
GRAY,
AMBER
}
enum class AlbumUserRole {
EDITOR,
VIEWER
}
enum class MemoryType {
ON_THIS_DAY
}
enum class AssetVisibility {
TIMELINE,
HIDDEN,
ARCHIVE,
LOCKED
}
enum class SourceType(val value: String) {
MACHINE_LEARNING("machine-learning"),
EXIF("exif"),
MANUAL("manual");
companion object {
fun fromString(value: String): SourceType? = entries.find { it.value == value }
}
}
enum class UploadMethod {
MULTIPART,
RESUMABLE
}
enum class UploadErrorCode {
UNKNOWN,
ASSET_NOT_FOUND,
FILE_NOT_FOUND,
RESOURCE_NOT_FOUND,
INVALID_RESOURCE,
ENCODING_FAILED,
WRITE_FAILED,
NOT_ENOUGH_SPACE,
NETWORK_ERROR,
PHOTOS_INTERNAL_ERROR,
PHOTOS_UNKNOWN_ERROR,
NO_SERVER_URL,
NO_DEVICE_ID,
NO_ACCESS_TOKEN,
INTERRUPTED,
CANCELLED,
DOWNLOAD_STALLED,
FORCE_QUIT,
OUT_OF_RESOURCES,
BACKGROUND_UPDATES_DISABLED,
UPLOAD_TIMEOUT,
ICLOUD_RATE_LIMIT,
ICLOUD_THROTTLED,
INVALID_SERVER_RESPONSE,
}
enum class AssetType {
OTHER,
IMAGE,
VIDEO,
AUDIO
}
enum class EndpointStatus(val value: String) {
LOADING("loading"),
VALID("valid"),
ERROR("error"),
UNKNOWN("unknown");
companion object {
fun fromString(value: String): EndpointStatus? = entries.find { it.value == value }
}
}
// Endpoint data class
data class Endpoint(
val url: String,
val status: EndpointStatus
)

View File

@@ -1,168 +0,0 @@
package app.alextran.immich.schema
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import app.alextran.immich.upload.TaskConfig
import java.util.Date
@Dao
interface LocalAssetDao {
@Query("""
SELECT a.id, a.type FROM local_asset_entity a
WHERE EXISTS (
SELECT 1 FROM local_album_asset_entity laa
INNER JOIN local_album_entity la ON laa.album_id = la.id
WHERE laa.asset_id = a.id
AND la.backup_selection = 0 -- selected
)
AND NOT EXISTS (
SELECT 1 FROM local_album_asset_entity laa2
INNER JOIN local_album_entity la2 ON laa2.album_id = la2.id
WHERE laa2.asset_id = a.id
AND la2.backup_selection = 2 -- excluded
)
AND NOT EXISTS (
SELECT 1 FROM remote_asset_entity ra
WHERE ra.checksum = a.checksum
AND ra.owner_id = (SELECT string_value FROM store_entity WHERE id = 14) -- current_user
)
AND NOT EXISTS (
SELECT 1 FROM upload_tasks ut
WHERE ut.local_id = a.id
)
LIMIT :limit
""")
suspend fun getCandidatesForBackup(limit: Int): List<BackupCandidate>
}
@Dao
interface StoreDao {
@Query("SELECT * FROM store_entity WHERE id = :key")
suspend fun get(key: StoreKey): Store?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(store: Store)
// Extension functions for type-safe access
suspend fun <T> get(
typedKey: TypedStoreKey<T>,
storage: StorageType<T>
): T? {
val store = get(typedKey.key) ?: return null
return when (storage) {
is StorageType.IntStorage,
is StorageType.BoolStorage,
is StorageType.DateStorage -> {
store.intValue?.let { storage.fromDb(it) }
}
else -> {
store.stringValue?.let { storage.fromDb(it) }
}
}
}
suspend fun <T> set(
typedKey: TypedStoreKey<T>,
value: T,
storage: StorageType<T>
) {
val dbValue = storage.toDb(value)
val store = when (storage) {
is StorageType.IntStorage,
is StorageType.BoolStorage,
is StorageType.DateStorage -> {
Store(
id = typedKey.key,
stringValue = null,
intValue = dbValue as Int
)
}
else -> {
Store(
id = typedKey.key,
stringValue = dbValue as String,
intValue = null
)
}
}
insert(store)
}
}
@Dao
interface UploadTaskDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertAll(tasks: List<UploadTask>)
@Query("""
SELECT id FROM upload_tasks
WHERE status IN (:statuses)
""")
suspend fun getTaskIdsByStatus(statuses: List<TaskStatus>): List<Long>
@Query("""
UPDATE upload_tasks
SET status = 3, -- upload_pending
file_path = NULL,
attempts = 0
WHERE id IN (:taskIds)
""")
suspend fun resetOrphanedTasks(taskIds: List<Long>)
@Query("""
SELECT
t.attempts,
a.checksum,
a.created_at as createdAt,
a.name as fileName,
t.file_path as filePath,
a.is_favorite as isFavorite,
a.id as localId,
t.priority,
t.id as taskId,
a.type,
a.updated_at as updatedAt
FROM upload_tasks t
INNER JOIN local_asset_entity a ON t.local_id = a.id
WHERE t.status = 3 -- upload_pending
AND t.attempts < :maxAttempts
AND a.checksum IS NOT NULL
AND (t.retry_after IS NULL OR t.retry_after <= :currentTime)
ORDER BY t.priority DESC, t.created_at ASC
LIMIT :limit
""")
suspend fun getTasksForUpload(limit: Int, maxAttempts: Int = TaskConfig.MAX_ATTEMPTS, currentTime: Long = System.currentTimeMillis() / 1000): List<LocalAssetTaskData>
@Query("SELECT EXISTS(SELECT 1 FROM upload_tasks WHERE status = 3 LIMIT 1)") // upload_pending
suspend fun hasPendingTasks(): Boolean
@Query("""
UPDATE upload_tasks
SET attempts = :attempts,
last_error = :errorCode,
status = :status,
retry_after = :retryAfter
WHERE id = :taskId
""")
suspend fun updateTaskAfterFailure(
taskId: Long,
attempts: Int,
errorCode: UploadErrorCode,
status: TaskStatus,
retryAfter: Date?
)
@Query("UPDATE upload_tasks SET status = :status WHERE id = :id")
suspend fun updateStatus(id: Long, status: TaskStatus)
}
@Dao
interface UploadTaskStatDao {
@Query("SELECT * FROM upload_task_stats")
suspend fun getStats(): UploadTaskStat?
}

View File

@@ -1,93 +0,0 @@
package app.alextran.immich.schema
import com.google.gson.Gson
import java.net.URL
import java.util.Date
// Sealed interface representing storage types
sealed interface StorageType<T> {
fun toDb(value: T): Any
fun fromDb(value: Any): T
data object IntStorage : StorageType<Int> {
override fun toDb(value: Int) = value
override fun fromDb(value: Any) = value as Int
}
data object BoolStorage : StorageType<Boolean> {
override fun toDb(value: Boolean) = if (value) 1 else 0
override fun fromDb(value: Any) = (value as Int) == 1
}
data object StringStorage : StorageType<String> {
override fun toDb(value: String) = value
override fun fromDb(value: Any) = value as String
}
data object DateStorage : StorageType<Date> {
override fun toDb(value: Date) = value.time / 1000
override fun fromDb(value: Any) = Date((value as Long) * 1000)
}
data object UrlStorage : StorageType<URL> {
override fun toDb(value: URL) = value.toString()
override fun fromDb(value: Any) = URL(value as String)
}
class JsonStorage<T>(
private val clazz: Class<T>,
private val gson: Gson = Gson()
) : StorageType<T> {
override fun toDb(value: T) = gson.toJson(value)
override fun fromDb(value: Any) = gson.fromJson(value as String, clazz)
}
}
// Typed key wrapper
@JvmInline
value class TypedStoreKey<T>(val key: StoreKey) {
companion object {
// Factory methods for type-safe key creation
inline fun <reified T> of(key: StoreKey): TypedStoreKey<T> = TypedStoreKey(key)
}
}
// Registry mapping keys to their storage types
object StoreRegistry {
private val intKeys = setOf(
StoreKey.VERSION,
StoreKey.DEVICE_ID_HASH,
StoreKey.BACKUP_TRIGGER_DELAY
)
private val stringKeys = setOf(
StoreKey.CURRENT_USER,
StoreKey.DEVICE_ID,
StoreKey.ACCESS_TOKEN
)
fun usesIntStorage(key: StoreKey): Boolean = key in intKeys
fun usesStringStorage(key: StoreKey): Boolean = key in stringKeys
}
// Storage type registry for automatic selection
@Suppress("UNCHECKED_CAST")
object StorageTypes {
inline fun <reified T> get(): StorageType<T> = when (T::class) {
Int::class -> StorageType.IntStorage as StorageType<T>
Boolean::class -> StorageType.BoolStorage as StorageType<T>
String::class -> StorageType.StringStorage as StorageType<T>
Date::class -> StorageType.DateStorage as StorageType<T>
URL::class -> StorageType.UrlStorage as StorageType<T>
else -> StorageType.JsonStorage(T::class.java)
}
}
// Simplified extension functions with automatic storage
suspend inline fun <reified T> StoreDao.get(typedKey: TypedStoreKey<T>): T? {
return get(typedKey, StorageTypes.get<T>())
}
suspend inline fun <reified T> StoreDao.set(typedKey: TypedStoreKey<T>, value: T) {
set(typedKey, value, StorageTypes.get<T>())
}

View File

@@ -1,405 +0,0 @@
package app.alextran.immich.schema
import androidx.room.*
import java.net.URL
import java.util.Date
@Entity(tableName = "asset_face_entity")
data class AssetFace(
@PrimaryKey
val id: String,
@ColumnInfo(name = "asset_id")
val assetId: String,
@ColumnInfo(name = "person_id")
val personId: String?,
@ColumnInfo(name = "image_width")
val imageWidth: Int,
@ColumnInfo(name = "image_height")
val imageHeight: Int,
@ColumnInfo(name = "bounding_box_x1")
val boundingBoxX1: Int,
@ColumnInfo(name = "bounding_box_y1")
val boundingBoxY1: Int,
@ColumnInfo(name = "bounding_box_x2")
val boundingBoxX2: Int,
@ColumnInfo(name = "bounding_box_y2")
val boundingBoxY2: Int,
@ColumnInfo(name = "source_type")
val sourceType: SourceType
)
@Entity(tableName = "auth_user_entity")
data class AuthUser(
@PrimaryKey
val id: String,
val name: String,
val email: String,
@ColumnInfo(name = "is_admin")
val isAdmin: Boolean,
@ColumnInfo(name = "has_profile_image")
val hasProfileImage: Boolean,
@ColumnInfo(name = "profile_changed_at")
val profileChangedAt: Date,
@ColumnInfo(name = "avatar_color")
val avatarColor: AvatarColor,
@ColumnInfo(name = "quota_size_in_bytes")
val quotaSizeInBytes: Int,
@ColumnInfo(name = "quota_usage_in_bytes")
val quotaUsageInBytes: Int,
@ColumnInfo(name = "pin_code")
val pinCode: String?
)
@Entity(tableName = "local_album_entity")
data class LocalAlbum(
@PrimaryKey
val id: String,
@ColumnInfo(name = "backup_selection")
val backupSelection: BackupSelection,
@ColumnInfo(name = "linked_remote_album_id")
val linkedRemoteAlbumId: String?,
@ColumnInfo(name = "marker")
val marker: Boolean?,
val name: String,
@ColumnInfo(name = "is_ios_shared_album")
val isIosSharedAlbum: Boolean,
@ColumnInfo(name = "updated_at")
val updatedAt: Date
)
@Entity(
tableName = "local_album_asset_entity",
primaryKeys = ["asset_id", "album_id"]
)
data class LocalAlbumAsset(
@ColumnInfo(name = "asset_id")
val assetId: String,
@ColumnInfo(name = "album_id")
val albumId: String,
@ColumnInfo(name = "marker")
val marker: String?
)
@Entity(tableName = "local_asset_entity")
data class LocalAsset(
@PrimaryKey
val id: String,
val checksum: String?,
@ColumnInfo(name = "created_at")
val createdAt: Date,
@ColumnInfo(name = "duration_in_seconds")
val durationInSeconds: Int?,
val height: Int?,
@ColumnInfo(name = "is_favorite")
val isFavorite: Boolean,
val name: String,
val orientation: String,
val type: AssetType,
@ColumnInfo(name = "updated_at")
val updatedAt: Date,
val width: Int?
)
data class BackupCandidate(
val id: String,
val type: AssetType
)
@Entity(
tableName = "memory_asset_entity",
primaryKeys = ["asset_id", "album_id"]
)
data class MemoryAsset(
@ColumnInfo(name = "asset_id")
val assetId: String,
@ColumnInfo(name = "album_id")
val albumId: String
)
@Entity(tableName = "memory_entity")
data class Memory(
@PrimaryKey
val id: String,
@ColumnInfo(name = "created_at")
val createdAt: Date,
@ColumnInfo(name = "updated_at")
val updatedAt: Date,
@ColumnInfo(name = "deleted_at")
val deletedAt: Date?,
@ColumnInfo(name = "owner_id")
val ownerId: String,
val type: MemoryType,
val data: String,
@ColumnInfo(name = "is_saved")
val isSaved: Boolean,
@ColumnInfo(name = "memory_at")
val memoryAt: Date,
@ColumnInfo(name = "seen_at")
val seenAt: Date?,
@ColumnInfo(name = "show_at")
val showAt: Date?,
@ColumnInfo(name = "hide_at")
val hideAt: Date?
)
@Entity(
tableName = "partner_entity",
primaryKeys = ["shared_by_id", "shared_with_id"]
)
data class Partner(
@ColumnInfo(name = "shared_by_id")
val sharedById: String,
@ColumnInfo(name = "shared_with_id")
val sharedWithId: String,
@ColumnInfo(name = "in_timeline")
val inTimeline: Boolean
)
@Entity(tableName = "person_entity")
data class Person(
@PrimaryKey
val id: String,
@ColumnInfo(name = "created_at")
val createdAt: Date,
@ColumnInfo(name = "updated_at")
val updatedAt: Date,
@ColumnInfo(name = "owner_id")
val ownerId: String,
val name: String,
@ColumnInfo(name = "face_asset_id")
val faceAssetId: String?,
@ColumnInfo(name = "is_favorite")
val isFavorite: Boolean,
@ColumnInfo(name = "is_hidden")
val isHidden: Boolean,
val color: String?,
@ColumnInfo(name = "birth_date")
val birthDate: Date?
)
@Entity(tableName = "remote_album_entity")
data class RemoteAlbum(
@PrimaryKey
val id: String,
@ColumnInfo(name = "created_at")
val createdAt: Date,
val description: String?,
@ColumnInfo(name = "is_activity_enabled")
val isActivityEnabled: Boolean,
val name: String,
val order: Int,
@ColumnInfo(name = "owner_id")
val ownerId: String,
@ColumnInfo(name = "thumbnail_asset_id")
val thumbnailAssetId: String?,
@ColumnInfo(name = "updated_at")
val updatedAt: Date
)
@Entity(
tableName = "remote_album_asset_entity",
primaryKeys = ["asset_id", "album_id"]
)
data class RemoteAlbumAsset(
@ColumnInfo(name = "asset_id")
val assetId: String,
@ColumnInfo(name = "album_id")
val albumId: String
)
@Entity(
tableName = "remote_album_user_entity",
primaryKeys = ["album_id", "user_id"]
)
data class RemoteAlbumUser(
@ColumnInfo(name = "album_id")
val albumId: String,
@ColumnInfo(name = "user_id")
val userId: String,
val role: AlbumUserRole
)
@Entity(tableName = "remote_asset_entity")
data class RemoteAsset(
@PrimaryKey
val id: String,
val checksum: String,
@ColumnInfo(name = "is_favorite")
val isFavorite: Boolean,
@ColumnInfo(name = "deleted_at")
val deletedAt: Date?,
@ColumnInfo(name = "owner_id")
val ownerId: String,
@ColumnInfo(name = "local_date_time")
val localDateTime: Date?,
@ColumnInfo(name = "thumb_hash")
val thumbHash: String?,
@ColumnInfo(name = "library_id")
val libraryId: String?,
@ColumnInfo(name = "live_photo_video_id")
val livePhotoVideoId: String?,
@ColumnInfo(name = "stack_id")
val stackId: String?,
val visibility: AssetVisibility
)
@Entity(tableName = "remote_exif_entity")
data class RemoteExif(
@PrimaryKey
@ColumnInfo(name = "asset_id")
val assetId: String,
val city: String?,
val state: String?,
val country: String?,
@ColumnInfo(name = "date_time_original")
val dateTimeOriginal: Date?,
val description: String?,
val height: Int?,
val width: Int?,
@ColumnInfo(name = "exposure_time")
val exposureTime: String?,
@ColumnInfo(name = "f_number")
val fNumber: Double?,
@ColumnInfo(name = "file_size")
val fileSize: Int?,
@ColumnInfo(name = "focal_length")
val focalLength: Double?,
val latitude: Double?,
val longitude: Double?,
val iso: Int?,
val make: String?,
val model: String?,
val lens: String?,
val orientation: String?,
@ColumnInfo(name = "time_zone")
val timeZone: String?,
val rating: Int?,
@ColumnInfo(name = "projection_type")
val projectionType: String?
)
@Entity(tableName = "stack_entity")
data class Stack(
@PrimaryKey
val id: String,
@ColumnInfo(name = "created_at")
val createdAt: Date,
@ColumnInfo(name = "updated_at")
val updatedAt: Date,
@ColumnInfo(name = "owner_id")
val ownerId: String,
@ColumnInfo(name = "primary_asset_id")
val primaryAssetId: String
)
@Entity(tableName = "store_entity")
data class Store(
@PrimaryKey
val id: StoreKey,
@ColumnInfo(name = "string_value")
val stringValue: String?,
@ColumnInfo(name = "int_value")
val intValue: Int?
)
@Entity(tableName = "upload_task_entity")
data class UploadTask(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val attempts: Int,
@ColumnInfo(name = "created_at")
val createdAt: Date,
@ColumnInfo(name = "file_path")
val filePath: URL?,
@ColumnInfo(name = "is_live_photo")
val isLivePhoto: Boolean?,
@ColumnInfo(name = "last_error")
val lastError: UploadErrorCode?,
@ColumnInfo(name = "live_photo_video_id")
val livePhotoVideoId: String?,
@ColumnInfo(name = "local_id")
val localId: String,
val method: UploadMethod,
val priority: Float,
@ColumnInfo(name = "retry_after")
val retryAfter: Date?,
val status: TaskStatus
)
// Data class for query results
data class LocalAssetTaskData(
val attempts: Int,
val checksum: String,
val createdAt: Date,
val fileName: String,
val filePath: URL?,
val isFavorite: Boolean,
val localId: String,
val priority: Float,
val taskId: Long,
val type: AssetType,
val updatedAt: Date
)
@Entity(tableName = "upload_task_stats")
data class UploadTaskStat(
@ColumnInfo(name = "pending_downloads")
val pendingDownloads: Int,
@ColumnInfo(name = "pending_uploads")
val pendingUploads: Int,
@ColumnInfo(name = "queued_downloads")
val queuedDownloads: Int,
@ColumnInfo(name = "queued_uploads")
val queuedUploads: Int,
@ColumnInfo(name = "failed_downloads")
val failedDownloads: Int,
@ColumnInfo(name = "failed_uploads")
val failedUploads: Int,
@ColumnInfo(name = "completed_uploads")
val completedUploads: Int
)
@Entity(tableName = "user_entity")
data class User(
@PrimaryKey
val id: String,
val name: String,
val email: String,
@ColumnInfo(name = "has_profile_image")
val hasProfileImage: Boolean,
@ColumnInfo(name = "profile_changed_at")
val profileChangedAt: Date,
@ColumnInfo(name = "avatar_color")
val avatarColor: AvatarColor
)
@Entity(
tableName = "user_metadata_entity",
primaryKeys = ["user_id", "key"]
)
data class UserMetadata(
@ColumnInfo(name = "user_id")
val userId: String,
val key: Date,
val value: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as UserMetadata
if (userId != other.userId) return false
if (key != other.key) return false
if (!value.contentEquals(other.value)) return false
return true
}
override fun hashCode(): Int {
var result = userId.hashCode()
result = 31 * result + key.hashCode()
result = 31 * result + value.contentHashCode()
return result
}
}

View File

@@ -89,7 +89,10 @@ data class PlatformAsset (
val height: Long? = null,
val durationInSeconds: Long,
val orientation: Long,
val isFavorite: Boolean
val isFavorite: Boolean,
val adjustmentTime: Long? = null,
val latitude: Double? = null,
val longitude: Double? = null
)
{
companion object {
@@ -104,7 +107,10 @@ data class PlatformAsset (
val durationInSeconds = pigeonVar_list[7] as Long
val orientation = pigeonVar_list[8] as Long
val isFavorite = pigeonVar_list[9] as Boolean
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite)
val adjustmentTime = pigeonVar_list[10] as Long?
val latitude = pigeonVar_list[11] as Double?
val longitude = pigeonVar_list[12] as Double?
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, adjustmentTime, latitude, longitude)
}
}
fun toList(): List<Any?> {
@@ -119,6 +125,9 @@ data class PlatformAsset (
durationInSeconds,
orientation,
isFavorite,
adjustmentTime,
latitude,
longitude,
)
}
override fun equals(other: Any?): Boolean {

View File

@@ -4,7 +4,6 @@ import android.annotation.SuppressLint
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.Bundle
import android.provider.MediaStore
import android.util.Base64

View File

@@ -1,54 +0,0 @@
package app.alextran.immich.upload
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
object NetworkMonitor {
@Volatile
private var isConnected = false
@Volatile
private var isWifi = false
fun initialize(context: Context) {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(networkRequest, object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
isConnected = true
checkWifi(connectivityManager, network)
}
override fun onLost(network: Network) {
isConnected = false
isWifi = false
}
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
checkWifi(connectivityManager, network)
}
private fun checkWifi(cm: ConnectivityManager, network: Network) {
val capabilities = cm.getNetworkCapabilities(network)
isWifi = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
}
})
}
fun isConnected(): Boolean = isConnected
fun isWifiConnected(context: Context): Boolean {
if (!isConnected) return false
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
return capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
}
}

View File

@@ -1,8 +0,0 @@
package app.alextran.immich.upload
object TaskConfig {
const val MAX_ATTEMPTS = 3
const val MAX_PENDING_DOWNLOADS = 10
const val MAX_PENDING_UPLOADS = 10
const val MAX_ACTIVE_UPLOADS = 3
}

View File

@@ -1,450 +0,0 @@
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
package app.alextran.immich.upload
import android.util.Log
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object UploadTaskPigeonUtils {
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
fun deepEquals(a: Any?, b: Any?): Boolean {
if (a is ByteArray && b is ByteArray) {
return a.contentEquals(b)
}
if (a is IntArray && b is IntArray) {
return a.contentEquals(b)
}
if (a is LongArray && b is LongArray) {
return a.contentEquals(b)
}
if (a is DoubleArray && b is DoubleArray) {
return a.contentEquals(b)
}
if (a is Array<*> && b is Array<*>) {
return a.size == b.size &&
a.indices.all{ deepEquals(a[it], b[it]) }
}
if (a is List<*> && b is List<*>) {
return a.size == b.size &&
a.indices.all{ deepEquals(a[it], b[it]) }
}
if (a is Map<*, *> && b is Map<*, *>) {
return a.size == b.size && a.all {
(b as Map<Any?, Any?>).containsKey(it.key) &&
deepEquals(it.value, b[it.key])
}
}
return a == b
}
}
/**
* Error class for passing custom error details to Flutter via a thrown PlatformException.
* @property code The error code.
* @property message The error message.
* @property details The error details. Must be a datatype supported by the api codec.
*/
class FlutterError (
val code: String,
override val message: String? = null,
val details: Any? = null
) : Throwable()
enum class UploadApiErrorCode(val raw: Int) {
UNKNOWN(0),
ASSET_NOT_FOUND(1),
FILE_NOT_FOUND(2),
RESOURCE_NOT_FOUND(3),
INVALID_RESOURCE(4),
ENCODING_FAILED(5),
WRITE_FAILED(6),
NOT_ENOUGH_SPACE(7),
NETWORK_ERROR(8),
PHOTOS_INTERNAL_ERROR(9),
PHOTOS_UNKNOWN_ERROR(10),
INTERRUPTED(11),
CANCELLED(12),
DOWNLOAD_STALLED(13),
FORCE_QUIT(14),
OUT_OF_RESOURCES(15),
BACKGROUND_UPDATES_DISABLED(16),
UPLOAD_TIMEOUT(17),
I_CLOUD_RATE_LIMIT(18),
I_CLOUD_THROTTLED(19),
INVALID_RESPONSE(20),
BAD_REQUEST(21),
INTERNAL_SERVER_ERROR(22),
UNAUTHORIZED(23);
companion object {
fun ofRaw(raw: Int): UploadApiErrorCode? {
return values().firstOrNull { it.raw == raw }
}
}
}
enum class UploadApiStatus(val raw: Int) {
DOWNLOAD_PENDING(0),
DOWNLOAD_QUEUED(1),
DOWNLOAD_FAILED(2),
UPLOAD_PENDING(3),
UPLOAD_QUEUED(4),
UPLOAD_FAILED(5),
UPLOAD_COMPLETE(6),
UPLOAD_SKIPPED(7);
companion object {
fun ofRaw(raw: Int): UploadApiStatus? {
return values().firstOrNull { it.raw == raw }
}
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class UploadApiTaskStatus (
val id: String,
val filename: String,
val status: UploadApiStatus,
val errorCode: UploadApiErrorCode? = null,
val httpStatusCode: Long? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): UploadApiTaskStatus {
val id = pigeonVar_list[0] as String
val filename = pigeonVar_list[1] as String
val status = pigeonVar_list[2] as UploadApiStatus
val errorCode = pigeonVar_list[3] as UploadApiErrorCode?
val httpStatusCode = pigeonVar_list[4] as Long?
return UploadApiTaskStatus(id, filename, status, errorCode, httpStatusCode)
}
}
fun toList(): List<Any?> {
return listOf(
id,
filename,
status,
errorCode,
httpStatusCode,
)
}
override fun equals(other: Any?): Boolean {
if (other !is UploadApiTaskStatus) {
return false
}
if (this === other) {
return true
}
return UploadTaskPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
/** Generated class from Pigeon that represents data sent in messages. */
data class UploadApiTaskProgress (
val id: String,
val progress: Double,
val speed: Double? = null,
val totalBytes: Long? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): UploadApiTaskProgress {
val id = pigeonVar_list[0] as String
val progress = pigeonVar_list[1] as Double
val speed = pigeonVar_list[2] as Double?
val totalBytes = pigeonVar_list[3] as Long?
return UploadApiTaskProgress(id, progress, speed, totalBytes)
}
}
fun toList(): List<Any?> {
return listOf(
id,
progress,
speed,
totalBytes,
)
}
override fun equals(other: Any?): Boolean {
if (other !is UploadApiTaskProgress) {
return false
}
if (this === other) {
return true
}
return UploadTaskPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
private open class UploadTaskPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
129.toByte() -> {
return (readValue(buffer) as Long?)?.let {
UploadApiErrorCode.ofRaw(it.toInt())
}
}
130.toByte() -> {
return (readValue(buffer) as Long?)?.let {
UploadApiStatus.ofRaw(it.toInt())
}
}
131.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
UploadApiTaskStatus.fromList(it)
}
}
132.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
UploadApiTaskProgress.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is UploadApiErrorCode -> {
stream.write(129)
writeValue(stream, value.raw)
}
is UploadApiStatus -> {
stream.write(130)
writeValue(stream, value.raw)
}
is UploadApiTaskStatus -> {
stream.write(131)
writeValue(stream, value.toList())
}
is UploadApiTaskProgress -> {
stream.write(132)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
}
val UploadTaskPigeonMethodCodec = StandardMethodCodec(UploadTaskPigeonCodec())
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface UploadApi {
fun initialize(callback: (Result<Unit>) -> Unit)
fun refresh(callback: (Result<Unit>) -> Unit)
fun cancelAll(callback: (Result<Unit>) -> Unit)
fun enqueueAssets(localIds: List<String>, callback: (Result<Unit>) -> Unit)
fun enqueueFiles(paths: List<String>, callback: (Result<Unit>) -> Unit)
fun onConfigChange(key: Long, callback: (Result<Unit>) -> Unit)
companion object {
/** The codec used by UploadApi. */
val codec: MessageCodec<Any?> by lazy {
UploadTaskPigeonCodec()
}
/** Sets up an instance of `UploadApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: UploadApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.UploadApi.initialize$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.initialize{ result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(UploadTaskPigeonUtils.wrapError(error))
} else {
reply.reply(UploadTaskPigeonUtils.wrapResult(null))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.UploadApi.refresh$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.refresh{ result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(UploadTaskPigeonUtils.wrapError(error))
} else {
reply.reply(UploadTaskPigeonUtils.wrapResult(null))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.UploadApi.cancelAll$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.cancelAll{ result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(UploadTaskPigeonUtils.wrapError(error))
} else {
reply.reply(UploadTaskPigeonUtils.wrapResult(null))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.UploadApi.enqueueAssets$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val localIdsArg = args[0] as List<String>
api.enqueueAssets(localIdsArg) { result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(UploadTaskPigeonUtils.wrapError(error))
} else {
reply.reply(UploadTaskPigeonUtils.wrapResult(null))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.UploadApi.enqueueFiles$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val pathsArg = args[0] as List<String>
api.enqueueFiles(pathsArg) { result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(UploadTaskPigeonUtils.wrapError(error))
} else {
reply.reply(UploadTaskPigeonUtils.wrapResult(null))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.UploadApi.onConfigChange$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val keyArg = args[0] as Long
api.onConfigChange(keyArg) { result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(UploadTaskPigeonUtils.wrapError(error))
} else {
reply.reply(UploadTaskPigeonUtils.wrapResult(null))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
private class UploadTaskPigeonStreamHandler<T>(
val wrapper: UploadTaskPigeonEventChannelWrapper<T>
) : EventChannel.StreamHandler {
var pigeonSink: PigeonEventSink<T>? = null
override fun onListen(p0: Any?, sink: EventChannel.EventSink) {
pigeonSink = PigeonEventSink<T>(sink)
wrapper.onListen(p0, pigeonSink!!)
}
override fun onCancel(p0: Any?) {
pigeonSink = null
wrapper.onCancel(p0)
}
}
interface UploadTaskPigeonEventChannelWrapper<T> {
open fun onListen(p0: Any?, sink: PigeonEventSink<T>) {}
open fun onCancel(p0: Any?) {}
}
class PigeonEventSink<T>(private val sink: EventChannel.EventSink) {
fun success(value: T) {
sink.success(value)
}
fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
sink.error(errorCode, errorMessage, errorDetails)
}
fun endOfStream() {
sink.endOfStream()
}
}
abstract class StreamStatusStreamHandler : UploadTaskPigeonEventChannelWrapper<UploadApiTaskStatus> {
companion object {
fun register(messenger: BinaryMessenger, streamHandler: StreamStatusStreamHandler, instanceName: String = "") {
var channelName: String = "dev.flutter.pigeon.immich_mobile.UploadFlutterApi.streamStatus"
if (instanceName.isNotEmpty()) {
channelName += ".$instanceName"
}
val internalStreamHandler = UploadTaskPigeonStreamHandler<UploadApiTaskStatus>(streamHandler)
EventChannel(messenger, channelName, UploadTaskPigeonMethodCodec).setStreamHandler(internalStreamHandler)
}
}
}
abstract class StreamProgressStreamHandler : UploadTaskPigeonEventChannelWrapper<UploadApiTaskProgress> {
companion object {
fun register(messenger: BinaryMessenger, streamHandler: StreamProgressStreamHandler, instanceName: String = "") {
var channelName: String = "dev.flutter.pigeon.immich_mobile.UploadFlutterApi.streamProgress"
if (instanceName.isNotEmpty()) {
channelName += ".$instanceName"
}
val internalStreamHandler = UploadTaskPigeonStreamHandler<UploadApiTaskProgress>(streamHandler)
EventChannel(messenger, channelName, UploadTaskPigeonMethodCodec).setStreamHandler(internalStreamHandler)
}
}
}

View File

@@ -1,175 +0,0 @@
package app.alextran.immich.upload
import android.content.Context
import androidx.work.*
import app.alextran.immich.schema.AppDatabase
import app.alextran.immich.schema.AssetType
import app.alextran.immich.schema.StorageType
import app.alextran.immich.schema.StoreKey
import app.alextran.immich.schema.TaskStatus
import app.alextran.immich.schema.UploadMethod
import app.alextran.immich.schema.UploadTask
import kotlinx.coroutines.*
import kotlinx.coroutines.guava.await
import java.util.Date
import java.util.concurrent.TimeUnit
// TODO: this is almost entirely LLM-generated (ported from Swift), need to verify behavior
class UploadTaskImpl(context: Context) : UploadApi {
private val ctx: Context = context.applicationContext
private val db: AppDatabase = AppDatabase.getDatabase(ctx)
private val workManager: WorkManager = WorkManager.getInstance(ctx)
@Volatile
private var isInitialized = false
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun initialize(callback: (Result<Unit>) -> Unit) {
scope.launch {
try {
// Clean up orphaned tasks
val activeWorkInfos = workManager.getWorkInfosByTag(UPLOAD_WORK_TAG).await()
val activeTaskIds = activeWorkInfos
.filter { it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED }
.mapNotNull {
it.tags.find { tag -> tag.startsWith("task_") }?.substringAfter("task_")?.toLongOrNull()
}
.toSet()
db.uploadTaskDao().run {
withContext(Dispatchers.IO) {
// Find tasks marked as queued but not actually running
val dbQueuedIds = getTaskIdsByStatus(
listOf(
TaskStatus.DOWNLOAD_QUEUED,
TaskStatus.UPLOAD_QUEUED,
TaskStatus.UPLOAD_PENDING
)
)
val orphanIds = dbQueuedIds.filterNot { it in activeTaskIds }
if (orphanIds.isNotEmpty()) {
resetOrphanedTasks(orphanIds)
}
}
}
// Clean up temp files
val tempDir = getTempDirectory()
tempDir.deleteRecursively()
isInitialized = true
startBackup()
withContext(Dispatchers.Main) {
callback(Result.success(Unit))
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
callback(Result.failure(e))
}
}
}
}
override fun refresh(callback: (Result<Unit>) -> Unit) {
scope.launch {
try {
startBackup()
withContext(Dispatchers.Main) {
callback(Result.success(Unit))
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
callback(Result.failure(e))
}
}
}
}
private suspend fun startBackup() {
if (!isInitialized) return
withContext(Dispatchers.IO) {
try {
// Check if backup is enabled
val backupEnabled = db.storeDao().get(StoreKey.enableBackup, StorageType.BoolStorage)
if (backupEnabled != true) return@withContext
// Get upload statistics
val stats = db.uploadTaskStatDao().getStats() ?: return@withContext
val availableSlots = TaskConfig.MAX_PENDING_UPLOADS + TaskConfig.MAX_PENDING_DOWNLOADS -
(stats.pendingDownloads + stats.queuedDownloads + stats.pendingUploads + stats.queuedUploads)
if (availableSlots <= 0) return@withContext
// Find candidate assets for backup
val candidates = db.localAssetDao().getCandidatesForBackup(availableSlots)
if (candidates.isEmpty()) return@withContext
// Create upload tasks for candidates
db.uploadTaskDao().insertAll(candidates.map { candidate ->
UploadTask(
attempts = 0,
createdAt = Date(),
filePath = null,
isLivePhoto = null,
lastError = null,
livePhotoVideoId = null,
localId = candidate.id,
method = UploadMethod.MULTIPART,
priority = when (candidate.type) {
AssetType.IMAGE -> 0.5f
else -> 0.3f
},
retryAfter = null,
status = TaskStatus.UPLOAD_PENDING
)
})
// Start upload workers
enqueueUploadWorkers()
} catch (e: Exception) {
android.util.Log.e(TAG, "Backup queue error", e)
}
}
}
private fun enqueueUploadWorkers() {
// Create constraints
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
// Create work request
val uploadWorkRequest = OneTimeWorkRequestBuilder<UploadWorker>()
.setConstraints(constraints)
.addTag(UPLOAD_WORK_TAG)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
WorkRequest.MIN_BACKOFF_MILLIS,
TimeUnit.MILLISECONDS
)
.build()
workManager.enqueueUniqueWork(
UPLOAD_WORK_NAME,
ExistingWorkPolicy.KEEP,
uploadWorkRequest
)
}
private fun getTempDirectory(): java.io.File {
return java.io.File(ctx.cacheDir, "upload_temp").apply {
if (!exists()) mkdirs()
}
}
companion object {
private const val TAG = "UploadTaskImpl"
private const val UPLOAD_WORK_TAG = "immich_upload"
private const val UPLOAD_WORK_NAME = "immich_upload_unique"
}
}

View File

@@ -1,265 +0,0 @@
package app.alextran.immich.upload
import android.content.Context
import android.provider.MediaStore
import androidx.work.*
import app.alextran.immich.schema.AppDatabase
import app.alextran.immich.schema.AssetType
import app.alextran.immich.schema.LocalAssetTaskData
import app.alextran.immich.schema.StorageType
import app.alextran.immich.schema.StoreKey
import app.alextran.immich.schema.TaskStatus
import app.alextran.immich.schema.UploadErrorCode
import kotlinx.coroutines.*
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import java.io.File
import java.io.IOException
import java.net.URL
import java.util.*
import java.util.concurrent.TimeUnit
class UploadWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
private val db = AppDatabase.getDatabase(applicationContext)
private val client = createOkHttpClient()
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
try {
// Check if backup is enabled
val backupEnabled = db.storeDao().get(StoreKey.enableBackup, StorageType.BoolStorage)
if (backupEnabled != true) {
return@withContext Result.success()
}
// Get pending upload tasks
val tasks = db.uploadTaskDao().getTasksForUpload(TaskConfig.MAX_ACTIVE_UPLOADS)
if (tasks.isEmpty()) {
return@withContext Result.success()
}
// Process tasks concurrently
val results = tasks.map { task ->
async { processUploadTask(task) }
}.awaitAll()
// Check if we should continue processing
val hasMore = db.uploadTaskDao().hasPendingTasks()
if (hasMore) {
// Schedule next batch
enqueueNextBatch()
}
// Determine result based on processing outcomes
when {
results.all { it } -> Result.success()
results.any { it } -> Result.success() // Partial success
else -> Result.retry()
}
} catch (e: Exception) {
android.util.Log.e(TAG, "Upload worker error", e)
Result.retry()
}
}
private suspend fun processUploadTask(task: LocalAssetTaskData): Boolean {
return try {
// Get asset from MediaStore
val assetUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
.buildUpon()
.appendPath(task.localId)
.build()
val cursor = applicationContext.contentResolver.query(
assetUri,
arrayOf(MediaStore.Images.Media.DATA),
null,
null,
null
) ?: return handleFailure(task, UploadErrorCode.ASSET_NOT_FOUND)
val filePath = cursor.use {
if (it.moveToFirst()) {
it.getString(it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA))
} else null
} ?: return handleFailure(task, UploadErrorCode.ASSET_NOT_FOUND)
val file = File(filePath)
if (!file.exists()) {
return handleFailure(task, UploadErrorCode.FILE_NOT_FOUND)
}
// Get server configuration
val serverUrl = db.storeDao().get(StoreKey.serverEndpoint, StorageType.UrlStorage)
?: return handleFailure(task, UploadErrorCode.NO_SERVER_URL)
val accessToken = db.storeDao().get(StoreKey.accessToken, StorageType.StringStorage)
?: return handleFailure(task, UploadErrorCode.NO_ACCESS_TOKEN)
val deviceId = db.storeDao().get(StoreKey.deviceId, StorageType.StringStorage)
?: return handleFailure(task, UploadErrorCode.NO_DEVICE_ID)
// Check network constraints
val useWifiOnly = when (task.type) {
AssetType.IMAGE -> db.storeDao().get(StoreKey.useWifiForUploadPhotos, StorageType.BoolStorage) ?: false
AssetType.VIDEO -> db.storeDao().get(StoreKey.useWifiForUploadVideos, StorageType.BoolStorage) ?: false
else -> false
}
if (useWifiOnly && !NetworkMonitor.isWifiConnected(applicationContext)) {
// Wait for WiFi
return true
}
// Update task status
db.uploadTaskDao().updateStatus(task.taskId, TaskStatus.UPLOAD_QUEUED)
// Perform upload
uploadFile(task, file, serverUrl, accessToken, deviceId)
// Mark as complete
db.uploadTaskDao().updateStatus(task.taskId, TaskStatus.UPLOAD_COMPLETE)
true
} catch (e: Exception) {
android.util.Log.e(TAG, "Upload task ${task.taskId} failed", e)
handleFailure(task, UploadErrorCode.UNKNOWN)
}
}
private suspend fun uploadFile(
task: LocalAssetTaskData,
file: File,
serverUrl: URL,
accessToken: String,
deviceId: String
) {
val requestBody = createMultipartBody(task, file, deviceId)
val request = Request.Builder()
.url("${serverUrl}/api/upload")
.post(requestBody)
.header("x-immich-user-token", accessToken)
.tag(task.taskId)
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("Upload failed: ${response.code}")
}
}
}
private fun createMultipartBody(
task: LocalAssetTaskData,
file: File,
deviceId: String
): RequestBody {
val boundary = "Boundary-${UUID.randomUUID()}"
return object : RequestBody() {
override fun contentType() = "multipart/form-data; boundary=$boundary".toMediaType()
override fun writeTo(sink: okio.BufferedSink) {
// Write form fields
writeFormField(sink, boundary, "deviceAssetId", task.localId)
writeFormField(sink, boundary, "deviceId", deviceId)
writeFormField(sink, boundary, "fileCreatedAt", (task.createdAt.time / 1000).toString())
writeFormField(sink, boundary, "fileModifiedAt", (task.updatedAt.time / 1000).toString())
writeFormField(sink, boundary, "fileName", task.fileName)
writeFormField(sink, boundary, "isFavorite", task.isFavorite.toString())
// Write file
sink.writeUtf8("--$boundary\r\n")
sink.writeUtf8("Content-Disposition: form-data; name=\"assetData\"; filename=\"asset\"\r\n")
sink.writeUtf8("Content-Type: application/octet-stream\r\n\r\n")
file.inputStream().use { input ->
val buffer = ByteArray(8192)
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
sink.write(buffer, 0, bytesRead)
// Report progress (simplified - could be enhanced with listeners)
setProgressAsync(
workDataOf(
PROGRESS_TASK_ID to task.taskId,
PROGRESS_BYTES to file.length()
)
)
}
}
sink.writeUtf8("\r\n--$boundary--\r\n")
}
private fun writeFormField(sink: okio.BufferedSink, boundary: String, name: String, value: String) {
sink.writeUtf8("--$boundary\r\n")
sink.writeUtf8("Content-Disposition: form-data; name=\"$name\"\r\n\r\n")
sink.writeUtf8(value)
sink.writeUtf8("\r\n")
}
}
}
private suspend fun handleFailure(task: LocalAssetTaskData, code: UploadErrorCode): Boolean {
val newAttempts = task.attempts + 1
val status = if (newAttempts >= TaskConfig.MAX_ATTEMPTS) {
TaskStatus.UPLOAD_FAILED
} else {
TaskStatus.UPLOAD_PENDING
}
val retryAfter = if (status == TaskStatus.UPLOAD_PENDING) {
Date(System.currentTimeMillis() + (Math.pow(3.0, newAttempts.toDouble()) * 1000).toLong())
} else null
db.uploadTaskDao().updateTaskAfterFailure(
task.taskId,
newAttempts,
code,
status,
retryAfter
)
return false
}
private fun enqueueNextBatch() {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val nextWorkRequest = OneTimeWorkRequestBuilder<UploadWorker>()
.setConstraints(constraints)
.addTag(UPLOAD_WORK_TAG)
.setInitialDelay(1, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(applicationContext)
.enqueueUniqueWork(
UPLOAD_WORK_NAME,
ExistingWorkPolicy.KEEP,
nextWorkRequest
)
}
private fun createOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(300, TimeUnit.SECONDS)
.writeTimeout(300, TimeUnit.SECONDS)
.build()
}
companion object {
private const val TAG = "UploadWorker"
private const val UPLOAD_WORK_TAG = "immich_upload"
private const val UPLOAD_WORK_NAME = "immich_upload_unique"
const val PROGRESS_TASK_ID = "progress_task_id"
const val PROGRESS_BYTES = "progress_bytes"
}
}

View File

@@ -36,3 +36,4 @@ tasks.register("clean", Delete) {
tasks.named('wrapper') {
distributionType = Wrapper.DistributionType.ALL
}

File diff suppressed because one or more lines are too long

View File

@@ -32,6 +32,7 @@ target 'Runner' do
use_modular_headers!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
# share_handler addition start
target 'ShareExtension' do
inherit! :search_paths

View File

@@ -88,9 +88,9 @@ PODS:
- Flutter
- FlutterMacOS
- SAMKeychain (1.5.3)
- SDWebImage (5.21.3):
- SDWebImage/Core (= 5.21.3)
- SDWebImage/Core (5.21.3)
- SDWebImage (5.21.0):
- SDWebImage/Core (= 5.21.0)
- SDWebImage/Core (5.21.0)
- share_handler_ios (0.0.14):
- Flutter
- share_handler_ios/share_handler_ios_models (= 0.0.14)
@@ -107,16 +107,16 @@ PODS:
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- sqlite3 (3.49.2):
- sqlite3/common (= 3.49.2)
- sqlite3/common (3.49.2)
- sqlite3/dbstatvtab (3.49.2):
- sqlite3 (3.49.1):
- sqlite3/common (= 3.49.1)
- sqlite3/common (3.49.1)
- sqlite3/dbstatvtab (3.49.1):
- sqlite3/common
- sqlite3/fts5 (3.49.2):
- sqlite3/fts5 (3.49.1):
- sqlite3/common
- sqlite3/perf-threadsafe (3.49.2):
- sqlite3/perf-threadsafe (3.49.1):
- sqlite3/common
- sqlite3/rtree (3.49.2):
- sqlite3/rtree (3.49.1):
- sqlite3/common
- sqlite3_flutter_libs (0.0.1):
- Flutter
@@ -275,18 +275,18 @@ SPEC CHECKSUMS:
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
photo_manager: 1d80ae07a89a67dfbcae95953a1e5a24af7c3e62
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868
share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb
share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1
sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
sqlite3_flutter_libs: f8fc13346870e73fe35ebf6dbb997fbcd156b241
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
PODFILE CHECKSUM: 95621706d175fee669455a5946a602e2a775019c
PODFILE CHECKSUM: 7ce312f2beab01395db96f6969d90a447279cf45
COCOAPODS: 1.16.2

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@@ -77,16 +77,6 @@
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
FE4C52462EAFE736009EEB47 /* Embed ExtensionKit Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "$(EXTENSIONS_FOLDER_PATH)";
dstSubfolderSpec = 16;
files = (
);
name = "Embed ExtensionKit Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
@@ -146,11 +136,15 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
B231F52D2E93A44A00BC45D1 /* Core */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Core;
sourceTree = "<group>";
};
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Sync;
sourceTree = "<group>";
};
@@ -162,23 +156,10 @@
path = WidgetExtension;
sourceTree = "<group>";
};
FE14355D2EC446E90009D5AC /* Upload */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Upload;
sourceTree = "<group>";
};
FEA74CE22ED223690014C832 /* Repositories */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Repositories;
sourceTree = "<group>";
};
FEB3BA112EBD52860081A5EB /* Schemas */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Schemas;
sourceTree = "<group>";
};
FEE084F22EC172080045228E /* Schemas */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Schemas;
sourceTree = "<group>";
};
@@ -286,10 +267,7 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
FEA74CE22ED223690014C832 /* Repositories */,
FE14355D2EC446E90009D5AC /* Upload */,
FEE084F22EC172080045228E /* Schemas */,
FEB3BA112EBD52860081A5EB /* Schemas */,
B231F52D2E93A44A00BC45D1 /* Core */,
B25D37792E72CA15008B6CA7 /* Connectivity */,
B21E34A62E5AF9760031FDB9 /* Background */,
@@ -367,7 +345,6 @@
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
D218A34AEE62BC1EF119F5B0 /* [CP] Embed Pods Frameworks */,
6724EEB7D74949FA08581154 /* [CP] Copy Pods Resources */,
FE4C52462EAFE736009EEB47 /* Embed ExtensionKit Extensions */,
);
buildRules = (
);
@@ -378,9 +355,6 @@
fileSystemSynchronizedGroups = (
B231F52D2E93A44A00BC45D1 /* Core */,
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
FE14355D2EC446E90009D5AC /* Upload */,
FEA74CE22ED223690014C832 /* Repositories */,
FEB3BA112EBD52860081A5EB /* Schemas */,
FEE084F22EC172080045228E /* Schemas */,
);
name = Runner;
@@ -433,7 +407,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 1620;
LastSwiftUpdateCheck = 1640;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
@@ -575,14 +549,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -611,14 +581,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -769,7 +735,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 33MF3D8ZGA;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
@@ -778,8 +744,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.121.0;
OTHER_SWIFT_FLAGS = "$(inherited) -D COCOAPODS -D DEBUG -Xllvm -sil-disable-pass=performance-linker";
PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.profile;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile;
PRODUCT_NAME = "Immich-Profile";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -914,7 +879,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 33MF3D8ZGA;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
@@ -923,8 +888,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.121.0;
OTHER_SWIFT_FLAGS = "$(inherited) -D COCOAPODS -D DEBUG -Xllvm -sil-disable-pass=performance-linker";
PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.vdebug;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug;
PRODUCT_NAME = "Immich-Debug";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -945,7 +909,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 33MF3D8ZGA;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
@@ -954,8 +918,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.121.0;
OTHER_SWIFT_FLAGS = "$(inherited) -D COCOAPODS -Xllvm -sil-disable-pass=performance-linker";
PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich;
PRODUCT_NAME = Immich;
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -979,7 +942,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
DEVELOPMENT_TEAM = 33MF3D8ZGA;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -996,7 +959,7 @@
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.vdebug.Widget;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug.Widget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
@@ -1022,7 +985,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
DEVELOPMENT_TEAM = 33MF3D8ZGA;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1038,7 +1001,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.Widget;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.Widget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -1062,7 +1025,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
DEVELOPMENT_TEAM = 33MF3D8ZGA;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1078,7 +1041,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.profile.Widget;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile.Widget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -1102,7 +1065,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 33MF3D8ZGA;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1119,7 +1082,7 @@
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.vdebug.ShareExtension;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
@@ -1146,7 +1109,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 33MF3D8ZGA;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1162,7 +1125,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.mertalev.immich.ShareExtension;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
@@ -1187,7 +1150,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 233;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 33MF3D8ZGA;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1203,7 +1166,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.mertakev.immich.profile.ShareExtension;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;

View File

@@ -28,6 +28,15 @@
"version" : "1.3.0"
}
},
{
"identity" : "swift-case-paths",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-case-paths",
"state" : {
"revision" : "6989976265be3f8d2b5802c722f9ba168e227c71",
"version" : "1.7.2"
}
},
{
"identity" : "swift-clocks",
"kind" : "remoteSourceControl",
@@ -123,8 +132,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-structured-queries",
"state" : {
"revision" : "9c84335373bae5f5c9f7b5f0adf3ae10f2cab5b9",
"version" : "0.25.2"
"revision" : "1447ea20550f6f02c4b48cc80931c3ed40a9c756",
"version" : "0.25.0"
}
},
{
@@ -136,6 +145,15 @@
"version" : "602.0.0"
}
},
{
"identity" : "swift-tagged",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-tagged",
"state" : {
"revision" : "3907a9438f5b57d317001dc99f3f11b46882272b",
"version" : "0.10.0"
}
},
{
"identity" : "xctest-dynamic-overlay",
"kind" : "remoteSourceControl",

View File

@@ -1,24 +1,21 @@
import BackgroundTasks
import Flutter
import SQLiteData
import UIKit
import network_info_plus
import path_provider_foundation
import permission_handler_apple
import photo_manager
import shared_preferences_foundation
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
private var backgroundCompletionHandlers: [String: () -> Void] = [:]
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Required for flutter_local_notification
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
}
GeneratedPluginRegistrant.register(with: self)
@@ -39,9 +36,7 @@ import shared_preferences_foundation
}
if !registry.hasPlugin("org.cocoapods.shared-preferences-foundation") {
SharedPreferencesPlugin.register(
with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!
)
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!)
}
if !registry.hasPlugin("org.cocoapods.permission-handler-apple") {
@@ -55,50 +50,13 @@ import shared_preferences_foundation
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
override func application(
_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void
) {
backgroundCompletionHandlers[identifier] = completionHandler
}
func completionHandler(forSession identifier: String) -> (() -> Void)? {
return backgroundCompletionHandlers.removeValue(forKey: identifier)
}
public static func registerPlugins(with engine: FlutterEngine) {
NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!)
ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl())
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl())
let statusListener = StatusEventListener()
StreamStatusStreamHandler.register(with: engine.binaryMessenger, streamHandler: statusListener)
let progressListener = ProgressEventListener()
StreamProgressStreamHandler.register(with: engine.binaryMessenger, streamHandler: progressListener)
let dbUrl = try! FileManager.default.url(
for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
).appendingPathComponent("immich.sqlite")
let db = try! DatabasePool(path: dbUrl.path)
let storeRepository = StoreRepository(db: db)
let taskRepository = TaskRepository(db: db)
UploadApiSetup.setUp(
binaryMessenger: engine.binaryMessenger,
api: UploadApiImpl(
storeRepository: storeRepository,
taskRepository: taskRepository,
statusListener: statusListener,
progressListener: progressListener
)
)
}
public static func cancelPlugins(with engine: FlutterEngine) {
(engine.valuePublished(byPlugin: NativeSyncApiImpl.name) as? NativeSyncApiImpl)?.detachFromEngine()
}

View File

@@ -29,15 +29,15 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
public static func registerBackgroundWorkers() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: processingTaskID, using: nil) { task in
if case let task as BGProcessingTask = task {
handleBackgroundProcessing(task: task)
if task is BGProcessingTask {
handleBackgroundProcessing(task: task as! BGProcessingTask)
}
}
BGTaskScheduler.shared.register(
forTaskWithIdentifier: refreshTaskID, using: nil) { task in
if case let task as BGAppRefreshTask = task {
handleBackgroundRefresh(task: task)
if task is BGAppRefreshTask {
handleBackgroundRefresh(task: task as! BGAppRefreshTask)
}
}
}

View File

@@ -350,12 +350,16 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin {
// If we have required Wi-Fi, we can check the isExpensive property
let requireWifi = defaults.value(forKey: "require_wifi") as? Bool ?? false
// The network is expensive and we have required Wi-Fi
// Therefore, we will simply complete the task without
// running it
if (requireWifi && NetworkMonitor.shared.isExpensive) {
return task.setTaskCompleted(success: true)
if (requireWifi) {
let wifiMonitor = NWPathMonitor(requiredInterfaceType: .wifi)
let isExpensive = wifiMonitor.currentPath.isExpensive
if (isExpensive) {
// The network is expensive and we have required Wi-Fi
// Therefore, we will simply complete the task without
// running it
task.setTaskCompleted(success: true)
return
}
}
// Schedule the next sync task so we can run this again later

View File

@@ -1,24 +1,17 @@
class ImmichPlugin: NSObject {
var detached: Bool
override init() {
detached = false
super.init()
}
func detachFromEngine() {
self.detached = true
}
func completeWhenActive<T>(for completion: @escaping (T) -> Void, with value: T) {
guard !self.detached else { return }
completion(value)
}
}
@inline(__always)
func dPrint(_ item: Any) {
#if DEBUG
print(item)
#endif
}

View File

@@ -137,8 +137,7 @@
<key>NSFaceIDUsageDescription</key>
<string>We need to use FaceID to allow access to your locked folder</string>
<key>NSLocalNetworkUsageDescription</key>
<string>We need local network permission to connect to the local server using IP address and
allow the casting feature to work</string>
<string>We need local network permission to connect to the local server using IP address and allow the casting feature to work</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
<key>NSLocationUsageDescription</key>

View File

@@ -1,203 +0,0 @@
import SQLiteData
protocol StoreProtocol {
func get<T: StoreConvertible<Int>>(_ key: StoreKey.Typed<T>) throws -> T?
func get<T: StoreConvertible<String>>(_ key: StoreKey.Typed<T>) throws -> T?
func set<T: StoreConvertible<Int>>(_ key: StoreKey.Typed<T>, value: T) throws
func set<T: StoreConvertible<String>>(_ key: StoreKey.Typed<T>, value: T) throws
func invalidateCache()
}
protocol StoreConvertible<StorageType> {
associatedtype StorageType
static var cacheKeyPath: ReferenceWritableKeyPath<StoreCache, [StoreKey: Self]> { get }
static func fromValue(_ value: StorageType) throws(StoreError) -> Self
static func toValue(_ value: Self) throws(StoreError) -> StorageType
}
final class StoreRepository: StoreProtocol {
private let db: DatabasePool
private static let cache = StoreCache()
private static var lock = os_unfair_lock()
init(db: DatabasePool) {
self.db = db
}
func get<T: StoreConvertible<Int>>(_ key: StoreKey.Typed<T>) throws -> T? {
os_unfair_lock_lock(&Self.lock)
defer { os_unfair_lock_unlock(&Self.lock) }
let cached = Self.cache.get(key)
if _fastPath(cached != nil) { return cached! }
return try db.read { conn in
let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) }
if let value = try query.fetchOne(conn) ?? nil {
let converted = try T.fromValue(value)
Self.cache.set(key, value: converted)
return converted
}
return nil
}
}
func get<T: StoreConvertible<String>>(_ key: StoreKey.Typed<T>) throws -> T? {
os_unfair_lock_lock(&Self.lock)
defer { os_unfair_lock_unlock(&Self.lock) }
let cached = Self.cache.get(key)
if _fastPath(cached != nil) { return cached! }
return try db.read { conn in
let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) }
if let value = try query.fetchOne(conn) ?? nil {
let converted = try T.fromValue(value)
Self.cache.set(key, value: converted)
return converted
}
return nil
}
}
func set<T: StoreConvertible<Int>>(_ key: StoreKey.Typed<T>, value: T) throws {
os_unfair_lock_lock(&Self.lock)
defer { os_unfair_lock_unlock(&Self.lock) }
let converted = try T.toValue(value)
try db.write { conn in
try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: converted) }.execute(conn)
}
Self.cache.set(key, value: value)
}
func set<T: StoreConvertible<String>>(_ key: StoreKey.Typed<T>, value: T) throws {
os_unfair_lock_lock(&Self.lock)
defer { os_unfair_lock_unlock(&Self.lock) }
let converted = try T.toValue(value)
try db.write { conn in
try Store.upsert { Store(id: key.rawValue, stringValue: converted, intValue: nil) }.execute(conn)
}
Self.cache.set(key, value: value)
}
func invalidateCache() {
Self.cache.reset()
}
}
enum StoreError: Error {
case invalidJSON(String)
case invalidURL(String)
case encodingFailed
case notFound
}
extension StoreConvertible {
fileprivate static func get(_ cache: StoreCache, key: StoreKey) -> Self? {
return cache[keyPath: cacheKeyPath][key]
}
fileprivate static func set(_ cache: StoreCache, key: StoreKey, value: Self?) {
cache[keyPath: cacheKeyPath][key] = value
}
fileprivate static func reset(_ cache: StoreCache) {
cache.reset()
}
}
final class StoreCache {
fileprivate var intCache: [StoreKey: Int] = [:]
fileprivate var boolCache: [StoreKey: Bool] = [:]
fileprivate var dateCache: [StoreKey: Date] = [:]
fileprivate var stringCache: [StoreKey: String] = [:]
fileprivate var urlCache: [StoreKey: URL] = [:]
fileprivate var endpointArrayCache: [StoreKey: [Endpoint]] = [:]
fileprivate var stringDictCache: [StoreKey: [String: String]] = [:]
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) -> T? {
return T.get(self, key: key.rawValue)
}
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T?) {
return T.set(self, key: key.rawValue, value: value)
}
func reset() {
intCache.removeAll(keepingCapacity: true)
boolCache.removeAll(keepingCapacity: true)
dateCache.removeAll(keepingCapacity: true)
stringCache.removeAll(keepingCapacity: true)
urlCache.removeAll(keepingCapacity: true)
endpointArrayCache.removeAll(keepingCapacity: true)
stringDictCache.removeAll(keepingCapacity: true)
}
}
extension Int: StoreConvertible {
static let cacheKeyPath = \StoreCache.intCache
static func fromValue(_ value: Int) -> Int { value }
static func toValue(_ value: Int) -> Int { value }
}
extension Bool: StoreConvertible {
static let cacheKeyPath = \StoreCache.boolCache
static func fromValue(_ value: Int) -> Bool { value == 1 }
static func toValue(_ value: Bool) -> Int { value ? 1 : 0 }
}
extension Date: StoreConvertible {
static let cacheKeyPath = \StoreCache.dateCache
static func fromValue(_ value: Int) -> Date { Date(timeIntervalSince1970: TimeInterval(value) / 1000) }
static func toValue(_ value: Date) -> Int { Int(value.timeIntervalSince1970 * 1000) }
}
extension String: StoreConvertible {
static let cacheKeyPath = \StoreCache.stringCache
static func fromValue(_ value: String) -> String { value }
static func toValue(_ value: String) -> String { value }
}
extension URL: StoreConvertible {
static let cacheKeyPath = \StoreCache.urlCache
static func fromValue(_ value: String) throws(StoreError) -> URL {
guard let url = URL(string: value) else {
throw StoreError.invalidURL(value)
}
return url
}
static func toValue(_ value: URL) -> String { value.absoluteString }
}
extension StoreConvertible<String> where Self: Codable {
static var jsonDecoder: JSONDecoder { JSONDecoder() }
static var jsonEncoder: JSONEncoder { JSONEncoder() }
static func fromValue(_ value: String) throws(StoreError) -> Self {
do {
return try jsonDecoder.decode(Self.self, from: Data(value.utf8))
} catch {
throw StoreError.invalidJSON(value)
}
}
static func toValue(_ value: Self) throws(StoreError) -> String {
let encoded: Data
do {
encoded = try jsonEncoder.encode(value)
} catch {
throw StoreError.encodingFailed
}
guard let string = String(data: encoded, encoding: .utf8) else {
throw StoreError.encodingFailed
}
return string
}
}
extension Array: StoreConvertible where Element == Endpoint {
static let cacheKeyPath = \StoreCache.endpointArrayCache
typealias StorageType = String
}
extension Dictionary: StoreConvertible where Key == String, Value == String {
static let cacheKeyPath = \StoreCache.stringDictCache
typealias StorageType = String
}

View File

@@ -1,279 +0,0 @@
import SQLiteData
protocol TaskProtocol {
func getTaskIds(status: TaskStatus) async throws -> [Int64]
func getBackupCandidates() async throws -> [LocalAssetCandidate]
func getBackupCandidates(ids: [String]) async throws -> [LocalAssetCandidate]
func getDownloadTasks() async throws -> [LocalAssetDownloadData]
func getUploadTasks() async throws -> [LocalAssetUploadData]
func markOrphansPending(ids: [Int64]) async throws
func markDownloadQueued(taskId: Int64, isLivePhoto: Bool, filePath: URL) async throws
func markUploadQueued(taskId: Int64) async throws
func markDownloadComplete(taskId: Int64, localId: String, hash: String?) async throws -> TaskStatus
func markUploadSuccess(taskId: Int64, livePhotoVideoId: String?) async throws
func retryOrFail(taskId: Int64, code: UploadErrorCode, status: TaskStatus) async throws
func enqueue(assets: [LocalAssetCandidate], imagePriority: Float, videoPriority: Float) async throws
func enqueue(files: [String]) async throws
func resolveError(code: UploadErrorCode) async throws
func getFilename(taskId: Int64) async throws -> String?
}
final class TaskRepository: TaskProtocol {
private let db: DatabasePool
init(db: DatabasePool) {
self.db = db
}
func getTaskIds(status: TaskStatus) async throws -> [Int64] {
return try await db.read { conn in
try UploadTask.select(\.id).where { $0.status.eq(status) }.fetchAll(conn)
}
}
func getBackupCandidates() async throws -> [LocalAssetCandidate] {
return try await db.read { conn in
return try LocalAsset.backupCandidates.fetchAll(conn)
}
}
func getBackupCandidates(ids: [String]) async throws -> [LocalAssetCandidate] {
return try await db.read { conn in
return try LocalAsset.backupCandidates.where { $0.id.in(ids) }.fetchAll(conn)
}
}
func getDownloadTasks() async throws -> [LocalAssetDownloadData] {
return try await db.read({ conn in
return try UploadTask.join(LocalAsset.all) { task, asset in task.localId.eq(asset.id) }
.where { task, _ in task.canRetry && task.noFatalError && LocalAsset.withChecksum.exists() }
.select { task, asset in
LocalAssetDownloadData.Columns(
checksum: asset.checksum,
createdAt: asset.createdAt,
filename: asset.name,
livePhotoVideoId: task.livePhotoVideoId,
localId: asset.id,
taskId: task.id,
updatedAt: asset.updatedAt
)
}
.order { task, asset in (task.priority.desc(), task.createdAt) }
.limit { _, _ in UploadTaskStat.availableDownloadSlots }
.fetchAll(conn)
})
}
func getUploadTasks() async throws -> [LocalAssetUploadData] {
return try await db.read({ conn in
return try UploadTask.join(LocalAsset.all) { task, asset in task.localId.eq(asset.id) }
.where { task, _ in task.canRetry && task.noFatalError && LocalAsset.withChecksum.exists() }
.select { task, asset in
LocalAssetUploadData.Columns(
filename: asset.name,
filePath: task.filePath.unwrapped,
priority: task.priority,
taskId: task.id,
type: asset.type
)
}
.order { task, asset in (task.priority.desc(), task.createdAt) }
.limit { task, _ in UploadTaskStat.availableUploadSlots }
.fetchAll(conn)
})
}
func markOrphansPending(ids: [Int64]) async throws {
try await db.write { conn in
try UploadTask.update {
$0.filePath = nil
$0.status = .downloadPending
}
.where { row in row.status.in([TaskStatus.downloadQueued, TaskStatus.uploadPending]) || row.id.in(ids) }
.execute(conn)
}
}
func markDownloadQueued(taskId: Int64, isLivePhoto: Bool, filePath: URL) async throws {
try await db.write { conn in
try UploadTask.update {
$0.status = .downloadQueued
$0.isLivePhoto = isLivePhoto
$0.filePath = filePath
}
.where { $0.id.eq(taskId) }.execute(conn)
}
}
func markUploadQueued(taskId: Int64) async throws {
try await db.write { conn in
try UploadTask.update { row in
row.status = .uploadQueued
row.filePath = nil
}
.where { $0.id.eq(taskId) }.execute(conn)
}
}
func markDownloadComplete(taskId: Int64, localId: String, hash: String?) async throws -> TaskStatus {
return try await db.write { conn in
if let hash {
try LocalAsset.update { $0.checksum = hash }.where { $0.id.eq(localId) }.execute(conn)
}
let status =
if let hash, try RemoteAsset.select(\.rowid).where({ $0.checksum.eq(hash) }).fetchOne(conn) != nil {
TaskStatus.uploadSkipped
} else {
TaskStatus.uploadPending
}
try UploadTask.update { $0.status = status }.where { $0.id.eq(taskId) }.execute(conn)
return status
}
}
func markUploadSuccess(taskId: Int64, livePhotoVideoId: String?) async throws {
try await db.write { conn in
let task =
try UploadTask
.update { $0.status = .uploadComplete }
.where { $0.id.eq(taskId) }
.returning(\.self)
.fetchOne(conn)
guard let task, let localId = task.localId, let isLivePhoto = task.isLivePhoto, isLivePhoto,
task.livePhotoVideoId == nil
else { return }
try UploadTask.insert {
UploadTask.Draft(
attempts: 0,
createdAt: Date(),
filePath: nil,
isLivePhoto: true,
lastError: nil,
livePhotoVideoId: livePhotoVideoId,
localId: localId,
method: .multipart,
priority: 0.7,
retryAfter: nil,
status: .downloadPending,
)
}.execute(conn)
}
}
func retryOrFail(taskId: Int64, code: UploadErrorCode, status: TaskStatus) async throws {
try await db.write { conn in
try UploadTask.update { row in
let retryOffset =
switch code {
case .iCloudThrottled, .iCloudRateLimit, .notEnoughSpace: 3000
default: 0
}
row.status = Case()
.when(row.localId.is(nil) && row.attempts.lte(TaskConfig.maxRetries), then: TaskStatus.uploadPending)
.when(row.attempts.lte(TaskConfig.maxRetries), then: TaskStatus.downloadPending)
.else(status)
row.attempts += 1
row.lastError = code
row.retryAfter = #sql("unixepoch('now') + (\(4 << row.attempts)) + \(retryOffset)")
}
.where { $0.id.eq(taskId) }.execute(conn)
}
}
func enqueue(assets: [LocalAssetCandidate], imagePriority: Float, videoPriority: Float) async throws {
try await db.write { conn in
var draft = draftStub
for candidate in assets {
draft.localId = candidate.id
draft.priority = candidate.type == .image ? imagePriority : videoPriority
try UploadTask.insert {
draft
} onConflict: {
($0.localId, $0.livePhotoVideoId)
}
.execute(conn)
}
}
}
func enqueue(files: [String]) async throws {
try await db.write { conn in
var draft = draftStub
draft.priority = 1.0
draft.status = .uploadPending
for file in files {
draft.filePath = URL(fileURLWithPath: file, isDirectory: false)
try UploadTask.insert { draft }.execute(conn)
}
}
}
func resolveError(code: UploadErrorCode) async throws {
try await db.write { conn in
try UploadTask.update { $0.lastError = nil }.where { $0.lastError.unwrapped.eq(code) }.execute(conn)
}
}
func getFilename(taskId: Int64) async throws -> String? {
try await db.read { conn in
try UploadTask.join(LocalAsset.all) { task, asset in task.localId.eq(asset.id) }.select(\.1.name).fetchOne(conn)
}
}
private var draftStub: UploadTask.Draft {
.init(
attempts: 0,
createdAt: Date(),
filePath: nil,
isLivePhoto: nil,
lastError: nil,
livePhotoVideoId: nil,
localId: nil,
method: .multipart,
priority: 0.5,
retryAfter: nil,
status: .downloadPending,
)
}
}
extension UploadTask.TableColumns {
var noFatalError: some QueryExpression<Bool> { lastError.is(nil) || !lastError.unwrapped.in(UploadErrorCode.fatal) }
var canRetry: some QueryExpression<Bool> {
attempts.lte(TaskConfig.maxRetries) && (retryAfter.is(nil) || retryAfter.unwrapped <= Date().unixTime)
}
}
extension LocalAlbum {
static let selected = Self.where { $0.backupSelection.eq(BackupSelection.selected) }
static let excluded = Self.where { $0.backupSelection.eq(BackupSelection.excluded) }
}
extension LocalAlbumAsset {
static let selected = Self.where {
$0.id.assetId.eq(LocalAsset.columns.id) && $0.id.albumId.in(LocalAlbum.selected.select(\.id))
}
static let excluded = Self.where {
$0.id.assetId.eq(LocalAsset.columns.id) && $0.id.albumId.in(LocalAlbum.excluded.select(\.id))
}
}
extension RemoteAsset {
static let currentUser = Self.where { _ in
ownerId.eq(Store.select(\.stringValue).where { $0.id.eq(StoreKey.currentUser.rawValue) }.unwrapped)
}
}
extension LocalAsset {
static let withChecksum = Self.where { $0.checksum.isNot(nil) }
static let shouldBackup = Self.where { _ in LocalAlbumAsset.selected.exists() && !LocalAlbumAsset.excluded.exists() }
static let notBackedUp = Self.where { local in
!RemoteAsset.currentUser.where { remote in local.checksum.eq(remote.checksum) }.exists()
}
static let backupCandidates = Self
.shouldBackup
.notBackedUp
.where { local in !UploadTask.where { $0.localId.eq(local.id) }.exists() }
.select { LocalAssetCandidate.Columns(id: $0.id, type: $0.type) }
.limit { _ in UploadTaskStat.availableSlots }
}

View File

@@ -9,6 +9,8 @@
<key>com.apple.developer.networking.wifi-info</key>
<true/>
<key>com.apple.security.application-groups</key>
<array/>
<array>
<string>group.app.immich.share</string>
</array>
</dict>
</plist>

View File

@@ -11,6 +11,8 @@
<key>com.apple.developer.networking.wifi-info</key>
<true/>
<key>com.apple.security.application-groups</key>
<array/>
<array>
<string>group.app.immich.share</string>
</array>
</dict>
</plist>

View File

@@ -1,25 +1,12 @@
import SQLiteData
extension Notification.Name {
static let networkDidConnect = Notification.Name("networkDidConnect")
static let downloadTaskDidComplete = Notification.Name("downloadTaskDidComplete")
static let uploadTaskDidComplete = Notification.Name("uploadTaskDidComplete")
}
struct Endpoint: Codable {
let url: URL
let status: Status
enum TaskConfig {
static let maxActiveDownloads = 3
static let maxPendingDownloads = 50
static let maxPendingUploads = 50
static let maxRetries = 10
static let sessionId = "app.mertalev.immich.upload"
static let downloadCheckIntervalNs: UInt64 = 30_000_000_000 // 30 seconds
static let downloadTimeoutS = TimeInterval(60)
static let progressThrottleInterval = TimeInterval(0.1)
static let transferSpeedAlpha = 0.4
static let originalsDir = FileManager.default.temporaryDirectory.appendingPathComponent(
"originals",
isDirectory: true
)
enum Status: String, Codable {
case loading, valid, error, unknown
}
}
enum StoreKey: Int, CaseIterable, QueryBindable {
@@ -60,6 +47,8 @@ enum StoreKey: Int, CaseIterable, QueryBindable {
static let deviceId = Typed<String>(rawValue: ._deviceId)
case _accessToken = 11
static let accessToken = Typed<String>(rawValue: ._accessToken)
case _serverEndpoint = 12
static let serverEndpoint = Typed<String>(rawValue: ._serverEndpoint)
case _sslClientCertData = 15
static let sslClientCertData = Typed<String>(rawValue: ._sslClientCertData)
case _sslClientPasswd = 16
@@ -78,12 +67,10 @@ enum StoreKey: Int, CaseIterable, QueryBindable {
static let externalEndpointList = Typed<[Endpoint]>(rawValue: ._externalEndpointList)
// MARK: - URL
case _serverUrl = 10
static let serverUrl = Typed<URL>(rawValue: ._serverUrl)
case _serverEndpoint = 12
static let serverEndpoint = Typed<URL>(rawValue: ._serverEndpoint)
case _localEndpoint = 134
static let localEndpoint = Typed<URL>(rawValue: ._localEndpoint)
case _serverUrl = 10
static let serverUrl = Typed<URL>(rawValue: ._serverUrl)
// MARK: - Date
case _backupFailedSince = 5
@@ -173,17 +160,6 @@ enum StoreKey: Int, CaseIterable, QueryBindable {
}
}
enum UploadHeaders: String {
case reprDigest = "Repr-Digest"
case userToken = "X-Immich-User-Token"
case assetData = "X-Immich-Asset-Data"
}
enum TaskStatus: Int, QueryBindable {
case downloadPending, downloadQueued, downloadFailed, uploadPending, uploadQueued, uploadFailed, uploadComplete,
uploadSkipped
}
enum BackupSelection: Int, QueryBindable {
case selected, none, excluded
}
@@ -199,89 +175,3 @@ enum AlbumUserRole: Int, QueryBindable {
enum MemoryType: Int, QueryBindable {
case onThisDay
}
enum AssetVisibility: Int, QueryBindable {
case timeline, hidden, archive, locked
}
enum SourceType: String, QueryBindable {
case machineLearning = "machine-learning"
case exif, manual
}
enum UploadMethod: Int, QueryBindable {
case multipart, resumable
}
enum UploadError: Error {
case fileCreationFailed
case iCloudError(UploadErrorCode)
case photosError(UploadErrorCode)
case unknown
var code: UploadErrorCode {
switch self {
case .iCloudError(let code), .photosError(let code):
return code
case .unknown:
return .unknown
case .fileCreationFailed:
return .writeFailed
}
}
}
enum UploadErrorCode: Int, QueryBindable {
case unknown
case assetNotFound
case fileNotFound
case resourceNotFound
case invalidResource
case encodingFailed
case writeFailed
case notEnoughSpace
case networkError
case photosInternalError
case photosUnknownError
case interrupted
case cancelled
case downloadStalled
case forceQuit
case outOfResources
case backgroundUpdatesDisabled
case uploadTimeout
case iCloudRateLimit
case iCloudThrottled
case invalidResponse
case badRequest
case internalServerError
case unauthorized
static let fatal: [UploadErrorCode] = [.assetNotFound, .resourceNotFound, .invalidResource, .badRequest, .unauthorized]
}
enum AssetType: Int, QueryBindable {
case other, image, video, audio
}
enum AssetMediaStatus: String, Codable {
case created, replaced, duplicate
}
struct Endpoint: Codable {
let url: URL
let status: Status
enum Status: String, Codable {
case loading, valid, error, unknown
}
}
struct UploadSuccessResponse: Codable {
let status: AssetMediaStatus
let id: String
}
struct UploadErrorResponse: Codable {
let message: String
}

View File

@@ -0,0 +1,146 @@
import SQLiteData
enum StoreError: Error {
case invalidJSON(String)
case invalidURL(String)
case encodingFailed
}
protocol StoreConvertible {
associatedtype StorageType
static func fromValue(_ value: StorageType) throws(StoreError) -> Self
static func toValue(_ value: Self) throws(StoreError) -> StorageType
}
extension Int: StoreConvertible {
static func fromValue(_ value: Int) -> Int { value }
static func toValue(_ value: Int) -> Int { value }
}
extension Bool: StoreConvertible {
static func fromValue(_ value: Int) -> Bool { value == 1 }
static func toValue(_ value: Bool) -> Int { value ? 1 : 0 }
}
extension Date: StoreConvertible {
static func fromValue(_ value: Int) -> Date { Date(timeIntervalSince1970: TimeInterval(value) / 1000) }
static func toValue(_ value: Date) -> Int { Int(value.timeIntervalSince1970 * 1000) }
}
extension String: StoreConvertible {
static func fromValue(_ value: String) -> String { value }
static func toValue(_ value: String) -> String { value }
}
extension URL: StoreConvertible {
static func fromValue(_ value: String) throws(StoreError) -> URL {
guard let url = URL(string: value) else {
throw StoreError.invalidURL(value)
}
return url
}
static func toValue(_ value: URL) -> String { value.absoluteString }
}
extension StoreConvertible where Self: Codable, StorageType == String {
static var jsonDecoder: JSONDecoder { JSONDecoder() }
static var jsonEncoder: JSONEncoder { JSONEncoder() }
static func fromValue(_ value: String) throws(StoreError) -> Self {
do {
return try jsonDecoder.decode(Self.self, from: Data(value.utf8))
} catch {
throw StoreError.invalidJSON(value)
}
}
static func toValue(_ value: Self) throws(StoreError) -> String {
let encoded: Data
do {
encoded = try jsonEncoder.encode(value)
} catch {
throw StoreError.encodingFailed
}
guard let string = String(data: encoded, encoding: .utf8) else {
throw StoreError.encodingFailed
}
return string
}
}
extension Array: StoreConvertible where Element: Codable {
typealias StorageType = String
}
extension Dictionary: StoreConvertible where Key == String, Value: Codable {
typealias StorageType = String
}
class StoreRepository {
private let db: DatabasePool
init(db: DatabasePool) {
self.db = db
}
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) throws -> T? where T.StorageType == Int {
let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) }
if let value = try db.read({ conn in try query.fetchOne(conn) }) ?? nil {
return try T.fromValue(value)
}
return nil
}
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) throws -> T? where T.StorageType == String {
let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) }
if let value = try db.read({ conn in try query.fetchOne(conn) }) ?? nil {
return try T.fromValue(value)
}
return nil
}
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) async throws -> T? where T.StorageType == Int {
let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) }
if let value = try await db.read({ conn in try query.fetchOne(conn) }) ?? nil {
return try T.fromValue(value)
}
return nil
}
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) async throws -> T? where T.StorageType == String {
let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) }
if let value = try await db.read({ conn in try query.fetchOne(conn) }) ?? nil {
return try T.fromValue(value)
}
return nil
}
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) throws where T.StorageType == Int {
let value = try T.toValue(value)
try db.write { conn in
try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: value) }.execute(conn)
}
}
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) throws where T.StorageType == String {
let value = try T.toValue(value)
try db.write { conn in
try Store.upsert { Store(id: key.rawValue, stringValue: value, intValue: nil) }.execute(conn)
}
}
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) async throws where T.StorageType == Int {
let value = try T.toValue(value)
try await db.write { conn in
try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: value) }.execute(conn)
}
}
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T) async throws where T.StorageType == String {
let value = try T.toValue(value)
try await db.write { conn in
try Store.upsert { Store(id: key.rawValue, stringValue: value, intValue: nil) }.execute(conn)
}
}
}

View File

@@ -1,227 +1,135 @@
import GRDB
import SQLiteData
extension QueryExpression where QueryValue: _OptionalProtocol {
// asserts column result cannot be nil
var unwrapped: SQLQueryExpression<QueryValue.Wrapped> {
SQLQueryExpression(self.queryFragment, as: QueryValue.Wrapped.self)
}
}
extension Date {
var unixTime: Date.UnixTimeRepresentation {
return Date.UnixTimeRepresentation(queryOutput: self)
}
}
@Table("asset_face_entity")
struct AssetFace: Identifiable {
struct AssetFace {
let id: String
@Column("asset_id")
let assetId: RemoteAsset.ID
@Column("person_id")
let personId: Person.ID?
@Column("image_width")
let assetId: String
let personId: String?
let imageWidth: Int
@Column("image_height")
let imageHeight: Int
@Column("bounding_box_x1")
let boundingBoxX1: Int
@Column("bounding_box_y1")
let boundingBoxY1: Int
@Column("bounding_box_x2")
let boundingBoxX2: Int
@Column("bounding_box_y2")
let boundingBoxY2: Int
@Column("source_type")
let sourceType: SourceType
let sourceType: String
}
@Table("auth_user_entity")
struct AuthUser: Identifiable {
struct AuthUser {
let id: String
let name: String
let email: String
@Column("is_admin")
let isAdmin: Bool
@Column("has_profile_image")
let hasProfileImage: Bool
@Column("profile_changed_at")
let profileChangedAt: Date
@Column("avatar_color")
let avatarColor: AvatarColor
@Column("quota_size_in_bytes")
let quotaSizeInBytes: Int
@Column("quota_usage_in_bytes")
let quotaUsageInBytes: Int
@Column("pin_code")
let pinCode: String?
}
@Table("local_album_entity")
struct LocalAlbum: Identifiable {
struct LocalAlbum {
let id: String
@Column("backup_selection")
let backupSelection: BackupSelection
@Column("linked_remote_album_id")
let linkedRemoteAlbumId: RemoteAlbum.ID?
@Column("marker")
let linkedRemoteAlbumId: String?
let marker_: Bool?
let name: String
@Column("is_ios_shared_album")
let isIosSharedAlbum: Bool
@Column("updated_at")
let updatedAt: Date
}
@Table("local_album_asset_entity")
struct LocalAlbumAsset {
let id: ID
@Column("marker")
let marker_: String?
@Selection
struct ID {
@Column("asset_id")
let assetId: String
@Column("album_id")
let albumId: String
}
}
@Table("local_asset_entity")
struct LocalAsset: Identifiable {
struct LocalAsset {
let id: String
let checksum: String?
@Column("created_at")
let createdAt: String
@Column("duration_in_seconds")
let createdAt: Date
let durationInSeconds: Int?
let height: Int?
@Column("is_favorite")
let isFavorite: Bool
let name: String
let orientation: String
let type: AssetType
@Column("updated_at")
let updatedAt: String
let type: Int
let updatedAt: Date
let width: Int?
}
@Selection
struct LocalAssetCandidate {
let id: LocalAsset.ID
let type: AssetType
}
@Selection
struct LocalAssetDownloadData {
let checksum: String?
let createdAt: String
let filename: String
let livePhotoVideoId: RemoteAsset.ID?
let localId: LocalAsset.ID
let taskId: UploadTask.ID
let updatedAt: String
}
@Selection
struct LocalAssetUploadData {
let filename: String
let filePath: URL
let priority: Float
let taskId: UploadTask.ID
let type: AssetType
}
@Table("memory_asset_entity")
struct MemoryAsset {
let id: ID
@Selection
struct ID {
@Column("asset_id")
let assetId: String
@Column("album_id")
let albumId: String
}
}
@Table("memory_entity")
struct Memory: Identifiable {
struct Memory {
let id: String
@Column("created_at")
let createdAt: Date
@Column("updated_at")
let updatedAt: Date
@Column("deleted_at")
let deletedAt: Date?
@Column("owner_id")
let ownerId: User.ID
let ownerId: String
let type: MemoryType
let data: String
@Column("is_saved")
let isSaved: Bool
@Column("memory_at")
let memoryAt: Date
@Column("seen_at")
let seenAt: Date?
@Column("show_at")
let showAt: Date?
@Column("hide_at")
let hideAt: Date?
}
@Table("partner_entity")
struct Partner {
let id: ID
@Column("in_timeline")
let inTimeline: Bool
@Selection
struct ID {
@Column("shared_by_id")
let sharedById: String
@Column("shared_with_id")
let sharedWithId: String
}
}
@Table("person_entity")
struct Person: Identifiable {
struct Person {
let id: String
@Column("created_at")
let createdAt: Date
@Column("updated_at")
let updatedAt: Date
@Column("owner_id")
let ownerId: String
let name: String
@Column("face_asset_id")
let faceAssetId: AssetFace.ID?
@Column("is_favorite")
let faceAssetId: String?
let isFavorite: Bool
@Column("is_hidden")
let isHidden: Bool
let color: String?
@Column("birth_date")
let birthDate: Date?
}
@Table("remote_album_entity")
struct RemoteAlbum: Identifiable {
struct RemoteAlbum {
let id: String
@Column("created_at")
let createdAt: Date
let description: String?
@Column("is_activity_enabled")
let isActivityEnabled: Bool
let name: String
let order: Int
@Column("owner_id")
let ownerId: String
@Column("thumbnail_asset_id")
let thumbnailAssetId: RemoteAsset.ID?
@Column("updated_at")
let thumbnailAssetId: String?
let updatedAt: Date
}
@@ -231,9 +139,7 @@ struct RemoteAlbumAsset {
@Selection
struct ID {
@Column("asset_id")
let assetId: String
@Column("album_id")
let albumId: String
}
}
@@ -245,55 +151,40 @@ struct RemoteAlbumUser {
@Selection
struct ID {
@Column("album_id")
let albumId: String
@Column("user_id")
let userId: String
}
}
@Table("remote_asset_entity")
struct RemoteAsset: Identifiable {
struct RemoteAsset {
let id: String
let checksum: String
@Column("is_favorite")
let isFavorite: Bool
@Column("deleted_at")
let checksum: String?
let deletedAt: Date?
@Column("owner_id")
let ownerId: User.ID
@Column("local_date_time")
let localDateTime: Date?
@Column("thumb_hash")
let thumbHash: String?
@Column("library_id")
let isFavorite: Int
let libraryId: String?
@Column("live_photo_video_id")
let livePhotoVideoId: String?
@Column("stack_id")
let stackId: Stack.ID?
let visibility: AssetVisibility
let localDateTime: Date?
let orientation: String
let ownerId: String
let stackId: String?
let visibility: Int
}
@Table("remote_exif_entity")
struct RemoteExif {
@Column("asset_id", primaryKey: true)
let assetId: RemoteAsset.ID
@Column(primaryKey: true)
let assetId: String
let city: String?
let state: String?
let country: String?
@Column("date_time_original")
let dateTimeOriginal: Date?
let description: String?
let height: Int?
let width: Int?
@Column("exposure_time")
let exposureTime: String?
@Column("f_number")
let fNumber: Double?
@Column("file_size")
let fileSize: Int?
@Column("focal_length")
let focalLength: Double?
let latitude: Double?
let longitude: Double?
@@ -302,101 +193,34 @@ struct RemoteExif {
let model: String?
let lens: String?
let orientation: String?
@Column("time_zone")
let timeZone: String?
let rating: Int?
@Column("projection_type")
let projectionType: String?
}
@Table("stack_entity")
struct Stack: Identifiable {
struct Stack {
let id: String
@Column("created_at")
let createdAt: Date
@Column("updated_at")
let updatedAt: Date
@Column("owner_id")
let ownerId: User.ID
@Column("primary_asset_id")
let ownerId: String
let primaryAssetId: String
}
@Table("store_entity")
struct Store: Identifiable {
struct Store {
let id: StoreKey
@Column("string_value")
let stringValue: String?
@Column("int_value")
let intValue: Int?
}
@Table("upload_tasks")
struct UploadTask: Identifiable {
let id: Int64
let attempts: Int
@Column("created_at", as: Date.UnixTimeRepresentation.self)
let createdAt: Date
@Column("file_path")
var filePath: URL?
@Column("is_live_photo")
let isLivePhoto: Bool?
@Column("last_error")
let lastError: UploadErrorCode?
@Column("live_photo_video_id")
let livePhotoVideoId: RemoteAsset.ID?
@Column("local_id")
var localId: LocalAsset.ID?
let method: UploadMethod
var priority: Float
@Column("retry_after", as: Date?.UnixTimeRepresentation.self)
let retryAfter: Date?
var status: TaskStatus
}
@Table("upload_task_stats")
struct UploadTaskStat {
@Column("pending_downloads")
let pendingDownloads: Int
@Column("pending_uploads")
let pendingUploads: Int
@Column("queued_downloads")
let queuedDownloads: Int
@Column("queued_uploads")
let queuedUploads: Int
@Column("failed_downloads")
let failedDownloads: Int
@Column("failed_uploads")
let failedUploads: Int
@Column("completed_uploads")
let completedUploads: Int
@Column("skipped_uploads")
let skippedUploads: Int
static let availableDownloadSlots = Self.select {
TaskConfig.maxPendingDownloads - ($0.pendingDownloads + $0.queuedDownloads)
}
static let availableUploadSlots = Self.select {
TaskConfig.maxPendingUploads - ($0.pendingUploads + $0.queuedUploads)
}
static let availableSlots = Self.select {
TaskConfig.maxPendingUploads + TaskConfig.maxPendingDownloads
- ($0.pendingDownloads + $0.queuedDownloads + $0.pendingUploads + $0.queuedUploads)
}
}
@Table("user_entity")
struct User: Identifiable {
struct User {
let id: String
let name: String
let email: String
@Column("has_profile_image")
let hasProfileImage: Bool
@Column("profile_changed_at")
let profileChangedAt: Date
@Column("avatar_color")
let avatarColor: AvatarColor
}
@@ -407,7 +231,6 @@ struct UserMetadata {
@Selection
struct ID {
@Column("user_id")
let userId: String
let key: Date
}

View File

@@ -140,6 +140,9 @@ struct PlatformAsset: Hashable {
var durationInSeconds: Int64
var orientation: Int64
var isFavorite: Bool
var adjustmentTime: Int64? = nil
var latitude: Double? = nil
var longitude: Double? = nil
// swift-format-ignore: AlwaysUseLowerCamelCase
@@ -154,6 +157,9 @@ struct PlatformAsset: Hashable {
let durationInSeconds = pigeonVar_list[7] as! Int64
let orientation = pigeonVar_list[8] as! Int64
let isFavorite = pigeonVar_list[9] as! Bool
let adjustmentTime: Int64? = nilOrValue(pigeonVar_list[10])
let latitude: Double? = nilOrValue(pigeonVar_list[11])
let longitude: Double? = nilOrValue(pigeonVar_list[12])
return PlatformAsset(
id: id,
@@ -165,7 +171,10 @@ struct PlatformAsset: Hashable {
height: height,
durationInSeconds: durationInSeconds,
orientation: orientation,
isFavorite: isFavorite
isFavorite: isFavorite,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude
)
}
func toList() -> [Any?] {
@@ -180,6 +189,9 @@ struct PlatformAsset: Hashable {
durationInSeconds,
orientation,
isFavorite,
adjustmentTime,
latitude,
longitude,
]
}
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {

View File

@@ -12,7 +12,10 @@ extension PHAsset {
height: Int64(pixelHeight),
durationInSeconds: Int64(duration),
orientation: 0,
isFavorite: isFavorite
isFavorite: isFavorite,
adjustmentTime: adjustmentTimestamp,
latitude: location?.coordinate.latitude,
longitude: location?.coordinate.longitude
)
}
@@ -23,6 +26,13 @@ extension PHAsset {
var filename: String? {
return value(forKey: "filename") as? String
}
var adjustmentTimestamp: Int64? {
if let date = value(forKey: "adjustmentTimestamp") as? Date {
return Int64(date.timeIntervalSince1970)
}
return nil
}
// This method is expected to be slow as it goes through the asset resources to fetch the originalFilename
var originalFilename: String? {
@@ -52,23 +62,6 @@ extension PHAsset {
return nil
}
func getLivePhotoResource() -> PHAssetResource? {
let resources = PHAssetResource.assetResources(for: self)
var livePhotoResource: PHAssetResource?
for resource in resources {
if resource.type == .fullSizePairedVideo {
return resource
}
if resource.type == .pairedVideo {
livePhotoResource = resource
}
}
return livePhotoResource
}
private func isValidResourceType(_ type: PHAssetResourceType) -> Bool {
switch mediaType {

View File

@@ -1,62 +0,0 @@
import StructuredFieldValues
struct AssetData: StructuredFieldValue {
static let structuredFieldType: StructuredFieldType = .dictionary
let deviceAssetId: String
let deviceId: String
let fileCreatedAt: String
let fileModifiedAt: String
let fileName: String
let isFavorite: Bool
let livePhotoVideoId: String?
static let boundary = "Boundary-\(UUID().uuidString)"
static let deviceAssetIdField = "--\(boundary)\r\nContent-Disposition: form-data; name=\"deviceAssetId\"\r\n\r\n"
.data(using: .utf8)!
static let deviceIdField = "\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"deviceId\"\r\n\r\n"
.data(using: .utf8)!
static let fileCreatedAtField =
"\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"fileCreatedAt\"\r\n\r\n"
.data(using: .utf8)!
static let fileModifiedAtField =
"\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"fileModifiedAt\"\r\n\r\n"
.data(using: .utf8)!
static let isFavoriteField = "\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"isFavorite\"\r\n\r\n"
.data(using: .utf8)!
static let livePhotoVideoIdField =
"\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"livePhotoVideoId\"\r\n\r\n"
.data(using: .utf8)!
static let trueData = "true".data(using: .utf8)!
static let falseData = "false".data(using: .utf8)!
static let footer = "\r\n--\(boundary)--\r\n".data(using: .utf8)!
static let contentType = "multipart/form-data; boundary=\(boundary)"
func multipart() -> (Data, Data) {
var header = Data()
header.append(Self.deviceAssetIdField)
header.append(deviceAssetId.data(using: .utf8)!)
header.append(Self.deviceIdField)
header.append(deviceId.data(using: .utf8)!)
header.append(Self.fileCreatedAtField)
header.append(fileCreatedAt.data(using: .utf8)!)
header.append(Self.fileModifiedAtField)
header.append(fileModifiedAt.data(using: .utf8)!)
header.append(Self.isFavoriteField)
header.append(isFavorite ? Self.trueData : Self.falseData)
if let livePhotoVideoId {
header.append(Self.livePhotoVideoIdField)
header.append(livePhotoVideoId.data(using: .utf8)!)
}
header.append(
"\r\n--\(Self.boundary)\r\nContent-Disposition: form-data; name=\"assetData\"; filename=\"\(fileName)\"\r\nContent-Type: application/octet-stream\r\n\r\n"
.data(using: .utf8)!
)
return (header, Self.footer)
}
}

View File

@@ -1,214 +0,0 @@
import SQLiteData
private let stateLock = NSLock()
private var transferStates: [Int64: NetworkTransferState] = [:]
private var responseData: [Int64: Data] = [:]
private let jsonDecoder = JSONDecoder()
private class NetworkTransferState {
var lastUpdateTime: Date
var totalBytesTransferred: Int64
var currentSpeed: Double?
init(lastUpdateTime: Date, totalBytesTransferred: Int64, currentSpeed: Double?) {
self.lastUpdateTime = lastUpdateTime
self.totalBytesTransferred = totalBytesTransferred
self.currentSpeed = currentSpeed
}
}
final class UploadApiDelegate<
TaskRepo: TaskProtocol,
StatusListener: TaskStatusListener,
ProgressListener: TaskProgressListener
>: NSObject, URLSessionDataDelegate, URLSessionTaskDelegate {
private let taskRepository: TaskRepo
private let statusListener: StatusListener
private let progressListener: ProgressListener
init(taskRepository: TaskRepo, statusListener: StatusListener, progressListener: ProgressListener) {
self.taskRepository = taskRepository
self.statusListener = statusListener
self.progressListener = progressListener
}
static func reset() {
stateLock.withLock {
transferStates.removeAll()
responseData.removeAll()
}
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
guard let taskIdStr = dataTask.taskDescription,
let taskId = Int64(taskIdStr)
else { return }
stateLock.withLock {
if var response = responseData[taskId] {
response.append(data)
} else {
responseData[taskId] = data
}
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
Task {
defer {
NotificationCenter.default.post(name: .uploadTaskDidComplete, object: nil)
}
guard let taskDescriptionId = task.taskDescription,
let taskId = Int64(taskDescriptionId)
else {
return dPrint("Unexpected: task without session ID completed")
}
defer {
stateLock.withLock { let _ = transferStates.removeValue(forKey: taskId) }
}
if let body = stateLock.withLock({ responseData.removeValue(forKey: taskId) }),
let response = task.response as? HTTPURLResponse
{
switch response.statusCode {
case 200, 201:
do {
let response = try jsonDecoder.decode(UploadSuccessResponse.self, from: body)
return await handleSuccess(taskId: taskId, response: response)
} catch {
return await handleFailure(taskId: taskId, code: .invalidResponse)
}
case 401: return await handleFailure(taskId: taskId, code: .unauthorized)
case 400..<500:
dPrint("Response \(response.statusCode): \(String(data: body, encoding: .utf8) ?? "No response body")")
return await handleFailure(taskId: taskId, code: .badRequest)
case 500..<600:
dPrint("Response \(response.statusCode): \(String(data: body, encoding: .utf8) ?? "No response body")")
return await handleFailure(taskId: taskId, code: .internalServerError)
default:
break
}
}
guard let urlError = error as? URLError else {
return await handleFailure(taskId: taskId)
}
if #available(iOS 17, *), let resumeData = urlError.uploadTaskResumeData {
return await handleFailure(taskDescriptionId: taskDescriptionId, session: session, resumeData: resumeData)
}
let code: UploadErrorCode =
switch urlError.backgroundTaskCancelledReason {
case .backgroundUpdatesDisabled: .backgroundUpdatesDisabled
case .insufficientSystemResources: .outOfResources
case .userForceQuitApplication: .forceQuit
default:
switch urlError.code {
case .networkConnectionLost, .notConnectedToInternet: .networkError
case .timedOut: .uploadTimeout
case .resourceUnavailable, .fileDoesNotExist: .fileNotFound
default: .unknown
}
}
await handleFailure(taskId: taskId, code: code)
}
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didSendBodyData bytesSent: Int64,
totalBytesSent: Int64,
totalBytesExpectedToSend: Int64
) {
guard let sessionTaskId = task.taskDescription, let taskId = Int64(sessionTaskId) else { return }
let currentTime = Date()
let state = stateLock.withLock {
if let existing = transferStates[taskId] {
return existing
}
let new = NetworkTransferState(
lastUpdateTime: currentTime,
totalBytesTransferred: totalBytesSent,
currentSpeed: nil
)
transferStates[taskId] = new
return new
}
let timeDelta = currentTime.timeIntervalSince(state.lastUpdateTime)
guard timeDelta > 0 else { return }
let bytesDelta = totalBytesSent - state.totalBytesTransferred
let instantSpeed = Double(bytesDelta) / timeDelta
let currentSpeed =
if let previousSpeed = state.currentSpeed {
TaskConfig.transferSpeedAlpha * instantSpeed + (1 - TaskConfig.transferSpeedAlpha) * previousSpeed
} else {
instantSpeed
}
state.currentSpeed = currentSpeed
state.lastUpdateTime = currentTime
state.totalBytesTransferred = totalBytesSent
self.progressListener.onTaskProgress(
UploadApiTaskProgress(
id: sessionTaskId,
progress: Double(totalBytesSent) / Double(totalBytesExpectedToSend),
speed: currentSpeed
)
)
}
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
dPrint("All background events delivered for session: \(session.configuration.identifier ?? "unknown")")
DispatchQueue.main.async {
if let identifier = session.configuration.identifier,
let appDelegate = UIApplication.shared.delegate as? AppDelegate,
let completionHandler = appDelegate.completionHandler(forSession: identifier)
{
completionHandler()
}
}
}
private func handleSuccess(taskId: Int64, response: UploadSuccessResponse) async {
dPrint("Upload succeeded for task \(taskId), server ID: \(response.id)")
do {
try await taskRepository.markUploadSuccess(taskId: taskId, livePhotoVideoId: response.id)
statusListener.onTaskStatus(
UploadApiTaskStatus(
id: String(taskId),
filename: (try? await taskRepository.getFilename(taskId: taskId)) ?? "",
status: .uploadComplete
)
)
} catch {
dPrint(
"Failed to update upload success status for session task \(taskId): \(error.localizedDescription)"
)
}
}
private func handleFailure(taskId: Int64, code: UploadErrorCode = .unknown) async {
dPrint("Upload failed for task \(taskId) with code \(code)")
try? await taskRepository.retryOrFail(taskId: taskId, code: code, status: .uploadFailed)
statusListener.onTaskStatus(
UploadApiTaskStatus(
id: String(taskId),
filename: (try? await taskRepository.getFilename(taskId: taskId)) ?? "",
status: .uploadFailed
)
)
}
@available(iOS 17, *)
private func handleFailure(taskDescriptionId: String, session: URLSession, resumeData: Data) async {
dPrint("Resuming upload for task \(taskDescriptionId)")
let resumeTask = session.uploadTask(withResumeData: resumeData)
resumeTask.taskDescription = taskDescriptionId
resumeTask.resume()
}
}

View File

@@ -1,305 +0,0 @@
import CryptoKit
import Photos
private var queueProcessingTask: Task<Void, Never>?
private var queueProcessingLock = NSLock()
private let resourceManager = PHAssetResourceManager.default()
private final class RequestRef {
var id: PHAssetResourceDataRequestID?
var lastProgressTime = Date()
var didStall = false
}
final class DownloadQueue<
StoreRepo: StoreProtocol,
TaskRepo: TaskProtocol,
StatusListener: TaskStatusListener,
ProgressListener: TaskProgressListener
> {
private let storeRepository: StoreRepo
private let taskRepository: TaskRepo
private let statusListener: StatusListener
private let progressListener: ProgressListener
private var uploadObserver: NSObjectProtocol?
private var networkObserver: NSObjectProtocol?
init(
storeRepository: StoreRepo,
taskRepository: TaskRepo,
statusListener: StatusListener,
progressListener: ProgressListener
) {
self.storeRepository = storeRepository
self.taskRepository = taskRepository
self.statusListener = statusListener
self.progressListener = progressListener
uploadObserver = NotificationCenter.default.addObserver(forName: .uploadTaskDidComplete, object: nil, queue: nil) {
[weak self] _ in
self?.startQueueProcessing()
}
networkObserver = NotificationCenter.default.addObserver(forName: .networkDidConnect, object: nil, queue: nil) {
[weak self] _ in
dPrint("Network connected")
self?.startQueueProcessing()
}
}
deinit {
uploadObserver.map(NotificationCenter.default.removeObserver(_:))
networkObserver.map(NotificationCenter.default.removeObserver(_:))
}
func enqueueAssets(localIds: [String]) async throws {
guard !localIds.isEmpty else { return dPrint("No assets to enqueue") }
defer { startQueueProcessing() }
let candidates = try await taskRepository.getBackupCandidates(ids: localIds)
guard !candidates.isEmpty else { return dPrint("No candidates to enqueue") }
try await taskRepository.enqueue(assets: candidates, imagePriority: 0.9, videoPriority: 0.8)
dPrint("Enqueued \(candidates.count) assets for upload")
}
func startQueueProcessing() {
dPrint("Starting download queue processing")
queueProcessingLock.withLock {
guard queueProcessingTask == nil else { return }
queueProcessingTask = Task {
await startDownloads()
queueProcessingLock.withLock { queueProcessingTask = nil }
}
}
}
private func startDownloads() async {
dPrint("Processing download queue")
guard await UIApplication.shared.applicationState != .background else {
return dPrint("Not processing downloads in background") // TODO: run in processing tasks
}
guard NetworkMonitor.shared.isConnected,
let backupEnabled = try? storeRepository.get(StoreKey.enableBackup), backupEnabled,
let deviceId = try? storeRepository.get(StoreKey.deviceId)
else { return dPrint("Download queue paused: missing preconditions") }
do {
let tasks = try await taskRepository.getDownloadTasks()
if tasks.isEmpty { return dPrint("No download tasks to process") }
try await withThrowingTaskGroup(of: Void.self) { group in
var iterator = tasks.makeIterator()
for _ in 0..<min(TaskConfig.maxActiveDownloads, tasks.count) {
if let task = iterator.next() {
group.addTask { await self.downloadAndQueue(task, deviceId: deviceId) }
}
}
while try await group.next() != nil {
if let task = iterator.next() {
group.addTask { await self.downloadAndQueue(task, deviceId: deviceId) }
}
}
}
} catch {
dPrint("Download queue error: \(error)")
}
}
private func downloadAndQueue(_ task: LocalAssetDownloadData, deviceId: String) async {
defer { startQueueProcessing() }
dPrint("Starting download for task \(task.taskId)")
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [task.localId], options: nil).firstObject
else {
dPrint("Asset not found")
return await handleFailure(task: task, code: .assetNotFound)
}
let isLivePhoto = asset.mediaSubtypes.contains(.photoLive)
let isMotion = isLivePhoto && task.livePhotoVideoId != nil
guard let resource = isMotion ? asset.getLivePhotoResource() : asset.getResource() else {
dPrint("Resource not found")
return await handleFailure(task: task, code: .resourceNotFound)
}
let fileDir = TaskConfig.originalsDir
let fileName = "\(resource.assetLocalIdentifier.replacingOccurrences(of: "/", with: "_"))_\(resource.type.rawValue)"
let filePath = fileDir.appendingPathComponent(fileName)
do {
try FileManager.default.createDirectory(
at: fileDir,
withIntermediateDirectories: true,
attributes: nil
)
} catch {
dPrint("Failed to create directory for download task \(task.taskId): \(error)")
return await handleFailure(task: task, code: .writeFailed, filePath: filePath)
}
do {
try await taskRepository.markDownloadQueued(taskId: task.taskId, isLivePhoto: isLivePhoto, filePath: filePath)
} catch {
return dPrint("Failed to set file path for download task \(task.taskId): \(error)")
}
statusListener.onTaskStatus(
UploadApiTaskStatus(id: String(task.taskId), filename: filePath.path, status: .downloadQueued)
)
do {
let hash = try await download(task: task, asset: asset, resource: resource, to: filePath, deviceId: deviceId)
let status = try await taskRepository.markDownloadComplete(taskId: task.taskId, localId: task.localId, hash: hash)
statusListener.onTaskStatus(
UploadApiTaskStatus(
id: String(task.taskId),
filename: task.filename,
status: UploadApiStatus(rawValue: status.rawValue)!
)
)
NotificationCenter.default.post(name: .downloadTaskDidComplete, object: nil)
} catch let error as UploadError {
dPrint("Download failed for task \(task.taskId): \(error)")
await handleFailure(task: task, code: error.code, filePath: filePath)
} catch {
dPrint("Download failed for task \(task.taskId): \(error)")
await handleFailure(task: task, code: .unknown, filePath: filePath)
}
}
func download(
task: LocalAssetDownloadData,
asset: PHAsset,
resource: PHAssetResource,
to filePath: URL,
deviceId: String
) async throws
-> String?
{
dPrint("Downloading asset resource \(resource.assetLocalIdentifier) of type \(resource.type.rawValue)")
let options = PHAssetResourceRequestOptions()
options.isNetworkAccessAllowed = true
let (header, footer) = AssetData(
deviceAssetId: task.localId,
deviceId: deviceId,
fileCreatedAt: task.createdAt,
fileModifiedAt: task.updatedAt,
fileName: resource.originalFilename,
isFavorite: asset.isFavorite,
livePhotoVideoId: nil
).multipart()
guard let fileHandle = try? FileHandle.createOrOverwrite(atPath: filePath.path) else {
dPrint("Failed to open file handle for download task \(task.taskId), path: \(filePath.path)")
throw UploadError.fileCreationFailed
}
try fileHandle.write(contentsOf: header)
var lastProgressTime = Date()
let taskIdStr = String(task.taskId)
options.progressHandler = { progress in
lastProgressTime = Date()
self.progressListener.onTaskProgress(UploadApiTaskProgress(id: taskIdStr, progress: progress))
}
let request = RequestRef()
let timeoutTask = Task {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: TaskConfig.downloadCheckIntervalNs)
request.didStall = Date().timeIntervalSince(lastProgressTime) > TaskConfig.downloadTimeoutS
if request.didStall {
if let requestId = request.id {
resourceManager.cancelDataRequest(requestId)
}
break
}
}
}
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
var hasher = task.checksum == nil && task.livePhotoVideoId == nil ? Insecure.SHA1() : nil
request.id = resourceManager.requestData(
for: resource,
options: options,
dataReceivedHandler: { data in
guard let requestId = request.id else { return }
do {
hasher?.update(data: data)
try fileHandle.write(contentsOf: data)
} catch {
request.id = nil
resourceManager.cancelDataRequest(requestId)
}
},
completionHandler: { error in
timeoutTask.cancel()
switch error {
case let e as NSError where e.domain == "CloudPhotoLibraryErrorDomain":
dPrint("iCloud error during download: \(e)")
let code: UploadErrorCode =
switch e.code {
case 1005: .iCloudRateLimit
case 81: .iCloudThrottled
default: .photosUnknownError
}
continuation.resume(throwing: UploadError.iCloudError(code))
case let e as PHPhotosError:
dPrint("Photos error during download: \(e)")
let code: UploadErrorCode =
switch e.code {
case .notEnoughSpace: .notEnoughSpace
case .missingResource: .resourceNotFound
case .networkError: .networkError
case .internalError: .photosInternalError
case .invalidResource: .invalidResource
case .operationInterrupted: .interrupted
case .userCancelled where request.didStall: .downloadStalled
case .userCancelled: .cancelled
default: .photosUnknownError
}
continuation.resume(throwing: UploadError.photosError(code))
case .some:
dPrint("Unknown error during download: \(String(describing: error))")
continuation.resume(throwing: UploadError.unknown)
case .none:
dPrint("Download completed for task \(task.taskId)")
do {
try fileHandle.write(contentsOf: footer)
continuation.resume(returning: hasher.map { hasher in Data(hasher.finalize()).base64EncodedString() })
} catch {
try? FileManager.default.removeItem(at: filePath)
continuation.resume(throwing: UploadError.fileCreationFailed)
}
}
}
)
}
} onCancel: {
if let requestId = request.id {
resourceManager.cancelDataRequest(requestId)
}
}
}
private func handleFailure(task: LocalAssetDownloadData, code: UploadErrorCode, filePath: URL? = nil) async {
dPrint("Handling failure for task \(task.taskId) with code \(code.rawValue)")
do {
if let filePath {
try? FileManager.default.removeItem(at: filePath)
}
try await taskRepository.retryOrFail(taskId: task.taskId, code: code, status: .downloadFailed)
statusListener.onTaskStatus(
UploadApiTaskStatus(
id: String(task.taskId),
filename: task.filename,
status: .downloadFailed
)
)
} catch {
dPrint("Failed to update download failure status for task \(task.taskId): \(error)")
}
}
}

View File

@@ -1,65 +0,0 @@
protocol TaskProgressListener {
func onTaskProgress(_ event: UploadApiTaskProgress)
}
protocol TaskStatusListener {
func onTaskStatus(_ event: UploadApiTaskStatus)
}
final class StatusEventListener: StreamStatusStreamHandler, TaskStatusListener, @unchecked Sendable {
var eventSink: PigeonEventSink<UploadApiTaskStatus>?
override func onListen(withArguments arguments: Any?, sink: PigeonEventSink<UploadApiTaskStatus>) {
eventSink = sink
}
func onTaskStatus(_ event: UploadApiTaskStatus) {
if let eventSink {
DispatchQueue.main.async { eventSink.success(event) }
}
}
func onEventsDone() {
DispatchQueue.main.async {
self.eventSink?.endOfStream()
self.eventSink = nil
}
}
}
final class ProgressEventListener: StreamProgressStreamHandler, TaskProgressListener, @unchecked Sendable {
var eventSink: PigeonEventSink<UploadApiTaskProgress>?
private var lastReportTimes: [String: Date] = [:]
private let lock = NSLock()
override func onListen(withArguments arguments: Any?, sink: PigeonEventSink<UploadApiTaskProgress>) {
eventSink = sink
}
func onTaskProgress(_ event: UploadApiTaskProgress) {
guard let eventSink,
lock.withLock({
let now = Date()
if let lastReport = lastReportTimes[event.id] {
guard now.timeIntervalSince(lastReport) >= TaskConfig.progressThrottleInterval else {
return false
}
}
lastReportTimes[event.id] = now
return true
})
else { return }
DispatchQueue.main.async { eventSink.success(event) }
}
func onEventsDone() {
DispatchQueue.main.async {
self.eventSink?.endOfStream()
self.eventSink = nil
self.lock.withLock {
self.lastReportTimes.removeAll()
}
}
}
}

View File

@@ -1,22 +0,0 @@
import Network
final class NetworkMonitor {
static let shared = NetworkMonitor()
private let monitor = NWPathMonitor()
private(set) var isConnected = false
private(set) var isExpensive = false
private init() {
monitor.pathUpdateHandler = { [weak self] path in
guard let self else { return }
let wasConnected = self.isConnected
self.isConnected = path.status == .satisfied
self.isExpensive = path.isExpensive
if !wasConnected && self.isConnected {
NotificationCenter.default.post(name: .networkDidConnect, object: nil)
}
}
monitor.start(queue: .global(qos: .default))
}
}

View File

@@ -1,197 +0,0 @@
private var queueProcessingTask: Task<Void, Never>?
private var queueProcessingLock = NSLock()
final class UploadQueue<StoreRepo: StoreProtocol, TaskRepo: TaskProtocol, StatusListener: TaskStatusListener> {
private let storeRepository: StoreRepo
private let taskRepository: TaskRepo
private let statusListener: StatusListener
private let cellularSession: URLSession
private let wifiOnlySession: URLSession
private var uploadObserver: NSObjectProtocol?
private var downloadObserver: NSObjectProtocol?
private var networkObserver: NSObjectProtocol?
init(
storeRepository: StoreRepo,
taskRepository: TaskRepo,
statusListener: StatusListener,
cellularSession: URLSession,
wifiOnlySession: URLSession
) {
self.storeRepository = storeRepository
self.taskRepository = taskRepository
self.cellularSession = cellularSession
self.wifiOnlySession = wifiOnlySession
self.statusListener = statusListener
uploadObserver = NotificationCenter.default.addObserver(forName: .uploadTaskDidComplete, object: nil, queue: nil) {
[weak self] _ in
self?.startQueueProcessing()
}
downloadObserver = NotificationCenter.default.addObserver(
forName: .downloadTaskDidComplete,
object: nil,
queue: nil
) { [weak self] _ in
self?.startQueueProcessing()
}
networkObserver = NotificationCenter.default.addObserver(forName: .networkDidConnect, object: nil, queue: nil) {
[weak self] _ in
self?.startQueueProcessing()
}
}
deinit {
uploadObserver.map(NotificationCenter.default.removeObserver(_:))
downloadObserver.map(NotificationCenter.default.removeObserver(_:))
networkObserver.map(NotificationCenter.default.removeObserver(_:))
}
func enqueueFiles(paths: [String]) async throws {
guard !paths.isEmpty else { return dPrint("No paths to enqueue") }
guard let deviceId = try? storeRepository.get(StoreKey.deviceId) else {
throw StoreError.notFound
}
defer { startQueueProcessing() }
try await withThrowingTaskGroup(of: Void.self, returning: Void.self) { group in
let date = Date()
try FileManager.default.createDirectory(
at: TaskConfig.originalsDir,
withIntermediateDirectories: true,
attributes: nil
)
for path in paths {
group.addTask {
let inputURL = URL(fileURLWithPath: path, isDirectory: false)
let outputURL = TaskConfig.originalsDir.appendingPathComponent(UUID().uuidString)
let resources = try inputURL.resourceValues(forKeys: [.creationDateKey, .contentModificationDateKey])
let formatter = ISO8601DateFormatter()
let (header, footer) = AssetData(
deviceAssetId: "",
deviceId: deviceId,
fileCreatedAt: formatter.string(from: resources.creationDate ?? date),
fileModifiedAt: formatter.string(from: resources.contentModificationDate ?? date),
fileName: resources.name ?? inputURL.lastPathComponent,
isFavorite: false,
livePhotoVideoId: nil
).multipart()
do {
let writeHandle = try FileHandle.createOrOverwrite(atPath: outputURL.path)
try writeHandle.write(contentsOf: header)
let readHandle = try FileHandle(forReadingFrom: inputURL)
let bufferSize = 1024 * 1024
while true {
let data = try readHandle.read(upToCount: bufferSize)
guard let data = data, !data.isEmpty else { break }
try writeHandle.write(contentsOf: data)
}
try writeHandle.write(contentsOf: footer)
} catch {
try? FileManager.default.removeItem(at: outputURL)
throw error
}
}
}
try await group.waitForAll()
}
try await taskRepository.enqueue(files: paths)
dPrint("Enqueued \(paths.count) assets for upload")
}
func startQueueProcessing() {
dPrint("Starting upload queue processing")
queueProcessingLock.withLock {
guard queueProcessingTask == nil else { return }
queueProcessingTask = Task {
await startUploads()
queueProcessingLock.withLock { queueProcessingTask = nil }
}
}
}
private func startUploads() async {
dPrint("Processing upload queue")
guard NetworkMonitor.shared.isConnected,
let backupEnabled = try? storeRepository.get(StoreKey.enableBackup), backupEnabled,
let url = try? storeRepository.get(StoreKey.serverEndpoint),
let accessToken = try? storeRepository.get(StoreKey.accessToken)
else { return dPrint("Upload queue paused: missing preconditions") }
do {
let tasks = try await taskRepository.getUploadTasks()
if tasks.isEmpty { return dPrint("No upload tasks to process") }
await withTaskGroup(of: Void.self) { group in
for task in tasks {
group.addTask { await self.startUpload(multipart: task, url: url, accessToken: accessToken) }
}
}
} catch {
dPrint("Upload queue error: \(error)")
}
}
private func startUpload(multipart task: LocalAssetUploadData, url: URL, accessToken: String) async {
dPrint("Uploading asset resource at \(task.filePath) of task \(task.taskId)")
defer { startQueueProcessing() }
let session =
switch task.type {
case .image:
(try? storeRepository.get(StoreKey.useWifiForUploadPhotos)) ?? false ? wifiOnlySession : cellularSession
case .video:
(try? storeRepository.get(StoreKey.useWifiForUploadVideos)) ?? false ? wifiOnlySession : cellularSession
default: wifiOnlySession
}
var request = URLRequest(url: url.appendingPathComponent("/assets"))
request.httpMethod = "POST"
request.setValue(accessToken, forHTTPHeaderField: UploadHeaders.userToken.rawValue)
request.setValue(AssetData.contentType, forHTTPHeaderField: "Content-Type")
let sessionTask = session.uploadTask(with: request, fromFile: task.filePath)
sessionTask.taskDescription = String(task.taskId)
sessionTask.priority = task.priority
do {
try? FileManager.default.removeItem(at: task.filePath) // upload task already copied the file
try await taskRepository.markUploadQueued(taskId: task.taskId)
statusListener.onTaskStatus(
UploadApiTaskStatus(
id: String(task.taskId),
filename: task.filename,
status: .uploadQueued,
)
)
sessionTask.resume()
dPrint("Upload started for task \(task.taskId) using \(session == wifiOnlySession ? "WiFi" : "Cellular") session")
} catch {
dPrint("Upload failed for \(task.taskId), could not update queue status: \(error.localizedDescription)")
}
}
private func handleFailure(task: LocalAssetUploadData, code: UploadErrorCode) async {
do {
try await taskRepository.retryOrFail(taskId: task.taskId, code: code, status: .uploadFailed)
statusListener.onTaskStatus(
UploadApiTaskStatus(
id: String(task.taskId),
filename: task.filename,
status: .uploadFailed
)
)
} catch {
dPrint("Failed to update upload failure status for task \(task.taskId): \(error)")
}
}
}

View File

@@ -1,482 +0,0 @@
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
import Foundation
#if os(iOS)
import Flutter
#elseif os(macOS)
import FlutterMacOS
#else
#error("Unsupported platform.")
#endif
private func wrapResult(_ result: Any?) -> [Any?] {
return [result]
}
private func wrapError(_ error: Any) -> [Any?] {
if let pigeonError = error as? PigeonError {
return [
pigeonError.code,
pigeonError.message,
pigeonError.details,
]
}
if let flutterError = error as? FlutterError {
return [
flutterError.code,
flutterError.message,
flutterError.details,
]
}
return [
"\(error)",
"\(type(of: error))",
"Stacktrace: \(Thread.callStackSymbols)",
]
}
private func isNullish(_ value: Any?) -> Bool {
return value is NSNull || value == nil
}
private func nilOrValue<T>(_ value: Any?) -> T? {
if value is NSNull { return nil }
return value as! T?
}
func deepEqualsUploadTask(_ lhs: Any?, _ rhs: Any?) -> Bool {
let cleanLhs = nilOrValue(lhs) as Any?
let cleanRhs = nilOrValue(rhs) as Any?
switch (cleanLhs, cleanRhs) {
case (nil, nil):
return true
case (nil, _), (_, nil):
return false
case is (Void, Void):
return true
case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable):
return cleanLhsHashable == cleanRhsHashable
case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]):
guard cleanLhsArray.count == cleanRhsArray.count else { return false }
for (index, element) in cleanLhsArray.enumerated() {
if !deepEqualsUploadTask(element, cleanRhsArray[index]) {
return false
}
}
return true
case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]):
guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false }
for (key, cleanLhsValue) in cleanLhsDictionary {
guard cleanRhsDictionary.index(forKey: key) != nil else { return false }
if !deepEqualsUploadTask(cleanLhsValue, cleanRhsDictionary[key]!) {
return false
}
}
return true
default:
// Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue.
return false
}
}
func deepHashUploadTask(value: Any?, hasher: inout Hasher) {
if let valueList = value as? [AnyHashable] {
for item in valueList { deepHashUploadTask(value: item, hasher: &hasher) }
return
}
if let valueDict = value as? [AnyHashable: AnyHashable] {
for key in valueDict.keys {
hasher.combine(key)
deepHashUploadTask(value: valueDict[key]!, hasher: &hasher)
}
return
}
if let hashableValue = value as? AnyHashable {
hasher.combine(hashableValue.hashValue)
}
return hasher.combine(String(describing: value))
}
enum UploadApiErrorCode: Int {
case unknown = 0
case assetNotFound = 1
case fileNotFound = 2
case resourceNotFound = 3
case invalidResource = 4
case encodingFailed = 5
case writeFailed = 6
case notEnoughSpace = 7
case networkError = 8
case photosInternalError = 9
case photosUnknownError = 10
case interrupted = 11
case cancelled = 12
case downloadStalled = 13
case forceQuit = 14
case outOfResources = 15
case backgroundUpdatesDisabled = 16
case uploadTimeout = 17
case iCloudRateLimit = 18
case iCloudThrottled = 19
case invalidResponse = 20
case badRequest = 21
case internalServerError = 22
case unauthorized = 23
}
enum UploadApiStatus: Int {
case downloadPending = 0
case downloadQueued = 1
case downloadFailed = 2
case uploadPending = 3
case uploadQueued = 4
case uploadFailed = 5
case uploadComplete = 6
case uploadSkipped = 7
}
/// Generated class from Pigeon that represents data sent in messages.
struct UploadApiTaskStatus: Hashable {
var id: String
var filename: String
var status: UploadApiStatus
var errorCode: UploadApiErrorCode? = nil
var httpStatusCode: Int64? = nil
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> UploadApiTaskStatus? {
let id = pigeonVar_list[0] as! String
let filename = pigeonVar_list[1] as! String
let status = pigeonVar_list[2] as! UploadApiStatus
let errorCode: UploadApiErrorCode? = nilOrValue(pigeonVar_list[3])
let httpStatusCode: Int64? = nilOrValue(pigeonVar_list[4])
return UploadApiTaskStatus(
id: id,
filename: filename,
status: status,
errorCode: errorCode,
httpStatusCode: httpStatusCode
)
}
func toList() -> [Any?] {
return [
id,
filename,
status,
errorCode,
httpStatusCode,
]
}
static func == (lhs: UploadApiTaskStatus, rhs: UploadApiTaskStatus) -> Bool {
return deepEqualsUploadTask(lhs.toList(), rhs.toList()) }
func hash(into hasher: inout Hasher) {
deepHashUploadTask(value: toList(), hasher: &hasher)
}
}
/// Generated class from Pigeon that represents data sent in messages.
struct UploadApiTaskProgress: Hashable {
var id: String
var progress: Double
var speed: Double? = nil
var totalBytes: Int64? = nil
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> UploadApiTaskProgress? {
let id = pigeonVar_list[0] as! String
let progress = pigeonVar_list[1] as! Double
let speed: Double? = nilOrValue(pigeonVar_list[2])
let totalBytes: Int64? = nilOrValue(pigeonVar_list[3])
return UploadApiTaskProgress(
id: id,
progress: progress,
speed: speed,
totalBytes: totalBytes
)
}
func toList() -> [Any?] {
return [
id,
progress,
speed,
totalBytes,
]
}
static func == (lhs: UploadApiTaskProgress, rhs: UploadApiTaskProgress) -> Bool {
return deepEqualsUploadTask(lhs.toList(), rhs.toList()) }
func hash(into hasher: inout Hasher) {
deepHashUploadTask(value: toList(), hasher: &hasher)
}
}
private class UploadTaskPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? {
switch type {
case 129:
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
if let enumResultAsInt = enumResultAsInt {
return UploadApiErrorCode(rawValue: enumResultAsInt)
}
return nil
case 130:
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
if let enumResultAsInt = enumResultAsInt {
return UploadApiStatus(rawValue: enumResultAsInt)
}
return nil
case 131:
return UploadApiTaskStatus.fromList(self.readValue() as! [Any?])
case 132:
return UploadApiTaskProgress.fromList(self.readValue() as! [Any?])
default:
return super.readValue(ofType: type)
}
}
}
private class UploadTaskPigeonCodecWriter: FlutterStandardWriter {
override func writeValue(_ value: Any) {
if let value = value as? UploadApiErrorCode {
super.writeByte(129)
super.writeValue(value.rawValue)
} else if let value = value as? UploadApiStatus {
super.writeByte(130)
super.writeValue(value.rawValue)
} else if let value = value as? UploadApiTaskStatus {
super.writeByte(131)
super.writeValue(value.toList())
} else if let value = value as? UploadApiTaskProgress {
super.writeByte(132)
super.writeValue(value.toList())
} else {
super.writeValue(value)
}
}
}
private class UploadTaskPigeonCodecReaderWriter: FlutterStandardReaderWriter {
override func reader(with data: Data) -> FlutterStandardReader {
return UploadTaskPigeonCodecReader(data: data)
}
override func writer(with data: NSMutableData) -> FlutterStandardWriter {
return UploadTaskPigeonCodecWriter(data: data)
}
}
class UploadTaskPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
static let shared = UploadTaskPigeonCodec(readerWriter: UploadTaskPigeonCodecReaderWriter())
}
var uploadTaskPigeonMethodCodec = FlutterStandardMethodCodec(readerWriter: UploadTaskPigeonCodecReaderWriter());
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol UploadApi {
func initialize(completion: @escaping (Result<Void, Error>) -> Void)
func refresh(completion: @escaping (Result<Void, Error>) -> Void)
func cancelAll(completion: @escaping (Result<Void, Error>) -> Void)
func enqueueAssets(localIds: [String], completion: @escaping (Result<Void, Error>) -> Void)
func enqueueFiles(paths: [String], completion: @escaping (Result<Void, Error>) -> Void)
func onConfigChange(key: Int64, completion: @escaping (Result<Void, Error>) -> Void)
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class UploadApiSetup {
static var codec: FlutterStandardMessageCodec { UploadTaskPigeonCodec.shared }
/// Sets up an instance of `UploadApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: UploadApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let initializeChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.UploadApi.initialize\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
initializeChannel.setMessageHandler { _, reply in
api.initialize { result in
switch result {
case .success:
reply(wrapResult(nil))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
initializeChannel.setMessageHandler(nil)
}
let refreshChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.UploadApi.refresh\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
refreshChannel.setMessageHandler { _, reply in
api.refresh { result in
switch result {
case .success:
reply(wrapResult(nil))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
refreshChannel.setMessageHandler(nil)
}
let cancelAllChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.UploadApi.cancelAll\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
cancelAllChannel.setMessageHandler { _, reply in
api.cancelAll { result in
switch result {
case .success:
reply(wrapResult(nil))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
cancelAllChannel.setMessageHandler(nil)
}
let enqueueAssetsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.UploadApi.enqueueAssets\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
enqueueAssetsChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let localIdsArg = args[0] as! [String]
api.enqueueAssets(localIds: localIdsArg) { result in
switch result {
case .success:
reply(wrapResult(nil))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
enqueueAssetsChannel.setMessageHandler(nil)
}
let enqueueFilesChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.UploadApi.enqueueFiles\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
enqueueFilesChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let pathsArg = args[0] as! [String]
api.enqueueFiles(paths: pathsArg) { result in
switch result {
case .success:
reply(wrapResult(nil))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
enqueueFilesChannel.setMessageHandler(nil)
}
let onConfigChangeChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.UploadApi.onConfigChange\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
onConfigChangeChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let keyArg = args[0] as! Int64
api.onConfigChange(key: keyArg) { result in
switch result {
case .success:
reply(wrapResult(nil))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
onConfigChangeChannel.setMessageHandler(nil)
}
}
}
private class PigeonStreamHandler<ReturnType>: NSObject, FlutterStreamHandler {
private let wrapper: PigeonEventChannelWrapper<ReturnType>
private var pigeonSink: PigeonEventSink<ReturnType>? = nil
init(wrapper: PigeonEventChannelWrapper<ReturnType>) {
self.wrapper = wrapper
}
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
-> FlutterError?
{
pigeonSink = PigeonEventSink<ReturnType>(events)
wrapper.onListen(withArguments: arguments, sink: pigeonSink!)
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
pigeonSink = nil
wrapper.onCancel(withArguments: arguments)
return nil
}
}
class PigeonEventChannelWrapper<ReturnType> {
func onListen(withArguments arguments: Any?, sink: PigeonEventSink<ReturnType>) {}
func onCancel(withArguments arguments: Any?) {}
}
class PigeonEventSink<ReturnType> {
private let sink: FlutterEventSink
init(_ sink: @escaping FlutterEventSink) {
self.sink = sink
}
func success(_ value: ReturnType) {
sink(value)
}
func error(code: String, message: String?, details: Any?) {
sink(FlutterError(code: code, message: message, details: details))
}
func endOfStream() {
sink(FlutterEndOfEventStream)
}
}
class StreamStatusStreamHandler: PigeonEventChannelWrapper<UploadApiTaskStatus> {
static func register(with messenger: FlutterBinaryMessenger,
instanceName: String = "",
streamHandler: StreamStatusStreamHandler) {
var channelName = "dev.flutter.pigeon.immich_mobile.UploadFlutterApi.streamStatus"
if !instanceName.isEmpty {
channelName += ".\(instanceName)"
}
let internalStreamHandler = PigeonStreamHandler<UploadApiTaskStatus>(wrapper: streamHandler)
let channel = FlutterEventChannel(name: channelName, binaryMessenger: messenger, codec: uploadTaskPigeonMethodCodec)
channel.setStreamHandler(internalStreamHandler)
}
}
class StreamProgressStreamHandler: PigeonEventChannelWrapper<UploadApiTaskProgress> {
static func register(with messenger: FlutterBinaryMessenger,
instanceName: String = "",
streamHandler: StreamProgressStreamHandler) {
var channelName = "dev.flutter.pigeon.immich_mobile.UploadFlutterApi.streamProgress"
if !instanceName.isEmpty {
channelName += ".\(instanceName)"
}
let internalStreamHandler = PigeonStreamHandler<UploadApiTaskProgress>(wrapper: streamHandler)
let channel = FlutterEventChannel(name: channelName, binaryMessenger: messenger, codec: uploadTaskPigeonMethodCodec)
channel.setStreamHandler(internalStreamHandler)
}
}

View File

@@ -1,192 +0,0 @@
import SQLiteData
extension FileHandle {
static func createOrOverwrite(atPath path: String) throws -> FileHandle {
let fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0o644)
guard fd >= 0 else {
throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno))
}
return FileHandle(fileDescriptor: fd, closeOnDealloc: true)
}
}
final class UploadApiImpl<
StoreRepo: StoreProtocol,
TaskRepo: TaskProtocol,
StatusListener: TaskStatusListener,
ProgressListener: TaskProgressListener
>: ImmichPlugin, UploadApi {
private let storeRepository: StoreRepo
private let taskRepository: TaskRepo
private let downloadQueue: DownloadQueue<StoreRepo, TaskRepo, StatusListener, ProgressListener>
private let uploadQueue: UploadQueue<StoreRepo, TaskRepo, StatusListener>
private var isInitialized = false
private let initLock = NSLock()
private var backupTask: Task<Void, Never>?
private let backupLock = NSLock()
private let cellularSession: URLSession
private let wifiOnlySession: URLSession
init(
storeRepository: StoreRepo,
taskRepository: TaskRepo,
statusListener: StatusListener,
progressListener: ProgressListener
) {
self.taskRepository = taskRepository
let delegate = UploadApiDelegate(
taskRepository: taskRepository,
statusListener: statusListener,
progressListener: progressListener
)
let cellularConfig = URLSessionConfiguration.background(withIdentifier: "\(TaskConfig.sessionId).cellular")
cellularConfig.allowsCellularAccess = true
cellularConfig.waitsForConnectivity = true
self.cellularSession = URLSession(configuration: cellularConfig, delegate: delegate, delegateQueue: nil)
let wifiOnlyConfig = URLSessionConfiguration.background(withIdentifier: "\(TaskConfig.sessionId).wifi")
wifiOnlyConfig.allowsCellularAccess = false
wifiOnlyConfig.waitsForConnectivity = true
self.wifiOnlySession = URLSession(configuration: wifiOnlyConfig, delegate: delegate, delegateQueue: nil)
self.storeRepository = storeRepository
self.uploadQueue = UploadQueue(
storeRepository: storeRepository,
taskRepository: taskRepository,
statusListener: statusListener,
cellularSession: cellularSession,
wifiOnlySession: wifiOnlySession
)
self.downloadQueue = DownloadQueue(
storeRepository: storeRepository,
taskRepository: taskRepository,
statusListener: statusListener,
progressListener: progressListener
)
}
func initialize(completion: @escaping (Result<Void, any Error>) -> Void) {
Task(priority: .high) {
do {
async let dbIds = taskRepository.getTaskIds(status: .uploadQueued)
async let cellularTasks = cellularSession.allTasks
async let wifiTasks = wifiOnlySession.allTasks
var dbTaskIds = Set(try await dbIds)
func validateTasks(_ tasks: [URLSessionTask]) {
for task in tasks {
if let taskIdStr = task.taskDescription, let taskId = Int64(taskIdStr), task.state != .canceling {
dbTaskIds.remove(taskId)
} else {
task.cancel()
}
}
}
validateTasks(await cellularTasks)
validateTasks(await wifiTasks)
try await taskRepository.markOrphansPending(ids: Array(dbTaskIds))
try? FileManager.default.removeItem(at: TaskConfig.originalsDir)
initLock.withLock { isInitialized = true }
startBackup()
self.completeWhenActive(for: completion, with: .success(()))
} catch {
self.completeWhenActive(for: completion, with: .failure(error))
}
}
}
func refresh(completion: @escaping (Result<Void, any Error>) -> Void) {
Task {
startBackup()
self.completeWhenActive(for: completion, with: .success(()))
}
}
func startBackup() {
dPrint("Starting backup task")
guard (initLock.withLock { isInitialized }) else { return dPrint("Not initialized, skipping backup") }
backupLock.withLock {
guard backupTask == nil else { return dPrint("Backup task already running") }
backupTask = Task {
await _startBackup()
backupLock.withLock { backupTask = nil }
}
}
}
func cancelAll(completion: @escaping (Result<Void, any Error>) -> Void) {
Task {
async let cellularTasks = cellularSession.allTasks
async let wifiTasks = wifiOnlySession.allTasks
cancelSessionTasks(await cellularTasks)
cancelSessionTasks(await wifiTasks)
self.completeWhenActive(for: completion, with: .success(()))
}
}
func enqueueAssets(localIds: [String], completion: @escaping (Result<Void, any Error>) -> Void) {
Task {
do {
try await downloadQueue.enqueueAssets(localIds: localIds)
self.completeWhenActive(for: completion, with: .success(()))
} catch {
self.completeWhenActive(for: completion, with: .failure(error))
}
}
}
func enqueueFiles(paths: [String], completion: @escaping (Result<Void, any Error>) -> Void) {
Task {
do {
try await uploadQueue.enqueueFiles(paths: paths)
self.completeWhenActive(for: completion, with: .success(()))
} catch {
self.completeWhenActive(for: completion, with: .failure(error))
}
}
}
func onConfigChange(key: Int64, completion: @escaping (Result<Void, any Error>) -> Void) {
storeRepository.invalidateCache()
Task {
if let key = StoreKey(rawValue: Int(key)), key == ._accessToken {
try? await taskRepository.resolveError(code: .unauthorized)
}
startBackup()
self.completeWhenActive(for: completion, with: .success(()))
}
}
private func cancelSessionTasks(_ tasks: [URLSessionTask]) {
dPrint("Canceling \(tasks.count) tasks")
for task in tasks {
task.cancel()
}
}
private func _startBackup() async {
defer {
downloadQueue.startQueueProcessing()
uploadQueue.startQueueProcessing()
}
do {
let candidates = try await taskRepository.getBackupCandidates()
guard !candidates.isEmpty else { return dPrint("No candidates for backup") }
try await taskRepository.enqueue(assets: candidates, imagePriority: 0.5, videoPriority: 0.3)
dPrint("Backup enqueued \(candidates.count) assets for upload")
} catch {
print("Backup queue error: \(error)")
}
}
}

View File

@@ -1,35 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>IntentsSupported</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>NSExtensionActivationRule</key>
<string>SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments,
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>IntentsSupported</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>NSExtensionActivationRule</key>
<string>SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments,
$attachment, ( ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url"
|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" || ANY
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" || ANY
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.movie" || ANY
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" ) ).@count &gt; 0
).@count &gt; 0 </string>
<key>PHSupportedMediaTypes</key>
<array>
<string>Video</string>
<string>Image</string>
</array>
</dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
</dict>
</dict>
</plist>
<key>PHSupportedMediaTypes</key>
<array>
<string>Video</string>
<string>Image</string>
</array>
</dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
</dict>
</dict>
</plist>

View File

@@ -3,6 +3,8 @@
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array/>
<array>
<string>group.app.immich.share</string>
</array>
</dict>
</plist>

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