Compare commits

...

19 Commits

Author SHA1 Message Date
mertalev 4423a8f8a4 pass cancel token to icloud download 2026-05-28 01:57:39 -04:00
mertalev 77fd2ba919 more cleanup 2026-05-28 00:44:11 -04:00
mertalev 1318dafdc4 use pattern matching 2026-05-27 20:02:51 -04:00
Timon 26714f6bfe fix(server): prevent locked assets from leaking to partners (#28652)
* fix(server): prevent locked assets from leaking to partners

* fix tests
2026-05-27 17:33:49 -04:00
Lauritz Tieste a5ce3fc927 fix: Refresh local album overview page after asset deletion (#28586)
fix: invalidate local album provider on asset delete
2026-05-28 01:20:32 +05:30
shenlong 3b23f71a3f refactor: cleanup metadata (#28485)
jason-ify metadata

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-28 01:19:25 +05:30
shenlong dec33cadd9 fix: verify form disposal before notifyListeners (#28578)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-27 19:45:48 +00:00
Daniel Dietzler 80c15a5e27 fix: workflow drag and drop (#28650) 2026-05-27 14:05:38 -05:00
Yaros 936c28a40b feat(mobile): improve downloading algorithm for sharing (#27312)
* feat(mobile): better downloading while sharing

* chore: separate download group

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-27 17:00:49 +00:00
Spencer Stingley 1a837a28ac fix: dev container properly builds @immich/plugin-sdk for import (#28620)
Co-authored-by: Spencer Stingley <accounts@blankcanvas.io>
2026-05-27 12:41:35 -04:00
shenlong 8d5d12b108 chore: upgrade flutter to 3.44.0 (#28537)
* chore: upgrade flutter to 3.44.0

# Conflicts:
#	mise.lock

* static analysis fix

* fix ios ci

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-27 11:14:44 -05:00
Daniel Dietzler dd7a94135f refactor: workflow components (#28648) 2026-05-27 18:08:12 +02:00
Jason Rasmussen 1acc511b5c chore: install mise in Dockerfile.dev (#28649) 2026-05-27 11:43:17 -04:00
Daniel Dietzler 452e88267a fix: strip metadata from timeline responses for shared links without exif sharing (#28644) 2026-05-27 17:29:37 +02:00
Timon b941108cbd chore: update documentation to use mise commands (#28515) 2026-05-27 10:33:23 -04:00
Jason Rasmussen e46f2843f7 refactor: asset create event (#28647) 2026-05-27 10:28:02 -04:00
Jason Rasmussen cf991e7b1b feat: workflow actions (#28639) 2026-05-27 10:24:31 -04:00
Thomas van Gemert 748a13104a chore(docs): update FAQ with profile picture change instructions (#28634)
Update FAQ with profile picture change instructions

Based on this discussion (https://github.com/immich-app/immich/discussions/27168) it seems I am not the only one confused by how Immich lets you change your profile picture. An addition to the FAQ will help. I also added another horizontal separator to be consistent with the rest of the document.
2026-05-27 09:17:12 -05:00
Brandon Wees 2dd6b47714 fix: OCR bounding box positioning (#28568) 2026-05-27 12:01:30 +02:00
109 changed files with 1516 additions and 1626 deletions
@@ -8,6 +8,8 @@ log "Preparing Immich Web Frontend"
log ""
run_cmd pnpm --filter @immich/sdk install
run_cmd pnpm --filter @immich/sdk build
run_cmd pnpm --filter @immich/plugin-sdk install
run_cmd pnpm --filter @immich/plugin-sdk build
run_cmd pnpm --filter immich-web install
log "Starting Immich Web Frontend"
+4
View File
@@ -230,6 +230,10 @@ jobs:
- name: Generate platform APIs
run: mise //mobile:codegen:pigeon
- name: Resolve iOS Swift Packages
working-directory: ./mobile
run: flutter build ios --config-only --no-codesign
- name: Setup Ruby
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0
with:
+4 -9
View File
@@ -21,7 +21,7 @@ services:
volumes:
- ..:/usr/src/app
# - ../../ui:/usr/src/ui
- pnpm_cache:/buildcache/pnpm_cache
- build_cache:/buildcache
- server_node_modules:/usr/src/app/server/node_modules
- web_node_modules:/usr/src/app/web/node_modules
- github_node_modules:/usr/src/app/.github/node_modules
@@ -45,11 +45,11 @@ services:
target: dev
command:
- |
pnpm install
mise install
touch /tmp/init-complete
exec tail -f /dev/null
volumes:
- pnpm_store_server:/buildcache/pnpm-store
- build_cache:/buildcache
restart: 'no'
healthcheck:
test: ['CMD', 'test', '-f', '/tmp/init-complete']
@@ -73,7 +73,6 @@ services:
volumes:
- ${UPLOAD_LOCATION}/photos:/data
- /etc/localtime:/etc/localtime:ro
- pnpm_store_server:/buildcache/pnpm-store
- ../packages/plugin-core:/build/plugins/immich-plugin-core
env_file:
- .env
@@ -122,8 +121,6 @@ services:
ports:
- 3000:3000
- 24678:24678
volumes:
- pnpm_store_web:/buildcache/pnpm-store
restart: unless-stopped
depends_on:
immich-init:
@@ -203,9 +200,7 @@ volumes:
model_cache:
prometheus_data:
grafana_data:
pnpm_cache:
pnpm_store_server:
pnpm_store_web:
build_cache:
server_node_modules:
web_node_modules:
github_node_modules:
+6
View File
@@ -26,6 +26,8 @@ For organizations seeking to resell Immich, we have established the following gu
When in doubt or if you have an edge case scenario, we encourage you to contact us directly via email to discuss the use of our trademark. We can provide clear guidance on what is acceptable and what is not. You can reach out at: questions@immich.app
---
## User
### How can I reset the admin password?
@@ -36,6 +38,10 @@ The admin password can be reset by running the [reset-admin-password](/administr
You can see the list of all users by running [list-users](/administration/server-commands.md) Command on the Immich-server.
### How can I change my profile picture?
View a single photo, press the three dots in the top-right to show context menu, and select "Set as profile picture". In the pop-up, use your mouse scroll wheel to zoom in the picture until it completely fills the circle. Click and drag the picture to align it to your liking. Press "Save" to save your changes.
---
## Mobile App
+2 -2
View File
@@ -5,7 +5,7 @@ After making any changes in the `server/src/schema`, a database migration need t
1. Run the command
```bash
pnpm run migrations:generate <migration-name>
mise //server:migrations generate <migration-name>
```
2. Check if the migration file makes sense.
@@ -18,7 +18,7 @@ The server will automatically detect `*.ts` file changes and restart. Part of th
If you need to undo the most recently applied migration—for example, when developing or testing on schema changes—run:
```bash
pnpm run migrations:revert
mise //server:migrations revert
```
This command rolls back the latest migration and brings the database schema back to its previous state.
+19 -30
View File
@@ -252,44 +252,33 @@ To connect the mobile app to your Dev Container:
The Dev Container supports multiple ways to run tests:
#### Using Mise Commands (Recommended)
```bash
# Run tests for specific components
mise run checklist # in `server/`, `web/`, `packages/cli`
# Server
mise //server:test # unit tests
mise //server:test-medium # medium / integration tests
# Web
mise //web:test # unit tests
# E2E
mise //e2e:test # API tests
mise //e2e:test-web # web UI tests (Playwright)
# Run all checks for a component
mise //server:checklist
mise //web:checklist
```
#### Using PNPM Directly
```bash
# Server tests
cd /workspaces/immich/server
pnpm test # Run all tests
pnpm run test:medium # Medium tests (integration tests)
pnpm run test:watch # Watch mode
pnpm run test:cov # Coverage report
# Web tests
cd /workspaces/immich/web
pnpm test # Run all tests
pnpm run test:watch # Watch mode
# E2E tests
cd /workspaces/immich/e2e
pnpm run test # Run API tests
pnpm run test:web # Run web UI tests
```
### Additional Make Commands
### Additional Commands
```bash
# API generation
make open-api # Generate OpenAPI specs
make open-api-typescript # Generate TypeScript SDK
make open-api-dart # Generate Dart SDK
mise //:open-api # Generate OpenAPI specs
mise //:open-api-typescript # Generate TypeScript SDK
mise //:open-api-dart # Generate Dart SDK
# Database
mise sql # Sync database schema
mise //server:sql # Sync database schema
```
### Debugging
+32 -13
View File
@@ -8,34 +8,42 @@ When contributing code through a pull request, please check the following:
## Web Checks
- [ ] `pnpm run lint` (linting via ESLint)
- [ ] `pnpm run format` (formatting via Prettier)
- [ ] `pnpm run check:svelte` (Type checking via SvelteKit)
- [ ] `pnpm run check:typescript` (check typescript)
- [ ] `pnpm test` (unit tests)
- [ ] `mise //web:lint` (linting via ESLint)
- [ ] `mise //web:format` (formatting via Prettier)
- [ ] `mise //web:check-svelte` (type checking via SvelteKit)
- [ ] `mise //web:check-typescript` (type checking via `tsc`)
- [ ] `mise //web:test` (unit tests)
:::tip AIO
Run all web checks with `pnpm run check:all`
Run all web checks with `mise //web:checklist`
:::
:::tip Auto Fix
Use `mise //web:lint-fix` and `mise //web:format-fix` to automatically correct some issues.
:::
## Documentation
- [ ] `pnpm run format` (formatting via Prettier)
- [ ] `mise //docs:format` (formatting via Prettier)
- [ ] Update the `_redirects` file if you have renamed a page or removed it from the documentation.
:::tip Auto Fix
Use `mise //docs:format-fix` to automatically fix formatting.
:::
## Server Checks
- [ ] `pnpm run lint` (linting via ESLint)
- [ ] `pnpm run format` (formatting via Prettier)
- [ ] `pnpm run check` (Type checking via `tsc`)
- [ ] `pnpm test` (unit tests)
- [ ] `mise //server:lint` (linting via ESLint)
- [ ] `mise //server:format` (formatting via Prettier)
- [ ] `mise //server:check` (type checking via `tsc`)
- [ ] `mise //server:test` (unit tests)
:::tip AIO
Run all server checks with `pnpm run check:all`
Run all server checks with `mise //server:checklist`
:::
:::tip Auto Fix
You can use `pnpm run __:fix` to potentially correct some issues automatically for `pnpm run format` and `lint`.
Use `mise //server:lint-fix` and `mise //server:format-fix` to automatically correct some issues.
:::
## Mobile Checklist
@@ -53,6 +61,17 @@ Run all these commands at once with `mise //mobile:checklist`
You can use `mise //mobile:lint-fix` to potentially correct some issues automatically for `mise //mobile:lint`.
:::
## Machine Learning Checklist
- [ ] `mise //machine-learning:lint` (linting via ruff)
- [ ] `mise //machine-learning:format` (formatting via ruff)
- [ ] `mise //machine-learning:check` (type checking via mypy)
- [ ] `mise //machine-learning:test` (unit tests via pytest)
:::tip AIO
Run all machine learning checks with `mise //machine-learning:checklist`
:::
## OpenAPI
The OpenAPI client libraries need to be regenerated whenever there are changes to the `immich-openapi-specs.json` file. Note that you should not modify this file directly as it is auto-generated. See [OpenAPI](/api.md) for more details.
+18 -17
View File
@@ -32,6 +32,10 @@ This environment includes the services below. Additional details are available i
All the services are packaged to run as with single Docker Compose command.
:::tip mise
[mise](https://mise.jdx.dev) is used throughout the project to manage tool versions and run tasks. [Install mise](https://mise.jdx.dev/installing-mise.html), then from the repo root run `mise trust` and `mise install` to get all required tools. Tasks for each service can be run from the repo root using `mise //namespace:task` (e.g. `mise //server:lint`). To list all available tasks, run `mise tasks ls --all`.
:::
### Server and web apps
1. Clone the project repo.
@@ -56,22 +60,23 @@ You can access the web from `http://your-machine-ip:3000` or `http://localhost:3
#### Connect web to a remote backend
If you only want to do web development connected to an existing, remote backend, follow these steps:
1. Build the Immich SDK - `pnpm --filter @immich/sdk install && pnpm --filter @immich/sdk build`
2. Enter the web directory - `cd web/`
3. Install web dependencies - `pnpm i`
4. Start the web development server
If you only want to do web development connected to an existing, remote backend, run from the repo root:
```bash
IMMICH_SERVER_URL=https://demo.immich.app/ pnpm run dev
IMMICH_SERVER_URL=https://demo.immich.app/ mise //web:start
```
This will install all dependencies (including the SDK) and start the dev server in one step. To connect to the hosted demo server specifically, use the shorthand:
```bash
mise //web:start-demo
```
If you're using PowerShell on Windows you may need to set the env var separately like so:
```powershell
$env:IMMICH_SERVER_URL = "https://demo.immich.app/"
pnpm run dev
mise //web:start
```
#### `@immich/ui`
@@ -90,20 +95,16 @@ To see local changes to `@immich/ui` in Immich, do the following:
#### Setup
1. [Install mise](https://mise.jdx.dev/installing-mise.html).
2. Change to the immich (root) directory and trust the mise config with `mise trust`.
3. Install tools with mise: `mise install`.
4. Change to the `mobile/` directory.
5. Run `flutter pub get` to install the dependencies.
6. Run `make translation` to generate the translation file.
7. Run `flutter run` to start the app.
1. Run `mise //mobile:install` to install Flutter dependencies.
2. Run `mise //mobile:translation` to generate the translation file.
3. Change to the `mobile/` directory and run `flutter run` to start the app.
#### Translation
To add a new translation text, enter the key-value pair in the `i18n/en.json` in the root of the immich project. Then, from the `mobile/` directory, run
To add a new translation text, enter the key-value pair in the `i18n/en.json` in the root of the immich project. Then run:
```bash
make translation
mise //mobile:translation
```
The mobile app asks you what backend to connect to. You can utilize the demo backend (https://demo.immich.app/) if you don't need to change server code or upload photos. Alternatively, you can run the server yourself per the instructions above.
+3 -4
View File
@@ -4,8 +4,8 @@
### Unit tests
Unit are run by calling `pnpm run test` from the `server/` directory.
You need to run `pnpm install` (in `server/`) before _once_.
Unit tests are run with `mise //server:test`.
You need to run `mise //server:install` before _once_.
### End to end tests
@@ -17,8 +17,7 @@ make e2e
Before you can run the tests, you need to run the following commands _once_:
- `pnpm install`
- `pnpm --filter @immich/sdk --filter @immich/cli build`
- `mise //e2e:ci-setup` (installs e2e, SDK, and CLI dependencies)
- `mise //:open-api`
Once the test environment is running, the e2e tests can be run via:
+1 -1
View File
@@ -3,7 +3,7 @@ run = "pnpm install --filter documentation --frozen-lockfile"
[tasks.start]
env._.path = "./node_modules/.bin"
run = "docusaurus --port 3005"
run = "docusaurus start --port 3005"
[tasks.build]
env._.path = "./node_modules/.bin"
+1 -3
View File
@@ -83,9 +83,7 @@ volumes:
model_cache:
prometheus_data:
grafana_data:
pnpm_cache:
pnpm_store_server:
pnpm_store_web:
build_cache:
server_node_modules:
web_node_modules:
github_node_modules:
+3
View File
@@ -840,6 +840,7 @@
"copy_error": "Copy error",
"copy_file_path": "Copy file path",
"copy_image": "Copy Image",
"copy_json": "Copy JSON",
"copy_link": "Copy link",
"copy_link_to_clipboard": "Copy link to clipboard",
"copy_password": "Copy password",
@@ -979,6 +980,8 @@
"downloading_media": "Downloading media",
"drag_to_reorder": "Drag to reorder",
"drop_files_to_upload": "Drop files anywhere to upload",
"duplicate": "Duplicate",
"duplicate_workflow": "Duplicate workflow",
"duplicates": "Duplicates",
"duplicates_description": "Resolve each group by indicating which, if any, are duplicates.",
"duration": "Duration",
+23 -1
View File
@@ -1,9 +1,31 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools."aqua:flutter/flutter"]]
version = "3.41.9"
version = "3.44.0"
backend = "aqua:flutter/flutter"
[tools."aqua:flutter/flutter"."platforms.linux-arm64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-arm64-musl"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-x64-musl"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.macos-arm64"]
checksum = "blake3:fb03aa5d9790205c948922ec3f0751c16e4575b09d6ae9dd4fbeb664a69f0e00"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.44.0-stable.zip"
[tools."aqua:flutter/flutter"."platforms.macos-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.44.0-stable.zip"
[tools."aqua:flutter/flutter"."platforms.windows-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.44.0-stable.zip"
[[tools.flutter]]
version = "3.41.9-stable"
backend = "asdf:flutter"
+1 -1
View File
@@ -16,7 +16,7 @@ config_roots = [
[tools]
node = "24.15.0"
"aqua:flutter/flutter" = "3.41.9"
"aqua:flutter/flutter" = "3.44.0"
pnpm = "10.33.4"
terragrunt = "1.0.3"
opentofu = "1.11.6"
-1
View File
@@ -1,5 +1,4 @@
{
"dart.flutterSdkPath": ".fvm/versions/3.41.9",
"dart.lineLength": 120,
"[dart]": {
"editor.rulers": [
+4
View File
@@ -5,3 +5,7 @@ android.nonTransitiveRClass=false
android.nonFinalResIds=false
org.gradle.caching=true
org.gradle.parallel=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
-123
View File
@@ -1,58 +1,23 @@
PODS:
- background_downloader (0.0.1):
- Flutter
- bonsoir_darwin (0.0.1):
- Flutter
- FlutterMacOS
- connectivity_plus (0.0.1):
- Flutter
- cupertino_http (0.0.1):
- Flutter
- FlutterMacOS
- device_info_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
- flutter_local_notifications (0.0.1):
- Flutter
- flutter_native_splash (2.4.3):
- Flutter
- flutter_secure_storage (6.0.0):
- Flutter
- flutter_udid (0.0.1):
- Flutter
- KeychainAccess
- flutter_web_auth_2 (5.0.0):
- Flutter
- fluttertoast (0.0.2):
- Flutter
- geolocator_apple (1.2.0):
- Flutter
- FlutterMacOS
- home_widget (0.0.1):
- Flutter
- image_picker_ios (0.0.1):
- Flutter
- integration_test (0.0.1):
- Flutter
- KeychainAccess (4.2.2)
- local_auth_darwin (0.0.1):
- Flutter
- FlutterMacOS
- MapLibre (6.14.0)
- maplibre_gl (0.0.1):
- Flutter
- MapLibre (= 6.14.0)
- native_video_player (1.0.0):
- Flutter
- network_info_plus (0.0.1):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- permission_handler_apple (9.3.0):
- Flutter
- photo_manager (3.9.0):
- Flutter
- FlutterMacOS
- share_handler_ios (0.0.14):
- Flutter
- share_handler_ios/share_handler_ios_models (= 0.0.14)
@@ -61,144 +26,56 @@ PODS:
- Flutter
- share_handler_ios_models
- share_handler_ios_models (0.0.9)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
- wakelock_plus (0.0.1):
- Flutter
DEPENDENCIES:
- background_downloader (from `.symlinks/plugins/background_downloader/ios`)
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
- flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
- home_widget (from `.symlinks/plugins/home_widget/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
- network_info_plus (from `.symlinks/plugins/network_info_plus/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- photo_manager (from `.symlinks/plugins/photo_manager/darwin`)
- share_handler_ios (from `.symlinks/plugins/share_handler_ios/ios`)
- share_handler_ios_models (from `.symlinks/plugins/share_handler_ios/ios/Models`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS:
trunk:
- KeychainAccess
- MapLibre
EXTERNAL SOURCES:
background_downloader:
:path: ".symlinks/plugins/background_downloader/ios"
bonsoir_darwin:
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
cupertino_http:
:path: ".symlinks/plugins/cupertino_http/darwin"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
Flutter:
:path: Flutter
flutter_local_notifications:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
flutter_udid:
:path: ".symlinks/plugins/flutter_udid/ios"
flutter_web_auth_2:
:path: ".symlinks/plugins/flutter_web_auth_2/ios"
fluttertoast:
:path: ".symlinks/plugins/fluttertoast/ios"
geolocator_apple:
:path: ".symlinks/plugins/geolocator_apple/darwin"
home_widget:
:path: ".symlinks/plugins/home_widget/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
integration_test:
:path: ".symlinks/plugins/integration_test/ios"
local_auth_darwin:
:path: ".symlinks/plugins/local_auth_darwin/darwin"
maplibre_gl:
:path: ".symlinks/plugins/maplibre_gl/ios"
native_video_player:
:path: ".symlinks/plugins/native_video_player/ios"
network_info_plus:
:path: ".symlinks/plugins/network_info_plus/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
photo_manager:
:path: ".symlinks/plugins/photo_manager/darwin"
share_handler_ios:
:path: ".symlinks/plugins/share_handler_ios/ios"
share_handler_ios_models:
:path: ".symlinks/plugins/share_handler_ios/ios/Models"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS:
background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad
bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
flutter_udid: 92a5d31fe0526b7b6002a2318df702e12e7eb300
flutter_web_auth_2: 646fc9df97a01c59e5eea99b237da2c6360f8439
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
KeychainAccess: c0c4f7f38f6fc7bbe58f5702e25f7bd2f65abf51
local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb
MapLibre: 69e572367f4ef6287e18246cfafc39c80cdcabcd
maplibre_gl: 3c924e44725147b03dda33430ad216005b40555f
native_video_player: b65c58951ede2f93d103a25366bdebca95081265
network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
photo_manager: 25fd77df14f4f0ba5ef99e2c61814dde77e2bceb
share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb
share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
PODFILE CHECKSUM: 938abbae4114b9c2140c550a2a0d8f7c674f5dfe
@@ -39,6 +39,7 @@
FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; };
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; };
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FC2EC1725A0045228E /* StructuredFieldValues */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -129,6 +130,7 @@
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImagesImpl.swift; sourceTree = "<group>"; };
FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessing.swift; sourceTree = "<group>"; };
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -193,6 +195,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
FEE084F82EC172460045228E /* SQLiteData in Frameworks */,
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */,
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */,
@@ -247,6 +250,7 @@
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@@ -360,6 +364,9 @@
/* Begin PBXNativeTarget section */
97C146ED1CF9000F007C117D /* Runner */ = {
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
@@ -463,6 +470,7 @@
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */,
FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */,
);
@@ -1285,7 +1293,17 @@
package = FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */;
productName = StructuredFieldValues;
};
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -1,5 +1,4 @@
{
"originHash" : "9be33bfaa68721646604aefff3cabbdaf9a193da192aae024c265065671f6c49",
"pins" : [
{
"identity" : "combine-schedulers",
@@ -19,6 +18,24 @@
"version" : "7.8.0"
}
},
{
"identity" : "keychainaccess",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kishikawakatsumi/KeychainAccess",
"state" : {
"revision" : "84e546727d66f1adc5439debad16270d0fdd04e7",
"version" : "4.2.2"
}
},
{
"identity" : "maplibre-gl-native-distribution",
"kind" : "remoteSourceControl",
"location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git",
"state" : {
"revision" : "60d9bb85c94ce6e7fc4406cd32529fd12bdb7809",
"version" : "6.14.0"
}
},
{
"identity" : "sqlite-data",
"kind" : "remoteSourceControl",
@@ -146,5 +163,5 @@
}
}
],
"version" : 3
"version" : 2
}
@@ -5,6 +5,24 @@
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Immich.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
@@ -1,5 +1,4 @@
{
"originHash" : "9be33bfaa68721646604aefff3cabbdaf9a193da192aae024c265065671f6c49",
"pins" : [
{
"identity" : "combine-schedulers",
@@ -19,6 +18,24 @@
"version" : "7.9.0"
}
},
{
"identity" : "keychainaccess",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kishikawakatsumi/KeychainAccess",
"state" : {
"revision" : "84e546727d66f1adc5439debad16270d0fdd04e7",
"version" : "4.2.2"
}
},
{
"identity" : "maplibre-gl-native-distribution",
"kind" : "remoteSourceControl",
"location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git",
"state" : {
"revision" : "60d9bb85c94ce6e7fc4406cd32529fd12bdb7809",
"version" : "6.14.0"
}
},
{
"identity" : "sqlite-data",
"kind" : "remoteSourceControl",
@@ -146,5 +163,5 @@
}
}
],
"version" : 3
"version" : 2
}
+1
View File
@@ -23,6 +23,7 @@ const String kBackupLivePhotoGroup = 'backup_live_photo_group';
const String kDownloadGroupImage = 'group_image';
const String kDownloadGroupVideo = 'group_video';
const String kDownloadGroupLivePhoto = 'group_livephoto';
const String kShareDownloadGroup = 'group_share';
// Timeline constants
const int kTimelineNoneSegmentSize = 120;
+128 -3
View File
@@ -1,14 +1,25 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/config/album_config.dart';
import 'package:immich_mobile/domain/models/config/backup_config.dart';
import 'package:immich_mobile/domain/models/config/cleanup_config.dart';
import 'package:immich_mobile/domain/models/config/image_config.dart';
import 'package:immich_mobile/domain/models/config/map_config.dart';
import 'package:immich_mobile/domain/models/config/network_config.dart';
import 'package:immich_mobile/domain/models/config/slideshow_config.dart';
import 'package:immich_mobile/domain/models/config/theme_config.dart';
import 'package:immich_mobile/domain/models/config/timeline_config.dart';
import 'package:immich_mobile/domain/models/config/viewer_config.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
const defaultConfig = AppConfig();
class AppConfig {
final LogLevel logLevel;
final ThemeConfig theme;
final CleanupConfig cleanup;
final MapConfig map;
@@ -18,8 +29,10 @@ class AppConfig {
final SlideshowConfig slideshow;
final AlbumConfig album;
final BackupConfig backup;
final NetworkConfig network;
const AppConfig({
this.logLevel = .info,
this.theme = const .new(),
this.cleanup = const .new(),
this.map = const .new(),
@@ -29,9 +42,11 @@ class AppConfig {
this.slideshow = const .new(),
this.album = const .new(),
this.backup = const .new(),
this.network = const .new(),
});
AppConfig copyWith({
LogLevel? logLevel,
ThemeConfig? theme,
CleanupConfig? cleanup,
MapConfig? map,
@@ -41,7 +56,9 @@ class AppConfig {
SlideshowConfig? slideshow,
AlbumConfig? album,
BackupConfig? backup,
NetworkConfig? network,
}) => .new(
logLevel: logLevel ?? this.logLevel,
theme: theme ?? this.theme,
cleanup: cleanup ?? this.cleanup,
map: map ?? this.map,
@@ -51,12 +68,14 @@ class AppConfig {
slideshow: slideshow ?? this.slideshow,
album: album ?? this.album,
backup: backup ?? this.backup,
network: network ?? this.network,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is AppConfig &&
other.logLevel == logLevel &&
other.theme == theme &&
other.cleanup == cleanup &&
other.map == map &&
@@ -65,12 +84,118 @@ class AppConfig {
other.viewer == viewer &&
other.slideshow == slideshow &&
other.album == album &&
other.backup == backup);
other.backup == backup &&
other.network == network);
@override
int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer, slideshow, album, backup);
int get hashCode =>
Object.hash(logLevel, theme, cleanup, map, timeline, image, viewer, slideshow, album, backup, network);
@override
String toString() =>
'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup)';
'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network)';
T read<T extends Object>(MetadataKey<T> key) =>
(switch (key) {
.logLevel => logLevel,
.themePrimaryColor => theme.primaryColor,
.themeMode => theme.mode,
.themeDynamic => theme.dynamicTheme,
.themeColorfulInterface => theme.colorfulInterface,
.imagePreferRemote => image.preferRemote,
.imageLoadOriginal => image.loadOriginal,
.viewerLoopVideo => viewer.loopVideo,
.viewerLoadOriginalVideo => viewer.loadOriginalVideo,
.viewerAutoPlayVideo => viewer.autoPlayVideo,
.viewerTapToNavigate => viewer.tapToNavigate,
.networkAutoEndpointSwitching => network.autoEndpointSwitching,
.networkPreferredWifiName => network.preferredWifiName,
.networkLocalEndpoint => network.localEndpoint,
.networkExternalEndpointList => network.externalEndpointList,
.networkCustomHeaders => network.customHeaders,
.albumSortMode => album.sortMode,
.albumIsReverse => album.isReverse,
.albumIsGrid => album.isGrid,
.backupEnabled => backup.enabled,
.backupUseCellularForVideos => backup.useCellularForVideos,
.backupUseCellularForPhotos => backup.useCellularForPhotos,
.backupRequireCharging => backup.requireCharging,
.backupTriggerDelay => backup.triggerDelay,
.backupSyncAlbums => backup.syncAlbums,
.timelineTilesPerRow => timeline.tilesPerRow,
.timelineGroupAssetsBy => timeline.groupAssetsBy,
.timelineStorageIndicator => timeline.storageIndicator,
.mapShowFavoriteOnly => map.favoritesOnly,
.mapRelativeDate => map.relativeDays,
.mapIncludeArchived => map.includeArchived,
.mapThemeMode => map.themeMode,
.mapWithPartners => map.withPartners,
.cleanupKeepFavorites => cleanup.keepFavorites,
.cleanupKeepMediaType => cleanup.keepMediaType,
.cleanupKeepAlbumIds => cleanup.keepAlbumIds,
.cleanupCutoffDaysAgo => cleanup.cutoffDaysAgo,
.cleanupDefaultsInitialized => cleanup.defaultsInitialized,
.slideshowTransition => slideshow.transition,
.slideshowRepeat => slideshow.repeat,
.slideshowDuration => slideshow.duration,
.slideshowLook => slideshow.look,
.slideshowDirection => slideshow.direction,
})
as T;
factory AppConfig.fromEntries(Map<MetadataKey<Object>, Object> entries) {
var config = const AppConfig();
for (final MapEntry(key: key, value: value) in entries.entries) {
config = config.write(key, value);
}
return config;
}
AppConfig write<T extends Object>(MetadataKey<T> key, T value) {
return switch (key) {
.logLevel => copyWith(logLevel: value as LogLevel),
.themePrimaryColor => copyWith(theme: theme.copyWith(primaryColor: value as ImmichColorPreset)),
.themeMode => copyWith(theme: theme.copyWith(mode: value as ThemeMode)),
.themeDynamic => copyWith(theme: theme.copyWith(dynamicTheme: value as bool)),
.themeColorfulInterface => copyWith(theme: theme.copyWith(colorfulInterface: value as bool)),
.imagePreferRemote => copyWith(image: image.copyWith(preferRemote: value as bool)),
.imageLoadOriginal => copyWith(image: image.copyWith(loadOriginal: value as bool)),
.viewerLoopVideo => copyWith(viewer: viewer.copyWith(loopVideo: value as bool)),
.viewerLoadOriginalVideo => copyWith(viewer: viewer.copyWith(loadOriginalVideo: value as bool)),
.viewerAutoPlayVideo => copyWith(viewer: viewer.copyWith(autoPlayVideo: value as bool)),
.viewerTapToNavigate => copyWith(viewer: viewer.copyWith(tapToNavigate: value as bool)),
.networkAutoEndpointSwitching => copyWith(network: network.copyWith(autoEndpointSwitching: value as bool)),
.networkPreferredWifiName => copyWith(network: network.copyWith(preferredWifiName: (value as String))),
.networkLocalEndpoint => copyWith(network: network.copyWith(localEndpoint: (value as String))),
.networkExternalEndpointList => copyWith(network: network.copyWith(externalEndpointList: value as List<String>)),
.networkCustomHeaders => copyWith(network: network.copyWith(customHeaders: value as Map<String, String>)),
.albumSortMode => copyWith(album: album.copyWith(sortMode: value as AlbumSortMode)),
.albumIsReverse => copyWith(album: album.copyWith(isReverse: value as bool)),
.albumIsGrid => copyWith(album: album.copyWith(isGrid: value as bool)),
.backupEnabled => copyWith(backup: backup.copyWith(enabled: value as bool)),
.backupUseCellularForVideos => copyWith(backup: backup.copyWith(useCellularForVideos: value as bool)),
.backupUseCellularForPhotos => copyWith(backup: backup.copyWith(useCellularForPhotos: value as bool)),
.backupRequireCharging => copyWith(backup: backup.copyWith(requireCharging: value as bool)),
.backupTriggerDelay => copyWith(backup: backup.copyWith(triggerDelay: value as int)),
.backupSyncAlbums => copyWith(backup: backup.copyWith(syncAlbums: value as bool)),
.timelineTilesPerRow => copyWith(timeline: timeline.copyWith(tilesPerRow: value as int)),
.timelineGroupAssetsBy => copyWith(timeline: timeline.copyWith(groupAssetsBy: value as GroupAssetsBy)),
.timelineStorageIndicator => copyWith(timeline: timeline.copyWith(storageIndicator: value as bool)),
.mapShowFavoriteOnly => copyWith(map: map.copyWith(favoritesOnly: value as bool)),
.mapRelativeDate => copyWith(map: map.copyWith(relativeDays: value as int)),
.mapIncludeArchived => copyWith(map: map.copyWith(includeArchived: value as bool)),
.mapThemeMode => copyWith(map: map.copyWith(themeMode: value as ThemeMode)),
.mapWithPartners => copyWith(map: map.copyWith(withPartners: value as bool)),
.cleanupKeepFavorites => copyWith(cleanup: cleanup.copyWith(keepFavorites: value as bool)),
.cleanupKeepMediaType => copyWith(cleanup: cleanup.copyWith(keepMediaType: value as AssetKeepType)),
.cleanupKeepAlbumIds => copyWith(cleanup: cleanup.copyWith(keepAlbumIds: value as List<String>)),
.cleanupCutoffDaysAgo => copyWith(cleanup: cleanup.copyWith(cutoffDaysAgo: value as int)),
.cleanupDefaultsInitialized => copyWith(cleanup: cleanup.copyWith(defaultsInitialized: value as bool)),
.slideshowTransition => copyWith(slideshow: slideshow.copyWith(transition: value as bool)),
.slideshowRepeat => copyWith(slideshow: slideshow.copyWith(repeat: value as bool)),
.slideshowDuration => copyWith(slideshow: slideshow.copyWith(duration: value as int)),
.slideshowLook => copyWith(slideshow: slideshow.copyWith(look: value as SlideshowLook)),
.slideshowDirection => copyWith(slideshow: slideshow.copyWith(direction: value as SlideshowDirection)),
};
}
}
@@ -2,15 +2,15 @@ import 'package:flutter/foundation.dart';
class NetworkConfig {
final bool autoEndpointSwitching;
final String? preferredWifiName;
final String? localEndpoint;
final String preferredWifiName;
final String localEndpoint;
final List<String> externalEndpointList;
final Map<String, String> customHeaders;
const NetworkConfig({
this.autoEndpointSwitching = false,
this.preferredWifiName,
this.localEndpoint,
this.preferredWifiName = '',
this.localEndpoint = '',
this.externalEndpointList = const [],
this.customHeaders = const {},
});
@@ -1,22 +0,0 @@
import 'package:immich_mobile/domain/models/config/network_config.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
class SystemConfig {
final LogLevel logLevel;
final NetworkConfig network;
const SystemConfig({this.logLevel = .info, this.network = const .new()});
SystemConfig copyWith({LogLevel? logLevel, NetworkConfig? network}) =>
SystemConfig(logLevel: logLevel ?? this.logLevel, network: network ?? this.network);
@override
bool operator ==(Object other) =>
identical(this, other) || (other is SystemConfig && other.logLevel == logLevel && other.network == network);
@override
int get hashCode => Object.hash(logLevel, network);
@override
String toString() => 'SystemConfig(logLevel: $logLevel, network: $network)';
}
+72 -117
View File
@@ -1,142 +1,105 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/config/system_config.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
enum MetadataDomain<T extends Object> {
appConfig<AppConfig>('config.app'),
systemConfig<SystemConfig>('config.system');
enum MetadataScope {
user, // keys with this scope are deleted on logout
system;
final String prefix;
const MetadataDomain(this.prefix);
const MetadataScope();
}
enum MetadataKey<T extends Object> {
// Theme
themePrimaryColor<ImmichColorPreset>(.appConfig, 'theme.primaryColor', .indigo, _EnumCodec(ImmichColorPreset.values)),
themeMode<ThemeMode>(.appConfig, 'theme.mode', .system, _EnumCodec(ThemeMode.values)),
themeDynamic<bool>(.appConfig, 'theme.dynamic', false),
themeColorfulInterface<bool>(.appConfig, 'theme.colorfulInterface', true),
themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)),
themeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
themeDynamic<bool>(),
themeColorfulInterface<bool>(),
// Image
imagePreferRemote<bool>(.appConfig, 'image.preferRemote', false),
imageLoadOriginal<bool>(.appConfig, 'image.loadOriginal', false),
imagePreferRemote<bool>(),
imageLoadOriginal<bool>(),
// Viewer
viewerLoopVideo<bool>(.appConfig, 'viewer.loopVideo', true),
viewerLoadOriginalVideo<bool>(.appConfig, 'viewer.loadOriginalVideo', false),
viewerAutoPlayVideo<bool>(.appConfig, 'viewer.autoPlayVideo', true),
viewerTapToNavigate<bool>(.appConfig, 'viewer.tapToNavigate', false),
viewerLoopVideo<bool>(),
viewerLoadOriginalVideo<bool>(),
viewerAutoPlayVideo<bool>(),
viewerTapToNavigate<bool>(),
// Network
networkAutoEndpointSwitching<bool>(.systemConfig, 'network.autoEndpointSwitching', false),
networkPreferredWifiName<String>(.systemConfig, 'network.preferredWifiName', ''),
networkLocalEndpoint<String>(.systemConfig, 'network.localEndpoint', ''),
networkExternalEndpointList<List<String>>(
.systemConfig,
'network.externalEndpointList',
[],
_ListCodec(_PrimitiveCodec.string),
),
networkAutoEndpointSwitching<bool>(scope: .system),
networkPreferredWifiName<String>(scope: .system),
networkLocalEndpoint<String>(scope: .system),
networkExternalEndpointList<List<String>>(scope: .system, codec: _ListCodec(_PrimitiveCodec.string)),
networkCustomHeaders<Map<String, String>>(
.systemConfig,
'network.customHeaders',
{},
_MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
scope: .system,
codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
),
// Album
albumSortMode<AlbumSortMode>(
.appConfig,
'album.sortMode',
AlbumSortMode.mostRecent,
_EnumCodec(AlbumSortMode.values),
),
albumIsReverse<bool>(.appConfig, 'album.isReverse', true),
albumIsGrid<bool>(.appConfig, 'album.isGrid', false),
albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)),
albumIsReverse<bool>(),
albumIsGrid<bool>(),
// Backup
backupEnabled<bool>(.appConfig, 'backup.enabled', false),
backupUseCellularForVideos<bool>(.appConfig, 'backup.useCellularForVideos', false),
backupUseCellularForPhotos<bool>(.appConfig, 'backup.useCellularForPhotos', false),
backupRequireCharging<bool>(.appConfig, 'backup.requireCharging', false),
backupTriggerDelay<int>(.appConfig, 'backup.triggerDelay', 30),
backupSyncAlbums<bool>(.appConfig, 'backup.syncAlbums', false),
backupEnabled<bool>(),
backupUseCellularForVideos<bool>(),
backupUseCellularForPhotos<bool>(),
backupRequireCharging<bool>(),
backupTriggerDelay<int>(),
backupSyncAlbums<bool>(),
// Timeline
timelineTilesPerRow<int>(.appConfig, 'timeline.tilesPerRow', 4),
timelineGroupAssetsBy<GroupAssetsBy>(
.appConfig,
'timeline.groupAssetsBy',
GroupAssetsBy.day,
_EnumCodec(GroupAssetsBy.values),
),
timelineStorageIndicator<bool>(.appConfig, 'timeline.storageIndicator', true),
timelineTilesPerRow<int>(),
timelineGroupAssetsBy<GroupAssetsBy>(codec: _EnumCodec(GroupAssetsBy.values)),
timelineStorageIndicator<bool>(),
// Log
logLevel<LogLevel>(.systemConfig, 'log.level', .info, _EnumCodec(LogLevel.values)),
logLevel<LogLevel>(scope: .system, codec: _EnumCodec(LogLevel.values)),
// Map
mapShowFavoriteOnly<bool>(.appConfig, 'map.showFavoriteOnly', false),
mapRelativeDate<int>(.appConfig, 'map.relativeDate', 0),
mapIncludeArchived<bool>(.appConfig, 'map.includeArchived', false),
mapThemeMode<ThemeMode>(.appConfig, 'map.themeMode', .system, _EnumCodec(ThemeMode.values)),
mapWithPartners<bool>(.appConfig, 'map.withPartners', false),
mapShowFavoriteOnly<bool>(),
mapRelativeDate<int>(),
mapIncludeArchived<bool>(),
mapThemeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
mapWithPartners<bool>(),
// Cleanup
cleanupKeepFavorites<bool>(.appConfig, 'cleanup.keepFavorites', true),
cleanupKeepMediaType<AssetKeepType>(
.appConfig,
'cleanup.keepMediaType',
AssetKeepType.none,
_EnumCodec(AssetKeepType.values),
),
cleanupKeepAlbumIds<List<String>>(.appConfig, 'cleanup.keepAlbumIds', [], _ListCodec(_PrimitiveCodec.string)),
cleanupCutoffDaysAgo<int>(.appConfig, 'cleanup.cutoffDaysAgo', -1),
cleanupDefaultsInitialized<bool>(.appConfig, 'cleanup.defaultsInitialized', false),
cleanupKeepFavorites<bool>(),
cleanupKeepMediaType<AssetKeepType>(codec: _EnumCodec(AssetKeepType.values)),
cleanupKeepAlbumIds<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
cleanupCutoffDaysAgo<int>(),
cleanupDefaultsInitialized<bool>(),
// Slideshow
slideshowTransition<bool>(.appConfig, 'slideshow.transition', true),
slideshowRepeat<bool>(.appConfig, 'slideshow.repeat', true),
slideshowDuration<int>(.appConfig, 'slideshow.duration', 5),
slideshowLook<SlideshowLook>(.appConfig, 'slideshow.look', SlideshowLook.contain, _EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(
.appConfig,
'slideshow.direction',
SlideshowDirection.forward,
_EnumCodec(SlideshowDirection.values),
);
slideshowTransition<bool>(),
slideshowRepeat<bool>(),
slideshowDuration<int>(),
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
final MetadataDomain domain;
final String name;
final T defaultValue;
final MetadataScope scope;
final _MetadataCodec<T>? _codecOverride;
const MetadataKey(this.domain, this.name, this.defaultValue, [this._codecOverride]);
const MetadataKey({this.scope = .user, _MetadataCodec<T>? codec}) : _codecOverride = codec;
String get key => '${domain.prefix}.$name';
_MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forPrimitive(defaultValue);
_MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forType(T);
String encode(T value) => _codec.encode(value);
T decode(String raw) => _codec.decode(raw) ?? defaultValue;
static Map<String, MetadataKey<Object>> asKeyMap() => {for (var value in MetadataKey.values) value.key: value};
T decode(String raw) => _codec.decode(raw);
}
sealed class _MetadataCodec<T extends Object> {
const _MetadataCodec();
String encode(T value);
T? decode(String raw);
T decode(String raw);
static const Map<Type, _MetadataCodec<Object>> _primitives = {
int: _PrimitiveCodec.integer,
@@ -146,12 +109,10 @@ sealed class _MetadataCodec<T extends Object> {
DateTime: _DateTimeCodec(),
};
static _MetadataCodec<T> forPrimitive<T extends Object>(T sample) {
final codec = _primitives[sample.runtimeType];
static _MetadataCodec<T> forType<T extends Object>(Type runtimeType) {
final codec = _primitives[runtimeType];
if (codec == null) {
throw StateError(
'No primitive codec for ${sample.runtimeType}. Provide an explicit codec when defining the MetadataKey.',
);
throw StateError('No primitive codec for $runtimeType. Provide an explicit codec when defining the MetadataKey.');
}
return codec as _MetadataCodec<T>;
}
@@ -166,7 +127,7 @@ final class _EnumCodec<T extends Enum> extends _MetadataCodec<T> {
String encode(T value) => value.name;
@override
T? decode(String raw) => values.firstWhereOrNull((v) => v.name == raw);
T decode(String raw) => values.firstWhere((v) => v.name == raw);
}
final class _DateTimeCodec extends _MetadataCodec<DateTime> {
@@ -176,7 +137,7 @@ final class _DateTimeCodec extends _MetadataCodec<DateTime> {
String encode(DateTime value) => value.toIso8601String();
@override
DateTime? decode(String raw) => DateTime.tryParse(raw);
DateTime decode(String raw) => DateTime.parse(raw);
}
final class _MapCodec<K extends Object, V extends Object> extends _MetadataCodec<Map<K, V>> {
@@ -193,29 +154,26 @@ final class _MapCodec<K extends Object, V extends Object> extends _MetadataCodec
}
@override
Map<K, V>? decode(String raw) {
Map<K, V> decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) {
return null;
return {};
}
final result = <K, V>{};
for (final entry in decoded.entries) {
final rawKey = entry.key;
final rawValue = entry.value;
if (rawKey is! String || rawValue is! String) {
return null;
return {};
}
final k = _keyCodec.decode(rawKey);
final v = _valueCodec.decode(rawValue);
if (k == null || v == null) {
return null;
}
result[k] = v;
}
return result;
} on FormatException {
return null;
return {};
}
}
}
@@ -229,32 +187,29 @@ final class _ListCodec<T extends Object> extends _MetadataCodec<List<T>> {
String encode(List<T> value) => jsonEncode(value.map(_elementCodec.encode).toList());
@override
List<T>? decode(String raw) {
List<T> decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! List) {
return null;
return [];
}
final result = <T>[];
for (final item in decoded) {
if (item is! String) {
return null;
return [];
}
final element = _elementCodec.decode(item);
if (element == null) {
return null;
}
result.add(element);
}
return result;
} on FormatException {
return null;
return [];
}
}
}
final class _PrimitiveCodec<T extends Object> extends _MetadataCodec<T> {
final T? Function(String) _parse;
final T Function(String) _parse;
const _PrimitiveCodec._(this._parse);
@@ -262,12 +217,12 @@ final class _PrimitiveCodec<T extends Object> extends _MetadataCodec<T> {
String encode(T value) => value.toString();
@override
T? decode(String raw) => _parse(raw);
T decode(String raw) => _parse(raw);
static const integer = _PrimitiveCodec<int>._(int.tryParse);
static const real = _PrimitiveCodec<double>._(double.tryParse);
static const boolean = _PrimitiveCodec<bool>._(bool.tryParse);
static const integer = _PrimitiveCodec<int>._(int.parse);
static const real = _PrimitiveCodec<double>._(double.parse);
static const boolean = _PrimitiveCodec<bool>._(bool.parse);
static const string = _PrimitiveCodec<String>._(_identity);
static String? _identity(String s) => s;
static String _identity(String s) => s;
}
@@ -8,11 +8,7 @@ class AssetService {
final RemoteAssetRepository _remoteAssetRepository;
final DriftLocalAssetRepository _localAssetRepository;
const AssetService({
required RemoteAssetRepository remoteAssetRepository,
required DriftLocalAssetRepository localAssetRepository,
}) : _remoteAssetRepository = remoteAssetRepository,
_localAssetRepository = localAssetRepository;
const AssetService({required this._remoteAssetRepository, required this._localAssetRepository});
Future<BaseAsset?> getAsset(BaseAsset asset) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
@@ -61,11 +61,9 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
bool _isCleanedUp = false;
BackgroundWorkerBgService({required Drift drift, required DriftLogger driftLogger})
: _drift = drift,
_driftLogger = driftLogger,
_backgroundHostApi = BackgroundWorkerBgHostApi() {
_ref = ProviderContainer(overrides: [driftProvider.overrideWith(driftOverride(drift))]);
BackgroundWorkerBgService({required this._drift, required this._driftLogger})
: _backgroundHostApi = BackgroundWorkerBgHostApi() {
_ref = ProviderContainer(overrides: [driftProvider.overrideWith(driftOverride(_drift))]);
BackgroundWorkerFlutterApi.setUp(this);
}
+6 -11
View File
@@ -21,18 +21,13 @@ class HashService {
final _log = Logger('HashService');
HashService({
required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required NativeSyncApi nativeSyncApi,
bool Function()? cancelChecker,
required this._localAlbumRepository,
required this._localAssetRepository,
required this._trashedLocalAssetRepository,
required this._nativeSyncApi,
this._cancelChecker,
int? batchSize,
}) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_cancelChecker = cancelChecker,
_nativeSyncApi = nativeSyncApi,
_batchSize = batchSize ?? kBatchHashFileLimit;
}) : _batchSize = batchSize ?? kBatchHashFileLimit;
bool get isCancelled => _cancelChecker?.call() ?? false;
@@ -28,18 +28,13 @@ class LocalSyncService {
final Logger _log = Logger("DeviceSyncService");
LocalSyncService({
required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required AssetMediaRepository assetMediaRepository,
required IPermissionRepository permissionRepository,
required NativeSyncApi nativeSyncApi,
}) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_assetMediaRepository = assetMediaRepository,
_permissionRepository = permissionRepository,
_nativeSyncApi = nativeSyncApi;
required this._localAlbumRepository,
required this._localAssetRepository,
required this._nativeSyncApi,
required this._trashedLocalAssetRepository,
required this._assetMediaRepository,
required this._permissionRepository,
});
Future<void> sync({bool full = false}) async {
final Stopwatch stopwatch = Stopwatch()..start();
+1 -1
View File
@@ -56,7 +56,7 @@ class LogService {
}) async {
final instance = LogService._(logRepository, metadataRepository, shouldBuffer);
await logRepository.truncate(limit: kLogTruncateLimit);
final level = instance._metadataRepository.systemConfig.logLevel;
final level = instance._metadataRepository.appConfig.logLevel;
Logger.root.level = Level.LEVELS.elementAtOrNull(level.index) ?? Level.INFO;
return instance;
}
+1 -1
View File
@@ -10,7 +10,7 @@ typedef MapQuery = ({MapMarkerSource markerSource});
class MapFactory {
final DriftMapRepository _mapRepository;
const MapFactory({required DriftMapRepository mapRepository}) : _mapRepository = mapRepository;
const MapFactory({required this._mapRepository});
MapService remote(List<String> ownerIds, TimelineMapOptions options) =>
MapService(_mapRepository.remote(ownerIds, options));
@@ -234,24 +234,13 @@ class RemoteAlbumService {
final pendingAdds = <Future<void>>[];
final localById = {for (final a in localAssets) a.id: a};
final UploadCallbacks(:onProgress, :onSuccess, :onError, :onICloudProgress) = userCallbacks;
final wrappedCallbacks = UploadCallbacks(
onProgress: (localId, filename, bytes, totalBytes) => _runUploadCallback(
'Upload progress callback failed for $localId',
() => userCallbacks.onProgress?.call(localId, filename, bytes, totalBytes),
),
onICloudProgress: (localId, progress) => _runUploadCallback(
'iCloud progress callback failed for $localId',
() => userCallbacks.onICloudProgress?.call(localId, progress),
),
onError: (localId, errorMessage) => _runUploadCallback(
'Upload error callback failed for $localId',
() => userCallbacks.onError?.call(localId, errorMessage),
),
onProgress: onProgress,
onICloudProgress: onICloudProgress,
onError: onError,
onSuccess: (localId, remoteId) {
_runUploadCallback(
'Upload success callback failed for $localId',
() => userCallbacks.onSuccess?.call(localId, remoteId),
);
onSuccess?.call(localId, remoteId);
final source = localById[localId];
if (source == null) {
_logger.warning('Upload success for $localId but source LocalAsset missing; skipping album link');
@@ -259,29 +248,22 @@ class RemoteAlbumService {
}
pendingAdds.add(
_linkUploadedAssetToAlbum(albumId, remoteId, uploader, source)
.then<void>((added) {
addedCount += added;
})
.catchError((Object error, StackTrace stack) {
_logger.warning('Failed to add uploaded asset $remoteId to album $albumId', error, stack);
}),
.then<void>((added) => addedCount += added)
.onError(
(error, stack) =>
_logger.warning('Failed to add uploaded asset $remoteId to album $albumId', error, stack),
),
);
},
);
await _uploadService.uploadManual(localAssets, callbacks: wrappedCallbacks);
await _uploadService.uploadManual(localAssets, cancelToken: null, callbacks: wrappedCallbacks);
await Future.wait(pendingAdds);
return addedCount;
}
void _runUploadCallback(String message, void Function() callback) {
try {
callback();
} catch (error, stack) {
_logger.warning(message, error, stack);
}
}
// TODO: this is a poorly designed flow; adding a "stub" just to satisfy FK constraints is hacky,
// it goes out of its way to insert one at a time, and it swallows errors that should be surfaced to the user.
/// Links a freshly-uploaded asset to an album, ensuring the local DB
/// reflects the change without waiting for the next sync. We call the API
/// (server is the source of truth), then upsert a placeholder
@@ -9,7 +9,7 @@ final AppSetting = SettingsService(storeService: StoreService.I);
class SettingsService {
final StoreService _storeService;
const SettingsService({required StoreService storeService}) : _storeService = storeService;
const SettingsService({required this._storeService});
T get<T>(Setting<T> setting) => _storeService.get(setting.storeKey, setting.defaultValue);
@@ -41,24 +41,16 @@ class SyncStreamService {
final bool Function()? _cancelChecker;
SyncStreamService({
required SyncApiRepository syncApiRepository,
required SyncStreamRepository syncStreamRepository,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required AssetMediaRepository assetMediaRepository,
required IPermissionRepository permissionRepository,
required SyncMigrationRepository syncMigrationRepository,
required ApiService api,
bool Function()? cancelChecker,
}) : _syncApiRepository = syncApiRepository,
_syncStreamRepository = syncStreamRepository,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_assetMediaRepository = assetMediaRepository,
_permissionRepository = permissionRepository,
_syncMigrationRepository = syncMigrationRepository,
_api = api,
_cancelChecker = cancelChecker;
required this._syncApiRepository,
required this._syncStreamRepository,
required this._localAssetRepository,
required this._trashedLocalAssetRepository,
required this._assetMediaRepository,
required this._permissionRepository,
required this._syncMigrationRepository,
required this._api,
this._cancelChecker,
});
bool get isCancelled => _cancelChecker?.call() ?? false;
@@ -41,11 +41,7 @@ class TimelineFactory {
final DriftTimelineRepository _timelineRepository;
final MetadataRepository _metadataRepository;
const TimelineFactory({
required DriftTimelineRepository timelineRepository,
required MetadataRepository metadataRepository,
}) : _timelineRepository = timelineRepository,
_metadataRepository = metadataRepository;
const TimelineFactory({required this._timelineRepository, required this._metadataRepository});
GroupAssetsBy get groupBy {
final group = _metadataRepository.appConfig.timeline.groupAssetsBy;
@@ -108,12 +104,7 @@ class TimelineService {
TimelineService(TimelineQuery query)
: this._(assetSource: query.assetSource, bucketSource: query.bucketSource, origin: query.origin);
TimelineService._({
required TimelineAssetSource assetSource,
required TimelineBucketSource bucketSource,
required this.origin,
}) : _assetSource = assetSource,
_bucketSource = bucketSource {
TimelineService._({required this._assetSource, required this._bucketSource, required this.origin}) {
_bucketSubscription = _bucketSource().listen((buckets) {
_mutex.run(() async {
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
+1 -3
View File
@@ -12,9 +12,7 @@ class UserService {
final UserApiRepository _userApiRepository;
final StoreService _storeService;
UserService({required UserApiRepository userApiRepository, required StoreService storeService})
: _userApiRepository = userApiRepository,
_storeService = storeService;
UserService({required this._userApiRepository, required this._storeService});
UserDto getMyUser() {
return _storeService.get(StoreKey.currentUser);
@@ -1,14 +1,12 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/config/system_config.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class MetadataRepository extends DriftDatabaseRepository {
final Drift _db;
final Map<MetadataKey, Object> _cache = {};
MetadataRepository._(this._db) : super(_db);
@@ -25,153 +23,50 @@ class MetadataRepository extends DriftDatabaseRepository {
AppConfig _appConfig = const .new();
AppConfig get appConfig => _appConfig;
SystemConfig _systemConfig = const .new();
SystemConfig get systemConfig => _systemConfig;
static Future<MetadataRepository> ensureInitialized(Drift db) async {
if (_instance == null) {
final instance = MetadataRepository._(db);
await instance._hydrate();
await instance.refresh();
_instance = instance;
}
return _instance!;
}
static Future<void> refresh() async {
instance._cache.clear();
instance._appConfig = const .new();
instance._systemConfig = const .new();
await instance._hydrate();
}
Future<void> _hydrate() async => _hydrateCache(await _db.select(_db.metadataEntity).get());
T _read<T extends Object>(MetadataKey<T> key) => (_cache[key] as T?) ?? key.defaultValue;
Future<void> refresh() async => _applyOverrides(await _db.select(_db.metadataEntity).get());
Future<void> write<T extends Object, U extends T>(MetadataKey<T> key, U value) async {
if (_read(key) == value) {
if (value == _appConfig.read(key)) {
return;
}
await _db
.into(_db.metadataEntity)
.insertOnConflictUpdate(
MetadataEntityCompanion.insert(key: key.key, value: key.encode(value), updatedAt: Value(DateTime.now())),
);
_updateCache(key, value);
}
Future<void> delete<T extends Object>(MetadataKey<T> key) async {
await (_db.delete(_db.metadataEntity)..where((t) => t.key.equals(key.key))).go();
_updateCache(key, key.defaultValue);
}
Stream<AppConfig> watchAppConfig() => _watchDomain(.appConfig).distinct();
Stream<SystemConfig> watchSystemConfig() => _watchDomain(.systemConfig).distinct();
Stream<T> _watchDomain<T extends Object>(MetadataDomain<T> domain) {
final query = _db.select(_db.metadataEntity)..where((t) => t.key.like('${domain.prefix}.%'));
return query.watch().map((rows) {
_hydrateCache(rows);
return domain.config(this);
});
}
void _hydrateCache(List<MetadataEntityData> rows) {
final keyMap = MetadataKey.asKeyMap();
for (final row in rows) {
final key = keyMap[row.key];
if (key == null) {
continue;
}
_updateCache(key, key.decode(row.value));
if (value == defaultConfig.read(key)) {
await (_db.delete(_db.metadataEntity)..where((t) => t.key.equals(key.name))).go();
} else {
await _db
.into(_db.metadataEntity)
.insertOnConflictUpdate(
MetadataEntityCompanion.insert(key: key.name, value: key.encode(value), updatedAt: Value(DateTime.now())),
);
}
_appConfig = _appConfig.write(key, value);
}
void _updateCache<T extends Object>(MetadataKey<T> key, T value) {
if (_cache[key] == value) {
return;
}
_cache[key] = value;
key.domain.rebuild(this);
}
}
extension<T extends Object> on MetadataDomain<T> {
T config(MetadataRepository repo) => switch (this) {
.appConfig => repo._appConfig as T,
.systemConfig => repo._systemConfig as T,
};
void rebuild(MetadataRepository repo) {
switch (this) {
case .appConfig:
repo._appConfig = .new(
theme: .new(
mode: repo._read(.themeMode),
primaryColor: repo._read(.themePrimaryColor),
dynamicTheme: repo._read(.themeDynamic),
colorfulInterface: repo._read(.themeColorfulInterface),
),
cleanup: .new(
keepFavorites: repo._read(.cleanupKeepFavorites),
keepMediaType: repo._read(.cleanupKeepMediaType),
keepAlbumIds: repo._read(.cleanupKeepAlbumIds),
cutoffDaysAgo: repo._read(.cleanupCutoffDaysAgo),
defaultsInitialized: repo._read(.cleanupDefaultsInitialized),
),
map: .new(
relativeDays: repo._read(.mapRelativeDate),
favoritesOnly: repo._read(.mapShowFavoriteOnly),
includeArchived: repo._read(.mapIncludeArchived),
themeMode: repo._read(.mapThemeMode),
withPartners: repo._read(.mapWithPartners),
),
timeline: .new(
tilesPerRow: repo._read(.timelineTilesPerRow),
groupAssetsBy: repo._read(.timelineGroupAssetsBy),
storageIndicator: repo._read(.timelineStorageIndicator),
),
image: .new(preferRemote: repo._read(.imagePreferRemote), loadOriginal: repo._read(.imageLoadOriginal)),
viewer: .new(
loopVideo: repo._read(.viewerLoopVideo),
loadOriginalVideo: repo._read(.viewerLoadOriginalVideo),
autoPlayVideo: repo._read(.viewerAutoPlayVideo),
tapToNavigate: repo._read(.viewerTapToNavigate),
),
slideshow: .new(
transition: repo._read(.slideshowTransition),
repeat: repo._read(.slideshowRepeat),
duration: repo._read(.slideshowDuration),
look: repo._read(.slideshowLook),
direction: repo._read(.slideshowDirection),
),
album: .new(
sortMode: repo._read(.albumSortMode),
isReverse: repo._read(.albumIsReverse),
isGrid: repo._read(.albumIsGrid),
),
backup: .new(
enabled: repo._read(.backupEnabled),
useCellularForVideos: repo._read(.backupUseCellularForVideos),
useCellularForPhotos: repo._read(.backupUseCellularForPhotos),
requireCharging: repo._read(.backupRequireCharging),
triggerDelay: repo._read(.backupTriggerDelay),
syncAlbums: repo._read(.backupSyncAlbums),
),
);
case .systemConfig:
repo._systemConfig = .new(
logLevel: repo._read(.logLevel),
network: .new(
autoEndpointSwitching: repo._read(.networkAutoEndpointSwitching),
preferredWifiName: repo._read(.networkPreferredWifiName).nullIfEmpty,
localEndpoint: repo._read(.networkLocalEndpoint).nullIfEmpty,
externalEndpointList: repo._read(.networkExternalEndpointList),
customHeaders: repo._read(.networkCustomHeaders),
),
);
}
Stream<AppConfig> watchConfig() => _db.select(_db.metadataEntity).watch().map((rows) {
_applyOverrides(rows);
return _appConfig;
});
void _applyOverrides(List<MetadataEntityData> rows) {
_appConfig = AppConfig.fromEntries(
rows.fold({}, (overrides, row) {
final metadataKey = MetadataKey.values.firstWhereOrNull((key) => key.name == row.key);
if (metadataKey == null) {
return overrides;
}
return {...overrides, metadataKey: metadataKey.decode(row.value)};
}),
);
}
}
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -5,66 +6,54 @@ import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
typedef OnProgress = void Function(String id, double progress);
class StorageRepository {
final log = Logger('StorageRepository');
static final log = Logger('StorageRepository');
StorageRepository();
const StorageRepository();
Future<File?> getFileForAsset(String assetId) async {
File? file;
final log = Logger('StorageRepository');
try {
final entity = await AssetEntity.fromId(assetId);
file = await entity?.originFile;
if (file == null) {
log.warning("Cannot get file for asset $assetId");
return null;
}
final exists = await file.exists();
if (!exists) {
log.warning("File for asset $assetId does not exist");
return null;
}
} catch (error, stackTrace) {
log.warning("Error getting file for asset $assetId", error, stackTrace);
}
return file;
Future<File?> getAssetFile(String assetId, {OnProgress? onProgress, Completer<void>? cancelToken}) {
return _getFileForAsset(assetId, isMotion: false, onProgress: onProgress, cancelToken: cancelToken);
}
Future<File?> getMotionFileForAsset(LocalAsset asset) async {
File? file;
final log = Logger('StorageRepository');
Future<File?> getMotionFile(String assetId, {OnProgress? onProgress, Completer<void>? cancelToken}) {
return _getFileForAsset(assetId, isMotion: true, onProgress: onProgress, cancelToken: cancelToken);
}
Future<File?> _getFileForAsset(
String assetId, {
bool isMotion = false,
OnProgress? onProgress,
Completer<void>? cancelToken,
}) async {
final entity = await AssetEntity.fromId(assetId);
if (entity == null) {
log.warning("Cannot get AssetEntity for asset $assetId");
return null;
}
PMProgressHandler? progressHandler;
StreamSubscription<PMProgressState>? progressSubscription;
PMCancelToken? pmCancelToken;
if (cancelToken != null) {
progressHandler = PMProgressHandler();
progressSubscription = progressHandler.stream.listen((event) => onProgress?.call(assetId, event.progress));
pmCancelToken = PMCancelToken();
unawaited(cancelToken.future.then((_) => pmCancelToken!.cancelRequest()));
}
try {
final entity = await AssetEntity.fromId(asset.id);
file = await entity?.originFileWithSubtype;
if (file == null) {
log.warning(
"Cannot get motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
);
return null;
}
final exists = await file.exists();
if (!exists) {
log.warning("Motion file for asset ${asset.id} does not exist");
return null;
}
return await entity.loadFile(withSubtype: isMotion, progressHandler: progressHandler, cancelToken: pmCancelToken);
} catch (error, stackTrace) {
log.warning(
"Error getting motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
error,
stackTrace,
);
log.warning("Error loading file for asset $assetId", error, stackTrace);
return null;
} finally {
unawaited(progressSubscription?.cancel());
}
return file;
}
Future<AssetEntity?> getAssetEntityForAsset(LocalAsset asset) async {
final log = Logger('StorageRepository');
AssetEntity? entity;
try {
@@ -99,39 +88,7 @@ class StorageRepository {
}
}
Future<File?> loadFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async {
try {
final entity = await AssetEntity.fromId(assetId);
if (entity == null) {
log.warning("Cannot get AssetEntity for asset $assetId");
return null;
}
return await entity.loadFile(progressHandler: progressHandler);
} catch (error, stackTrace) {
log.warning("Error loading file from cloud for asset $assetId", error, stackTrace);
return null;
}
}
Future<File?> loadMotionFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async {
try {
final entity = await AssetEntity.fromId(assetId);
if (entity == null) {
log.warning("Cannot get AssetEntity for asset $assetId");
return null;
}
return await entity.loadFile(withSubtype: true, progressHandler: progressHandler);
} catch (error, stackTrace) {
log.warning("Error loading motion file from cloud for asset $assetId", error, stackTrace);
return null;
}
}
Future<void> clearCache() async {
final log = Logger('StorageRepository');
try {
await PhotoManager.clearFileCache();
} catch (error, stackTrace) {
@@ -43,7 +43,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
_searchController = TextEditingController();
_searchFocusNode = FocusNode();
_enableSyncUploadAlbum.value = ref.read(metadataProvider).appConfig.backup.syncAlbums;
_enableSyncUploadAlbum.value = ref.read(appConfigProvider).backup.syncAlbums;
ref.read(backupAlbumProvider.notifier).getAll();
_initialTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
@@ -55,7 +55,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
return;
}
final enableSyncUploadAlbum = ref.read(metadataProvider).appConfig.backup.syncAlbums;
final enableSyncUploadAlbum = ref.read(appConfigProvider).backup.syncAlbums;
final selectedAlbums = ref
.read(backupAlbumProvider)
.where((a) => a.backupSelection == BackupSelection.selected)
@@ -19,7 +19,7 @@ class DriftBackupOptionsPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
bool hasPopped = false;
final previousBackup = ref.read(metadataProvider).appConfig.backup;
final previousBackup = ref.read(appConfigProvider).backup;
final previousCellularForVideos = previousBackup.useCellularForVideos;
final previousCellularForPhotos = previousBackup.useCellularForPhotos;
return PopScope(
@@ -22,7 +22,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
final headers = useState<List<SettingsHeader>>([]);
final setInitialHeaders = useState(false);
final storedHeaders = ref.read(metadataProvider).systemConfig.network.customHeaders;
final storedHeaders = ref.read(metadataProvider).appConfig.network.customHeaders;
if (!setInitialHeaders.value) {
storedHeaders.forEach((k, v) {
final header = SettingsHeader();
@@ -7,7 +7,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
@@ -36,7 +36,7 @@ class BootstrapErrorWidget extends StatelessWidget {
@override
Widget build(BuildContext _) {
final immichTheme = MetadataKey.themePrimaryColor.defaultValue.themeOfPreset;
final immichTheme = defaultConfig.theme.primaryColor.themeOfPreset;
return EasyLocalization(
supportedLocales: locales.values.toList(),
@@ -9,6 +9,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
/// This delete action has the following behavior:
/// - Prompt to delete the asset locally
@@ -39,6 +40,8 @@ class DeleteLocalActionButton extends ConsumerWidget {
return;
}
ref.invalidate(localAlbumProvider);
final successMessage = 'delete_local_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {
@@ -14,7 +14,9 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class _SharePreparingDialog extends StatelessWidget {
const _SharePreparingDialog();
final ValueNotifier<double?> progress;
const _SharePreparingDialog({required this.progress});
@override
Widget build(BuildContext context) {
@@ -22,8 +24,24 @@ class _SharePreparingDialog extends StatelessWidget {
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
Container(margin: const EdgeInsets.only(top: 12), child: const Text('share_dialog_preparing').tr()),
Container(margin: const EdgeInsets.only(bottom: 12), child: const Text('share_dialog_preparing').tr()),
SizedBox(
width: 240,
child: ValueListenableBuilder<double?>(
valueListenable: progress,
builder: (context, value, _) {
final percent = value == null ? null : (value * 100).clamp(0, 100);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
LinearProgressIndicator(value: value, minHeight: 8.0),
if (percent != null)
Container(margin: const EdgeInsets.only(top: 8), child: Text('${percent.toStringAsFixed(0)}%')),
],
);
},
),
),
],
),
);
@@ -43,32 +61,39 @@ class ShareActionButton extends ConsumerWidget {
}
final cancelCompleter = Completer<void>();
const preparingDialog = _SharePreparingDialog();
final progress = ValueNotifier<double?>(null);
final preparingDialog = _SharePreparingDialog(progress: progress);
await showDialog(
context: context,
builder: (BuildContext buildContext) {
ref.read(actionProvider.notifier).shareAssets(source, context, cancelCompleter: cancelCompleter).then((
ActionResult result,
) {
if (cancelCompleter.isCompleted || !context.mounted) {
return;
}
ref
.read(actionProvider.notifier)
.shareAssets(
source,
context,
cancelCompleter: cancelCompleter,
onAssetDownloadProgress: (value) => progress.value = value,
)
.then((ActionResult result) {
if (cancelCompleter.isCompleted || !context.mounted) {
return;
}
ref.read(multiSelectProvider.notifier).reset();
ref.read(multiSelectProvider.notifier).reset();
if (!result.success) {
ImmichToast.show(
context: context,
msg: 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
);
}
if (!result.success) {
ImmichToast.show(
context: context,
msg: 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
);
}
buildContext.pop();
});
buildContext.pop();
});
// show a loading spinner with a "Preparing" message
// Show download progress with a "Preparing" message
return preparingDialog;
},
barrierDismissible: false,
@@ -77,6 +102,7 @@ class ShareActionButton extends ConsumerWidget {
if (!cancelCompleter.isCompleted) {
cancelCompleter.complete();
}
progress.dispose();
});
}
@@ -6,13 +6,13 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
@@ -108,7 +108,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
try {
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
final file = await StorageRepository().getFileForAsset(id);
final file = await ref.read(storageRepositoryProvider).getAssetFile(id);
if (!mounted) {
return null;
}
@@ -296,16 +296,12 @@ class _ThumbnailRenderBox extends RenderBox {
bool isRepaintBoundary = true;
_ThumbnailRenderBox({
required ui.Image? image,
required ui.Image? previousImage,
required double fadeValue,
required BoxFit fit,
required Gradient placeholderGradient,
}) : _image = image,
_previousImage = previousImage,
_fadeValue = fadeValue,
_fit = fit,
_placeholderGradient = placeholderGradient;
required this._image,
required this._previousImage,
required this._fadeValue,
required this._fit,
required this._placeholderGradient,
});
@override
void paint(PaintingContext context, Offset offset) {
@@ -62,14 +62,11 @@ class RenderFixedRow extends RenderBox
RenderBoxContainerDefaultsMixin<RenderBox, _RowParentData> {
RenderFixedRow({
List<RenderBox>? children,
required double height,
required List<double> widths,
required double spacing,
required TextDirection textDirection,
}) : _height = height,
_widths = widths,
_spacing = spacing,
_textDirection = textDirection {
required this._height,
required this._widths,
required this._spacing,
required this._textDirection,
}) {
addAll(children);
}
@@ -578,9 +578,7 @@ class _SlideFadeTransition extends StatelessWidget {
final Animation<double> _animation;
final Widget _child;
const _SlideFadeTransition({required Animation<double> animation, required Widget child})
: _animation = animation,
_child = child;
const _SlideFadeTransition({required this._animation, required this._child});
@override
Widget build(BuildContext context) {
@@ -397,7 +397,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
final grid = CustomScrollView(
primary: true,
physics: _scrollPhysics,
cacheExtent: maxHeight * 2,
scrollCacheExtent: .pixels(maxHeight * 2),
slivers: [
if (isSelectionMode) const SelectionSliverAppBar() else if (widget.appBar != null) widget.appBar!,
if (widget.topSliverWidget != null) widget.topSliverWidget!,
@@ -503,7 +503,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
class _SliverSegmentedList extends SliverMultiBoxAdaptorWidget {
final List<Segment> _segments;
const _SliverSegmentedList({required List<Segment> segments, required super.delegate}) : _segments = segments;
const _SliverSegmentedList({required this._segments, required super.delegate});
@override
_RenderSliverTimelineBoxAdaptor createRenderObject(BuildContext context) =>
@@ -527,8 +527,7 @@ class _RenderSliverTimelineBoxAdaptor extends RenderSliverMultiBoxAdaptor {
markNeedsLayout();
}
_RenderSliverTimelineBoxAdaptor({required super.childManager, required List<Segment> segments})
: _segments = segments;
_RenderSliverTimelineBoxAdaptor({required super.childManager, required this._segments});
int getMinChildIndexForScrollOffset(double offset) =>
_segments.findByOffset(offset)?.getMinChildIndexForScrollOffset(offset) ?? 0;
+3 -3
View File
@@ -130,7 +130,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
await _apiService.updateHeaders();
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final headerMap = _ref.read(metadataProvider).systemConfig.network.customHeaders;
final headerMap = _ref.read(metadataProvider).appConfig.network.customHeaders;
final customHeaders = headerMap.isEmpty ? null : jsonEncode(headerMap);
await _widgetService.writeCredentials(serverEndpoint, accessToken, customHeaders);
@@ -187,11 +187,11 @@ class AuthNotifier extends StateNotifier<AuthState> {
}
String? getSavedWifiName() {
return _ref.read(metadataProvider).systemConfig.network.preferredWifiName;
return _ref.read(metadataProvider).appConfig.network.preferredWifiName;
}
String? getSavedLocalEndpoint() {
return _ref.read(metadataProvider).systemConfig.network.localEndpoint;
return _ref.read(metadataProvider).appConfig.network.localEndpoint;
}
/// Returns the current server endpoint (with /api) URL from the store
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:logging/logging.dart';
import 'package:immich_mobile/constants/constants.dart';
@@ -273,7 +274,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
onProgress: _handleForegroundBackupProgress,
onSuccess: _handleForegroundBackupSuccess,
onError: _handleForegroundBackupError,
onICloudProgress: _handleICloudProgress,
onICloudProgress: CurrentPlatform.isIOS ? _handleICloudProgress : null,
),
);
}
@@ -282,7 +283,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
_cancelToken?.complete();
_cancelToken = null;
_uploadSpeedManager.clear();
state = state.copyWith(uploadItems: {}, iCloudDownloadProgress: {});
state = state.copyWith(uploadItems: const {}, iCloudDownloadProgress: const {});
}
void _handleICloudProgress(String localAssetId, double progress) {
@@ -465,11 +465,17 @@ class ActionNotifier extends Notifier<void> {
ActionSource source,
BuildContext context, {
Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) async {
final ids = _getAssets(source).toList(growable: false);
try {
await _service.shareAssets(ids, context, cancelCompleter: cancelCompleter);
await _service.shareAssets(
ids,
context,
cancelCompleter: cancelCompleter,
onAssetDownloadProgress: onAssetDownloadProgress,
);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to share assets', error, stack);
@@ -1,20 +1,12 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/config/system_config.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
final metadataProvider = Provider.autoDispose<MetadataRepository>((_) => MetadataRepository.instance);
final appConfigProvider = Provider.autoDispose<AppConfig>((ref) {
final repo = ref.watch(metadataProvider);
final subscription = repo.watchAppConfig().listen((event) => ref.state = event);
final subscription = repo.watchConfig().listen((event) => ref.state = event);
ref.onDispose(subscription.cancel);
return repo.appConfig;
});
final systemConfigProvider = Provider.autoDispose<SystemConfig>((ref) {
final repo = ref.watch(metadataProvider);
final subscription = repo.watchSystemConfig().listen((event) => ref.state = event);
ref.onDispose(subscription.cancel);
return repo.systemConfig;
});
@@ -1,4 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
final storageRepositoryProvider = Provider<StorageRepository>((ref) => StorageRepository());
final storageRepositoryProvider = Provider<StorageRepository>((ref) => const StorageRepository());
@@ -1,31 +1,29 @@
import 'dart:async';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:share_plus/share_plus.dart';
final assetMediaRepositoryProvider = Provider(
(ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider), ref.watch(nativeSyncApiProvider)),
);
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.watch(nativeSyncApiProvider)));
class AssetMediaRepository {
final AssetApiRepository _assetApiRepository;
final NativeSyncApi _nativeSyncApi;
static final Logger _log = Logger("AssetMediaRepository");
const AssetMediaRepository(this._assetApiRepository, this._nativeSyncApi);
const AssetMediaRepository(this._nativeSyncApi);
Future<bool> _androidSupportsTrash() async {
if (Platform.isAndroid) {
@@ -107,10 +105,29 @@ class AssetMediaRepository {
);
}
// TODO: make this more efficient
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context, {Completer<void>? cancelCompleter}) async {
Future<int> shareAssets(
List<BaseAsset> assets,
BuildContext context, {
Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) async {
final downloadedXFiles = <XFile>[];
final tempFiles = <File>[];
final totalAssets = assets.length;
var processedAssets = 0;
void updateProgress([double currentAssetProgress = 0.0]) {
if (totalAssets <= 0) {
onAssetDownloadProgress?.call(1.0);
return;
}
final normalizedAssetProgress = currentAssetProgress.clamp(0.0, 1.0);
final overallProgress = ((processedAssets + normalizedAssetProgress) / totalAssets).clamp(0.0, 1.0);
onAssetDownloadProgress?.call(overallProgress);
}
updateProgress();
for (var asset in assets) {
if (cancelCompleter != null && cancelCompleter.isCompleted) {
@@ -127,6 +144,8 @@ class AssetMediaRepository {
if (localId != null && !asset.isEdited) {
File? f = await AssetEntity(id: localId, width: 1, height: 1, typeInt: 0).originFile;
downloadedXFiles.add(XFile(f!.path));
processedAssets++;
updateProgress();
if (CurrentPlatform.isIOS) {
tempFiles.add(f);
}
@@ -134,22 +153,50 @@ class AssetMediaRepository {
final remoteId = (asset is RemoteAsset) ? asset.id : asset.remoteId;
if (remoteId == null) {
_log.warning("Asset has no remote ID for sharing: $asset");
processedAssets++;
updateProgress();
continue;
}
final tempDir = await getTemporaryDirectory();
final name = asset.name;
final tempFile = await File('${tempDir.path}/$name').create();
final res = await _assetApiRepository.downloadAsset(remoteId, edited: true);
final taskId = 'share-$remoteId-${DateTime.now().microsecondsSinceEpoch}';
final sanitizedFilename = asset.name.replaceAll(RegExp(r'[\\/]'), '_');
final task = DownloadTask(
taskId: taskId,
url: getOriginalUrlForRemoteId(remoteId, edited: asset.isEdited),
headers: ApiService.getRequestHeaders(),
filename: sanitizedFilename,
baseDirectory: BaseDirectory.temporary,
group: kShareDownloadGroup,
updates: Updates.statusAndProgress,
);
final statusUpdate = await FileDownloader().download(
task,
onProgress: (value) {
if (cancelCompleter != null && cancelCompleter.isCompleted) {
unawaited(FileDownloader().cancelTaskWithId(taskId));
return;
}
updateProgress(value);
},
);
if (res.statusCode != 200) {
_log.severe("Download for $name failed", res.toLoggerString());
continue;
if (cancelCompleter != null && cancelCompleter.isCompleted) {
await _cleanupTempFiles(tempFiles);
return 0;
}
await tempFile.writeAsBytes(res.bodyBytes);
downloadedXFiles.add(XFile(tempFile.path));
tempFiles.add(tempFile);
if (statusUpdate.status == TaskStatus.complete) {
final filePath = await task.filePath();
final file = File(filePath);
tempFiles.add(file);
downloadedXFiles.add(XFile(filePath));
processedAssets++;
updateProgress();
continue;
}
_log.severe("Download for ${asset.name} failed with status ${statusUpdate.status}", statusUpdate.exception);
processedAssets++;
updateProgress();
}
}
+8 -10
View File
@@ -1,40 +1,38 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
final authRepositoryProvider = Provider<AuthRepository>(
(ref) => AuthRepository(ref.watch(driftProvider), ref.watch(metadataProvider)),
(ref) => AuthRepository(ref.watch(driftProvider), ref.watch(appConfigProvider)),
);
class AuthRepository {
final Drift _drift;
final MetadataRepository _metadata;
final AppConfig _config;
const AuthRepository(this._drift, this._metadata);
const AuthRepository(this._drift, this._config);
Future<void> clearLocalData() async {
await SyncStreamRepository(_drift).reset();
}
bool getEndpointSwitchingFeature() {
return _metadata.systemConfig.network.autoEndpointSwitching;
return _config.network.autoEndpointSwitching;
}
String? getPreferredWifiName() {
return _metadata.systemConfig.network.preferredWifiName;
return _config.network.preferredWifiName;
}
String? getLocalEndpoint() {
return _metadata.systemConfig.network.localEndpoint;
return _config.network.localEndpoint;
}
List<AuxilaryEndpoint> getExternalEndpointList() {
return _metadata.systemConfig.network.externalEndpointList
.map((url) => AuxilaryEndpoint(url: url, status: .valid))
.toList();
return _config.network.externalEndpointList.map((url) => AuxilaryEndpoint(url: url, status: .valid)).toList();
}
}
+44 -92
View File
@@ -10,7 +10,6 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:logging/logging.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/utils/debug_print.dart';
final uploadRepositoryProvider = Provider((ref) => UploadRepository());
@@ -20,21 +19,14 @@ class UploadRepository {
void Function(TaskProgressUpdate)? onTaskProgress;
UploadRepository() {
FileDownloader().registerCallbacks(
group: kBackupGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks(
group: kBackupLivePhotoGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks(
group: kManualUploadGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
final downloader = FileDownloader();
for (final group in const [kBackupGroup, kBackupLivePhotoGroup, kManualUploadGroup]) {
downloader.registerCallbacks(
group: group,
taskStatusCallback: onUploadStatus,
taskProgressCallback: onTaskProgress,
);
}
}
Future<void> enqueueBackground(UploadTask task) {
@@ -66,28 +58,6 @@ class UploadRepository {
return FileDownloader().start();
}
Future<void> getUploadInfo() async {
final [enqueuedTasks, runningTasks, canceledTasks, waitingTasks, pausedTasks] = await Future.wait([
FileDownloader().database.allRecordsWithStatus(TaskStatus.enqueued, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.running, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.canceled, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.waitingToRetry, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.paused, group: kBackupGroup),
]);
dPrint(
() =>
"""
Upload Info:
Enqueued: ${enqueuedTasks.length}
Running: ${runningTasks.length}
Canceled: ${canceledTasks.length}
Waiting: ${waitingTasks.length}
Paused: ${pausedTasks.length}
""",
);
}
Future<UploadResult> uploadFile({
required File file,
required String originalFileName,
@@ -111,41 +81,30 @@ class UploadRepository {
baseRequest.fields.addAll(fields);
baseRequest.files.add(assetRawUploadData);
final response = await NetworkRepository.client.send(baseRequest);
final responseBodyString = await response.stream.bytesToString();
final StreamedResponse(:statusCode, :stream) = await NetworkRepository.client.send(baseRequest);
final responseBodyString = await stream.bytesToString();
if (![200, 201].contains(response.statusCode)) {
String? errorMessage;
if (response.statusCode == 413) {
errorMessage = 'Error(413) File is too large to upload';
return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage);
}
try {
final error = jsonDecode(responseBodyString);
errorMessage = error['message'] ?? error['error'];
} catch (_) {
errorMessage = responseBodyString.isNotEmpty
? responseBodyString
: 'Upload failed with status ${response.statusCode}';
}
return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage);
}
try {
final responseBody = jsonDecode(responseBodyString);
return UploadResult.success(remoteAssetId: responseBody['id'] as String);
} catch (e) {
return UploadResult.error(errorMessage: 'Failed to parse server response');
}
return switch ((statusCode, _tryJsonDecode(responseBodyString))) {
(200 || 201, {'id': String id}) => UploadSuccess(remoteAssetId: id),
(413, _) => const UploadError(statusCode: 413, message: 'File is too large to upload'),
(_, {'message': String message}) => UploadError(statusCode: statusCode, message: message),
_ => UploadError(statusCode: statusCode, message: 'Upload failed with status $statusCode'),
};
} on RequestAbortedException {
logger.warning("Upload $logContext was cancelled");
return UploadResult.cancelled();
return const UploadCancelled();
} catch (error, stackTrace) {
logger.warning("Error uploading $logContext: ${error.toString()}: $stackTrace");
return UploadResult.error(errorMessage: error.toString());
return UploadError(message: error.toString());
}
}
@pragma('vm:prefer-inline')
Map? _tryJsonDecode(String s) {
try {
return (jsonDecode(s) as Map);
} catch (_) {
return null;
}
}
}
@@ -180,30 +139,23 @@ class ProgressMultipartRequest extends MultipartRequest with Abortable {
}
}
class UploadResult {
final bool isSuccess;
final bool isCancelled;
final String? remoteAssetId;
final String? errorMessage;
sealed class UploadResult {
const UploadResult();
}
final class UploadSuccess extends UploadResult {
final String remoteAssetId;
const UploadSuccess({required this.remoteAssetId});
}
final class UploadError extends UploadResult {
final String message;
final int? statusCode;
const UploadResult({
required this.isSuccess,
required this.isCancelled,
this.remoteAssetId,
this.errorMessage,
this.statusCode,
});
factory UploadResult.success({required String remoteAssetId}) {
return UploadResult(isSuccess: true, isCancelled: false, remoteAssetId: remoteAssetId);
}
factory UploadResult.error({String? errorMessage, int? statusCode}) {
return UploadResult(isSuccess: false, isCancelled: false, errorMessage: errorMessage, statusCode: statusCode);
}
factory UploadResult.cancelled() {
return const UploadResult(isSuccess: false, isCancelled: true);
}
const UploadError({required this.message, this.statusCode});
}
final class UploadCancelled extends UploadResult {
const UploadCancelled();
}
+12 -2
View File
@@ -269,8 +269,18 @@ class ActionService {
await _assetApiRepository.unStack(stackIds);
}
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context, {Completer<void>? cancelCompleter}) {
return _assetMediaRepository.shareAssets(assets, context, cancelCompleter: cancelCompleter);
Future<int> shareAssets(
List<BaseAsset> assets,
BuildContext context, {
Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) {
return _assetMediaRepository.shareAssets(
assets,
context,
cancelCompleter: cancelCompleter,
onAssetDownloadProgress: onAssetDownloadProgress,
);
}
Future<List<bool>> downloadAll(List<RemoteAsset> assets) {
+3 -3
View File
@@ -177,9 +177,9 @@ class ApiService {
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
urls.add(serverEndpoint);
}
final network = MetadataRepository.instance.systemConfig.network;
final network = MetadataRepository.instance.appConfig.network;
final localEndpoint = network.localEndpoint;
if (localEndpoint != null) {
if (localEndpoint.isNotEmpty) {
urls.add(localEndpoint);
}
for (final url in network.externalEndpointList) {
@@ -191,7 +191,7 @@ class ApiService {
}
static Map<String, String> getRequestHeaders() {
return MetadataRepository.instance.systemConfig.network.customHeaders;
return MetadataRepository.instance.appConfig.network.customHeaders;
}
ApiClient get apiClient => _apiClient;
@@ -266,8 +266,6 @@ class BackgroundUploadService {
return null;
}
File? file;
/// iOS LivePhoto has two files: a photo and a video.
/// They are uploaded separately, with video file being upload first, then returned with the assetId
/// The assetId is then used as a metadata for the photo file upload task.
@@ -278,11 +276,9 @@ class BackgroundUploadService {
/// The cancel operation will only cancel the video group (normal group), the photo group will not
/// be touched, as the video file is already uploaded.
if (entity.isLivePhoto) {
file = await _storageRepository.getMotionFileForAsset(asset);
} else {
file = await _storageRepository.getFileForAsset(asset.id);
}
final file = await (entity.isLivePhoto
? _storageRepository.getMotionFile(asset.id)
: _storageRepository.getAssetFile(asset.id));
if (file == null) {
_logger.warning("Failed to get file for asset ${asset.id} - ${asset.name}");
@@ -330,7 +326,7 @@ class BackgroundUploadService {
return null;
}
final file = await _storageRepository.getFileForAsset(asset.id);
final file = await _storageRepository.getAssetFile(asset.id);
if (file == null) {
return null;
}
@@ -20,7 +20,6 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
/// Callbacks for upload progress and status updates
class UploadCallbacks {
@@ -99,7 +98,7 @@ class ForegroundUploadService {
final requireWifi = _shouldRequireWiFi(asset);
return requireWifi && !hasWifi;
},
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
processItem: (asset) => _uploadSingleAsset(asset, cancelToken: cancelToken, callbacks: callbacks),
);
}
}
@@ -125,14 +124,14 @@ class ForegroundUploadService {
continue;
}
await _uploadSingleAsset(asset, cancelToken, callbacks: callbacks);
await _uploadSingleAsset(asset, cancelToken: cancelToken, callbacks: callbacks);
}
}
/// Manually upload picked local assets
Future<void> uploadManual(
List<LocalAsset> localAssets, {
Completer<void>? cancelToken,
required Completer<void>? cancelToken,
UploadCallbacks callbacks = const UploadCallbacks(),
}) async {
if (localAssets.isEmpty) {
@@ -142,7 +141,7 @@ class ForegroundUploadService {
await _executeWithWorkerPool<LocalAsset>(
items: localAssets,
cancelToken: cancelToken,
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
processItem: (asset) => _uploadSingleAsset(asset, cancelToken: cancelToken, callbacks: callbacks),
);
}
@@ -170,11 +169,11 @@ class ForegroundUploadService {
onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes),
);
if (result.isSuccess) {
onSuccess?.call(fileId);
} else if (!result.isCancelled && result.errorMessage != null) {
onError?.call(fileId, result.errorMessage!);
}
return switch (result) {
UploadSuccess() => onSuccess?.call(fileId),
UploadError(:final message) => onError?.call(fileId, message),
UploadCancelled() => null,
};
},
);
}
@@ -216,7 +215,7 @@ class ForegroundUploadService {
final item = items[index];
if (shouldSkip?.call(item) ?? false) {
if (shouldSkip != null && shouldSkip(item)) {
continue;
}
@@ -233,78 +232,48 @@ class ForegroundUploadService {
}
Future<void> _uploadSingleAsset(
LocalAsset asset,
Completer<void>? cancelToken, {
LocalAsset asset, {
required Completer<void>? cancelToken,
required UploadCallbacks callbacks,
}) async {
File? file;
final UploadCallbacks(:onProgress, :onSuccess, :onError, :onICloudProgress) = callbacks;
File? assetFile;
File? livePhotoFile;
try {
final entity = await _storageRepository.getAssetEntityForAsset(asset);
if (entity == null) {
callbacks.onError?.call(
onError?.call(
asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
);
return;
}
final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id);
if (!isAvailableLocally && CurrentPlatform.isIOS) {
_logger.info("Loading iCloud asset ${asset.id} - ${asset.name}");
// Create progress handler for iCloud download
PMProgressHandler? progressHandler;
StreamSubscription? progressSubscription;
progressHandler = PMProgressHandler();
progressSubscription = progressHandler.stream.listen((event) {
callbacks.onICloudProgress?.call(asset.localId!, event.progress);
});
try {
file = await _storageRepository.loadFileFromCloud(asset.id, progressHandler: progressHandler);
if (entity.isLivePhoto) {
livePhotoFile = await _storageRepository.loadMotionFileFromCloud(
asset.id,
progressHandler: progressHandler,
);
}
} finally {
await progressSubscription.cancel();
}
} else {
// Get files locally
file = await _storageRepository.getFileForAsset(asset.id);
File? file;
if (entity.isLivePhoto) {
file = await _storageRepository.getMotionFile(asset.id, cancelToken: cancelToken, onProgress: onICloudProgress);
if (file == null) {
_logger.warning("Failed to get file ${asset.id} - ${asset.name}");
callbacks.onError?.call(
_logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}");
onError?.call(
asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
);
return;
}
// For live photos, get the motion video file
if (entity.isLivePhoto) {
livePhotoFile = await _storageRepository.getMotionFileForAsset(asset);
if (livePhotoFile == null) {
_logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}");
callbacks.onError?.call(
asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
);
}
}
livePhotoFile = file;
}
file = await _storageRepository.getAssetFile(asset.id, cancelToken: cancelToken, onProgress: onICloudProgress);
if (file == null) {
_logger.warning("Failed to obtain file from iCloud for asset ${asset.id} - ${asset.name}");
callbacks.onError?.call(asset.localId!, "asset_not_found_on_icloud".t());
_logger.warning("Failed to get file ${asset.id} - ${asset.name}");
onError?.call(
asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
);
return;
}
assetFile = file;
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
@@ -330,11 +299,9 @@ class ForegroundUploadService {
};
// Upload live photo video first if available
String? livePhotoVideoId;
if (entity.isLivePhoto && livePhotoFile != null) {
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path));
final onProgress = callbacks.onProgress;
final livePhotoResult = await _uploadRepository.uploadFile(
file: livePhotoFile,
originalFileName: livePhotoTitle,
@@ -346,15 +313,16 @@ class ForegroundUploadService {
logContext: 'livePhotoVideo[${asset.localId}]',
);
if (livePhotoResult.isSuccess && livePhotoResult.remoteAssetId != null) {
livePhotoVideoId = livePhotoResult.remoteAssetId;
switch (livePhotoResult) {
case UploadSuccess(:final remoteAssetId):
fields['livePhotoVideoId'] = remoteAssetId;
case UploadError(:final message):
onError?.call(asset.localId!, "Failed to upload live photo video: $message");
return;
case UploadCancelled():
}
}
if (livePhotoVideoId != null) {
fields['livePhotoVideoId'] = livePhotoVideoId;
}
// Add cloudId metadata only to the still image, not the motion video, becasue when the sync id happens, the motion video can get associated with the wrong still image.
if (CurrentPlatform.isIOS && asset.cloudId != null) {
fields['metadata'] = jsonEncode([
@@ -371,7 +339,6 @@ class ForegroundUploadService {
]);
}
final onProgress = callbacks.onProgress;
final result = await _uploadRepository.uploadFile(
file: file,
originalFileName: originalFileName,
@@ -383,34 +350,33 @@ class ForegroundUploadService {
logContext: 'asset[${asset.localId}]',
);
if (result.isSuccess && result.remoteAssetId != null) {
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
} else if (result.isCancelled) {
_logger.warning(() => "Backup was cancelled by the user");
shouldAbortUpload = true;
} else if (result.errorMessage != null) {
_logger.severe(
() =>
"Error(${result.statusCode}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt} | ${result.errorMessage}",
);
callbacks.onError?.call(asset.localId!, result.errorMessage!);
if (result.errorMessage == "Quota has been exceeded!") {
switch (result) {
case UploadSuccess(:final remoteAssetId):
onSuccess?.call(asset.localId!, remoteAssetId);
case UploadCancelled():
shouldAbortUpload = true;
}
_logger.warning("Upload was cancelled by the user for asset ${asset.localId}");
case UploadError(:final message, :final statusCode):
shouldAbortUpload |= message == "Quota has been exceeded!";
_logger.severe(
"Error(${statusCode ?? 'unknown'}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt} | $message",
);
onError?.call(asset.localId!, message);
}
} catch (error, stackTrace) {
_logger.severe(() => "Error backup asset: ${error.toString()}", stackTrace);
callbacks.onError?.call(asset.localId!, error.toString());
_logger.severe("Asset backup failed", error, stackTrace);
onError?.call(asset.localId!, error.toString());
} finally {
if (Platform.isIOS) {
try {
await file?.delete();
await livePhotoFile?.delete();
} catch (error, stackTrace) {
_logger.severe(() => "ERROR deleting file: ${error.toString()}", stackTrace);
}
unawaited(
Future.wait([
if (assetFile != null) assetFile.delete(),
if (livePhotoFile != null) livePhotoFile.delete(),
]).onError((error, stackTrace) {
_logger.severe("Post-upload file cleanup failed", error, stackTrace);
return const [];
}),
);
}
}
}
@@ -446,7 +412,7 @@ class ForegroundUploadService {
logContext: 'shareIntent[$deviceAssetId]',
);
} catch (e) {
return UploadResult.error(errorMessage: e.toString());
return UploadError(message: e.toString());
}
}
+18 -9
View File
@@ -1,10 +1,12 @@
import 'dart:async';
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
@@ -136,15 +138,11 @@ Future<void> _migrateTo26(Drift drift) async {
Future<void> _migrateAlbumSortMode(_StoreMigrator migrator) async {
final raw = await migrator.readLegacyStoreInt(StoreKey.legacySelectedAlbumSortOrder.id);
if (raw == null) {
final mode = AlbumSortMode.values.firstWhereOrNull((e) => raw != null && e.storeIndex == raw);
if (mode == null) {
return;
}
final mode = AlbumSortMode.values.firstWhere(
(e) => e.storeIndex == raw,
orElse: () => MetadataKey.albumSortMode.defaultValue,
);
migrator.stage(StoreKey.legacySelectedAlbumSortOrder, MetadataKey.albumSortMode, mode);
}
@@ -208,7 +206,11 @@ class _StoreMigrator {
return;
}
final enumValue = values.elementAtOrNull(index) ?? newKey.defaultValue;
final enumValue = values.elementAtOrNull(index);
if (enumValue == null) {
return;
}
_cache[newKey] = enumValue;
_migratedStoreIds.add(legacyKey.id);
}
@@ -223,7 +225,11 @@ class _StoreMigrator {
return;
}
final enumValue = values.firstWhere((e) => e.name == name, orElse: () => newKey.defaultValue);
final enumValue = values.firstWhereOrNull((e) => e.name == name);
if (enumValue == null) {
return;
}
_cache[newKey] = enumValue;
_migratedStoreIds.add(legacyKey.id);
}
@@ -267,9 +273,12 @@ class _StoreMigrator {
Future<void> complete() async {
await _db.batch((batch) {
for (final entry in _cache.entries) {
if (entry.value == defaultConfig.read(entry.key)) {
continue;
}
batch.insert(
_db.metadataEntity,
MetadataEntityCompanion(key: Value(entry.key.key), value: Value(entry.key.encode(entry.value))),
MetadataEntityCompanion(key: Value(entry.key.name), value: Value(entry.key.encode(entry.value))),
mode: InsertMode.insertOrReplace,
);
}
@@ -31,7 +31,7 @@ class AdvancedSettings extends HookConsumerWidget {
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final isManageMediaSupported = useState(false);
final manageMediaAndroidPermission = useState(false);
final levelId = useState<int>(ref.read(systemConfigProvider).logLevel.index);
final levelId = useState<int>(ref.read(appConfigProvider).logLevel.index);
final preferRemote = useState(ref.read(appConfigProvider).image.preferRemote);
useValueChanged(
preferRemote.value,
@@ -1,12 +1,13 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/services/localization.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
class LanguageSettings extends HookConsumerWidget {
@@ -84,7 +85,7 @@ class LanguageSettings extends HookConsumerWidget {
padding: const EdgeInsets.all(8),
itemCount: filteredLocaleEntries.value.length,
itemExtent: 64.0,
cacheExtent: 100,
scrollCacheExtent: const .pixels(100),
itemBuilder: (context, index) {
final countryName = filteredLocaleEntries.value[index].key;
final localeValue = filteredLocaleEntries.value[index].value;
@@ -36,10 +36,6 @@ class ExternalNetworkPreference extends HookConsumerWidget {
}
handleReorder(int oldIndex, int newIndex) {
if (oldIndex < newIndex) {
newIndex -= 1;
}
final entry = entries.value.removeAt(oldIndex);
entries.value.insert(newIndex, entry);
entries.value = [...entries.value];
@@ -68,7 +64,7 @@ class ExternalNetworkPreference extends HookConsumerWidget {
}
useEffect(() {
final urls = ref.read(metadataProvider).systemConfig.network.externalEndpointList;
final urls = ref.read(appConfigProvider).network.externalEndpointList;
if (urls.isEmpty) {
return null;
@@ -113,7 +109,7 @@ class ExternalNetworkPreference extends HookConsumerWidget {
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: entries.value.length,
onReorder: handleReorder,
onReorderItem: handleReorder,
itemBuilder: (context, index) {
return EndpointInput(
key: Key(index.toString()),
@@ -19,7 +19,7 @@ class NetworkingSettings extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentEndpoint = getServerUrl();
final featureEnabled = useState(ref.read(systemConfigProvider).network.autoEndpointSwitching);
final featureEnabled = useState(ref.read(appConfigProvider).network.autoEndpointSwitching);
useValueChanged<bool, void>(featureEnabled.value, (_, __) {
ref.read(metadataProvider).write(.networkAutoEndpointSwitching, featureEnabled.value);
});
+9
View File
@@ -78,6 +78,15 @@ alias = "migration"
description = "Generate database migrations"
run = "dart run drift_dev make-migrations"
[tasks.install]
alias = "install"
description = "Install flutter dependencies"
run = "flutter pub get"
[tasks.start]
alias = "start"
description = "Start flutter app"
run = "flutter run"
# Internal tasks
[tasks."i18n:loader"]
@@ -276,8 +276,6 @@ class TimeBucketAssetResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'city',
'country',
'createdAt',
'duration',
'fileCreatedAt',
@@ -10,9 +10,16 @@ class ImmichFormController extends ChangeNotifier {
FutureOr<void> Function()? onSubmit;
final formKey = GlobalKey<FormState>();
bool _isDisposed = false;
bool _isLoading = false;
bool get isLoading => _isLoading;
@override
void dispose() {
_isDisposed = true;
super.dispose();
}
Future<void> submit() async {
if (_isLoading) {
return;
@@ -27,7 +34,9 @@ class ImmichFormController extends ChangeNotifier {
await onSubmit?.call();
} finally {
_isLoading = false;
notifyListeners();
if (!_isDisposed) {
notifyListeners();
}
}
}
}
@@ -38,13 +47,7 @@ class ImmichForm extends StatefulWidget {
final String? submitText;
final IconData? submitIcon;
const ImmichForm({
super.key,
this.onSubmit,
this.submitText,
this.submitIcon,
required this.builder,
});
const ImmichForm({super.key, this.onSubmit, this.submitText, this.submitIcon, required this.builder});
@override
State<ImmichForm> createState() => _ImmichFormState();
+12 -12
View File
@@ -5,18 +5,18 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
sha256: "3b19a47f6ea7c2632760777c78174f47f6aec1e05f0cd611380d4593b8af1dbc"
url: "https://pub.dev"
source: hosted
version: "93.0.0"
version: "96.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0"
url: "https://pub.dev"
source: hosted
version: "10.0.1"
version: "10.2.0"
ansicolor:
dependency: transitive
description:
@@ -1088,10 +1088,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.18.0"
mime:
dependency: transitive
description:
@@ -1719,10 +1719,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.11"
thumbhash:
dependency: "direct main"
description:
@@ -1775,10 +1775,10 @@ packages:
dependency: transitive
description:
name: url_launcher_android
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
url: "https://pub.dev"
source: hosted
version: "6.3.29"
version: "6.3.30"
url_launcher_ios:
dependency: transitive
description:
@@ -1996,5 +1996,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.11.0 <4.0.0"
flutter: "3.41.9"
dart: ">=3.12.0 <4.0.0"
flutter: "3.44.0"
+2 -2
View File
@@ -5,8 +5,8 @@ publish_to: 'none'
version: 3.0.0+3047
environment:
sdk: '>=3.11.0 <4.0.0'
flutter: 3.41.9
sdk: '>=3.12.0 <4.0.0'
flutter: 3.44.0
dependencies:
async: ^2.13.1
@@ -1,7 +1,7 @@
import 'package:collection/collection.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/config/system_config.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
@@ -39,7 +39,7 @@ void main() {
registerFallbackValue(LogLevel.info);
when(() => mockLogRepo.truncate(limit: any(named: 'limit'))).thenAnswer((_) async => {});
when(() => mockMetadataRepository.systemConfig).thenReturn(const SystemConfig(logLevel: LogLevel.fine));
when(() => mockMetadataRepository.appConfig).thenReturn(const AppConfig(logLevel: LogLevel.fine));
when(() => mockMetadataRepository.write<LogLevel, LogLevel>(MetadataKey.logLevel, any())).thenAnswer((_) async {});
when(() => mockLogRepo.getAll()).thenAnswer((_) async => []);
when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true);
@@ -59,7 +59,7 @@ void main() {
});
test('Sets log level based on the metadata repository', () {
verify(() => mockMetadataRepository.systemConfig).called(1);
verify(() => mockMetadataRepository.appConfig).called(1);
expect(Logger.root.level, Level.FINE);
});
});
@@ -23,7 +23,7 @@ void main() {
setUp(() async {
await ctx.db.delete(ctx.db.metadataEntity).go();
await MetadataRepository.refresh();
await MetadataRepository.instance.refresh();
});
group('defaults', () {
@@ -31,8 +31,8 @@ void main() {
expect(sut.appConfig.theme.mode, ThemeMode.system);
});
test('systemConfig returns key defaults when DB is empty', () {
expect(sut.systemConfig.logLevel, LogLevel.info);
test('appConfig returns key defaults when DB is empty', () {
expect(sut.appConfig.logLevel, LogLevel.info);
});
});
@@ -46,16 +46,14 @@ void main() {
await sut.write(.themeMode, ThemeMode.light);
await sut.write(.logLevel, LogLevel.severe);
expect(sut.appConfig.theme.mode, ThemeMode.light);
expect(sut.systemConfig.logLevel, LogLevel.severe);
expect(sut.appConfig.logLevel, LogLevel.severe);
});
});
group('delete', () {
test('removes the row and reverts to default', () async {
await sut.write(.themeMode, ThemeMode.dark);
expect(sut.appConfig.theme.mode, ThemeMode.dark);
await sut.delete(.themeMode);
await sut.write(.themeMode, ThemeMode.system);
expect(sut.appConfig.theme.mode, ThemeMode.system);
final rows = await ctx.db.select(ctx.db.metadataEntity).get();
@@ -63,13 +61,15 @@ void main() {
});
});
group('refresh', () {
group('delete', () {});
group('sync', () {
test('picks up rows that were inserted directly into the DB', () async {
await ctx.db
.into(ctx.db.metadataEntity)
.insert(
MetadataEntityCompanion.insert(
key: MetadataKey.themeMode.key,
key: MetadataKey.themeMode.name,
value: ThemeMode.dark.name,
updatedAt: Value(DateTime.now()),
),
@@ -78,7 +78,7 @@ void main() {
// Cache hasn't seen this row yet — view still returns the default.
expect(sut.appConfig.theme.mode, ThemeMode.system);
await MetadataRepository.refresh();
await MetadataRepository.instance.refresh();
expect(sut.appConfig.theme.mode, ThemeMode.dark);
});
@@ -88,7 +88,7 @@ void main() {
await ctx.db.delete(ctx.db.metadataEntity).go();
expect(sut.appConfig.theme.mode, ThemeMode.dark);
await MetadataRepository.refresh();
await MetadataRepository.instance.refresh();
expect(sut.appConfig.theme.mode, ThemeMode.system);
});
@@ -103,32 +103,20 @@ void main() {
),
);
await MetadataRepository.refresh();
await MetadataRepository.instance.refresh();
expect(sut.appConfig.theme.mode, ThemeMode.system);
});
});
group('watch', () {
test('watchAppConfig emits the new value after a write', () async {
final expectation = expectLater(sut.watchAppConfig().map((c) => c.theme.mode), emitsThrough(ThemeMode.dark));
final expectation = expectLater(sut.watchConfig().map((c) => c.theme.mode), emitsThrough(ThemeMode.dark));
await sut.write(MetadataKey.themeMode, ThemeMode.dark);
await expectation;
});
test('watchAppConfig does not emit when only system-config rows change', () async {
final emissions = <ThemeMode>[];
// skip(1) drops the on-subscribe replay so we only capture emissions caused by the write below.
final sub = sut.watchAppConfig().skip(1).listen((c) => emissions.add(c.theme.mode));
await sut.write(MetadataKey.logLevel, LogLevel.severe);
await pumpEventQueue();
await sub.cancel();
expect(emissions, isEmpty);
});
test('watchSystemConfig emits the new value after a write', () async {
final expectation = expectLater(sut.watchSystemConfig().map((c) => c.logLevel), emitsThrough(LogLevel.warning));
test('watchConfig emits the new value after a write', () async {
final expectation = expectLater(sut.watchConfig().map((c) => c.logLevel), emitsThrough(LogLevel.warning));
await sut.write(MetadataKey.logLevel, LogLevel.warning);
await expectation;
});
@@ -75,7 +75,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'OriginalPhoto.jpg');
final task = await sut.getUploadTask(asset);
@@ -92,7 +92,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null);
final task = await sut.getUploadTask(asset);
@@ -109,7 +109,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getMotionFileForAsset(asset)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getMotionFile(asset.id)).thenAnswer((_) async => mockFile);
when(
() => mockAssetMediaRepository.getOriginalFilename(asset.id),
).thenAnswer((_) async => 'OriginalLivePhoto.HEIC');
@@ -130,7 +130,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
when(
() => mockAssetMediaRepository.getOriginalFilename(asset.id),
).thenAnswer((_) async => 'OriginalLivePhoto.HEIC');
@@ -150,7 +150,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null);
final task = await sut.getLivePhotoUploadTask(asset, 'video-id-456');
@@ -194,7 +194,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getAssetFile(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
final task = await sutWithV24.getUploadTask(assetWithCloudId);
@@ -243,7 +243,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getAssetFile(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
final task = await sutAndroid.getUploadTask(assetWithCloudId);
@@ -281,7 +281,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithoutCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithoutCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getAssetFile(assetWithoutCloudId.id)).thenAnswer((_) async => mockFile);
when(
() => mockAssetMediaRepository.getOriginalFilename(assetWithoutCloudId.id),
).thenAnswer((_) async => 'test.jpg');
@@ -323,7 +323,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getAssetFile(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(
() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id),
).thenAnswer((_) async => 'livephoto.heic');
@@ -1,28 +1,16 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
void main() {
group('MetadataKey', () {
test('every key round-trips its default value losslessly', () {
for (final key in MetadataKey.values) {
final encoded = key.encode(key.defaultValue);
for (final key in MetadataKey.values) {
test('verify codec for $key', () {
final defaultValue = defaultConfig.read(key);
final encoded = key.encode(defaultValue);
final decoded = key.decode(encoded);
expect(decoded, key.defaultValue, reason: 'round-trip failed for ${key.name}');
}
});
test('decode falls back to the default value when the raw input is unparseable', () {
for (final key in MetadataKey.values) {
// String keys can decode any string. So skip them
if (key.defaultValue is String) {
continue;
}
expect(
key.decode('not a valid encoding for any key'),
key.defaultValue,
reason: 'fallback failed for ${key.name}',
);
}
});
expect(decoded, defaultValue, reason: 'round-trip failed for ${key.name}');
});
}
});
}
-2
View File
@@ -25324,8 +25324,6 @@
}
},
"required": [
"city",
"country",
"createdAt",
"duration",
"fileCreatedAt",
+2 -2
View File
@@ -2612,9 +2612,9 @@ export type TagUpdateDto = {
};
export type TimeBucketAssetResponseDto = {
/** Array of city names extracted from EXIF GPS data */
city: (string | null)[];
city?: (string | null)[];
/** Array of country names extracted from EXIF GPS data */
country: (string | null)[];
country?: (string | null)[];
/** Array of UTC timestamps when each asset was originally uploaded to Immich */
createdAt: string[];
/** Array of video/gif durations in milliseconds (null for static images) */
+34 -28
View File
@@ -609,7 +609,7 @@ importers:
version: 10.0.1(eslint@10.4.0(jiti@2.7.0))
'@nestjs/cli':
specifier: ^11.0.2
version: 11.0.21(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@24.12.4)(esbuild@0.28.0)(lightningcss@1.32.0)(prettier@3.8.3)
version: 11.0.21(@swc/core@1.15.33(@swc/helpers@0.5.22))(@types/node@24.12.4)(esbuild@0.28.0)(lightningcss@1.32.0)(prettier@3.8.3)
'@nestjs/schematics':
specifier: ^11.0.0
version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@6.0.3)
@@ -618,7 +618,7 @@ importers:
version: 11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(@nestjs/platform-express@11.1.21)
'@swc/core':
specifier: ^1.4.14
version: 1.15.33(@swc/helpers@0.5.21)
version: 1.15.33(@swc/helpers@0.5.22)
'@types/archiver':
specifier: ^7.0.0
version: 7.0.0
@@ -738,7 +738,7 @@ importers:
version: 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
unplugin-swc:
specifier: ^1.4.5
version: 1.5.9(@swc/core@1.15.33(@swc/helpers@0.5.21))(rollup@4.60.4)
version: 1.5.9(@swc/core@1.15.33(@swc/helpers@0.5.22))(rollup@4.60.4)
vite-tsconfig-paths:
specifier: ^6.0.0
version: 6.1.1(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0))
@@ -758,8 +758,8 @@ importers:
specifier: workspace:*
version: link:../packages/sdk
'@immich/ui':
specifier: ^0.79.0
version: 0.79.0(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))
specifier: ^0.79.2
version: 0.79.2(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))
'@mapbox/mapbox-gl-rtl-text':
specifier: 0.4.0
version: 0.4.0
@@ -1691,6 +1691,10 @@ packages:
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
engines: {node: '>=6.9.0'}
'@babel/runtime@7.29.7':
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
engines: {node: '>=6.9.0'}
'@babel/template@7.28.6':
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'}
@@ -3204,8 +3208,8 @@ packages:
resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==}
hasBin: true
'@immich/ui@0.79.0':
resolution: {integrity: sha512-UEQZrP8CTc4Kth1xCV8/6Xmk1P51GQKISC7vKqcrM0BO0fxCaNwJK8Ocn6R8baVqH52JYfPb1yQR9bweBnCjXw==}
'@immich/ui@0.79.2':
resolution: {integrity: sha512-tnpYhYHrjrFJK18QglRMzPUtHv6q5V6tW38HiAraQJBv7MCg+yaJDrdF8omM2L5F311FGlv1PZLJYvmR4e49PA==}
peerDependencies:
'@sveltejs/kit': ^2.13.0
svelte: ^5.0.0
@@ -4982,8 +4986,8 @@ packages:
'@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
'@swc/helpers@0.5.21':
resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
'@swc/helpers@0.5.22':
resolution: {integrity: sha512-/e2Ly3Docn9kYByap6TV4oquJ3wQuz3c+kC74riqtkwU9CwTMeuj6t2rW+bRr4pyOx/CYQM4wr0RgaKQwGEz0A==}
'@swc/types@0.1.26':
resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==}
@@ -13787,6 +13791,8 @@ snapshots:
'@babel/runtime@7.29.2': {}
'@babel/runtime@7.29.7': {}
'@babel/template@7.28.6':
dependencies:
'@babel/code-frame': 7.29.0
@@ -15879,7 +15885,7 @@ snapshots:
pg-connection-string: 2.13.0
postgres: 3.4.9
'@immich/ui@0.79.0(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))':
'@immich/ui@0.79.2(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))':
dependencies:
'@internationalized/date': 3.12.1
'@mdi/js': 7.4.47
@@ -16035,7 +16041,7 @@ snapshots:
'@internationalized/date@3.12.1':
dependencies:
'@swc/helpers': 0.5.21
'@swc/helpers': 0.5.22
'@ioredis/commands@1.5.1': {}
@@ -16438,7 +16444,7 @@ snapshots:
bullmq: 5.76.10
tslib: 2.8.1
'@nestjs/cli@11.0.21(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@24.12.4)(esbuild@0.28.0)(lightningcss@1.32.0)(prettier@3.8.3)':
'@nestjs/cli@11.0.21(@swc/core@1.15.33(@swc/helpers@0.5.22))(@types/node@24.12.4)(esbuild@0.28.0)(lightningcss@1.32.0)(prettier@3.8.3)':
dependencies:
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
'@angular-devkit/schematics': 19.2.24(chokidar@4.0.3)
@@ -16449,17 +16455,17 @@ snapshots:
chokidar: 4.0.3
cli-table3: 0.6.5
commander: 4.1.1
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0))
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0))
glob: 13.0.6
node-emoji: 1.11.0
ora: 5.4.1
tsconfig-paths: 4.2.0
tsconfig-paths-webpack-plugin: 4.2.0
typescript: 5.9.3
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)
webpack-node-externals: 3.0.0
optionalDependencies:
'@swc/core': 1.15.33(@swc/helpers@0.5.21)
'@swc/core': 1.15.33(@swc/helpers@0.5.22)
transitivePeerDependencies:
- '@minify-html/node'
- '@swc/css'
@@ -17438,7 +17444,7 @@ snapshots:
'@slorber/react-helmet-async@1.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@babel/runtime': 7.29.2
'@babel/runtime': 7.29.7
invariant: 2.2.4
prop-types: 15.8.1
react: 19.2.6
@@ -17647,7 +17653,7 @@ snapshots:
'@swc/core-win32-x64-msvc@1.15.33':
optional: true
'@swc/core@1.15.33(@swc/helpers@0.5.21)':
'@swc/core@1.15.33(@swc/helpers@0.5.22)':
dependencies:
'@swc/counter': 0.1.3
'@swc/types': 0.1.26
@@ -17664,11 +17670,11 @@ snapshots:
'@swc/core-win32-arm64-msvc': 1.15.33
'@swc/core-win32-ia32-msvc': 1.15.33
'@swc/core-win32-x64-msvc': 1.15.33
'@swc/helpers': 0.5.21
'@swc/helpers': 0.5.22
'@swc/counter@0.1.3': {}
'@swc/helpers@0.5.21':
'@swc/helpers@0.5.22':
dependencies:
tslib: 2.8.1
@@ -21084,7 +21090,7 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)):
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)):
dependencies:
'@babel/code-frame': 7.29.0
chalk: 4.1.2
@@ -21099,7 +21105,7 @@ snapshots:
semver: 7.8.0
tapable: 2.3.3
typescript: 5.9.3
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)
form-data-encoder@2.1.4: {}
@@ -25761,15 +25767,15 @@ snapshots:
- bare-abort-controller
- react-native-b4a
terser-webpack-plugin@5.6.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)):
terser-webpack-plugin@5.6.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.3
terser: 5.47.1
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)
optionalDependencies:
'@swc/core': 1.15.33(@swc/helpers@0.5.21)
'@swc/core': 1.15.33(@swc/helpers@0.5.22)
esbuild: 0.28.0
lightningcss: 1.32.0
@@ -26176,10 +26182,10 @@ snapshots:
unpipe@1.0.0: {}
unplugin-swc@1.5.9(@swc/core@1.15.33(@swc/helpers@0.5.21))(rollup@4.60.4):
unplugin-swc@1.5.9(@swc/core@1.15.33(@swc/helpers@0.5.22))(rollup@4.60.4):
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.60.4)
'@swc/core': 1.15.33(@swc/helpers@0.5.21)
'@swc/core': 1.15.33(@swc/helpers@0.5.22)
load-tsconfig: 0.2.5
unplugin: 2.3.11
transitivePeerDependencies:
@@ -26578,7 +26584,7 @@ snapshots:
webpack-virtual-modules@0.6.2: {}
webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0):
webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0):
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.9
@@ -26602,7 +26608,7 @@ snapshots:
neo-async: 2.6.2
schema-utils: 4.3.3
tapable: 2.3.3
terser-webpack-plugin: 5.6.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0))
terser-webpack-plugin: 5.6.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0))
watchpack: 2.5.1
webpack-sources: 3.4.1
transitivePeerDependencies:
+12 -11
View File
@@ -1,14 +1,10 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:202605051129@sha256:d07d8fcdb7e9f3ac22a811e87761ebf341ed0bb91956b89097540c2ed3fb9ca3 AS dev
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp \
PNPM_HOME=/buildcache/pnpm-store
RUN npm install --global corepack@latest && \
corepack enable pnpm && \
echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \
COPY --from=ghcr.io/jdx/mise:2026.5.11@sha256:2ba959e4827f845fe0c4cfb4814089e790dc513040ef74f9e14925f446412a51 /usr/local/bin/mise /usr/local/bin/mise
RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \
echo "cache-dir=/buildcache/pnpm-cache" >> /usr/local/etc/npmrc && \
echo "# Retry configuration - default is 2" >> /usr/local/etc/npmrc && \
@@ -16,12 +12,17 @@ RUN npm install --global corepack@latest && \
mkdir -p /buildcache/pnpm-store /buildcache/pnpm-cache /buildcache/node-gyp && \
chmod -R o+rw /buildcache
ENV CI=1
ENV PNPM_HOME=/buildcache/pnpm-store
ENV MISE_DATA_DIR=/buildcache/mise
ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/mise.toml
ENV PATH="${MISE_DATA_DIR}/shims:${PATH}:/usr/src/app/server/bin:/usr/src/app/web/bin"
ENV IMMICH_ENV=development
ENV NVIDIA_DRIVER_CAPABILITIES=all
ENV NVIDIA_VISIBLE_DEVICES=all
WORKDIR /usr/src/app
ENV PATH="${PATH}:/usr/src/app/server/bin:/usr/src/app/web/bin" \
IMMICH_ENV=development \
NVIDIA_DRIVER_CAPABILITIES=all \
NVIDIA_VISIBLE_DEVICES=all
ENTRYPOINT ["tini", "--", "/bin/bash", "-c"]
FROM dev AS dev-container-server
+2 -2
View File
@@ -107,8 +107,8 @@ const TimeBucketAssetResponseSchema = z
livePhotoVideoId: z
.array(z.string().nullable())
.describe('Array of live photo video asset IDs (null for non-live photos)'),
city: z.array(z.string().nullable()).describe('Array of city names extracted from EXIF GPS data'),
country: z.array(z.string().nullable()).describe('Array of country names extracted from EXIF GPS data'),
city: z.array(z.string().nullable()).optional().describe('Array of city names extracted from EXIF GPS data'),
country: z.array(z.string().nullable()).optional().describe('Array of country names extracted from EXIF GPS data'),
latitude: z
.array(z.number().nullable())
.optional()
+4 -4
View File
@@ -384,8 +384,6 @@ with
asset."fileCreatedAt" at time zone 'utc' as "fileCreatedAt",
asset."createdAt" at time zone 'utc' as "createdAt",
encode("asset"."thumbhash", 'base64') as "thumbhash",
"asset_exif"."city",
"asset_exif"."country",
"asset_exif"."projectionType",
coalesce(
case
@@ -398,6 +396,8 @@ with
end,
1
) as "ratio",
"asset_exif"."city",
"asset_exif"."country",
"stack"
from
"asset"
@@ -432,8 +432,6 @@ with
),
"agg" as (
select
coalesce(array_agg("city"), '{}') as "city",
coalesce(array_agg("country"), '{}') as "country",
coalesce(array_agg("duration"), '{}') as "duration",
coalesce(array_agg("id"), '{}') as "id",
coalesce(array_agg("visibility"), '{}') as "visibility",
@@ -449,6 +447,8 @@ with
coalesce(array_agg("ratio"), '{}') as "ratio",
coalesce(array_agg("status"), '{}') as "status",
coalesce(array_agg("thumbhash"), '{}') as "thumbhash",
coalesce(array_agg("city"), '{}') as "city",
coalesce(array_agg("country"), '{}') as "country",
coalesce(json_agg("stack"), '[]') as "stack"
from
"cte"
+9 -4
View File
@@ -786,8 +786,6 @@ export class AssetRepository {
sql`asset."fileCreatedAt" at time zone 'utc'`.as('fileCreatedAt'),
sql`asset."createdAt" at time zone 'utc'`.as('createdAt'),
eb.fn('encode', ['asset.thumbhash', sql.lit('base64')]).as('thumbhash'),
'asset_exif.city',
'asset_exif.country',
'asset_exif.projectionType',
eb.fn
.coalesce(
@@ -801,6 +799,9 @@ export class AssetRepository {
)
.as('ratio'),
])
.$if(!auth.sharedLink || auth.sharedLink.showExif, (qb) =>
qb.select(['asset_exif.city', 'asset_exif.country']),
)
.$if(!!options.withCoordinates, (qb) => qb.select(['asset_exif.latitude', 'asset_exif.longitude']))
.where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null)
.$if(options.visibility == undefined, withDefaultVisibility)
@@ -875,8 +876,6 @@ export class AssetRepository {
qb
.selectFrom('cte')
.select((eb) => [
eb.fn.coalesce(eb.fn('array_agg', ['city']), sql.lit('{}')).as('city'),
eb.fn.coalesce(eb.fn('array_agg', ['country']), sql.lit('{}')).as('country'),
eb.fn.coalesce(eb.fn('array_agg', ['duration']), sql.lit('{}')).as('duration'),
eb.fn.coalesce(eb.fn('array_agg', ['id']), sql.lit('{}')).as('id'),
eb.fn.coalesce(eb.fn('array_agg', ['visibility']), sql.lit('{}')).as('visibility'),
@@ -894,6 +893,12 @@ export class AssetRepository {
eb.fn.coalesce(eb.fn('array_agg', ['status']), sql.lit('{}')).as('status'),
eb.fn.coalesce(eb.fn('array_agg', ['thumbhash']), sql.lit('{}')).as('thumbhash'),
])
.$if(!auth.sharedLink || auth.sharedLink.showExif, (qb) =>
qb.select((eb) => [
eb.fn.coalesce(eb.fn('array_agg', ['city']), sql.lit('{}')).as('city'),
eb.fn.coalesce(eb.fn('array_agg', ['country']), sql.lit('{}')).as('country'),
]),
)
.$if(!!options.withCoordinates, (qb) =>
qb.select((eb) => [
eb.fn.coalesce(eb.fn('array_agg', ['latitude']), sql.lit('{}')).as('latitude'),
+2 -2
View File
@@ -9,7 +9,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { ImmichWorker, JobStatus, MetadataKey, QueueName, UserAvatarColor, UserStatus } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { JobItem, JobSource } from 'src/types';
import { JobItem, JobSource, UploadFile } from 'src/types';
type EmitHandlers = Partial<{ [T in EmitEvent]: Array<EventItem<T>> }>;
@@ -42,7 +42,7 @@ type EventMap = {
AlbumInvite: [{ id: string; userId: string; senderName: string }];
// asset events
AssetCreate: [{ asset: Asset }];
AssetCreate: [{ asset: Asset; file: UploadFile }];
AssetTag: [{ assetId: string }];
AssetUntag: [{ assetId: string }];
AssetHide: [{ assetId: string; userId: string }];
@@ -339,7 +339,6 @@ describe(AssetMediaService.name, () => {
});
expect(mocks.asset.create).toHaveBeenCalled();
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, file.size);
expect(mocks.storage.utimes).toHaveBeenCalledWith(
file.originalPath,
expect.any(Date),
+65 -81
View File
@@ -146,17 +146,79 @@ export class AssetMediaService extends BaseService {
{ userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId },
);
}
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
const asset = await this.assetRepository.create({
ownerId: auth.user.id,
libraryId: null,
checksum: file.checksum,
checksumAlgorithm: ChecksumAlgorithm.sha1File,
originalPath: file.originalPath,
fileCreatedAt: dto.fileCreatedAt,
fileModifiedAt: dto.fileModifiedAt,
localDateTime: dto.fileCreatedAt,
type: mimeTypes.assetType(file.originalPath),
isFavorite: dto.isFavorite,
duration: dto.duration || null,
visibility: dto.visibility ?? AssetVisibility.Timeline,
livePhotoVideoId: dto.livePhotoVideoId,
originalFileName: dto.filename || file.originalName,
});
if (dto.metadata?.length) {
await this.assetRepository.upsertMetadata(asset.id, dto.metadata);
}
if (sidecarFile) {
await this.assetRepository.upsertFile({
assetId: asset.id,
path: sidecarFile.originalPath,
type: AssetFileType.Sidecar,
});
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
}
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
await this.assetRepository.upsertExif({
exif: { assetId: asset.id, fileSizeInByte: file.size },
lockedPropertiesBehavior: 'override',
});
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } });
if (auth.sharedLink) {
await this.addToSharedLink(auth.sharedLink, asset.id);
}
await this.userRepository.updateUsage(auth.user.id, file.size);
await this.eventRepository.emit('AssetCreate', { asset, file });
return { id: asset.id, status: AssetMediaStatus.CREATED };
} catch (error: any) {
return this.handleUploadError(error, auth, file, sidecarFile);
// clean up files
await this.jobRepository.queue({
name: JobName.FileDelete,
data: { files: [file.originalPath, sidecarFile?.originalPath] },
});
// handle duplicates with a success response
if (isAssetChecksumConstraint(error)) {
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum);
if (!duplicateId) {
this.logger.error(`Error locating duplicate for checksum constraint`);
throw new InternalServerErrorException();
}
if (auth.sharedLink) {
await this.addToSharedLink(auth.sharedLink, duplicateId);
}
this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`);
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
}
this.logger.error(`Error uploading file ${error}`, error?.stack);
throw error;
}
}
@@ -285,84 +347,6 @@ export class AssetMediaService extends BaseService {
: this.sharedLinkRepository.addAssets(sharedLink.id, [assetId]));
}
private async handleUploadError(
error: any,
auth: AuthDto,
file: UploadFile,
sidecarFile?: UploadFile,
): Promise<AssetMediaResponseDto> {
// clean up files
await this.jobRepository.queue({
name: JobName.FileDelete,
data: { files: [file.originalPath, sidecarFile?.originalPath] },
});
// handle duplicates with a success response
if (isAssetChecksumConstraint(error)) {
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum);
if (!duplicateId) {
this.logger.error(`Error locating duplicate for checksum constraint`);
throw new InternalServerErrorException();
}
if (auth.sharedLink) {
await this.addToSharedLink(auth.sharedLink, duplicateId);
}
this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`);
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
}
this.logger.error(`Error uploading file ${error}`, error?.stack);
throw error;
}
private async create(ownerId: string, dto: AssetMediaCreateDto, file: UploadFile, sidecarFile?: UploadFile) {
const asset = await this.assetRepository.create({
ownerId,
libraryId: null,
checksum: file.checksum,
checksumAlgorithm: ChecksumAlgorithm.sha1File,
originalPath: file.originalPath,
fileCreatedAt: dto.fileCreatedAt,
fileModifiedAt: dto.fileModifiedAt,
localDateTime: dto.fileCreatedAt,
type: mimeTypes.assetType(file.originalPath),
isFavorite: dto.isFavorite,
duration: dto.duration || null,
visibility: dto.visibility ?? AssetVisibility.Timeline,
livePhotoVideoId: dto.livePhotoVideoId,
originalFileName: dto.filename || file.originalName,
});
if (dto.metadata?.length) {
await this.assetRepository.upsertMetadata(asset.id, dto.metadata);
}
if (sidecarFile) {
await this.assetRepository.upsertFile({
assetId: asset.id,
path: sidecarFile.originalPath,
type: AssetFileType.Sidecar,
});
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
}
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
await this.assetRepository.upsertExif({
exif: { assetId: asset.id, fileSizeInByte: file.size },
lockedPropertiesBehavior: 'override',
});
await this.eventRepository.emit('AssetCreate', { asset });
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } });
return asset;
}
private requireQuota(auth: AuthDto, size: number) {
if (auth.user.quotaSizeInBytes !== null && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) {
throw new BadRequestException('Quota has been exceeded!');
+9 -5
View File
@@ -75,7 +75,7 @@ export class SearchService extends BaseService {
const page = dto.page ?? 1;
const size = dto.size || 250;
const userIds = await this.getUserIdsToSearch(auth);
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const { hasNextPage, items } = await this.searchRepository.searchMetadata(
{ page, size },
{
@@ -103,7 +103,7 @@ export class SearchService extends BaseService {
requireElevatedPermission(auth);
}
const userIds = await this.getUserIdsToSearch(auth);
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const items = await this.searchRepository.searchRandom(dto.size || 250, { ...dto, userIds });
return items.map((item) => mapAsset(item, { auth }));
}
@@ -113,7 +113,7 @@ export class SearchService extends BaseService {
requireElevatedPermission(auth);
}
const userIds = await this.getUserIdsToSearch(auth);
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const items = await this.searchRepository.searchLargeAssets(dto.size || 250, { ...dto, userIds });
return items.map((item) => mapAsset(item, { auth }));
}
@@ -128,7 +128,7 @@ export class SearchService extends BaseService {
throw new BadRequestException('Smart search is not enabled');
}
const userIds = this.getUserIdsToSearch(auth);
const userIds = this.getUserIdsToSearch(auth, dto.visibility);
let embedding;
if (dto.query) {
const key = machineLearning.clip.modelName + dto.query + dto.language;
@@ -202,7 +202,11 @@ export class SearchService extends BaseService {
}
}
private async getUserIdsToSearch(auth: AuthDto): Promise<string[]> {
private async getUserIdsToSearch(auth: AuthDto, visibility?: AssetVisibility): Promise<string[]> {
// Locked assets are personal. Never include partner IDs, regardless of A's elevated session.
if (visibility === AssetVisibility.Locked) {
return [auth.user.id];
}
const partnerIds = await getMyPartnerIds({
userId: auth.user.id,
repository: this.partnerRepository,
@@ -204,5 +204,16 @@ describe(TimelineService.name, () => {
}),
).rejects.toThrow(BadRequestException);
});
it('should throw an error if withPartners is true and visibility is locked', async () => {
await expect(
sut.getTimeBucket(authStub.adminWithElevatedPermission, {
timeBucket: 'bucket',
visibility: AssetVisibility.Locked,
withPartners: true,
userId: authStub.adminWithElevatedPermission.user.id,
}),
).rejects.toThrow(BadRequestException);
});
});
});
+7 -2
View File
@@ -66,14 +66,19 @@ export class TimelineService extends BaseService {
await this.requireAccess({ auth, permission: Permission.TagRead, ids: [dto.tagId] });
}
if (auth.sharedLink && !auth.sharedLink.showExif) {
dto.withCoordinates = false;
}
if (dto.withPartners) {
const requestedLocked = dto.visibility === AssetVisibility.Locked;
const requestedArchived = dto.visibility === AssetVisibility.Archive || dto.visibility === undefined;
const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false;
const requestedTrash = dto.isTrashed === true;
if (requestedArchived || requestedFavorite || requestedTrash) {
if (requestedLocked || requestedArchived || requestedFavorite || requestedTrash) {
throw new BadRequestException(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
);
}
}
+7 -1
View File
@@ -3,7 +3,7 @@ import { Updateable } from 'kysely';
import { DateTime } from 'luxon';
import { SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { OnJob } from 'src/decorators';
import { OnEvent, OnJob } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
@@ -11,6 +11,7 @@ import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences }
import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { UserFindOptions } from 'src/repositories/user.repository';
import { UserTable } from 'src/schema/tables/user.table';
import { BaseService } from 'src/services/base.service';
@@ -230,6 +231,11 @@ export class UserService extends BaseService {
};
}
@OnEvent({ name: 'AssetCreate' })
async onAssetCreate({ asset, file }: ArgOf<'AssetCreate'>) {
await this.userRepository.updateUsage(asset.ownerId, file.size);
}
@OnJob({ name: JobName.UserSyncUsage, queue: QueueName.BackgroundTask })
async handleUserSyncUsage(): Promise<JobStatus> {
await this.userRepository.syncUsage();
+7
View File
@@ -41,6 +41,13 @@ export const authStub = {
id: 'token-id',
} as AuthSession,
}),
adminWithElevatedPermission: Object.freeze<AuthDto>({
user: authUser.admin,
session: {
id: 'token-id-elevated',
hasElevatedPermission: true,
} as AuthSession,
}),
adminSharedLink: Object.freeze({
user: authUser.admin,
sharedLink: {
@@ -43,9 +43,11 @@ describe(AssetService.name, () => {
ctx.getMock(EventRepository).emit.mockResolvedValue();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const fileSizeInByte = 12_345;
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
await ctx.newExif({ assetId: asset.id, fileSizeInByte });
const auth = factory.auth({ user: { id: user.id } });
await expect(
@@ -56,7 +58,7 @@ describe(AssetService.name, () => {
fileCreatedAt: new Date(),
assetData: Buffer.from('some data'),
},
mediumFactory.uploadFile(),
mediumFactory.uploadFile({ size: fileSizeInByte }),
),
).resolves.toEqual({
id: expect.any(String),
@@ -65,6 +67,7 @@ describe(AssetService.name, () => {
expect(ctx.getMock(EventRepository).emit).toHaveBeenCalledWith('AssetCreate', {
asset: expect.objectContaining({}),
file: expect.objectContaining({ size: fileSizeInByte }),
});
});
@@ -1,10 +1,11 @@
import { BadRequestException } from '@nestjs/common';
import { Kysely } from 'kysely';
import { AssetVisibility } from 'src/enum';
import { AssetVisibility, SharedLinkType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
import { DB } from 'src/schema';
import { TimelineService } from 'src/services/timeline.service';
import { newMediumService } from 'test/medium.factory';
@@ -50,13 +51,13 @@ describe(TimelineService.name, () => {
const response1 = sut.getTimeBuckets(auth, { withPartners: true, visibility: AssetVisibility.Archive });
await expect(response1).rejects.toBeInstanceOf(BadRequestException);
await expect(response1).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
);
const response2 = sut.getTimeBuckets(auth, { withPartners: true });
await expect(response2).rejects.toBeInstanceOf(BadRequestException);
await expect(response2).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
);
});
@@ -66,13 +67,13 @@ describe(TimelineService.name, () => {
const response1 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: false });
await expect(response1).rejects.toBeInstanceOf(BadRequestException);
await expect(response1).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
);
const response2 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: true });
await expect(response2).rejects.toBeInstanceOf(BadRequestException);
await expect(response2).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
);
});
@@ -82,7 +83,7 @@ describe(TimelineService.name, () => {
const response = sut.getTimeBuckets(auth, { withPartners: true, isTrashed: true });
await expect(response).rejects.toBeInstanceOf(BadRequestException);
await expect(response).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
);
});
@@ -207,4 +208,32 @@ describe(TimelineService.name, () => {
expect(response2).toEqual(expect.objectContaining({ id: [asset2.id, asset1.id], isFavorite: [true, false] }));
});
});
it('should strip geodata metadata if shared link without exif', async () => {
const { sut, ctx } = setup();
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({
ownerId: user.id,
localDateTime: new Date('1970-02-12'),
deletedAt: new Date(),
});
const { album } = await ctx.newAlbum({ ownerId: user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
const { id: sharedLinkId } = await sharedLinkRepo.create({
allowUpload: false,
key: Buffer.from('123'),
type: SharedLinkType.Album,
userId: user.id,
albumId: album.id,
});
await ctx.newExif({ assetId: asset.id, city: 'Austin', country: 'USA' });
const auth = factory.auth({ sharedLink: { id: sharedLinkId, showExif: false } });
const rawResponse = await sut.getTimeBucket(auth, { albumId: album.id, timeBucket: '1970-02-01', isTrashed: true });
const response = JSON.parse(rawResponse);
expect(response).not.toEqual(expect.objectContaining({ city: expect.any(Array), country: expect.any(Array) }));
});
});
+1 -1
View File
@@ -27,7 +27,7 @@
"@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "workspace:*",
"@immich/ui": "^0.79.0",
"@immich/ui": "^0.79.2",
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
"@mdi/js": "^7.4.47",
"@noble/hashes": "^2.2.0",
+89 -74
View File
@@ -149,29 +149,35 @@
return { width: 1, height: 1 };
});
const { insetInlineStart, top, rasterWidth, rasterHeight, rasterScale } = $derived.by(() => {
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
const { width, height } = scaleFn(imageDimensions, container);
if (maxRasterPixels === 0) {
const { insetInlineStart, top, displayWidth, displayHeight, rasterWidth, rasterHeight, rasterScale } = $derived.by(
() => {
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
const { width, height } = scaleFn(imageDimensions, container);
if (maxRasterPixels === 0) {
return {
insetInlineStart: (container.width - width) / 2 + 'px',
top: (container.height - height) / 2 + 'px',
displayWidth: width + 'px',
displayHeight: height + 'px',
rasterWidth: width + 'px',
rasterHeight: height + 'px',
rasterScale: 1,
};
}
const nativeRatio = imageDimensions.width / width;
const budgetRatio = Math.sqrt(maxRasterPixels / Math.max(width * height, 1));
const rasterRatio = Math.max(1, Math.min(nativeRatio, budgetRatio));
return {
insetInlineStart: (container.width - width) / 2 + 'px',
top: (container.height - height) / 2 + 'px',
rasterWidth: width + 'px',
rasterHeight: height + 'px',
rasterScale: 1,
displayWidth: width + 'px',
displayHeight: height + 'px',
rasterWidth: width * rasterRatio + 'px',
rasterHeight: height * rasterRatio + 'px',
rasterScale: 1 / rasterRatio,
};
}
const nativeRatio = imageDimensions.width / width;
const budgetRatio = Math.sqrt(maxRasterPixels / Math.max(width * height, 1));
const rasterRatio = Math.max(1, Math.min(nativeRatio, budgetRatio));
return {
insetInlineStart: (container.width - width) / 2 + 'px',
top: (container.height - height) / 2 + 'px',
rasterWidth: width * rasterRatio + 'px',
rasterHeight: height * rasterRatio + 'px',
rasterScale: 1 / rasterRatio,
};
});
},
);
const { status } = $derived(adaptiveImageLoader);
const alt = $derived(status.urls.preview ? $getAltText(toTimelineAsset(asset)) : '');
@@ -216,69 +222,78 @@
{@render backdrop?.()}
<div
class="pointer-events-none absolute"
class="pointer-events-none absolute overflow-hidden"
style:inset-inline-start={insetInlineStart}
style:top
style:width={rasterWidth}
style:height={rasterHeight}
style:transform="scale({rasterScale})"
style:transform-origin="0 0"
style:will-change={maxRasterPixels > 0 ? 'transform' : undefined}
style:width={displayWidth}
style:height={displayHeight}
>
{#if show.alphaBackground}
<AlphaBackground />
{/if}
{#if show.thumbhash}
{#if asset.thumbhash}
<!-- Thumbhash / spinner layer -->
<Thumbhash base64ThumbHash={asset.thumbhash} class="absolute size-full" />
{:else if show.spinner}
<DelayedLoadingSpinner />
<div
style:width={rasterWidth}
style:height={rasterHeight}
style:transform="scale({rasterScale})"
style:transform-origin="0 0"
style:will-change={maxRasterPixels > 0 ? 'transform' : undefined}
>
{#if show.alphaBackground}
<AlphaBackground />
{/if}
{/if}
{#if show.thumbnail}
<ImageLayer
{adaptiveImageLoader}
width={rasterWidth}
height={rasterHeight}
quality="thumbnail"
src={status.urls.thumbnail}
alt=""
role="presentation"
bind:ref={thumbnailElement}
/>
{/if}
{#if show.thumbhash}
{#if asset.thumbhash}
<!-- Thumbhash / spinner layer -->
<Thumbhash base64ThumbHash={asset.thumbhash} class="absolute size-full" />
{:else if show.spinner}
<DelayedLoadingSpinner />
{/if}
{/if}
{#if show.brokenAsset}
<BrokenAsset class="absolute size-full text-xl" />
{/if}
{#if show.thumbnail}
<ImageLayer
{adaptiveImageLoader}
width={rasterWidth}
height={rasterHeight}
quality="thumbnail"
src={status.urls.thumbnail}
alt=""
role="presentation"
bind:ref={thumbnailElement}
/>
{/if}
{#if show.preview}
<ImageLayer
{adaptiveImageLoader}
{alt}
width={rasterWidth}
height={rasterHeight}
{overlays}
quality="preview"
src={status.urls.preview}
bind:ref={previewElement}
/>
{/if}
{#if show.brokenAsset}
<BrokenAsset class="absolute size-full text-xl" />
{/if}
{#if show.original}
<ImageLayer
{adaptiveImageLoader}
{alt}
width={rasterWidth}
height={rasterHeight}
{overlays}
quality="original"
src={status.urls.original}
bind:ref={originalElement}
/>
{#if show.preview}
<ImageLayer
{adaptiveImageLoader}
{alt}
width={rasterWidth}
height={rasterHeight}
quality="preview"
src={status.urls.preview}
bind:ref={previewElement}
/>
{/if}
{#if show.original}
<ImageLayer
{adaptiveImageLoader}
{alt}
width={rasterWidth}
height={rasterHeight}
quality="original"
src={status.urls.original}
bind:ref={originalElement}
/>
{/if}
</div>
{#if overlays}
<div class="pointer-events-none absolute inset-0">
{@render overlays()}
</div>
{/if}
</div>
</div>
+1 -14
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import Image from '$lib/components/Image.svelte';
import type { AdaptiveImageLoader, ImageQuality } from '$lib/utils/adaptive-image-loader.svelte';
import type { Snippet } from 'svelte';
type Props = {
adaptiveImageLoader: AdaptiveImageLoader;
@@ -12,20 +11,9 @@
ref?: HTMLImageElement;
width: string;
height: string;
overlays?: Snippet;
};
let {
adaptiveImageLoader,
quality,
src,
alt = '',
role,
ref = $bindable(),
width,
height,
overlays,
}: Props = $props();
let { adaptiveImageLoader, quality, src, alt = '', role, ref = $bindable(), width, height }: Props = $props();
</script>
{#key adaptiveImageLoader}
@@ -42,6 +30,5 @@
draggable={false}
data-testid={quality}
/>
{@render overlays?.()}
</div>
{/key}
@@ -179,8 +179,8 @@ export class TimelineMonth {
);
const timelineAsset: TimelineAsset = {
city: bucketAssets.city[i],
country: bucketAssets.country[i],
city: bucketAssets.city?.[i] ?? null,
country: bucketAssets.country?.[i] ?? null,
duration: bucketAssets.duration[i],
id: bucketAssets.id[i],
visibility: bucketAssets.visibility[i],
@@ -0,0 +1,53 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Route } from '$lib/route';
import { handleCreateWorkflow } from '$lib/services/workflow.service';
import { WorkflowTrigger, type WorkflowResponseDto } from '@immich/sdk';
import { Field, FormModal, Input, Textarea, VStack } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
workflow: WorkflowResponseDto;
onClose: () => void;
};
const { workflow, onClose }: Props = $props();
let name = $state(workflow.name ?? '');
let description = $state(workflow.description ?? '');
let trigger = $state<WorkflowTrigger>(workflow.trigger);
const onSubmit = async () => {
const response = await handleCreateWorkflow({
name,
description,
trigger,
steps: workflow.steps,
enabled: false,
});
if (response) {
await goto(Route.viewWorkflow({ id: response.id }));
onClose();
}
};
</script>
<FormModal
title={$t('duplicate_workflow')}
{onClose}
{onSubmit}
disabled={!name || !trigger}
size="medium"
submitText={$t('create')}
>
<VStack gap={4}>
<Field label={$t('name')} required>
<Input placeholder={$t('workflow_name')} bind:value={name} />
</Field>
<Field label={$t('description')}>
<Textarea grow placeholder={$t('workflow_description')} bind:value={description} />
</Field>
</VStack>
</FormModal>
+1 -1
View File
@@ -42,7 +42,7 @@ import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
import { downloadUrl } from '$lib/utils/asset-utils';
import { downloadUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
+3 -30
View File
@@ -3,10 +3,8 @@ import { toastManager, type ActionItem } from '@immich/ui';
import { mdiContentCopy, mdiDownload, mdiUpload } from '@mdi/js';
import { isEqual } from 'lodash-es';
import type { MessageFormatter } from 'svelte-i18n';
import { downloadManager } from '$lib/managers/download-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { copyToClipboard } from '$lib/utils';
import { downloadBlob } from '$lib/utils/asset-utils';
import { copyToClipboard, downloadJson } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
@@ -19,7 +17,7 @@ export const getSystemConfigActions = (
title: $t('copy_to_clipboard'),
description: $t('admin.copy_config_to_clipboard_description'),
icon: mdiContentCopy,
onAction: () => handleCopyToClipboard(config),
onAction: () => copyToClipboard(config),
shortcuts: { shift: true, key: 'c' },
};
@@ -27,7 +25,7 @@ export const getSystemConfigActions = (
title: $t('export_as_json'),
description: $t('admin.export_config_as_json_description'),
icon: mdiDownload,
onAction: () => handleDownloadConfig(config),
onAction: () => downloadJson(config, 'immich-config.json'),
shortcuts: [
{ shift: true, key: 's' },
{ shift: true, key: 'd' },
@@ -65,31 +63,6 @@ export const handleSystemConfigSave = async (update: Partial<SystemConfigDto>) =
}
};
// https://stackoverflow.com/questions/16167581/sort-object-properties-and-json-stringify/43636793#43636793
const jsonReplacer = (_key: string, value: unknown) =>
value instanceof Object && !Array.isArray(value)
? Object.keys(value)
.sort()
// eslint-disable-next-line unicorn/no-array-reduce
.reduce((sorted: { [key: string]: unknown }, key) => {
sorted[key] = (value as { [key: string]: unknown })[key];
return sorted;
}, {})
: value;
export const handleCopyToClipboard = async (config: SystemConfigDto) => {
await copyToClipboard(JSON.stringify(config, jsonReplacer, 2));
};
export const handleDownloadConfig = (config: SystemConfigDto) => {
const blob = new Blob([JSON.stringify(config, jsonReplacer, 2)], { type: 'application/json' });
const downloadKey = 'immich-config.json';
downloadManager.add(downloadKey, blob.size);
downloadManager.update(downloadKey, blob.size);
downloadBlob(blob, downloadKey);
setTimeout(() => downloadManager.clear(downloadKey), 5000);
};
export const handleUploadConfig = () => {
const input = globalThis.document.createElement('input');
input.setAttribute('type', 'file');

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