Compare commits

..

20 Commits

Author SHA1 Message Date
alextran1502
3d7a33a387 chore: release v2.4.0 2025-12-09 18:28:03 +00:00
idubnori
7af99b8606 feat(mobile): move top bar buttons into kebabu menu in AssetViewer (#24461)
* chore(mobile):  i18n: "open_asset_info" in viewer kebab menu

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

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

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

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

* chore: styling

---------

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

# Conflicts:
#	i18n/en.json

* Format

* format fix

---------

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

* refactor: use intrinsicheight

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

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

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

* feat: revert exisitng buttons, adjust label name

* unify MenuAnchor usage

---------

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

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

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

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

Using rootless docker:

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

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

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

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

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

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

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

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

.. starts fine.

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

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

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

* chore: implement suggestions

* lint

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-04 04:10:12 +00:00
174 changed files with 2576 additions and 7075 deletions

View File

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

View File

@@ -222,6 +222,7 @@ jobs:
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
working-directory: ./mobile/ios
- name: Install CocoaPods dependencies
@@ -229,13 +230,6 @@ jobs:
run: |
pod install
- name: Install Fastlane
working-directory: ./mobile/ios
run: |
gem install bundler
bundle config set --local path 'vendor/bundle'
bundle install
- name: Create API Key
env:
API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}

108
CHANGELOG.md Normal file
View File

@@ -0,0 +1,108 @@
# v2.4.0
## Highlights
{{RELEASE HIGHLIGHTS}}
As always, please consider supporting the project.
🎉 Cheers! 🎉
----
And as always, bugs are fixed, and many other improvements also come with this release.
<!-- Release notes generated using configuration in .github/release.yml at main -->
## What's Changed
### 🫥 Deprecated Changes
* feat: queues by @jrasm91 in https://github.com/immich-app/immich/pull/24142
### 🚀 Features
* feat: improve performance: don't sort timeline buckets from server by @midzelis in https://github.com/immich-app/immich/pull/24032
* feat: command palette by @danieldietzler in https://github.com/immich-app/immich/pull/23693
* feat(web): Shared album owner labels by @xCJPECKOVERx in https://github.com/immich-app/immich/pull/21171
* feat(mobile): persist album sorting & layout in settings by @YarosMallorca in https://github.com/immich-app/immich/pull/22133
* feat: queue detail page by @jrasm91 in https://github.com/immich-app/immich/pull/24352
* chore(mobile): add kebabu menu in asset viewer by @idubnori in https://github.com/immich-app/immich/pull/24387
### 🌟 Enhancements
* feat(web): allow navigating the map with arrow keys by @lukashass in https://github.com/immich-app/immich/pull/24080
* feat: separate camera and lens info in detail panel by @fabianbees in https://github.com/immich-app/immich/pull/23670
* feat(web): shared link card tweaks by @jrasm91 in https://github.com/immich-app/immich/pull/24192
* feat(server): exclude syncthing folders from external libraries by @SaphuA in https://github.com/immich-app/immich/pull/24240
* feat(web): search type selection dropdown by @YarosMallorca in https://github.com/immich-app/immich/pull/24091
* feat: header context menu by @jrasm91 in https://github.com/immich-app/immich/pull/24374
* feat(mobile): move top bar buttons into kebabu menu in AssetViewer by @idubnori in https://github.com/immich-app/immich/pull/24461
### 🐛 Bug fixes
* fix: effect loop by @jrasm91 in https://github.com/immich-app/immich/pull/24014
* fix: do not clear hash on updated_at change by @shenlong-tanwen in https://github.com/immich-app/immich/pull/24039
* fix: disable animation "add to" action menu by @bwees in https://github.com/immich-app/immich/pull/24040
* fix: Use correct app store link by @Mraedis in https://github.com/immich-app/immich/pull/24062
* fix: show archived assets in favorite page by @bwees in https://github.com/immich-app/immich/pull/24052
* fix(mobile): first video memory on page doesn't play by @YarosMallorca in https://github.com/immich-app/immich/pull/23906
* feat(web): show detected faces in spherical photos by @meesfrensel in https://github.com/immich-app/immich/pull/23974
* fix: add users to album by @danieldietzler in https://github.com/immich-app/immich/pull/24133
* fix(server): sanitize DB_URL for pg_dumpall to remove unknown query params by @lutostag in https://github.com/immich-app/immich/pull/23333
* fix: use proper updatedAt value in local assets by @shenlong-tanwen in https://github.com/immich-app/immich/pull/24137
* fix: albums page reactivity loops by @danieldietzler in https://github.com/immich-app/immich/pull/24046
* fix: getAspectRatio fallback to db width and height by @shenlong-tanwen in https://github.com/immich-app/immich/pull/24131
* fix(web): fix support & feedback modal wrapping by @Snowknight26 in https://github.com/immich-app/immich/pull/24018
* fix: don't get OCR data in shared link by @alextran1502 in https://github.com/immich-app/immich/pull/24152
* fix: duration extraction by @jrasm91 in https://github.com/immich-app/immich/pull/24178
* fix(ml): Upgrade ONNX Runtime to v1.22.1 to fix ROCm build failures by @LukaPrebil in https://github.com/immich-app/immich/pull/24045
* fix: update timeline-manager after archive actions by @midzelis in https://github.com/immich-app/immich/pull/24010
* fix: theme switcher by @jrasm91 in https://github.com/immich-app/immich/pull/24209
* fix: label 'for' attributes in user-api-key-grid by @kimsey0 in https://github.com/immich-app/immich/pull/24232
* fix(mobile): enable backup text overflows by @YarosMallorca in https://github.com/immich-app/immich/pull/24227
* fix(web): integrate zoom toggle button into panorama photo viewer by @meesfrensel in https://github.com/immich-app/immich/pull/24189
* fix(web): use full tag path when creating nested subtags by @NiklasvonM in https://github.com/immich-app/immich/pull/24249
* fix: only generate memory based on users assets by @alextran1502 in https://github.com/immich-app/immich/pull/24151
* fix(mobile): docs link by @mmomjian in https://github.com/immich-app/immich/pull/24277
* fix(server): use bigrams for cjk by @mertalev in https://github.com/immich-app/immich/pull/24285
* fix(ml): do not upscale preview by @mertalev in https://github.com/immich-app/immich/pull/24322
* fix(web): open onboarding documentation link in new tab by @carbonemys in https://github.com/immich-app/immich/pull/24289
* fix(mobile): use correct timezone displayed in the info sheet by @kao-byte in https://github.com/immich-app/immich/pull/24310
* fix(web): folder view sort oder by @etnoy in https://github.com/immich-app/immich/pull/24337
* fix(server): do not delete offline assets by @mertalev in https://github.com/immich-app/immich/pull/24355
* fix: exposure info and better readability by @alextran1502 in https://github.com/immich-app/immich/pull/24344
* fix: Adjust the zoom level by @jforseth210 in https://github.com/immich-app/immich/pull/24353
* fix: local full sync on Android on resume by @alextran1502 in https://github.com/immich-app/immich/pull/24348
* fix(web): Add minimum content size to logo for consistent visual on small screens by @kiloomar in https://github.com/immich-app/immich/pull/24372
* fix: use adjustment time in iOS for hash reset by @shenlong-tanwen in https://github.com/immich-app/immich/pull/24047
* fix(server): update exiftool-vendored to v34 for more robust metadata extraction by @skatsubo in https://github.com/immich-app/immich/pull/24424
* fix(mobile): cannot create album while name field is focused by @YarosMallorca in https://github.com/immich-app/immich/pull/24449
* fix(web): [album table view] long album title overflows table row by @simonkub in https://github.com/immich-app/immich/pull/24450
* fix(mobile): fix overflow text in backup card by @YarosMallorca in https://github.com/immich-app/immich/pull/24448
* fix(mobile): timeline bottom padding on selection by @YarosMallorca in https://github.com/immich-app/immich/pull/24480
* feat(mobile): Localized backup upload details page by @ArnyminerZ in https://github.com/immich-app/immich/pull/21136
### 📚 Documentation
* docs: DB_STORAGE_TYPE is only used by the database container by @dionysius in https://github.com/immich-app/immich/pull/24215
* fix(docs): build `cli` for e2e tests by @roschaefer in https://github.com/immich-app/immich/pull/24184
* docs(faq): add more info on archiving by @etnoy in https://github.com/immich-app/immich/pull/24326
* fix(docs): server and machine-learning use IMMICH_HOST and IMMICH_PORT by @dionysius in https://github.com/immich-app/immich/pull/24335
* fix: prevent OOM on nginx reverse proxy servers by @NicholasFlamy in https://github.com/immich-app/immich/pull/24351
* fix(docs): obsolete docs about rootless docker by @roschaefer in https://github.com/immich-app/immich/pull/24376
* fix(docs): websockets in nginx example by @fourthwall in https://github.com/immich-app/immich/pull/24411
### 🌐 Translations
* chore: add new language requests by @danieldietzler in https://github.com/immich-app/immich/pull/23991
## New Contributors
* @ujjwal123123 made their first contribution in https://github.com/immich-app/immich/pull/24101
* @lutostag made their first contribution in https://github.com/immich-app/immich/pull/23333
* @LukaPrebil made their first contribution in https://github.com/immich-app/immich/pull/24045
* @kimsey0 made their first contribution in https://github.com/immich-app/immich/pull/24232
* @SaphuA made their first contribution in https://github.com/immich-app/immich/pull/24240
* @dionysius made their first contribution in https://github.com/immich-app/immich/pull/24215
* @NiklasvonM made their first contribution in https://github.com/immich-app/immich/pull/24249
* @kao-byte made their first contribution in https://github.com/immich-app/immich/pull/24098
* @carbonemys made their first contribution in https://github.com/immich-app/immich/pull/24289
* @kiloomar made their first contribution in https://github.com/immich-app/immich/pull/24372
* @fourthwall made their first contribution in https://github.com/immich-app/immich/pull/24411
* @simonkub made their first contribution in https://github.com/immich-app/immich/pull/24450
* @ArnyminerZ made their first contribution in https://github.com/immich-app/immich/pull/21136
**Full Changelog**: https://github.com/immich-app/immich/compare/v2.3.1...v2.4.0
---

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.103",
"version": "2.2.104",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
@@ -31,7 +31,7 @@
"eslint-plugin-unicorn": "^62.0.0",
"globals": "^16.0.0",
"mock-fs": "^5.2.0",
"prettier": "^3.2.5",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.0.0",
"typescript": "^5.3.3",
"typescript-eslint": "^8.28.0",

View File

@@ -58,10 +58,6 @@ services:
IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues
IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://docs.immich.app
IMMICH_THIRD_PARTY_SUPPORT_URL: https://docs.immich.app/community-guides
ulimits:
nofile:
soft: 1048576
hard: 1048576
ports:
- 9230:9230
- 9231:9231
@@ -100,10 +96,6 @@ services:
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
ulimits:
nofile:
soft: 1048576
hard: 1048576
restart: unless-stopped
depends_on:
immich-server:

View File

@@ -32,8 +32,6 @@ server {
# enable websockets: http://nginx.org/en/docs/http/websocket.html
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_redirect off;
# set timeout
@@ -43,6 +41,8 @@ server {
location / {
proxy_pass http://<backend_url>:2283;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# useful when using Let's Encrypt http-01 challenge

View File

@@ -38,7 +38,7 @@
"@docusaurus/module-type-aliases": "~3.9.0",
"@docusaurus/tsconfig": "^3.7.0",
"@docusaurus/types": "^3.7.0",
"prettier": "^3.2.4",
"prettier": "^3.7.4",
"typescript": "^5.1.6"
},
"browserslist": {

View File

@@ -1,4 +1,8 @@
[
{
"label": "v2.4.0",
"url": "https://docs.v2.4.0.archive.immich.app"
},
{
"label": "v2.3.1",
"url": "https://docs.v2.3.1.archive.immich.app"

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "2.3.1",
"version": "2.4.0",
"description": "",
"main": "index.js",
"type": "module",
@@ -36,14 +36,14 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^62.0.0",
"exiftool-vendored": "^33.0.0",
"exiftool-vendored": "^34.0.0",
"globals": "^16.0.0",
"jose": "^5.6.3",
"luxon": "^3.4.4",
"oidc-provider": "^9.0.0",
"pg": "^8.11.3",
"pngjs": "^7.0.0",
"prettier": "^3.2.5",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.0.0",
"sharp": "^0.34.5",
"socket.io-client": "^4.7.4",

View File

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

View File

@@ -78,7 +78,6 @@
"exclusion_pattern_description": "Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have folders that contain files you don't want to import, such as RAW files.",
"export_config_as_json_description": "Download the current system config as a JSON file",
"external_libraries_page_description": "Admin external library page",
"external_library_management": "External Library Management",
"face_detection": "Face detection",
"face_detection_description": "Detect the faces in assets using machine learning. For videos, only the thumbnail is considered. \"Refresh\" (re-)processes all assets. \"Reset\" additionally clears all current face data. \"Missing\" queues assets that haven't been processed yet. Detected faces will be queued for Facial Recognition after Face Detection is complete, grouping them into existing or new people.",
"facial_recognition_job_description": "Group detected faces into people. This step runs after Face Detection is complete. \"Reset\" (re-)clusters all faces. \"Missing\" queues faces that don't have a person assigned.",
@@ -653,6 +652,7 @@
"backup_options_page_title": "Backup options",
"backup_setting_subtitle": "Manage background and foreground upload settings",
"backup_settings_subtitle": "Manage upload settings",
"backup_upload_details_page_more_details": "Tap for more details",
"backward": "Backward",
"biometric_auth_enabled": "Biometric authentication enabled",
"biometric_locked_out": "You are locked out of biometric authentication",
@@ -719,6 +719,7 @@
"check_corrupt_asset_backup_button": "Perform check",
"check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.",
"check_logs": "Check Logs",
"checksum": "Checksum",
"choose_matching_people_to_merge": "Choose matching people to merge",
"city": "City",
"clear": "Clear",
@@ -804,9 +805,6 @@
"created_at": "Created",
"creating_linked_albums": "Creating linked albums...",
"crop": "Crop",
"crop_aspect_ratio_fixed": "Fixed",
"crop_aspect_ratio_free": "Free",
"crop_aspect_ratio_original": "Original",
"curated_object_page_title": "Things",
"current_device": "Current device",
"current_pin_code": "Current PIN code",
@@ -935,9 +933,7 @@
"editor_close_without_save_prompt": "The changes will not be saved",
"editor_close_without_save_title": "Close editor?",
"editor_crop_tool_h2_aspect_ratios": "Aspect ratios",
"editor_crop_tool_h2_mirror": "Mirror",
"editor_crop_tool_h2_rotation": "Rotation",
"editor_reset_all_changes": "Reset all changes",
"email": "Email",
"email_notifications": "Email notifications",
"empty_folder": "This folder is empty",
@@ -1172,6 +1168,7 @@
"header_settings_header_name_input": "Header name",
"header_settings_header_value_input": "Header value",
"headers_settings_tile_title": "Custom proxy headers",
"height": "Height",
"hi_user": "Hi {name} ({email})",
"hide_all_people": "Hide all people",
"hide_gallery": "Hide gallery",
@@ -1294,6 +1291,7 @@
"local": "Local",
"local_asset_cast_failed": "Unable to cast an asset that is not uploaded to the server",
"local_assets": "Local Assets",
"local_id": "Local ID",
"local_media_summary": "Local Media Summary",
"local_network": "Local network",
"local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network",
@@ -1410,8 +1408,6 @@
"minimize": "Minimize",
"minute": "Minute",
"minutes": "Minutes",
"mirror_horizontal": "Horizontal",
"mirror_vertical": "Vertical",
"missing": "Missing",
"mobile_app": "Mobile App",
"mobile_app_download_onboarding_note": "Download the companion mobile app using the following options",
@@ -1765,8 +1761,6 @@
"role": "Role",
"role_editor": "Editor",
"role_viewer": "Viewer",
"rotate_ccw": "CCW 90°",
"rotate_cw": "CW 90°",
"running": "Running",
"save": "Save",
"save_to_gallery": "Save to gallery",
@@ -2228,6 +2222,7 @@
"week": "Week",
"welcome": "Welcome",
"welcome_to_immich": "Welcome to Immich",
"width": "Width",
"wifi_name": "Wi-Fi Name",
"workflow": "Workflow",
"wrong_pin_code": "Wrong PIN code",

View File

@@ -1,6 +1,6 @@
[project]
name = "immich-ml"
version = "2.3.1"
version = "2.4.0"
description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.10,<4.0"

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3028,
"android.injected.version.name" => "2.3.1",
"android.injected.version.code" => 3029,
"android.injected.version.name" => "2.4.0",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -1,8 +1,10 @@
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
class AssetService {
final RemoteAssetRepository _remoteAssetRepository;
@@ -56,11 +58,22 @@ class AssetService {
}
Future<double> getAspectRatio(BaseAsset asset) async {
bool isFlipped;
double? width;
double? height;
width = asset.width?.toDouble();
height = asset.height?.toDouble();
if (asset.hasRemote) {
final exif = await getExif(asset);
isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
width = asset.width?.toDouble();
height = asset.height?.toDouble();
} else if (asset is LocalAsset) {
isFlipped = CurrentPlatform.isAndroid && (asset.orientation == 90 || asset.orientation == 270);
width = asset.width?.toDouble();
height = asset.height?.toDouble();
} else {
isFlipped = false;
}
if (width == null || height == null) {
if (asset.hasRemote) {
@@ -76,8 +89,10 @@ class AssetService {
}
}
if (width != null && height != null && height > 0) {
return width / height;
final orientedWidth = isFlipped ? height : width;
final orientedHeight = isFlipped ? width : height;
if (orientedWidth != null && orientedHeight != null && orientedHeight > 0) {
return orientedWidth / orientedHeight;
}
return 1.0;

View File

@@ -22,7 +22,6 @@ import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey;
import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole, UserMetadataKey;
@@ -195,8 +194,6 @@ class SyncStreamRepository extends DriftDatabaseRepository {
livePhotoVideoId: Value(asset.livePhotoVideoId),
stackId: Value(asset.stackId),
libraryId: Value(asset.libraryId),
width: Value(asset.width),
height: Value(asset.height),
);
batch.insert(
@@ -248,21 +245,10 @@ class SyncStreamRepository extends DriftDatabaseRepository {
await _db.batch((batch) {
for (final exif in data) {
int? width;
int? height;
if (ExifDtoConverter.isOrientationFlipped(exif.orientation)) {
width = exif.exifImageHeight;
height = exif.exifImageWidth;
} else {
width = exif.exifImageWidth;
height = exif.exifImageHeight;
}
batch.update(
_db.remoteAssetEntity,
RemoteAssetEntityCompanion(width: Value(width), height: Value(height)),
where: (row) => row.id.equals(exif.assetId) & row.width.isNull() & row.height.isNull(),
RemoteAssetEntityCompanion(width: Value(exif.exifImageWidth), height: Value(exif.exifImageHeight)),
where: (row) => row.id.equals(exif.assetId),
);
}
});

View File

@@ -98,7 +98,7 @@ class DriftUploadDetailPage extends ConsumerWidget {
),
),
Text(
'Tap for more details',
"backup_upload_details_page_more_details".t(context: context),
style: context.textTheme.bodySmall?.copyWith(
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
),
@@ -239,14 +239,20 @@ class FileDetailDialog extends ConsumerWidget {
const SizedBox(height: 24),
if (asset != null) ...[
_buildInfoSection(context, [
_buildInfoRow(context, "Filename", path.basename(uploadStatus.filename)),
_buildInfoRow(context, "Local ID", asset.id),
_buildInfoRow(context, "File Size", formatHumanReadableBytes(uploadStatus.fileSize, 2)),
if (asset.width != null) _buildInfoRow(context, "Width", "${asset.width}px"),
if (asset.height != null) _buildInfoRow(context, "Height", "${asset.height}px"),
_buildInfoRow(context, "Created At", asset.createdAt.toString()),
_buildInfoRow(context, "Updated At", asset.updatedAt.toString()),
if (asset.checksum != null) _buildInfoRow(context, "Checksum", asset.checksum!),
_buildInfoRow(context, "filename".t(context: context), path.basename(uploadStatus.filename)),
_buildInfoRow(context, "local_id".t(context: context), asset.id),
_buildInfoRow(
context,
"file_size".t(context: context),
formatHumanReadableBytes(uploadStatus.fileSize, 2),
),
if (asset.width != null) _buildInfoRow(context, "width".t(context: context), "${asset.width}px"),
if (asset.height != null)
_buildInfoRow(context, "height".t(context: context), "${asset.height}px"),
_buildInfoRow(context, "created_at".t(context: context), asset.createdAt.toString()),
_buildInfoRow(context, "updated_at".t(context: context), asset.updatedAt.toString()),
if (asset.checksum != null)
_buildInfoRow(context, "checksum".t(context: context), asset.checksum!),
]),
],
],

View File

@@ -37,7 +37,7 @@ class DriftActivitiesPage extends HookConsumerWidget {
child: Scaffold(
appBar: AppBar(
title: Text(album.name),
actions: [const LikeActivityActionButton(menuItem: true)],
actions: [const LikeActivityActionButton(iconOnly: true)],
actionsPadding: const EdgeInsets.only(right: 8),
),
body: activities.widgetWhen(

View File

@@ -27,8 +27,19 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
bool isAlbumTitleTextFieldFocus = false;
Set<BaseAsset> selectedAssets = {};
@override
void initState() {
super.initState();
albumTitleController.addListener(_onTitleChanged);
}
void _onTitleChanged() {
setState(() {});
}
@override
void dispose() {
albumTitleController.removeListener(_onTitleChanged);
albumTitleController.dispose();
albumDescriptionController.dispose();
albumTitleTextFieldFocusNode.dispose();

View File

@@ -21,12 +21,36 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_shee
enum AddToMenuItem { album, archive, unarchive, lockedFolder }
class AddActionButton extends ConsumerWidget {
const AddActionButton({super.key});
class AddActionButton extends ConsumerStatefulWidget {
const AddActionButton({super.key, this.originalTheme});
Future<void> _showAddOptions(BuildContext context, WidgetRef ref) async {
final ThemeData? originalTheme;
@override
ConsumerState<AddActionButton> createState() => _AddActionButtonState();
}
class _AddActionButtonState extends ConsumerState<AddActionButton> {
void _handleMenuSelection(AddToMenuItem selected) {
switch (selected) {
case AddToMenuItem.album:
_openAlbumSelector();
break;
case AddToMenuItem.archive:
performArchiveAction(context, ref, source: ActionSource.viewer);
break;
case AddToMenuItem.unarchive:
performUnArchiveAction(context, ref, source: ActionSource.viewer);
break;
case AddToMenuItem.lockedFolder:
performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
break;
}
}
List<Widget> _buildMenuChildren() {
final asset = ref.read(currentAssetNotifier);
if (asset == null) return;
if (asset == null) return [];
final user = ref.read(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
@@ -35,93 +59,57 @@ class AddActionButton extends ConsumerWidget {
final hasRemote = asset is RemoteAsset;
final showArchive = isOwner && !isInLockedView && hasRemote && !isArchived;
final showUnarchive = isOwner && !isInLockedView && hasRemote && isArchived;
final menuItemHeight = 30.0;
final List<PopupMenuEntry<AddToMenuItem>> items = [
PopupMenuItem(
enabled: false,
textStyle: context.textTheme.labelMedium,
height: 40,
child: Text("add_to_bottom_bar".tr()),
return [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text("add_to_bottom_bar".tr(), style: context.textTheme.labelMedium),
),
PopupMenuItem(
height: menuItemHeight,
value: AddToMenuItem.album,
child: ListTile(leading: const Icon(Icons.photo_album_outlined), title: Text("album".tr())),
BaseActionButton(
iconData: Icons.photo_album_outlined,
label: "album".tr(),
menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.album),
),
const PopupMenuDivider(),
PopupMenuItem(enabled: false, textStyle: context.textTheme.labelMedium, height: 40, child: Text("move_to".tr())),
if (isOwner) ...[
const Divider(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text("move_to".tr(), style: context.textTheme.labelMedium),
),
if (showArchive)
PopupMenuItem(
height: menuItemHeight,
value: AddToMenuItem.archive,
child: ListTile(leading: const Icon(Icons.archive_outlined), title: Text("archive".tr())),
BaseActionButton(
iconData: Icons.archive_outlined,
label: "archive".tr(),
menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.archive),
),
if (showUnarchive)
PopupMenuItem(
height: menuItemHeight,
value: AddToMenuItem.unarchive,
child: ListTile(leading: const Icon(Icons.unarchive_outlined), title: Text("unarchive".tr())),
BaseActionButton(
iconData: Icons.unarchive_outlined,
label: "unarchive".tr(),
menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.unarchive),
),
PopupMenuItem(
height: menuItemHeight,
value: AddToMenuItem.lockedFolder,
child: ListTile(leading: const Icon(Icons.lock_outline), title: Text("locked_folder".tr())),
BaseActionButton(
iconData: Icons.lock_outline,
label: "locked_folder".tr(),
menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.lockedFolder),
),
],
];
final AddToMenuItem? selected = await showMenu<AddToMenuItem>(
context: context,
color: context.themeData.scaffoldBackgroundColor,
position: _menuPosition(context),
items: items,
popUpAnimationStyle: AnimationStyle.noAnimation,
);
if (selected == null) {
return;
}
switch (selected) {
case AddToMenuItem.album:
_openAlbumSelector(context, ref);
break;
case AddToMenuItem.archive:
await performArchiveAction(context, ref, source: ActionSource.viewer);
break;
case AddToMenuItem.unarchive:
await performUnArchiveAction(context, ref, source: ActionSource.viewer);
break;
case AddToMenuItem.lockedFolder:
await performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
break;
}
}
RelativeRect _menuPosition(BuildContext context) {
final renderObject = context.findRenderObject();
if (renderObject is! RenderBox) {
return RelativeRect.fill;
}
final size = renderObject.size;
final position = renderObject.localToGlobal(Offset.zero);
return RelativeRect.fromLTRB(position.dx, position.dy - size.height - 200, position.dx + size.width, position.dy);
}
void _openAlbumSelector(BuildContext context, WidgetRef ref) {
void _openAlbumSelector() {
final currentAsset = ref.read(currentAssetNotifier);
if (currentAsset == null) {
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
return;
}
final List<Widget> slivers = [
AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(context, ref, album)),
];
final List<Widget> slivers = [AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(album))];
showModalBottomSheet(
context: context,
@@ -141,7 +129,7 @@ class AddActionButton extends ConsumerWidget {
);
}
Future<void> _addCurrentAssetToAlbum(BuildContext context, WidgetRef ref, RemoteAlbum album) async {
Future<void> _addCurrentAssetToAlbum(RemoteAlbum album) async {
final latest = ref.read(currentAssetNotifier);
if (latest == null) {
@@ -174,17 +162,38 @@ class AddActionButton extends ConsumerWidget {
}
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) {
return const SizedBox.shrink();
}
return Builder(
builder: (buttonContext) {
final themeData = widget.originalTheme ?? context.themeData;
return MenuAnchor(
consumeOutsideTap: true,
style: MenuStyle(
backgroundColor: WidgetStatePropertyAll(themeData.scaffoldBackgroundColor),
surfaceTintColor: const WidgetStatePropertyAll(Colors.grey),
elevation: const WidgetStatePropertyAll(4),
shape: const WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
),
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
),
menuChildren: widget.originalTheme != null
? [
Theme(
data: widget.originalTheme!,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: _buildMenuChildren()),
),
]
: _buildMenuChildren(),
builder: (context, controller, child) {
return BaseActionButton(
iconData: Icons.add,
label: "add_to_bottom_bar".tr(),
onPressed: () => _showAddOptions(buttonContext, ref),
onPressed: () => controller.isOpen ? controller.close() : controller.open(),
);
},
);

View File

@@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class BaseActionButton extends StatelessWidget {
class BaseActionButton extends ConsumerWidget {
const BaseActionButton({
super.key,
required this.label,
@@ -11,6 +12,7 @@ class BaseActionButton extends StatelessWidget {
this.onLongPressed,
this.maxWidth = 90.0,
this.minWidth,
this.iconOnly = false,
this.menuItem = false,
});
@@ -19,25 +21,42 @@ class BaseActionButton extends StatelessWidget {
final Color? iconColor;
final double maxWidth;
final double? minWidth;
/// When true, renders only an IconButton without text label
final bool iconOnly;
/// When true, renders as a MenuItemButton for use in MenuAnchor menus
final bool menuItem;
final void Function()? onPressed;
final void Function()? onLongPressed;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0);
final iconTheme = IconTheme.of(context);
final iconSize = iconTheme.size ?? 24.0;
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
final textColor = context.themeData.textTheme.labelLarge?.color;
if (menuItem) {
if (iconOnly) {
return IconButton(
onPressed: onPressed,
icon: Icon(iconData, size: iconSize, color: iconColor),
);
}
if (menuItem) {
final theme = context.themeData;
final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant;
return MenuItemButton(
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
leadingIcon: Icon(iconData, color: effectiveIconColor),
onPressed: onPressed,
child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16)),
);
}
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: MaterialButton(

View File

@@ -7,8 +7,9 @@ import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
class CastActionButton extends ConsumerWidget {
const CastActionButton({super.key, this.menuItem = true});
const CastActionButton({super.key, this.iconOnly = false, this.menuItem = false});
final bool iconOnly;
final bool menuItem;
@override
@@ -22,6 +23,7 @@ class CastActionButton extends ConsumerWidget {
onPressed: () {
showDialog(context: context, builder: (context) => const CastDialog());
},
iconOnly: iconOnly,
menuItem: menuItem,
);
}

View File

@@ -10,8 +10,9 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class DownloadActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const DownloadActionButton({super.key, required this.source, this.menuItem = false});
const DownloadActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref, BackgroundSyncManager backgroundSyncManager) async {
if (!context.mounted) {
@@ -38,6 +39,7 @@ class DownloadActionButton extends ConsumerWidget {
iconData: Icons.download,
maxWidth: 95,
label: "download".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref, backgroundManager),
);

View File

@@ -10,9 +10,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
class FavoriteActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const FavoriteActionButton({super.key, required this.source, this.menuItem = false});
const FavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
@@ -44,6 +45,7 @@ class FavoriteActionButton extends ConsumerWidget {
return BaseActionButton(
iconData: Icons.favorite_border_rounded,
label: "favorite".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);

View File

@@ -12,8 +12,9 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
import 'package:immich_mobile/providers/user.provider.dart';
class LikeActivityActionButton extends ConsumerWidget {
const LikeActivityActionButton({super.key, this.menuItem = false});
const LikeActivityActionButton({super.key, this.iconOnly = false, this.menuItem = false});
final bool iconOnly;
final bool menuItem;
@override
@@ -49,6 +50,7 @@ class LikeActivityActionButton extends ConsumerWidget {
iconData: liked != null ? Icons.favorite : Icons.favorite_border,
label: "like".t(context: context),
onPressed: () => onTap(liked),
iconOnly: iconOnly,
menuItem: menuItem,
);
},
@@ -57,6 +59,7 @@ class LikeActivityActionButton extends ConsumerWidget {
loading: () => BaseActionButton(
iconData: Icons.favorite_border,
label: "like".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
),
error: (error, stack) => Text('error_saving_image'.tr(args: [error.toString()])),

View File

@@ -5,8 +5,9 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
class MotionPhotoActionButton extends ConsumerWidget {
const MotionPhotoActionButton({super.key, this.menuItem = true});
const MotionPhotoActionButton({super.key, this.iconOnly = false, this.menuItem = false});
final bool iconOnly;
final bool menuItem;
@override
@@ -17,6 +18,7 @@ class MotionPhotoActionButton extends ConsumerWidget {
iconData: isPlaying ? Icons.motion_photos_pause_outlined : Icons.play_circle_outline_rounded,
label: "play_motion_photo".t(context: context),
onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle,
iconOnly: iconOnly,
menuItem: menuItem,
);
}

View File

@@ -10,9 +10,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
class UnFavoriteActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const UnFavoriteActionButton({super.key, required this.source, this.menuItem = false});
const UnFavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
@@ -45,6 +46,7 @@ class UnFavoriteActionButton extends ConsumerWidget {
iconData: Icons.favorite_rounded,
label: "unfavorite".t(context: context),
onPressed: () => _onTap(context, ref),
iconOnly: iconOnly,
menuItem: menuItem,
);
}

View File

@@ -38,11 +38,13 @@ class ViewerBottomBar extends ConsumerWidget {
opacity = 0;
}
final originalTheme = context.themeData;
final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer),
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (asset.type == AssetType.image) const EditImageActionButton(),
if (asset.hasRemote) const AddActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
if (isOwner) ...[
asset.isLocalOnly

View File

@@ -4,25 +4,19 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
const ViewerTopAppBar({super.key});
@@ -41,15 +35,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final isInLockedView = ref.watch(inLockedViewProvider);
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final timelineOrigin = ref.read(timelineServiceProvider).origin;
final showViewInTimelineButton =
timelineOrigin != TimelineOrigin.main &&
timelineOrigin != TimelineOrigin.deepLink &&
timelineOrigin != TimelineOrigin.trash &&
timelineOrigin != TimelineOrigin.archive &&
timelineOrigin != TimelineOrigin.localAlbum &&
isOwner;
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
@@ -62,11 +47,10 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
opacity = 0;
}
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final originalTheme = context.themeData;
final actions = <Widget>[
if (asset.isRemoteOnly) const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
if (album != null && album.isActivityEnabled && album.isShared)
IconButton(
icon: const Icon(Icons.chat_outlined),
@@ -74,28 +58,16 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true));
},
),
if (showViewInTimelineButton)
IconButton(
onPressed: () async {
await context.maybePop();
await context.navigateTo(const TabShellRoute(children: [MainTimelineRoute()]));
EventStream.shared.emit(ScrollToDateEvent(asset.createdAt));
},
icon: const Icon(Icons.image_search),
tooltip: 'view_in_timeline'.t(context: context),
),
if (asset.hasRemote && isOwner && !asset.isFavorite)
const FavoriteActionButton(source: ActionSource.viewer, menuItem: true),
const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
if (asset.hasRemote && isOwner && asset.isFavorite)
const UnFavoriteActionButton(source: ActionSource.viewer, menuItem: true),
if (asset.isMotionPhoto) const MotionPhotoActionButton(menuItem: true),
const _KebabMenu(),
const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
ViewerKebabMenu(originalTheme: originalTheme),
];
final lockedViewActions = <Widget>[
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
const _KebabMenu(),
];
final lockedViewActions = <Widget>[ViewerKebabMenu(originalTheme: originalTheme)];
return IgnorePointer(
ignoring: opacity < 255,
@@ -122,20 +94,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
Size get preferredSize => const Size.fromHeight(60.0);
}
class _KebabMenu extends ConsumerWidget {
const _KebabMenu();
@override
Widget build(BuildContext context, WidgetRef ref) {
return IconButton(
onPressed: () {
EventStream.shared.emit(const ViewerOpenBottomSheetEvent());
},
icon: const Icon(Icons.more_vert_rounded),
);
}
}
class _AppBarBackButton extends ConsumerWidget {
const _AppBarBackButton();

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
class ViewerKebabMenu extends ConsumerWidget {
const ViewerKebabMenu({super.key, this.originalTheme});
final ThemeData? originalTheme;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) {
return const SizedBox.shrink();
}
final user = ref.watch(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final timelineOrigin = ref.read(timelineServiceProvider).origin;
final kebabContext = ViewerKebabMenuButtonContext(
asset: asset,
isOwner: isOwner,
isCasting: isCasting,
timelineOrigin: timelineOrigin,
originalTheme: originalTheme,
);
final menuChildren = ViewerKebabMenuButtonBuilder.build(kebabContext, context, ref);
return MenuAnchor(
consumeOutsideTap: true,
style: MenuStyle(
backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor),
surfaceTintColor: const WidgetStatePropertyAll(Colors.grey),
elevation: const WidgetStatePropertyAll(4),
shape: const WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
),
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
),
menuChildren: [
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 150),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: menuChildren,
),
),
],
builder: (context, controller, child) {
return IconButton(
icon: const Icon(Icons.more_vert_rounded),
onPressed: () => controller.isOpen ? controller.close() : controller.open(),
);
},
);
}
}

View File

@@ -324,7 +324,11 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
const scrubberBottomPadding = 100.0;
final bottomPadding = context.padding.bottom + (widget.appBar == null ? 0 : scrubberBottomPadding);
const bottomSheetOpenModifier = 120.0;
final bottomPadding =
context.padding.bottom +
(widget.appBar == null ? 0 : scrubberBottomPadding) +
(isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
final grid = CustomScrollView(
primary: true,
@@ -347,7 +351,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
addRepaintBoundaries: false,
),
),
const SliverPadding(padding: EdgeInsets.only(bottom: scrubberBottomPadding)),
SliverPadding(padding: EdgeInsets.only(bottom: bottomPadding)),
],
);

View File

@@ -167,7 +167,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: LoginRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: ChangePasswordRoute.page),
AutoRoute(page: SearchRoute.page, guards: [_authGuard, _duplicateGuard], maintainState: false),
CustomRoute(
AutoRoute(
page: TabControllerRoute.page,
guards: [_authGuard, _duplicateGuard],
children: [
@@ -176,9 +176,8 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: LibraryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: AlbumsRoute.page, guards: [_authGuard, _duplicateGuard]),
],
transitionsBuilder: TransitionsBuilders.fadeIn,
),
CustomRoute(
AutoRoute(
page: TabShellRoute.page,
guards: [_authGuard, _duplicateGuard],
children: [
@@ -187,7 +186,6 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftLibraryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftAlbumsRoute.page, guards: [_authGuard, _duplicateGuard]),
],
transitionsBuilder: TransitionsBuilders.fadeIn,
),
CustomRoute(
page: GalleryViewerRoute.page,

View File

@@ -1,9 +1,18 @@
import 'package:flutter/widgets.dart';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
@@ -19,6 +28,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/routing/router.dart';
class ActionButtonContext {
final BaseAsset asset;
@@ -164,3 +174,98 @@ class ActionButtonBuilder {
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
}
}
class ViewerKebabMenuButtonContext {
final BaseAsset asset;
final bool isOwner;
final bool isCasting;
final TimelineOrigin timelineOrigin;
final ThemeData? originalTheme;
const ViewerKebabMenuButtonContext({
required this.asset,
required this.isOwner,
required this.isCasting,
required this.timelineOrigin,
this.originalTheme,
});
}
enum ViewerKebabMenuButtonType {
openInfo,
viewInTimeline,
cast,
download;
/// Defines which group each button belongs to.
/// Buttons in the same group will be displayed together,
/// with dividers separating different groups.
int get group => switch (this) {
ViewerKebabMenuButtonType.openInfo => 0,
ViewerKebabMenuButtonType.viewInTimeline => 1,
ViewerKebabMenuButtonType.cast => 1,
ViewerKebabMenuButtonType.download => 1,
};
bool shouldShow(ViewerKebabMenuButtonContext context) {
return switch (this) {
ViewerKebabMenuButtonType.openInfo => true,
ViewerKebabMenuButtonType.viewInTimeline =>
context.timelineOrigin != TimelineOrigin.main &&
context.timelineOrigin != TimelineOrigin.deepLink &&
context.timelineOrigin != TimelineOrigin.trash &&
context.timelineOrigin != TimelineOrigin.archive &&
context.timelineOrigin != TimelineOrigin.localAlbum &&
context.isOwner,
ViewerKebabMenuButtonType.cast => context.isCasting || context.asset.hasRemote,
ViewerKebabMenuButtonType.download => context.asset.isRemoteOnly,
};
}
ConsumerWidget buildButton(ViewerKebabMenuButtonContext context, BuildContext buildContext) {
return switch (this) {
ViewerKebabMenuButtonType.openInfo => BaseActionButton(
label: 'info'.tr(),
iconData: Icons.info_outline,
iconColor: context.originalTheme?.iconTheme.color,
menuItem: true,
onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()),
),
ViewerKebabMenuButtonType.viewInTimeline => BaseActionButton(
label: 'view_in_timeline'.t(context: buildContext),
iconData: Icons.image_search,
iconColor: context.originalTheme?.iconTheme.color,
menuItem: true,
onPressed: () async {
await buildContext.maybePop();
await buildContext.navigateTo(const TabShellRoute(children: [MainTimelineRoute()]));
EventStream.shared.emit(ScrollToDateEvent(context.asset.createdAt));
},
),
ViewerKebabMenuButtonType.cast => const CastActionButton(menuItem: true),
ViewerKebabMenuButtonType.download => const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
};
}
}
class ViewerKebabMenuButtonBuilder {
static List<Widget> build(ViewerKebabMenuButtonContext context, BuildContext buildContext, WidgetRef ref) {
final visibleButtons = ViewerKebabMenuButtonType.values.where((type) => type.shouldShow(context)).toList();
if (visibleButtons.isEmpty) return [];
final List<Widget> result = [];
int? lastGroup;
for (final type in visibleButtons) {
if (lastGroup != null && type.group != lastGroup) {
result.add(const Divider(height: 1));
}
result.add(type.buildButton(context, buildContext).build(buildContext, ref));
lastGroup = type.group;
}
return result;
}
}

View File

@@ -2,26 +2,27 @@ import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
class EntitiyCountTile extends StatelessWidget {
class EntityCountTile extends StatelessWidget {
final int count;
final String label;
final IconData icon;
const EntitiyCountTile({super.key, required this.count, required this.label, required this.icon});
const EntityCountTile({super.key, required this.count, required this.label, required this.icon});
String zeroPadding(int number, int targetWidth) {
final numStr = number.toString();
return numStr.length < targetWidth ? "0" * (targetWidth - numStr.length) : "";
}
int calculateMaxDigits(double availableWidth) {
const double charWidth = 11.0;
return (availableWidth / charWidth).floor().clamp(1, 8);
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final availableWidth = (screenWidth - 32 - 8) / 2;
const double charWidth = 11.0;
final maxDigits = ((availableWidth - 32) / charWidth).floor().clamp(1, 8);
return Container(
height: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerLow,
@@ -29,7 +30,6 @@ class EntitiyCountTile extends StatelessWidget {
border: Border.all(width: 0.5, color: context.colorScheme.outline.withAlpha(25)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Icon and Label
@@ -38,33 +38,30 @@ class EntitiyCountTile extends StatelessWidget {
children: [
Icon(icon, color: context.primaryColor),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16),
Flexible(
child: Text(
label,
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16),
),
),
],
),
const SizedBox(height: 12),
// Number
LayoutBuilder(
builder: (context, constraints) {
final maxDigits = calculateMaxDigits(constraints.maxWidth);
return RichText(
text: TextSpan(
style: const TextStyle(fontSize: 18, fontFamily: 'OverpassMono', fontWeight: FontWeight.w600),
children: [
TextSpan(
text: zeroPadding(count, maxDigits),
style: TextStyle(color: context.colorScheme.onSurfaceSecondary.withAlpha(75)),
),
TextSpan(
text: count.toString(),
style: TextStyle(color: context.primaryColor),
),
],
const Spacer(),
RichText(
text: TextSpan(
style: const TextStyle(fontSize: 18, fontFamily: 'OverpassMono', fontWeight: FontWeight.w600),
children: [
TextSpan(
text: zeroPadding(count, maxDigits),
style: TextStyle(color: context.colorScheme.onSurfaceSecondary.withAlpha(75)),
),
);
},
TextSpan(
text: count.toString(),
style: TextStyle(color: context.primaryColor),
),
],
),
),
],
),

View File

@@ -282,76 +282,87 @@ class _SyncStatsCounts extends ConsumerWidget {
_SectionHeaderText(text: "assets".t(context: context)),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 8.0,
children: [
Expanded(
child: EntitiyCountTile(
label: "local".t(context: context),
count: localAssetCount,
icon: Icons.smartphone,
// 1. Wrap in IntrinsicHeight
child: IntrinsicHeight(
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
// 2. Stretch children vertically to fill the IntrinsicHeight
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8.0,
children: [
Expanded(
child: EntityCountTile(
label: "local".t(context: context),
count: localAssetCount,
icon: Icons.smartphone,
),
),
),
Expanded(
child: EntitiyCountTile(
label: "remote".t(context: context),
count: remoteAssetCount,
icon: Icons.cloud,
Expanded(
child: EntityCountTile(
label: "remote".t(context: context),
count: remoteAssetCount,
icon: Icons.cloud,
),
),
),
],
],
),
),
),
_SectionHeaderText(text: "albums".t(context: context)),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 8.0,
children: [
Expanded(
child: EntitiyCountTile(
label: "local".t(context: context),
count: localAlbumCount,
icon: Icons.smartphone,
child: IntrinsicHeight(
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.stretch, // Added
spacing: 8.0,
children: [
Expanded(
child: EntityCountTile(
label: "local".t(context: context),
count: localAlbumCount,
icon: Icons.smartphone,
),
),
),
Expanded(
child: EntitiyCountTile(
label: "remote".t(context: context),
count: remoteAlbumCount,
icon: Icons.cloud,
Expanded(
child: EntityCountTile(
label: "remote".t(context: context),
count: remoteAlbumCount,
icon: Icons.cloud,
),
),
),
],
],
),
),
),
_SectionHeaderText(text: "other".t(context: context)),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 8.0,
children: [
Expanded(
child: EntitiyCountTile(
label: "memories".t(context: context),
count: memoryCount,
icon: Icons.calendar_today,
child: IntrinsicHeight(
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.stretch, // Added
spacing: 8.0,
children: [
Expanded(
child: EntityCountTile(
label: "memories".t(context: context),
count: memoryCount,
icon: Icons.calendar_today,
),
),
),
Expanded(
child: EntitiyCountTile(
label: "hashed_assets".t(context: context),
count: localHashedCount,
icon: Icons.tag,
Expanded(
child: EntityCountTile(
label: "hashed_assets".t(context: context),
count: localHashedCount,
icon: Icons.tag,
),
),
),
],
],
),
),
),
// To be removed once the experimental feature is stable
@@ -364,26 +375,29 @@ class _SyncStatsCounts extends ConsumerWidget {
return counts.when(
data: (c) => Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 8.0,
children: [
Expanded(
child: EntitiyCountTile(
label: "local".t(context: context),
count: c.total,
icon: Icons.delete_outline,
child: IntrinsicHeight(
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.stretch, // Added
spacing: 8.0,
children: [
Expanded(
child: EntityCountTile(
label: "local".t(context: context),
count: c.total,
icon: Icons.delete_outline,
),
),
),
Expanded(
child: EntitiyCountTile(
label: "hashed_assets".t(context: context),
count: c.hashed,
icon: Icons.tag,
Expanded(
child: EntityCountTile(
label: "hashed_assets".t(context: context),
count: c.hashed,
icon: Icons.tag,
),
),
),
],
],
),
),
),
loading: () => const CircularProgressIndicator(),

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 2.3.1
- API version: 2.4.0
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
@@ -101,9 +101,7 @@ Class | Method | HTTP request | Description
*AssetsApi* | [**deleteAssetMetadata**](doc//AssetsApi.md#deleteassetmetadata) | **DELETE** /assets/{id}/metadata/{key} | Delete asset metadata by key
*AssetsApi* | [**deleteAssets**](doc//AssetsApi.md#deleteassets) | **DELETE** /assets | Delete assets
*AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original | Download original asset
*AssetsApi* | [**editAsset**](doc//AssetsApi.md#editasset) | **PUT** /assets/{id}/edits | Applies edits to an existing asset
*AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | Retrieve assets by device ID
*AssetsApi* | [**getAssetEdits**](doc//AssetsApi.md#getassetedits) | **GET** /assets/{id}/edits | Retrieve edits for an existing asset
*AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} | Retrieve an asset
*AssetsApi* | [**getAssetMetadata**](doc//AssetsApi.md#getassetmetadata) | **GET** /assets/{id}/metadata | Get asset metadata
*AssetsApi* | [**getAssetMetadataByKey**](doc//AssetsApi.md#getassetmetadatabykey) | **GET** /assets/{id}/metadata/{key} | Retrieve asset metadata by key
@@ -111,7 +109,6 @@ Class | Method | HTTP request | Description
*AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics | Get asset statistics
*AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random | Get random assets
*AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback | Play asset video
*AssetsApi* | [**removeAssetEdits**](doc//AssetsApi.md#removeassetedits) | **DELETE** /assets/{id}/edits | Remove edits from an existing asset
*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace asset
*AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs | Run an asset job
*AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} | Update an asset
@@ -346,8 +343,6 @@ Class | Method | HTTP request | Description
- [AssetCopyDto](doc//AssetCopyDto.md)
- [AssetDeltaSyncDto](doc//AssetDeltaSyncDto.md)
- [AssetDeltaSyncResponseDto](doc//AssetDeltaSyncResponseDto.md)
- [AssetEditsDto](doc//AssetEditsDto.md)
- [AssetEditsDtoEditsInner](doc//AssetEditsDtoEditsInner.md)
- [AssetFaceCreateDto](doc//AssetFaceCreateDto.md)
- [AssetFaceDeleteDto](doc//AssetFaceDeleteDto.md)
- [AssetFaceResponseDto](doc//AssetFaceResponseDto.md)
@@ -391,7 +386,6 @@ Class | Method | HTTP request | Description
- [CreateAlbumDto](doc//CreateAlbumDto.md)
- [CreateLibraryDto](doc//CreateLibraryDto.md)
- [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md)
- [CropParameters](doc//CropParameters.md)
- [DatabaseBackupConfig](doc//DatabaseBackupConfig.md)
- [DownloadArchiveInfo](doc//DownloadArchiveInfo.md)
- [DownloadInfoDto](doc//DownloadInfoDto.md)
@@ -400,11 +394,6 @@ Class | Method | HTTP request | Description
- [DownloadUpdate](doc//DownloadUpdate.md)
- [DuplicateDetectionConfig](doc//DuplicateDetectionConfig.md)
- [DuplicateResponseDto](doc//DuplicateResponseDto.md)
- [EditAction](doc//EditAction.md)
- [EditActionCrop](doc//EditActionCrop.md)
- [EditActionListDto](doc//EditActionListDto.md)
- [EditActionMirror](doc//EditActionMirror.md)
- [EditActionRotate](doc//EditActionRotate.md)
- [EmailNotificationsResponse](doc//EmailNotificationsResponse.md)
- [EmailNotificationsUpdate](doc//EmailNotificationsUpdate.md)
- [ExifResponseDto](doc//ExifResponseDto.md)
@@ -441,8 +430,6 @@ Class | Method | HTTP request | Description
- [MemoryUpdateDto](doc//MemoryUpdateDto.md)
- [MergePersonDto](doc//MergePersonDto.md)
- [MetadataSearchDto](doc//MetadataSearchDto.md)
- [MirrorAxis](doc//MirrorAxis.md)
- [MirrorParameters](doc//MirrorParameters.md)
- [NotificationCreateDto](doc//NotificationCreateDto.md)
- [NotificationDeleteAllDto](doc//NotificationDeleteAllDto.md)
- [NotificationDto](doc//NotificationDto.md)
@@ -502,7 +489,6 @@ Class | Method | HTTP request | Description
- [ReactionLevel](doc//ReactionLevel.md)
- [ReactionType](doc//ReactionType.md)
- [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md)
- [RotateParameters](doc//RotateParameters.md)
- [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md)
- [SearchAssetResponseDto](doc//SearchAssetResponseDto.md)
- [SearchExploreItem](doc//SearchExploreItem.md)

View File

@@ -95,8 +95,6 @@ part 'model/asset_bulk_upload_check_result.dart';
part 'model/asset_copy_dto.dart';
part 'model/asset_delta_sync_dto.dart';
part 'model/asset_delta_sync_response_dto.dart';
part 'model/asset_edits_dto.dart';
part 'model/asset_edits_dto_edits_inner.dart';
part 'model/asset_face_create_dto.dart';
part 'model/asset_face_delete_dto.dart';
part 'model/asset_face_response_dto.dart';
@@ -140,7 +138,6 @@ part 'model/contributor_count_response_dto.dart';
part 'model/create_album_dto.dart';
part 'model/create_library_dto.dart';
part 'model/create_profile_image_response_dto.dart';
part 'model/crop_parameters.dart';
part 'model/database_backup_config.dart';
part 'model/download_archive_info.dart';
part 'model/download_info_dto.dart';
@@ -149,11 +146,6 @@ part 'model/download_response_dto.dart';
part 'model/download_update.dart';
part 'model/duplicate_detection_config.dart';
part 'model/duplicate_response_dto.dart';
part 'model/edit_action.dart';
part 'model/edit_action_crop.dart';
part 'model/edit_action_list_dto.dart';
part 'model/edit_action_mirror.dart';
part 'model/edit_action_rotate.dart';
part 'model/email_notifications_response.dart';
part 'model/email_notifications_update.dart';
part 'model/exif_response_dto.dart';
@@ -190,8 +182,6 @@ part 'model/memory_type.dart';
part 'model/memory_update_dto.dart';
part 'model/merge_person_dto.dart';
part 'model/metadata_search_dto.dart';
part 'model/mirror_axis.dart';
part 'model/mirror_parameters.dart';
part 'model/notification_create_dto.dart';
part 'model/notification_delete_all_dto.dart';
part 'model/notification_dto.dart';
@@ -251,7 +241,6 @@ part 'model/ratings_update.dart';
part 'model/reaction_level.dart';
part 'model/reaction_type.dart';
part 'model/reverse_geocoding_state_response_dto.dart';
part 'model/rotate_parameters.dart';
part 'model/search_album_response_dto.dart';
part 'model/search_asset_response_dto.dart';
part 'model/search_explore_item.dart';

View File

@@ -288,12 +288,10 @@ class AssetsApi {
///
/// * [String] id (required):
///
/// * [bool] edited:
///
/// * [String] key:
///
/// * [String] slug:
Future<Response> downloadAssetWithHttpInfo(String id, { bool? edited, String? key, String? slug, }) async {
Future<Response> downloadAssetWithHttpInfo(String id, { String? key, String? slug, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/original'
.replaceAll('{id}', id);
@@ -305,9 +303,6 @@ class AssetsApi {
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (edited != null) {
queryParams.addAll(_queryParams('', 'edited', edited));
}
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
@@ -337,13 +332,11 @@ class AssetsApi {
///
/// * [String] id (required):
///
/// * [bool] edited:
///
/// * [String] key:
///
/// * [String] slug:
Future<MultipartFile?> downloadAsset(String id, { bool? edited, String? key, String? slug, }) async {
final response = await downloadAssetWithHttpInfo(id, edited: edited, key: key, slug: slug, );
Future<MultipartFile?> downloadAsset(String id, { String? key, String? slug, }) async {
final response = await downloadAssetWithHttpInfo(id, key: key, slug: slug, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
@@ -357,67 +350,6 @@ class AssetsApi {
return null;
}
/// Applies edits to an existing asset
///
/// Applies a series of edit actions (crop, rotate, mirror) to the specified asset.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [EditActionListDto] editActionListDto (required):
Future<Response> editAssetWithHttpInfo(String id, EditActionListDto editActionListDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/edits'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = editActionListDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Applies edits to an existing asset
///
/// Applies a series of edit actions (crop, rotate, mirror) to the specified asset.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [EditActionListDto] editActionListDto (required):
Future<AssetEditsDto?> editAsset(String id, EditActionListDto editActionListDto,) async {
final response = await editAssetWithHttpInfo(id, editActionListDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetEditsDto',) as AssetEditsDto;
}
return null;
}
/// Retrieve assets by device ID
///
/// Get all asset of a device that are in the database, ID only.
@@ -478,63 +410,6 @@ class AssetsApi {
return null;
}
/// Retrieve edits for an existing asset
///
/// Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
Future<Response> getAssetEditsWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/edits'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Retrieve edits for an existing asset
///
/// Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.
///
/// Parameters:
///
/// * [String] id (required):
Future<AssetEditsDto?> getAssetEdits(String id,) async {
final response = await getAssetEditsWithHttpInfo(id,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetEditsDto',) as AssetEditsDto;
}
return null;
}
/// Retrieve an asset
///
/// Retrieve detailed information about a specific asset.
@@ -998,55 +873,6 @@ class AssetsApi {
return null;
}
/// Remove edits from an existing asset
///
/// Removes all edit actions (crop, rotate, mirror) associated with the specified asset.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
Future<Response> removeAssetEditsWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/edits'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Remove edits from an existing asset
///
/// Removes all edit actions (crop, rotate, mirror) associated with the specified asset.
///
/// Parameters:
///
/// * [String] id (required):
Future<void> removeAssetEdits(String id,) async {
final response = await removeAssetEditsWithHttpInfo(id,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Replace asset
///
/// Replace the asset with new file, without changing its id.
@@ -1592,14 +1418,12 @@ class AssetsApi {
///
/// * [String] id (required):
///
/// * [bool] edited:
///
/// * [String] key:
///
/// * [AssetMediaSize] size:
///
/// * [String] slug:
Future<Response> viewAssetWithHttpInfo(String id, { bool? edited, String? key, AssetMediaSize? size, String? slug, }) async {
Future<Response> viewAssetWithHttpInfo(String id, { String? key, AssetMediaSize? size, String? slug, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/thumbnail'
.replaceAll('{id}', id);
@@ -1611,9 +1435,6 @@ class AssetsApi {
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (edited != null) {
queryParams.addAll(_queryParams('', 'edited', edited));
}
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
@@ -1646,15 +1467,13 @@ class AssetsApi {
///
/// * [String] id (required):
///
/// * [bool] edited:
///
/// * [String] key:
///
/// * [AssetMediaSize] size:
///
/// * [String] slug:
Future<MultipartFile?> viewAsset(String id, { bool? edited, String? key, AssetMediaSize? size, String? slug, }) async {
final response = await viewAssetWithHttpInfo(id, edited: edited, key: key, size: size, slug: slug, );
Future<MultipartFile?> viewAsset(String id, { String? key, AssetMediaSize? size, String? slug, }) async {
final response = await viewAssetWithHttpInfo(id, key: key, size: size, slug: slug, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View File

@@ -238,10 +238,6 @@ class ApiClient {
return AssetDeltaSyncDto.fromJson(value);
case 'AssetDeltaSyncResponseDto':
return AssetDeltaSyncResponseDto.fromJson(value);
case 'AssetEditsDto':
return AssetEditsDto.fromJson(value);
case 'AssetEditsDtoEditsInner':
return AssetEditsDtoEditsInner.fromJson(value);
case 'AssetFaceCreateDto':
return AssetFaceCreateDto.fromJson(value);
case 'AssetFaceDeleteDto':
@@ -328,8 +324,6 @@ class ApiClient {
return CreateLibraryDto.fromJson(value);
case 'CreateProfileImageResponseDto':
return CreateProfileImageResponseDto.fromJson(value);
case 'CropParameters':
return CropParameters.fromJson(value);
case 'DatabaseBackupConfig':
return DatabaseBackupConfig.fromJson(value);
case 'DownloadArchiveInfo':
@@ -346,16 +340,6 @@ class ApiClient {
return DuplicateDetectionConfig.fromJson(value);
case 'DuplicateResponseDto':
return DuplicateResponseDto.fromJson(value);
case 'EditAction':
return EditActionTypeTransformer().decode(value);
case 'EditActionCrop':
return EditActionCrop.fromJson(value);
case 'EditActionListDto':
return EditActionListDto.fromJson(value);
case 'EditActionMirror':
return EditActionMirror.fromJson(value);
case 'EditActionRotate':
return EditActionRotate.fromJson(value);
case 'EmailNotificationsResponse':
return EmailNotificationsResponse.fromJson(value);
case 'EmailNotificationsUpdate':
@@ -428,10 +412,6 @@ class ApiClient {
return MergePersonDto.fromJson(value);
case 'MetadataSearchDto':
return MetadataSearchDto.fromJson(value);
case 'MirrorAxis':
return MirrorAxisTypeTransformer().decode(value);
case 'MirrorParameters':
return MirrorParameters.fromJson(value);
case 'NotificationCreateDto':
return NotificationCreateDto.fromJson(value);
case 'NotificationDeleteAllDto':
@@ -550,8 +530,6 @@ class ApiClient {
return ReactionTypeTypeTransformer().decode(value);
case 'ReverseGeocodingStateResponseDto':
return ReverseGeocodingStateResponseDto.fromJson(value);
case 'RotateParameters':
return RotateParameters.fromJson(value);
case 'SearchAlbumResponseDto':
return SearchAlbumResponseDto.fromJson(value);
case 'SearchAssetResponseDto':

View File

@@ -91,9 +91,6 @@ String parameterToString(dynamic value) {
if (value is Colorspace) {
return ColorspaceTypeTransformer().encode(value).toString();
}
if (value is EditAction) {
return EditActionTypeTransformer().encode(value).toString();
}
if (value is ImageFormat) {
return ImageFormatTypeTransformer().encode(value).toString();
}
@@ -115,9 +112,6 @@ String parameterToString(dynamic value) {
if (value is MemoryType) {
return MemoryTypeTypeTransformer().encode(value).toString();
}
if (value is MirrorAxis) {
return MirrorAxisTypeTransformer().encode(value).toString();
}
if (value is NotificationLevel) {
return NotificationLevelTypeTransformer().encode(value).toString();
}

View File

@@ -1,108 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetEditsDto {
/// Returns a new [AssetEditsDto] instance.
AssetEditsDto({
required this.assetId,
this.edits = const [],
});
String assetId;
/// list of edits
List<AssetEditsDtoEditsInner> edits;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetEditsDto &&
other.assetId == assetId &&
_deepEquality.equals(other.edits, edits);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(assetId.hashCode) +
(edits.hashCode);
@override
String toString() => 'AssetEditsDto[assetId=$assetId, edits=$edits]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'assetId'] = this.assetId;
json[r'edits'] = this.edits;
return json;
}
/// Returns a new [AssetEditsDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetEditsDto? fromJson(dynamic value) {
upgradeDto(value, "AssetEditsDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetEditsDto(
assetId: mapValueOfType<String>(json, r'assetId')!,
edits: AssetEditsDtoEditsInner.listFromJson(json[r'edits']),
);
}
return null;
}
static List<AssetEditsDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetEditsDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetEditsDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetEditsDto> mapFromJson(dynamic json) {
final map = <String, AssetEditsDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetEditsDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetEditsDto-objects as value to a dart map
static Map<String, List<AssetEditsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetEditsDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetEditsDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'assetId',
'edits',
};
}

View File

@@ -1,107 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetEditsDtoEditsInner {
/// Returns a new [AssetEditsDtoEditsInner] instance.
AssetEditsDtoEditsInner({
required this.action,
required this.parameters,
});
EditAction action;
MirrorParameters parameters;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetEditsDtoEditsInner &&
other.action == action &&
other.parameters == parameters;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(action.hashCode) +
(parameters.hashCode);
@override
String toString() => 'AssetEditsDtoEditsInner[action=$action, parameters=$parameters]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'action'] = this.action;
json[r'parameters'] = this.parameters;
return json;
}
/// Returns a new [AssetEditsDtoEditsInner] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetEditsDtoEditsInner? fromJson(dynamic value) {
upgradeDto(value, "AssetEditsDtoEditsInner");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetEditsDtoEditsInner(
action: EditAction.fromJson(json[r'action'])!,
parameters: MirrorParameters.fromJson(json[r'parameters'])!,
);
}
return null;
}
static List<AssetEditsDtoEditsInner> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetEditsDtoEditsInner>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetEditsDtoEditsInner.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetEditsDtoEditsInner> mapFromJson(dynamic json) {
final map = <String, AssetEditsDtoEditsInner>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetEditsDtoEditsInner.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetEditsDtoEditsInner-objects as value to a dart map
static Map<String, List<AssetEditsDtoEditsInner>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetEditsDtoEditsInner>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetEditsDtoEditsInner.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'action',
'parameters',
};
}

View File

@@ -23,7 +23,6 @@ class AssetResponseDto {
required this.fileCreatedAt,
required this.fileModifiedAt,
required this.hasMetadata,
required this.height,
required this.id,
required this.isArchived,
required this.isFavorite,
@@ -46,7 +45,6 @@ class AssetResponseDto {
this.unassignedFaces = const [],
required this.updatedAt,
required this.visibility,
required this.width,
});
/// base64 encoded sha1 hash
@@ -79,8 +77,6 @@ class AssetResponseDto {
bool hasMetadata;
num? height;
String id;
bool isArchived;
@@ -145,8 +141,6 @@ class AssetResponseDto {
AssetVisibility visibility;
num? width;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
other.checksum == checksum &&
@@ -159,7 +153,6 @@ class AssetResponseDto {
other.fileCreatedAt == fileCreatedAt &&
other.fileModifiedAt == fileModifiedAt &&
other.hasMetadata == hasMetadata &&
other.height == height &&
other.id == id &&
other.isArchived == isArchived &&
other.isFavorite == isFavorite &&
@@ -181,8 +174,7 @@ class AssetResponseDto {
other.type == type &&
_deepEquality.equals(other.unassignedFaces, unassignedFaces) &&
other.updatedAt == updatedAt &&
other.visibility == visibility &&
other.width == width;
other.visibility == visibility;
@override
int get hashCode =>
@@ -197,7 +189,6 @@ class AssetResponseDto {
(fileCreatedAt.hashCode) +
(fileModifiedAt.hashCode) +
(hasMetadata.hashCode) +
(height == null ? 0 : height!.hashCode) +
(id.hashCode) +
(isArchived.hashCode) +
(isFavorite.hashCode) +
@@ -219,11 +210,10 @@ class AssetResponseDto {
(type.hashCode) +
(unassignedFaces.hashCode) +
(updatedAt.hashCode) +
(visibility.hashCode) +
(width == null ? 0 : width!.hashCode);
(visibility.hashCode);
@override
String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, height=$height, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility, width=$width]';
String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -245,11 +235,6 @@ class AssetResponseDto {
json[r'fileCreatedAt'] = this.fileCreatedAt.toUtc().toIso8601String();
json[r'fileModifiedAt'] = this.fileModifiedAt.toUtc().toIso8601String();
json[r'hasMetadata'] = this.hasMetadata;
if (this.height != null) {
json[r'height'] = this.height;
} else {
// json[r'height'] = null;
}
json[r'id'] = this.id;
json[r'isArchived'] = this.isArchived;
json[r'isFavorite'] = this.isFavorite;
@@ -300,11 +285,6 @@ class AssetResponseDto {
json[r'unassignedFaces'] = this.unassignedFaces;
json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
json[r'visibility'] = this.visibility;
if (this.width != null) {
json[r'width'] = this.width;
} else {
// json[r'width'] = null;
}
return json;
}
@@ -327,9 +307,6 @@ class AssetResponseDto {
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!,
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!,
hasMetadata: mapValueOfType<bool>(json, r'hasMetadata')!,
height: json[r'height'] == null
? null
: num.parse('${json[r'height']}'),
id: mapValueOfType<String>(json, r'id')!,
isArchived: mapValueOfType<bool>(json, r'isArchived')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
@@ -352,9 +329,6 @@ class AssetResponseDto {
unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']),
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
visibility: AssetVisibility.fromJson(json[r'visibility'])!,
width: json[r'width'] == null
? null
: num.parse('${json[r'width']}'),
);
}
return null;
@@ -410,7 +384,6 @@ class AssetResponseDto {
'fileCreatedAt',
'fileModifiedAt',
'hasMetadata',
'height',
'id',
'isArchived',
'isFavorite',
@@ -424,7 +397,6 @@ class AssetResponseDto {
'type',
'updatedAt',
'visibility',
'width',
};
}

View File

@@ -1,135 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class CropParameters {
/// Returns a new [CropParameters] instance.
CropParameters({
required this.height,
required this.width,
required this.x,
required this.y,
});
/// Height of the crop
///
/// Minimum value: 1
num height;
/// Width of the crop
///
/// Minimum value: 1
num width;
/// Top-Left X coordinate of crop
///
/// Minimum value: 0
num x;
/// Top-Left Y coordinate of crop
///
/// Minimum value: 0
num y;
@override
bool operator ==(Object other) => identical(this, other) || other is CropParameters &&
other.height == height &&
other.width == width &&
other.x == x &&
other.y == y;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(height.hashCode) +
(width.hashCode) +
(x.hashCode) +
(y.hashCode);
@override
String toString() => 'CropParameters[height=$height, width=$width, x=$x, y=$y]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'height'] = this.height;
json[r'width'] = this.width;
json[r'x'] = this.x;
json[r'y'] = this.y;
return json;
}
/// Returns a new [CropParameters] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static CropParameters? fromJson(dynamic value) {
upgradeDto(value, "CropParameters");
if (value is Map) {
final json = value.cast<String, dynamic>();
return CropParameters(
height: num.parse('${json[r'height']}'),
width: num.parse('${json[r'width']}'),
x: num.parse('${json[r'x']}'),
y: num.parse('${json[r'y']}'),
);
}
return null;
}
static List<CropParameters> listFromJson(dynamic json, {bool growable = false,}) {
final result = <CropParameters>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = CropParameters.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, CropParameters> mapFromJson(dynamic json) {
final map = <String, CropParameters>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = CropParameters.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of CropParameters-objects as value to a dart map
static Map<String, List<CropParameters>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<CropParameters>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = CropParameters.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'height',
'width',
'x',
'y',
};
}

View File

@@ -1,88 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class EditAction {
/// Instantiate a new enum with the provided [value].
const EditAction._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const crop = EditAction._(r'crop');
static const rotate = EditAction._(r'rotate');
static const mirror = EditAction._(r'mirror');
/// List of all possible values in this [enum][EditAction].
static const values = <EditAction>[
crop,
rotate,
mirror,
];
static EditAction? fromJson(dynamic value) => EditActionTypeTransformer().decode(value);
static List<EditAction> listFromJson(dynamic json, {bool growable = false,}) {
final result = <EditAction>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = EditAction.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [EditAction] to String,
/// and [decode] dynamic data back to [EditAction].
class EditActionTypeTransformer {
factory EditActionTypeTransformer() => _instance ??= const EditActionTypeTransformer._();
const EditActionTypeTransformer._();
String encode(EditAction data) => data.value;
/// Decodes a [dynamic value][data] to a EditAction.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
EditAction? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'crop': return EditAction.crop;
case r'rotate': return EditAction.rotate;
case r'mirror': return EditAction.mirror;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [EditActionTypeTransformer] instance.
static EditActionTypeTransformer? _instance;
}

View File

@@ -1,107 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class EditActionCrop {
/// Returns a new [EditActionCrop] instance.
EditActionCrop({
required this.action,
required this.parameters,
});
EditAction action;
CropParameters parameters;
@override
bool operator ==(Object other) => identical(this, other) || other is EditActionCrop &&
other.action == action &&
other.parameters == parameters;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(action.hashCode) +
(parameters.hashCode);
@override
String toString() => 'EditActionCrop[action=$action, parameters=$parameters]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'action'] = this.action;
json[r'parameters'] = this.parameters;
return json;
}
/// Returns a new [EditActionCrop] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static EditActionCrop? fromJson(dynamic value) {
upgradeDto(value, "EditActionCrop");
if (value is Map) {
final json = value.cast<String, dynamic>();
return EditActionCrop(
action: EditAction.fromJson(json[r'action'])!,
parameters: CropParameters.fromJson(json[r'parameters'])!,
);
}
return null;
}
static List<EditActionCrop> listFromJson(dynamic json, {bool growable = false,}) {
final result = <EditActionCrop>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = EditActionCrop.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, EditActionCrop> mapFromJson(dynamic json) {
final map = <String, EditActionCrop>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = EditActionCrop.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of EditActionCrop-objects as value to a dart map
static Map<String, List<EditActionCrop>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<EditActionCrop>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = EditActionCrop.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'action',
'parameters',
};
}

View File

@@ -1,100 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class EditActionListDto {
/// Returns a new [EditActionListDto] instance.
EditActionListDto({
this.edits = const [],
});
/// list of edits
List<AssetEditsDtoEditsInner> edits;
@override
bool operator ==(Object other) => identical(this, other) || other is EditActionListDto &&
_deepEquality.equals(other.edits, edits);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(edits.hashCode);
@override
String toString() => 'EditActionListDto[edits=$edits]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'edits'] = this.edits;
return json;
}
/// Returns a new [EditActionListDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static EditActionListDto? fromJson(dynamic value) {
upgradeDto(value, "EditActionListDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return EditActionListDto(
edits: AssetEditsDtoEditsInner.listFromJson(json[r'edits']),
);
}
return null;
}
static List<EditActionListDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <EditActionListDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = EditActionListDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, EditActionListDto> mapFromJson(dynamic json) {
final map = <String, EditActionListDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = EditActionListDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of EditActionListDto-objects as value to a dart map
static Map<String, List<EditActionListDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<EditActionListDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = EditActionListDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'edits',
};
}

View File

@@ -1,107 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class EditActionMirror {
/// Returns a new [EditActionMirror] instance.
EditActionMirror({
required this.action,
required this.parameters,
});
EditAction action;
MirrorParameters parameters;
@override
bool operator ==(Object other) => identical(this, other) || other is EditActionMirror &&
other.action == action &&
other.parameters == parameters;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(action.hashCode) +
(parameters.hashCode);
@override
String toString() => 'EditActionMirror[action=$action, parameters=$parameters]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'action'] = this.action;
json[r'parameters'] = this.parameters;
return json;
}
/// Returns a new [EditActionMirror] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static EditActionMirror? fromJson(dynamic value) {
upgradeDto(value, "EditActionMirror");
if (value is Map) {
final json = value.cast<String, dynamic>();
return EditActionMirror(
action: EditAction.fromJson(json[r'action'])!,
parameters: MirrorParameters.fromJson(json[r'parameters'])!,
);
}
return null;
}
static List<EditActionMirror> listFromJson(dynamic json, {bool growable = false,}) {
final result = <EditActionMirror>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = EditActionMirror.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, EditActionMirror> mapFromJson(dynamic json) {
final map = <String, EditActionMirror>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = EditActionMirror.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of EditActionMirror-objects as value to a dart map
static Map<String, List<EditActionMirror>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<EditActionMirror>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = EditActionMirror.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'action',
'parameters',
};
}

View File

@@ -1,107 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class EditActionRotate {
/// Returns a new [EditActionRotate] instance.
EditActionRotate({
required this.action,
required this.parameters,
});
EditAction action;
RotateParameters parameters;
@override
bool operator ==(Object other) => identical(this, other) || other is EditActionRotate &&
other.action == action &&
other.parameters == parameters;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(action.hashCode) +
(parameters.hashCode);
@override
String toString() => 'EditActionRotate[action=$action, parameters=$parameters]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'action'] = this.action;
json[r'parameters'] = this.parameters;
return json;
}
/// Returns a new [EditActionRotate] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static EditActionRotate? fromJson(dynamic value) {
upgradeDto(value, "EditActionRotate");
if (value is Map) {
final json = value.cast<String, dynamic>();
return EditActionRotate(
action: EditAction.fromJson(json[r'action'])!,
parameters: RotateParameters.fromJson(json[r'parameters'])!,
);
}
return null;
}
static List<EditActionRotate> listFromJson(dynamic json, {bool growable = false,}) {
final result = <EditActionRotate>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = EditActionRotate.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, EditActionRotate> mapFromJson(dynamic json) {
final map = <String, EditActionRotate>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = EditActionRotate.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of EditActionRotate-objects as value to a dart map
static Map<String, List<EditActionRotate>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<EditActionRotate>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = EditActionRotate.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'action',
'parameters',
};
}

View File

@@ -1,85 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
/// Axis to mirror along
class MirrorAxis {
/// Instantiate a new enum with the provided [value].
const MirrorAxis._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const horizontal = MirrorAxis._(r'horizontal');
static const vertical = MirrorAxis._(r'vertical');
/// List of all possible values in this [enum][MirrorAxis].
static const values = <MirrorAxis>[
horizontal,
vertical,
];
static MirrorAxis? fromJson(dynamic value) => MirrorAxisTypeTransformer().decode(value);
static List<MirrorAxis> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MirrorAxis>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MirrorAxis.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [MirrorAxis] to String,
/// and [decode] dynamic data back to [MirrorAxis].
class MirrorAxisTypeTransformer {
factory MirrorAxisTypeTransformer() => _instance ??= const MirrorAxisTypeTransformer._();
const MirrorAxisTypeTransformer._();
String encode(MirrorAxis data) => data.value;
/// Decodes a [dynamic value][data] to a MirrorAxis.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
MirrorAxis? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'horizontal': return MirrorAxis.horizontal;
case r'vertical': return MirrorAxis.vertical;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [MirrorAxisTypeTransformer] instance.
static MirrorAxisTypeTransformer? _instance;
}

View File

@@ -1,100 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class MirrorParameters {
/// Returns a new [MirrorParameters] instance.
MirrorParameters({
required this.axis,
});
/// Axis to mirror along
MirrorAxis axis;
@override
bool operator ==(Object other) => identical(this, other) || other is MirrorParameters &&
other.axis == axis;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(axis.hashCode);
@override
String toString() => 'MirrorParameters[axis=$axis]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'axis'] = this.axis;
return json;
}
/// Returns a new [MirrorParameters] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static MirrorParameters? fromJson(dynamic value) {
upgradeDto(value, "MirrorParameters");
if (value is Map) {
final json = value.cast<String, dynamic>();
return MirrorParameters(
axis: MirrorAxis.fromJson(json[r'axis'])!,
);
}
return null;
}
static List<MirrorParameters> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MirrorParameters>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MirrorParameters.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, MirrorParameters> mapFromJson(dynamic json) {
final map = <String, MirrorParameters>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = MirrorParameters.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of MirrorParameters-objects as value to a dart map
static Map<String, List<MirrorParameters>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<MirrorParameters>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = MirrorParameters.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'axis',
};
}

View File

@@ -43,8 +43,6 @@ class Permission {
static const assetPeriodUpload = Permission._(r'asset.upload');
static const assetPeriodReplace = Permission._(r'asset.replace');
static const assetPeriodCopy = Permission._(r'asset.copy');
static const assetPeriodDerive = Permission._(r'asset.derive');
static const assetPeriodEdit = Permission._(r'asset.edit');
static const albumPeriodCreate = Permission._(r'album.create');
static const albumPeriodRead = Permission._(r'album.read');
static const albumPeriodUpdate = Permission._(r'album.update');
@@ -193,8 +191,6 @@ class Permission {
assetPeriodUpload,
assetPeriodReplace,
assetPeriodCopy,
assetPeriodDerive,
assetPeriodEdit,
albumPeriodCreate,
albumPeriodRead,
albumPeriodUpdate,
@@ -378,8 +374,6 @@ class PermissionTypeTransformer {
case r'asset.upload': return Permission.assetPeriodUpload;
case r'asset.replace': return Permission.assetPeriodReplace;
case r'asset.copy': return Permission.assetPeriodCopy;
case r'asset.derive': return Permission.assetPeriodDerive;
case r'asset.edit': return Permission.assetPeriodEdit;
case r'album.create': return Permission.albumPeriodCreate;
case r'album.read': return Permission.albumPeriodRead;
case r'album.update': return Permission.albumPeriodUpdate;

View File

@@ -1,100 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class RotateParameters {
/// Returns a new [RotateParameters] instance.
RotateParameters({
required this.angle,
});
/// Rotation angle in degrees
num angle;
@override
bool operator ==(Object other) => identical(this, other) || other is RotateParameters &&
other.angle == angle;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(angle.hashCode);
@override
String toString() => 'RotateParameters[angle=$angle]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'angle'] = this.angle;
return json;
}
/// Returns a new [RotateParameters] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static RotateParameters? fromJson(dynamic value) {
upgradeDto(value, "RotateParameters");
if (value is Map) {
final json = value.cast<String, dynamic>();
return RotateParameters(
angle: num.parse('${json[r'angle']}'),
);
}
return null;
}
static List<RotateParameters> listFromJson(dynamic json, {bool growable = false,}) {
final result = <RotateParameters>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = RotateParameters.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, RotateParameters> mapFromJson(dynamic json) {
final map = <String, RotateParameters>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = RotateParameters.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of RotateParameters-objects as value to a dart map
static Map<String, List<RotateParameters>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<RotateParameters>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = RotateParameters.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'angle',
};
}

View File

@@ -18,7 +18,6 @@ class SyncAssetV1 {
required this.duration,
required this.fileCreatedAt,
required this.fileModifiedAt,
required this.height,
required this.id,
required this.isFavorite,
required this.libraryId,
@@ -30,7 +29,6 @@ class SyncAssetV1 {
required this.thumbhash,
required this.type,
required this.visibility,
required this.width,
});
String checksum;
@@ -43,8 +41,6 @@ class SyncAssetV1 {
DateTime? fileModifiedAt;
int? height;
String id;
bool isFavorite;
@@ -67,8 +63,6 @@ class SyncAssetV1 {
AssetVisibility visibility;
int? width;
@override
bool operator ==(Object other) => identical(this, other) || other is SyncAssetV1 &&
other.checksum == checksum &&
@@ -76,7 +70,6 @@ class SyncAssetV1 {
other.duration == duration &&
other.fileCreatedAt == fileCreatedAt &&
other.fileModifiedAt == fileModifiedAt &&
other.height == height &&
other.id == id &&
other.isFavorite == isFavorite &&
other.libraryId == libraryId &&
@@ -87,8 +80,7 @@ class SyncAssetV1 {
other.stackId == stackId &&
other.thumbhash == thumbhash &&
other.type == type &&
other.visibility == visibility &&
other.width == width;
other.visibility == visibility;
@override
int get hashCode =>
@@ -98,7 +90,6 @@ class SyncAssetV1 {
(duration == null ? 0 : duration!.hashCode) +
(fileCreatedAt == null ? 0 : fileCreatedAt!.hashCode) +
(fileModifiedAt == null ? 0 : fileModifiedAt!.hashCode) +
(height == null ? 0 : height!.hashCode) +
(id.hashCode) +
(isFavorite.hashCode) +
(libraryId == null ? 0 : libraryId!.hashCode) +
@@ -109,11 +100,10 @@ class SyncAssetV1 {
(stackId == null ? 0 : stackId!.hashCode) +
(thumbhash == null ? 0 : thumbhash!.hashCode) +
(type.hashCode) +
(visibility.hashCode) +
(width == null ? 0 : width!.hashCode);
(visibility.hashCode);
@override
String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, height=$height, id=$id, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility, width=$width]';
String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -137,11 +127,6 @@ class SyncAssetV1 {
json[r'fileModifiedAt'] = this.fileModifiedAt!.toUtc().toIso8601String();
} else {
// json[r'fileModifiedAt'] = null;
}
if (this.height != null) {
json[r'height'] = this.height;
} else {
// json[r'height'] = null;
}
json[r'id'] = this.id;
json[r'isFavorite'] = this.isFavorite;
@@ -174,11 +159,6 @@ class SyncAssetV1 {
}
json[r'type'] = this.type;
json[r'visibility'] = this.visibility;
if (this.width != null) {
json[r'width'] = this.width;
} else {
// json[r'width'] = null;
}
return json;
}
@@ -196,7 +176,6 @@ class SyncAssetV1 {
duration: mapValueOfType<String>(json, r'duration'),
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r''),
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r''),
height: mapValueOfType<int>(json, r'height'),
id: mapValueOfType<String>(json, r'id')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
libraryId: mapValueOfType<String>(json, r'libraryId'),
@@ -208,7 +187,6 @@ class SyncAssetV1 {
thumbhash: mapValueOfType<String>(json, r'thumbhash'),
type: AssetTypeEnum.fromJson(json[r'type'])!,
visibility: AssetVisibility.fromJson(json[r'visibility'])!,
width: mapValueOfType<int>(json, r'width'),
);
}
return null;
@@ -261,7 +239,6 @@ class SyncAssetV1 {
'duration',
'fileCreatedAt',
'fileModifiedAt',
'height',
'id',
'isFavorite',
'libraryId',
@@ -273,7 +250,6 @@ class SyncAssetV1 {
'thumbhash',
'type',
'visibility',
'width',
};
}

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 2.3.1+3028
version: 2.4.0+3029
environment:
sdk: '>=3.8.0 <4.0.0'

View File

@@ -1,185 +0,0 @@
import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:openapi/api.dart';
SyncUserV1 _createUser({String id = 'user-1'}) {
return SyncUserV1(
id: id,
name: 'Test User',
email: 'test@test.com',
deletedAt: null,
avatarColor: null,
hasProfileImage: false,
profileChangedAt: DateTime(2024, 1, 1),
);
}
SyncAssetV1 _createAsset({
required String id,
required String checksum,
required String fileName,
String ownerId = 'user-1',
int? width,
int? height,
}) {
return SyncAssetV1(
id: id,
checksum: checksum,
originalFileName: fileName,
type: AssetTypeEnum.IMAGE,
ownerId: ownerId,
isFavorite: false,
fileCreatedAt: DateTime(2024, 1, 1),
fileModifiedAt: DateTime(2024, 1, 1),
localDateTime: DateTime(2024, 1, 1),
visibility: AssetVisibility.timeline,
width: width,
height: height,
deletedAt: null,
duration: null,
libraryId: null,
livePhotoVideoId: null,
stackId: null,
thumbhash: null,
);
}
SyncAssetExifV1 _createExif({
required String assetId,
required int width,
required int height,
required String orientation,
}) {
return SyncAssetExifV1(
assetId: assetId,
exifImageWidth: width,
exifImageHeight: height,
orientation: orientation,
city: null,
country: null,
dateTimeOriginal: null,
description: null,
exposureTime: null,
fNumber: null,
fileSizeInByte: null,
focalLength: null,
fps: null,
iso: null,
latitude: null,
lensModel: null,
longitude: null,
make: null,
model: null,
modifyDate: null,
profileDescription: null,
projectionType: null,
rating: null,
state: null,
timeZone: null,
);
}
void main() {
late Drift db;
late SyncStreamRepository sut;
setUp(() async {
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
sut = SyncStreamRepository(db);
});
tearDown(() async {
await db.close();
});
group('SyncStreamRepository - Dimension swapping based on orientation', () {
test('swaps dimensions for asset with rotated orientation', () async {
final flippedOrientations = ['5', '6', '7', '8', '90', '-90'];
for (final orientation in flippedOrientations) {
final assetId = 'asset-$orientation-degrees';
await sut.updateUsersV1([_createUser()]);
final asset = _createAsset(
id: assetId,
checksum: 'checksum-$orientation',
fileName: 'rotated_$orientation.jpg',
);
await sut.updateAssetsV1([asset]);
final exif = _createExif(
assetId: assetId,
width: 1920,
height: 1080,
orientation: orientation, // EXIF orientation value for 90 degrees CW
);
await sut.updateAssetsExifV1([exif]);
final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId));
final result = await query.getSingle();
expect(result.width, equals(1080));
expect(result.height, equals(1920));
}
});
test('does not swap dimensions for asset with normal orientation', () async {
final nonFlippedOrientations = ['1', '2', '3', '4'];
for (final orientation in nonFlippedOrientations) {
final assetId = 'asset-$orientation-degrees';
await sut.updateUsersV1([_createUser()]);
final asset = _createAsset(id: assetId, checksum: 'checksum-$orientation', fileName: 'normal_$orientation.jpg');
await sut.updateAssetsV1([asset]);
final exif = _createExif(
assetId: assetId,
width: 1920,
height: 1080,
orientation: orientation, // EXIF orientation value for normal
);
await sut.updateAssetsExifV1([exif]);
final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId));
final result = await query.getSingle();
expect(result.width, equals(1920));
expect(result.height, equals(1080));
}
});
test('does not update dimensions if asset already has width and height', () async {
const assetId = 'asset-with-dimensions';
const existingWidth = 1920;
const existingHeight = 1080;
const exifWidth = 3840;
const exifHeight = 2160;
await sut.updateUsersV1([_createUser()]);
final asset = _createAsset(
id: assetId,
checksum: 'checksum-with-dims',
fileName: 'with_dimensions.jpg',
width: existingWidth,
height: existingHeight,
);
await sut.updateAssetsV1([asset]);
final exif = _createExif(assetId: assetId, width: exifWidth, height: exifHeight, orientation: '6');
await sut.updateAssetsExifV1([exif]);
// Verify the asset still has original dimensions (not updated from EXIF)
final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId));
final result = await query.getSingle();
expect(result.width, equals(existingWidth), reason: 'Width should remain as originally set');
expect(result.height, equals(existingHeight), reason: 'Height should remain as originally set');
});
});
}

View File

@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
@@ -21,6 +22,42 @@ void main() {
});
group('getAspectRatio', () {
test('flips dimensions on Android for 90° and 270° orientations', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
for (final orientation in [90, 270]) {
final localAsset = TestUtils.createLocalAsset(
id: 'local-$orientation',
width: 1920,
height: 1080,
orientation: orientation,
);
final result = await sut.getAspectRatio(localAsset);
expect(result, 1080 / 1920, reason: 'Orientation $orientation should flip on Android');
}
});
test('does not flip dimensions on iOS regardless of orientation', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
for (final orientation in [0, 90, 270]) {
final localAsset = TestUtils.createLocalAsset(
id: 'local-$orientation',
width: 1920,
height: 1080,
orientation: orientation,
);
final result = await sut.getAspectRatio(localAsset);
expect(result, 1920 / 1080, reason: 'iOS should never flip dimensions');
}
});
test('fetches dimensions from remote repository when missing from asset', () async {
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: null, height: null);
@@ -75,23 +112,54 @@ void main() {
expect(result, 1.0);
});
test('handles local asset with remoteId and uses remote dimensions', () async {
test('handles local asset with remoteId and uses exif from remote', () async {
final localAsset = TestUtils.createLocalAsset(
id: 'local-1',
remoteId: 'remote-1',
width: null,
height: null,
width: 1920,
height: 1080,
orientation: 0,
);
when(
() => mockRemoteAssetRepository.get('remote-1'),
).thenAnswer((_) async => TestUtils.createRemoteAsset(id: 'remote-1', width: 1920, height: 1080));
final exif = const ExifInfo(orientation: '6');
when(() => mockRemoteAssetRepository.getExif('remote-1')).thenAnswer((_) async => exif);
final result = await sut.getAspectRatio(localAsset);
verify(() => mockRemoteAssetRepository.get('remote-1')).called(1);
expect(result, 1920 / 1080);
expect(result, 1080 / 1920);
});
test('handles various flipped EXIF orientations correctly', () async {
final flippedOrientations = ['5', '6', '7', '8', '90', '-90'];
for (final orientation in flippedOrientations) {
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-$orientation', width: 1920, height: 1080);
final exif = ExifInfo(orientation: orientation);
when(() => mockRemoteAssetRepository.getExif('remote-$orientation')).thenAnswer((_) async => exif);
final result = await sut.getAspectRatio(remoteAsset);
expect(result, 1080 / 1920, reason: 'Orientation $orientation should flip dimensions');
}
});
test('handles various non-flipped EXIF orientations correctly', () async {
final nonFlippedOrientations = ['1', '2', '3', '4'];
for (final orientation in nonFlippedOrientations) {
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-$orientation', width: 1920, height: 1080);
final exif = ExifInfo(orientation: orientation);
when(() => mockRemoteAssetRepository.getExif('remote-$orientation')).thenAnswer((_) async => exif);
final result = await sut.getAspectRatio(remoteAsset);
expect(result, 1920 / 1080, reason: 'Orientation $orientation should NOT flip dimensions');
}
});
});
}

View File

@@ -94,11 +94,25 @@ abstract final class SyncStreamStub {
required String ack,
DateTime? trashedAt,
}) {
return _assetV1(id: id, checksum: checksum, deletedAt: trashedAt ?? DateTime(2025, 1, 1), ack: ack);
return _assetV1(
id: id,
checksum: checksum,
deletedAt: trashedAt ?? DateTime(2025, 1, 1),
ack: ack,
);
}
static SyncEvent assetModified({required String id, required String checksum, required String ack}) {
return _assetV1(id: id, checksum: checksum, deletedAt: null, ack: ack);
static SyncEvent assetModified({
required String id,
required String checksum,
required String ack,
}) {
return _assetV1(
id: id,
checksum: checksum,
deletedAt: null,
ack: ack,
);
}
static SyncEvent _assetV1({
@@ -126,8 +140,6 @@ abstract final class SyncStreamStub {
thumbhash: null,
type: AssetTypeEnum.IMAGE,
visibility: AssetVisibility.timeline,
width: null,
height: null,
),
ack: ack,
);

View File

@@ -45,17 +45,5 @@ void main() {
addDefault(value, keys, defaultValue);
expect(value['alpha']['beta'], 'gamma');
});
test('addDefault with null', () {
dynamic value = jsonDecode("""
{
"download": {
"archiveSize": 4294967296,
"includeEmbeddedVideos": false
}
}
""");
expect(value['download']['unknownKey'], isNull);
});
});
}

View File

@@ -3187,173 +3187,6 @@
"x-immich-state": "Stable"
}
},
"/assets/{id}/edits": {
"delete": {
"description": "Removes all edit actions (crop, rotate, mirror) associated with the specified asset.",
"operationId": "removeAssetEdits",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Remove edits from an existing asset",
"tags": [
"Assets"
],
"x-immich-history": [
{
"version": "v2",
"state": "Added"
},
{
"version": "v2",
"state": "Beta"
}
],
"x-immich-permission": "asset.edit",
"x-immich-state": "Beta"
},
"get": {
"description": "Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.",
"operationId": "getAssetEdits",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetEditsDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Retrieve edits for an existing asset",
"tags": [
"Assets"
],
"x-immich-history": [
{
"version": "v2",
"state": "Added"
},
{
"version": "v2",
"state": "Beta"
}
],
"x-immich-permission": "asset.read",
"x-immich-state": "Beta"
},
"put": {
"description": "Applies a series of edit actions (crop, rotate, mirror) to the specified asset.",
"operationId": "editAsset",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EditActionListDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetEditsDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Applies edits to an existing asset",
"tags": [
"Assets"
],
"x-immich-history": [
{
"version": "v2",
"state": "Added"
},
{
"version": "v2",
"state": "Beta"
}
],
"x-immich-permission": "asset.edit",
"x-immich-state": "Beta"
}
},
"/assets/{id}/metadata": {
"get": {
"description": "Retrieve all metadata key-value pairs associated with the specified asset.",
@@ -3683,15 +3516,6 @@
"description": "Downloads the original file of the specified asset.",
"operationId": "downloadAsset",
"parameters": [
{
"name": "edited",
"required": false,
"in": "query",
"schema": {
"default": true,
"type": "boolean"
}
},
{
"name": "id",
"required": true,
@@ -3852,14 +3676,6 @@
"description": "Retrieve the thumbnail image for the specified asset.",
"operationId": "viewAsset",
"parameters": [
{
"name": "edited",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "id",
"required": true,
@@ -14452,7 +14268,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "2.3.1",
"version": "2.4.0",
"contact": {}
},
"tags": [
@@ -15290,36 +15106,6 @@
],
"type": "object"
},
"AssetEditsDto": {
"properties": {
"assetId": {
"format": "uuid",
"type": "string"
},
"edits": {
"description": "list of edits",
"items": {
"anyOf": [
{
"$ref": "#/components/schemas/EditActionCrop"
},
{
"$ref": "#/components/schemas/EditActionRotate"
},
{
"$ref": "#/components/schemas/EditActionMirror"
}
]
},
"type": "array"
}
},
"required": [
"assetId",
"edits"
],
"type": "object"
},
"AssetFaceCreateDto": {
"properties": {
"assetId": {
@@ -15920,10 +15706,6 @@
"hasMetadata": {
"type": "boolean"
},
"height": {
"nullable": true,
"type": "number"
},
"id": {
"type": "string"
},
@@ -16044,10 +15826,6 @@
"$ref": "#/components/schemas/AssetVisibility"
}
]
},
"width": {
"nullable": true,
"type": "number"
}
},
"required": [
@@ -16059,7 +15837,6 @@
"fileCreatedAt",
"fileModifiedAt",
"hasMetadata",
"height",
"id",
"isArchived",
"isFavorite",
@@ -16072,8 +15849,7 @@
"thumbhash",
"type",
"updatedAt",
"visibility",
"width"
"visibility"
],
"type": "object"
},
@@ -16437,37 +16213,6 @@
],
"type": "object"
},
"CropParameters": {
"properties": {
"height": {
"description": "Height of the crop",
"minimum": 1,
"type": "number"
},
"width": {
"description": "Width of the crop",
"minimum": 1,
"type": "number"
},
"x": {
"description": "Top-Left X coordinate of crop",
"minimum": 0,
"type": "number"
},
"y": {
"description": "Top-Left Y coordinate of crop",
"minimum": 0,
"type": "number"
}
},
"required": [
"height",
"width",
"x",
"y"
],
"type": "object"
},
"DatabaseBackupConfig": {
"properties": {
"cronExpression": {
@@ -16612,96 +16357,6 @@
],
"type": "object"
},
"EditAction": {
"enum": [
"crop",
"rotate",
"mirror"
],
"type": "string"
},
"EditActionCrop": {
"properties": {
"action": {
"allOf": [
{
"$ref": "#/components/schemas/EditAction"
}
]
},
"parameters": {
"$ref": "#/components/schemas/CropParameters"
}
},
"required": [
"action",
"parameters"
],
"type": "object"
},
"EditActionListDto": {
"properties": {
"edits": {
"description": "list of edits",
"items": {
"anyOf": [
{
"$ref": "#/components/schemas/EditActionCrop"
},
{
"$ref": "#/components/schemas/EditActionRotate"
},
{
"$ref": "#/components/schemas/EditActionMirror"
}
]
},
"type": "array"
}
},
"required": [
"edits"
],
"type": "object"
},
"EditActionMirror": {
"properties": {
"action": {
"allOf": [
{
"$ref": "#/components/schemas/EditAction"
}
]
},
"parameters": {
"$ref": "#/components/schemas/MirrorParameters"
}
},
"required": [
"action",
"parameters"
],
"type": "object"
},
"EditActionRotate": {
"properties": {
"action": {
"allOf": [
{
"$ref": "#/components/schemas/EditAction"
}
]
},
"parameters": {
"$ref": "#/components/schemas/RotateParameters"
}
},
"required": [
"action",
"parameters"
],
"type": "object"
},
"EmailNotificationsResponse": {
"properties": {
"albumInvite": {
@@ -17712,30 +17367,6 @@
},
"type": "object"
},
"MirrorAxis": {
"description": "Axis to mirror along",
"enum": [
"horizontal",
"vertical"
],
"type": "string"
},
"MirrorParameters": {
"properties": {
"axis": {
"allOf": [
{
"$ref": "#/components/schemas/MirrorAxis"
}
],
"description": "Axis to mirror along"
}
},
"required": [
"axis"
],
"type": "object"
},
"NotificationCreateDto": {
"properties": {
"data": {
@@ -18216,8 +17847,6 @@
"asset.upload",
"asset.replace",
"asset.copy",
"asset.derive",
"asset.edit",
"album.create",
"album.read",
"album.update",
@@ -19274,18 +18903,6 @@
],
"type": "object"
},
"RotateParameters": {
"properties": {
"angle": {
"description": "Rotation angle in degrees",
"type": "number"
}
},
"required": [
"angle"
],
"type": "object"
},
"SearchAlbumResponseDto": {
"properties": {
"count": {
@@ -21007,10 +20624,6 @@
"nullable": true,
"type": "string"
},
"height": {
"nullable": true,
"type": "integer"
},
"id": {
"type": "string"
},
@@ -21057,10 +20670,6 @@
"$ref": "#/components/schemas/AssetVisibility"
}
]
},
"width": {
"nullable": true,
"type": "integer"
}
},
"required": [
@@ -21069,7 +20678,6 @@
"duration",
"fileCreatedAt",
"fileModifiedAt",
"height",
"id",
"isFavorite",
"libraryId",
@@ -21080,8 +20688,7 @@
"stackId",
"thumbhash",
"type",
"visibility",
"width"
"visibility"
],
"type": "object"
},

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "2.3.1",
"version": "2.4.0",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",

View File

@@ -1,6 +1,6 @@
/**
* Immich
* 2.3.1
* 2.4.0
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@@ -349,7 +349,6 @@ export type AssetResponseDto = {
/** The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken. */
fileModifiedAt: string;
hasMetadata: boolean;
height: number | null;
id: string;
isArchived: boolean;
isFavorite: boolean;
@@ -374,7 +373,6 @@ export type AssetResponseDto = {
/** The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified. */
updatedAt: string;
visibility: AssetVisibility;
width: number | null;
};
export type ContributorCountResponseDto = {
assetCount: number;
@@ -555,45 +553,6 @@ export type UpdateAssetDto = {
rating?: number;
visibility?: AssetVisibility;
};
export type CropParameters = {
/** Height of the crop */
height: number;
/** Width of the crop */
width: number;
/** Top-Left X coordinate of crop */
x: number;
/** Top-Left Y coordinate of crop */
y: number;
};
export type EditActionCrop = {
action: EditAction;
parameters: CropParameters;
};
export type RotateParameters = {
/** Rotation angle in degrees */
angle: number;
};
export type EditActionRotate = {
action: EditAction;
parameters: RotateParameters;
};
export type MirrorParameters = {
/** Axis to mirror along */
axis: MirrorAxis;
};
export type EditActionMirror = {
action: EditAction;
parameters: MirrorParameters;
};
export type AssetEditsDto = {
assetId: string;
/** list of edits */
edits: (EditActionCrop | EditActionRotate | EditActionMirror)[];
};
export type EditActionListDto = {
/** list of edits */
edits: (EditActionCrop | EditActionRotate | EditActionMirror)[];
};
export type AssetMetadataResponseDto = {
key: AssetMetadataKey;
updatedAt: string;
@@ -2566,46 +2525,6 @@ export function updateAsset({ id, updateAssetDto }: {
body: updateAssetDto
})));
}
/**
* Remove edits from an existing asset
*/
export function removeAssetEdits({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/assets/${encodeURIComponent(id)}/edits`, {
...opts,
method: "DELETE"
}));
}
/**
* Retrieve edits for an existing asset
*/
export function getAssetEdits({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AssetEditsDto;
}>(`/assets/${encodeURIComponent(id)}/edits`, {
...opts
}));
}
/**
* Applies edits to an existing asset
*/
export function editAsset({ id, editActionListDto }: {
id: string;
editActionListDto: EditActionListDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AssetEditsDto;
}>(`/assets/${encodeURIComponent(id)}/edits`, oazapfts.json({
...opts,
method: "PUT",
body: editActionListDto
})));
}
/**
* Get asset metadata
*/
@@ -2677,8 +2596,7 @@ export function getAssetOcr({ id }: {
/**
* Download original asset
*/
export function downloadAsset({ edited, id, key, slug }: {
edited?: boolean;
export function downloadAsset({ id, key, slug }: {
id: string;
key?: string;
slug?: string;
@@ -2687,7 +2605,6 @@ export function downloadAsset({ edited, id, key, slug }: {
status: 200;
data: Blob;
}>(`/assets/${encodeURIComponent(id)}/original${QS.query(QS.explode({
edited,
key,
slug
}))}`, {
@@ -2718,8 +2635,7 @@ export function replaceAsset({ id, key, slug, assetMediaReplaceDto }: {
/**
* View asset thumbnail
*/
export function viewAsset({ edited, id, key, size, slug }: {
edited?: boolean;
export function viewAsset({ id, key, size, slug }: {
id: string;
key?: string;
size?: AssetMediaSize;
@@ -2729,7 +2645,6 @@ export function viewAsset({ edited, id, key, size, slug }: {
status: 200;
data: Blob;
}>(`/assets/${encodeURIComponent(id)}/thumbnail${QS.query(QS.explode({
edited,
key,
size,
slug
@@ -5304,8 +5219,6 @@ export enum Permission {
AssetUpload = "asset.upload",
AssetReplace = "asset.replace",
AssetCopy = "asset.copy",
AssetDerive = "asset.derive",
AssetEdit = "asset.edit",
AlbumCreate = "album.create",
AlbumRead = "album.read",
AlbumUpdate = "album.update",
@@ -5454,15 +5367,6 @@ export enum AssetJobName {
RegenerateThumbnail = "regenerate-thumbnail",
TranscodeVideo = "transcode-video"
}
export enum EditAction {
Crop = "crop",
Rotate = "rotate",
Mirror = "mirror"
}
export enum MirrorAxis {
Horizontal = "horizontal",
Vertical = "vertical"
}
export enum AssetMediaSize {
Fullsize = "fullsize",
Preview = "preview",

125
pnpm-lock.yaml generated
View File

@@ -20,8 +20,8 @@ importers:
.github:
devDependencies:
prettier:
specifier: ^3.5.3
version: 3.7.1
specifier: ^3.7.4
version: 3.7.4
cli:
dependencies:
@@ -85,7 +85,7 @@ importers:
version: 10.1.8(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-prettier:
specifier: ^5.1.3
version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.7.1)
version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.7.4)
eslint-plugin-unicorn:
specifier: ^62.0.0
version: 62.0.0(eslint@9.39.1(jiti@2.6.1))
@@ -96,11 +96,11 @@ importers:
specifier: ^5.2.0
version: 5.5.0
prettier:
specifier: ^3.2.5
version: 3.7.1
specifier: ^3.7.4
version: 3.7.4
prettier-plugin-organize-imports:
specifier: ^4.0.0
version: 4.3.0(prettier@3.7.1)(typescript@5.9.3)
version: 4.3.0(prettier@3.7.4)(typescript@5.9.3)
typescript:
specifier: ^5.3.3
version: 5.9.3
@@ -184,8 +184,8 @@ importers:
specifier: ^3.7.0
version: 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
prettier:
specifier: ^3.2.4
version: 3.7.1
specifier: ^3.7.4
version: 3.7.4
typescript:
specifier: ^5.1.6
version: 5.9.3
@@ -239,13 +239,13 @@ importers:
version: 10.1.8(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-prettier:
specifier: ^5.1.3
version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.7.1)
version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.7.4)
eslint-plugin-unicorn:
specifier: ^62.0.0
version: 62.0.0(eslint@9.39.1(jiti@2.6.1))
exiftool-vendored:
specifier: ^33.0.0
version: 33.5.0
specifier: ^34.0.0
version: 34.0.0
globals:
specifier: ^16.0.0
version: 16.5.0
@@ -265,11 +265,11 @@ importers:
specifier: ^7.0.0
version: 7.0.0
prettier:
specifier: ^3.2.5
version: 3.7.1
specifier: ^3.7.4
version: 3.7.4
prettier-plugin-organize-imports:
specifier: ^4.0.0
version: 4.3.0(prettier@3.7.1)(typescript@5.9.3)
version: 4.3.0(prettier@3.7.4)(typescript@5.9.3)
sharp:
specifier: ^0.34.5
version: 0.34.5
@@ -428,8 +428,8 @@ importers:
specifier: 4.3.3
version: 4.3.3
exiftool-vendored:
specifier: ^33.0.0
version: 33.5.0
specifier: ^34.0.0
version: 34.0.0
express:
specifier: ^5.1.0
version: 5.2.0
@@ -547,9 +547,6 @@ importers:
thumbhash:
specifier: ^0.1.1
version: 0.1.1
transformation-matrix:
specifier: ^3.1.0
version: 3.1.0
ua-parser-js:
specifier: ^2.0.0
version: 2.0.6
@@ -658,7 +655,7 @@ importers:
version: 10.1.8(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-prettier:
specifier: ^5.1.3
version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.7.1)
version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.7.4)
eslint-plugin-unicorn:
specifier: ^62.0.0
version: 62.0.0(eslint@9.39.1(jiti@2.6.1))
@@ -675,11 +672,11 @@ importers:
specifier: ^7.0.0
version: 7.0.0
prettier:
specifier: ^3.0.2
version: 3.7.1
specifier: ^3.7.4
version: 3.7.4
prettier-plugin-organize-imports:
specifier: ^4.0.0
version: 4.3.0(prettier@3.7.1)(typescript@5.9.3)
version: 4.3.0(prettier@3.7.4)(typescript@5.9.3)
sql-formatter:
specifier: ^15.0.0
version: 15.6.10
@@ -720,8 +717,8 @@ importers:
specifier: file:../open-api/typescript-sdk
version: link:../open-api/typescript-sdk
'@immich/ui':
specifier: ^0.49.2
version: 0.49.3(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)
specifier: ^0.50.0
version: 0.50.0(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)
'@mapbox/mapbox-gl-rtl-text':
specifier: 0.2.3
version: 0.2.3(mapbox-gl@1.13.3)
@@ -907,17 +904,17 @@ importers:
specifier: ^16.0.0
version: 16.5.0
prettier:
specifier: ^3.4.2
version: 3.7.1
specifier: ^3.7.4
version: 3.7.4
prettier-plugin-organize-imports:
specifier: ^4.0.0
version: 4.3.0(prettier@3.7.1)(typescript@5.9.3)
version: 4.3.0(prettier@3.7.4)(typescript@5.9.3)
prettier-plugin-sort-json:
specifier: ^4.1.1
version: 4.1.1(prettier@3.7.1)
version: 4.1.1(prettier@3.7.4)
prettier-plugin-svelte:
specifier: ^3.3.3
version: 3.4.0(prettier@3.7.1)(svelte@5.45.2)
version: 3.4.0(prettier@3.7.4)(svelte@5.45.2)
rollup-plugin-visualizer:
specifier: ^6.0.0
version: 6.0.5(rollup@4.53.3)
@@ -2992,8 +2989,8 @@ packages:
peerDependencies:
svelte: ^5.0.0
'@immich/ui@0.49.3':
resolution: {integrity: sha512-joqT72Y6gmGK6z25Suzr2VhYANrLo43g20T4UHmbQenz/z/Ax6sl1Ao9SjIOwEkKMm9N3Txoh7WOOzmHVl04OA==}
'@immich/ui@0.50.0':
resolution: {integrity: sha512-7AW9SRZTAgal8xlkUAxm7o4+pSG7HcKb+Bh9JpWLaDRRdGyPCZMmsNa9CjZglOQ7wkAD07tQ9u4+zezBLe0dlQ==}
peerDependencies:
svelte: ^5.0.0
@@ -3239,6 +3236,7 @@ packages:
'@koa/router@14.0.0':
resolution: {integrity: sha512-LBSu5K0qAaaQcXX/0WIB9PGDevyCxxpnc1uq13vV/CgObaVxuis5hKl3Eboq/8gcb6ebnkAStW9NB/Em2eYyFA==}
engines: {node: '>= 20'}
deprecated: Please upgrade to v15 or higher. All reported bugs in this version are fixed in newer releases, dependencies have been updated, and security has been improved.
'@koddsson/eslint-plugin-tscompat@0.2.0':
resolution: {integrity: sha512-Oqd4kWSX0LiO9wWHjcmDfXZNC7TotFV/tLRhwCFU3XUeb//KYvJ75c9OmeSJ+vBv5lkCeB+xYsqyNrBc5j18XA==}
@@ -5506,8 +5504,8 @@ packages:
resolution: {integrity: sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==}
hasBin: true
batch-cluster@15.0.1:
resolution: {integrity: sha512-eUmh0ld1AUPKTEmdzwGF9QTSexXAyt9rA1F5zDfW1wUi3okA3Tal4NLdCeFI6aiKpBenQhR6NmK9bW9tBHTGPQ==}
batch-cluster@16.0.0:
resolution: {integrity: sha512-+T7Ho09ikx/kP4P8M+GEnpuePzRQa4gTUhtPIu6ApFC8+0GY0sri1y1PuB+yfXlQWl5DkHC/e58z3U6g0qCz/A==}
engines: {node: '>=20'}
batch@0.6.1:
@@ -6851,17 +6849,17 @@ packages:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'}
exiftool-vendored.exe@13.42.0:
resolution: {integrity: sha512-6AFybe5IakduMWleuQBfep9OWGSVZSedt2uKL+LzufRsATp+beOF7tZyKtMztjb6VRH1GF/4F9EvBVam6zm70w==}
exiftool-vendored.exe@13.43.0:
resolution: {integrity: sha512-EENHNz86tYY5yHGPtGB2mto3FIGstQvEhrcU34f7fm4RMxBKNfTWYOGkhU1jzvjOi+V4575LQX/FUES1TwgUbQ==}
os: [win32]
exiftool-vendored.pl@13.42.0:
resolution: {integrity: sha512-EF5IdxQNIJIvZjHf4bG4jnwAHVVSLkYZToo2q+Mm89kSuppKfRvHz/lngIxN0JALE8rFdC4zt6NWY/PKqRdCcg==}
exiftool-vendored.pl@13.43.0:
resolution: {integrity: sha512-0ApWaQ/pxaliPK7HzTxVA0sg/wZ8vl7UtFVhCyWhGQg01WfZkFrKwKmELB0Bnn01WTfgIuMadba8ccmFvpmJag==}
os: ['!win32']
hasBin: true
exiftool-vendored@33.5.0:
resolution: {integrity: sha512-7cCh6izwdmC5ZaCxpHFehnExIr2Yp7CJuxHg4WFiGcm81yyxXLtvSE+85ep9VsNwhlOtSpk+XxiqrlddjY5lAw==}
exiftool-vendored@34.0.0:
resolution: {integrity: sha512-rhIe4XGE7kh76nwytwHtq6qK/pc1mpOBHRV++gudFeG2PfAp3XIVQbFWCLK3S4l9I4AWYOe4mxk8mW8l1oHRTw==}
engines: {node: '>=20.0.0'}
expect-type@1.2.1:
@@ -9768,8 +9766,8 @@ packages:
prettier: ^3.0.0
svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0
prettier@3.7.1:
resolution: {integrity: sha512-RWKXE4qB3u5Z6yz7omJkjWwmTfLdcbv44jUVHC5NpfXwFGzvpQM798FGv/6WNK879tc+Cn0AAyherCl1KjbyZQ==}
prettier@3.7.4:
resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==}
engines: {node: '>=14'}
hasBin: true
@@ -11043,9 +11041,6 @@ packages:
resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
engines: {node: '>=18'}
transformation-matrix@3.1.0:
resolution: {integrity: sha512-oYubRWTi2tYFHAL2J8DLvPIqIYcYZ0fSOi2vmSy042Ho4jBW2ce6VP7QfD44t65WQz6bw5w1Pk22J7lcUpaTKA==}
tree-dump@1.1.0:
resolution: {integrity: sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==}
engines: {node: '>=10.0'}
@@ -14523,7 +14518,7 @@ snapshots:
'@fig/complete-commander@3.2.0(commander@11.1.0)':
dependencies:
commander: 11.1.0
prettier: 3.7.1
prettier: 3.7.4
'@floating-ui/core@1.7.3':
dependencies:
@@ -14706,7 +14701,7 @@ snapshots:
dependencies:
svelte: 5.45.2
'@immich/ui@0.49.3(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)':
'@immich/ui@0.50.0(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)':
dependencies:
'@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.45.2)
'@internationalized/date': 3.10.0
@@ -15794,7 +15789,7 @@ snapshots:
'@react-email/render@1.4.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
html-to-text: 9.0.5
prettier: 3.7.1
prettier: 3.7.4
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
react-promise-suspense: 0.3.4
@@ -17586,7 +17581,7 @@ snapshots:
baseline-browser-mapping@2.8.31: {}
batch-cluster@15.0.1: {}
batch-cluster@16.0.0: {}
batch@0.6.1: {}
@@ -18913,10 +18908,10 @@ snapshots:
lodash.memoize: 4.1.2
semver: 7.7.3
eslint-plugin-prettier@5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.7.1):
eslint-plugin-prettier@5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.7.4):
dependencies:
eslint: 9.39.1(jiti@2.6.1)
prettier: 3.7.1
prettier: 3.7.4
prettier-linter-helpers: 1.0.0
synckit: 0.11.11
optionalDependencies:
@@ -19134,21 +19129,21 @@ snapshots:
signal-exit: 3.0.7
strip-final-newline: 2.0.0
exiftool-vendored.exe@13.42.0:
exiftool-vendored.exe@13.43.0:
optional: true
exiftool-vendored.pl@13.42.0: {}
exiftool-vendored.pl@13.43.0: {}
exiftool-vendored@33.5.0:
exiftool-vendored@34.0.0:
dependencies:
'@photostructure/tz-lookup': 11.3.0
'@types/luxon': 3.7.1
batch-cluster: 15.0.1
exiftool-vendored.pl: 13.42.0
batch-cluster: 16.0.0
exiftool-vendored.pl: 13.43.0
he: 1.2.0
luxon: 3.7.2
optionalDependencies:
exiftool-vendored.exe: 13.42.0
exiftool-vendored.exe: 13.43.0
expect-type@1.2.1: {}
@@ -22642,21 +22637,21 @@ snapshots:
dependencies:
fast-diff: 1.3.0
prettier-plugin-organize-imports@4.3.0(prettier@3.7.1)(typescript@5.9.3):
prettier-plugin-organize-imports@4.3.0(prettier@3.7.4)(typescript@5.9.3):
dependencies:
prettier: 3.7.1
prettier: 3.7.4
typescript: 5.9.3
prettier-plugin-sort-json@4.1.1(prettier@3.7.1):
prettier-plugin-sort-json@4.1.1(prettier@3.7.4):
dependencies:
prettier: 3.7.1
prettier: 3.7.4
prettier-plugin-svelte@3.4.0(prettier@3.7.1)(svelte@5.45.2):
prettier-plugin-svelte@3.4.0(prettier@3.7.4)(svelte@5.45.2):
dependencies:
prettier: 3.7.1
prettier: 3.7.4
svelte: 5.45.2
prettier@3.7.1: {}
prettier@3.7.4: {}
pretty-error@4.0.0:
dependencies:
@@ -24297,8 +24292,6 @@ snapshots:
punycode: 2.3.1
optional: true
transformation-matrix@3.1.0: {}
tree-dump@1.1.0(tslib@2.8.1):
dependencies:
tslib: 2.8.1

View File

@@ -50,13 +50,15 @@ RUN --mount=type=cache,id=pnpm-cli,target=/buildcache/pnpm-store \
FROM builder AS plugins
ARG TARGETPLATFORM
COPY --from=ghcr.io/jdx/mise:2025.11.3@sha256:ac26f5978c0e2783f3e68e58ce75eddb83e41b89bf8747c503bac2aa9baf22c5 /usr/local/bin/mise /usr/local/bin/mise
WORKDIR /usr/src/app
COPY ./plugins/mise.toml ./plugins/
ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/plugins/mise.toml
ENV MISE_DATA_DIR=/buildcache/mise
RUN --mount=type=cache,id=mise-tools,target=/buildcache/mise \
RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
mise install --cd plugins
COPY ./plugins ./plugins/
@@ -66,7 +68,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
--mount=type=cache,id=mise-tools,target=/buildcache/mise \
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
cd plugins && mise run build
FROM ghcr.io/immich-app/base-server-prod:202511261514@sha256:c04c1c38dd90e53455b180aedf93c3c63474c8d20ffe2c6d7a3a61a2181e6d29

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "2.3.1",
"version": "2.4.0",
"description": "",
"author": "",
"private": true,
@@ -70,7 +70,7 @@
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"cron": "4.3.3",
"exiftool-vendored": "^33.0.0",
"exiftool-vendored": "^34.0.0",
"express": "^5.1.0",
"fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2",
@@ -110,7 +110,6 @@
"socket.io": "^4.8.1",
"tailwindcss-preset-email": "^1.4.0",
"thumbhash": "^0.1.1",
"transformation-matrix": "^3.1.0",
"ua-parser-js": "^2.0.0",
"uuid": "^11.1.0",
"validator": "^13.12.0"
@@ -129,8 +128,8 @@
"@types/cookie-parser": "^1.4.8",
"@types/express": "^5.0.0",
"@types/fluent-ffmpeg": "^2.1.21",
"@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.10",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.14.197",
"@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1",
@@ -154,7 +153,7 @@
"mock-fs": "^5.2.0",
"node-gyp": "^12.0.0",
"pngjs": "^7.0.0",
"prettier": "^3.0.2",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.0.0",
"sql-formatter": "^15.0.0",
"supertest": "^7.1.0",

View File

@@ -33,7 +33,6 @@ import {
CheckExistingAssetsDto,
UploadFieldName,
} from 'src/dtos/asset-media.dto';
import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { ApiTag, ImmichHeader, Permission, RouteKey } from 'src/enum';
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
@@ -95,11 +94,10 @@ export class AssetMediaController {
async downloadAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Query() { edited }: AssetDownloadOriginalDto,
@Res() res: Response,
@Next() next: NextFunction,
) {
await sendFile(res, next, () => this.service.downloadOriginal(auth, id, edited ?? true), this.logger);
await sendFile(res, next, () => this.service.downloadOriginal(auth, id), this.logger);
}
@Put(':id/original')

View File

@@ -17,7 +17,6 @@ import {
UpdateAssetDto,
} from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEditsDto, EditActionListDto } from 'src/dtos/editing.dto';
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
import { ApiTag, Permission, RouteKey } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
@@ -198,42 +197,4 @@ export class AssetController {
deleteAssetMetadata(@Auth() auth: AuthDto, @Param() { id, key }: AssetMetadataRouteParams): Promise<void> {
return this.service.deleteMetadataByKey(auth, id, key);
}
@Put(':id/edits')
@Authenticated({ permission: Permission.AssetEdit })
@Endpoint({
summary: 'Applies edits to an existing asset',
description: 'Applies a series of edit actions (crop, rotate, mirror) to the specified asset.',
history: new HistoryBuilder().added('v2').beta('v2'),
})
editAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: EditActionListDto,
): Promise<AssetEditsDto> {
return this.service.editAsset(auth, id, dto);
}
@Get(':id/edits')
@Authenticated({ permission: Permission.AssetRead })
@Endpoint({
summary: 'Retrieve edits for an existing asset',
description: 'Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.',
history: new HistoryBuilder().added('v2').beta('v2'),
})
getAssetEdits(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetEditsDto> {
return this.service.getAssetEdits(auth, id);
}
@Delete(':id/edits')
@Authenticated({ permission: Permission.AssetEdit })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Remove edits from an existing asset',
description: 'Removes all edit actions (crop, rotate, mirror) associated with the specified asset.',
history: new HistoryBuilder().added('v2').beta('v2'),
})
removeAssetEdits(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.removeAssetEdits(auth, id);
}
}

View File

@@ -24,13 +24,7 @@ export interface MoveRequest {
};
}
export type GeneratedImageType =
| AssetPathType.Preview
| AssetPathType.Thumbnail
| AssetPathType.FullSize
| AssetPathType.EditedPreview
| AssetPathType.EditedThumbnail
| AssetPathType.EditedFullSize;
export type GeneratedImageType = AssetPathType.Preview | AssetPathType.Thumbnail | AssetPathType.FullSize;
export type GeneratedAssetType = GeneratedImageType | AssetPathType.EncodedVideo;
export type ThumbnailPathEntity = { id: string; ownerId: string };

View File

@@ -272,7 +272,6 @@ export type AssetFace = {
person?: Person | null;
updatedAt: Date;
updateId: string;
isVisible: boolean;
};
export type Plugin = Selectable<PluginTable>;
@@ -341,8 +340,6 @@ export const columns = {
'asset.originalPath',
'asset.ownerId',
'asset.type',
'asset.width',
'asset.height',
],
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type'],
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
@@ -393,8 +390,6 @@ export const columns = {
'asset.livePhotoVideoId',
'asset.stackId',
'asset.libraryId',
'asset.width',
'asset.height',
],
syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'],
syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'],

View File

@@ -19,9 +19,6 @@ export enum AssetMediaSize {
export class AssetMediaOptionsDto {
@ValidateEnum({ enum: AssetMediaSize, name: 'AssetMediaSize', optional: true })
size?: AssetMediaSize;
@ValidateBoolean({ optional: true })
edited?: boolean;
}
export enum UploadFieldName {

View File

@@ -3,7 +3,6 @@ import { Selectable } from 'kysely';
import { AssetFace, AssetFile, Exif, Stack, Tag, User } from 'src/database';
import { HistoryBuilder, Property } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { EditActionItem } from 'src/dtos/editing.dto';
import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto';
import {
AssetFaceWithoutPersonResponseDto,
@@ -14,8 +13,6 @@ import {
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { ImageDimensions } from 'src/types';
import { getDimensions } from 'src/utils/asset.util';
import { hexOrBufferToBase64 } from 'src/utils/bytes';
import { mimeTypes } from 'src/utils/mime-types';
import { ValidateEnum } from 'src/validation';
@@ -37,8 +34,6 @@ export class SanitizedAssetResponseDto {
duration!: string;
livePhotoVideoId?: string | null;
hasMetadata!: boolean;
width!: number | null;
height!: number | null;
}
export class AssetResponseDto extends SanitizedAssetResponseDto {
@@ -112,7 +107,6 @@ export type MapAsset = {
deviceId: string;
duplicateId: string | null;
duration: string | null;
edits?: EditActionItem[];
encodedVideoPath: string | null;
exifInfo?: Selectable<Exif> | null;
faces?: AssetFace[];
@@ -135,8 +129,6 @@ export type MapAsset = {
tags?: Tag[];
thumbhash: Buffer<ArrayBufferLike> | null;
type: AssetType;
width: number | null;
height: number | null;
};
export class AssetStackResponseDto {
@@ -155,20 +147,16 @@ export type AssetMapOptions = {
};
// TODO: this is inefficient
const peopleWithFaces = (
faces?: AssetFace[],
edits?: EditActionItem[],
assetDimensions?: ImageDimensions,
): PersonWithFacesResponseDto[] => {
const peopleWithFaces = (faces?: AssetFace[]): PersonWithFacesResponseDto[] => {
const result: PersonWithFacesResponseDto[] = [];
if (faces && edits && assetDimensions) {
if (faces) {
for (const face of faces) {
if (face.person) {
const existingPersonEntry = result.find((item) => item.id === face.person!.id);
if (existingPersonEntry) {
existingPersonEntry.faces.push(face);
} else {
result.push({ ...mapPerson(face.person!), faces: [mapFacesWithoutPerson(face, edits, assetDimensions)] });
result.push({ ...mapPerson(face.person!), faces: [mapFacesWithoutPerson(face)] });
}
}
}
@@ -202,14 +190,10 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
duration: entity.duration ?? '0:00:00.00000',
livePhotoVideoId: entity.livePhotoVideoId,
hasMetadata: false,
width: entity.width,
height: entity.height,
};
return sanitizedAssetResponse as AssetResponseDto;
}
const assetDimensions = entity.exifInfo ? getDimensions(entity.exifInfo) : undefined;
return {
id: entity.id,
createdAt: entity.createdAt,
@@ -235,7 +219,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
livePhotoVideoId: entity.livePhotoVideoId,
tags: entity.tags?.map((tag) => mapTag(tag)),
people: peopleWithFaces(entity.faces, entity.edits, assetDimensions),
people: peopleWithFaces(entity.faces),
unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)),
checksum: hexOrBufferToBase64(entity.checksum)!,
stack: withStack ? mapStack(entity) : undefined,
@@ -243,7 +227,5 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
hasMetadata: true,
duplicateId: entity.duplicateId,
resized: true,
width: entity.width,
height: entity.height,
};
}

View File

@@ -197,11 +197,6 @@ export class AssetCopyDto {
favorite?: boolean;
}
export class AssetDownloadOriginalDto {
@ValidateBoolean({ optional: true, default: true })
edited?: boolean;
}
export const mapStats = (stats: AssetStats): AssetStatsResponseDto => {
return {
images: stats[AssetType.Image],

View File

@@ -1,122 +0,0 @@
import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger';
import { ClassConstructor, plainToInstance, Transform, Type } from 'class-transformer';
import { IsEnum, IsInt, Min, ValidateNested } from 'class-validator';
import { IsAxisAlignedRotation, ValidateUUID } from 'src/validation';
export enum EditAction {
Crop = 'crop',
Rotate = 'rotate',
Mirror = 'mirror',
}
export enum MirrorAxis {
Horizontal = 'horizontal',
Vertical = 'vertical',
}
export class CropParameters {
@IsInt()
@Min(0)
@ApiProperty({ description: 'Top-Left X coordinate of crop' })
x!: number;
@IsInt()
@Min(0)
@ApiProperty({ description: 'Top-Left Y coordinate of crop' })
y!: number;
@IsInt()
@Min(1)
@ApiProperty({ description: 'Width of the crop' })
width!: number;
@IsInt()
@Min(1)
@ApiProperty({ description: 'Height of the crop' })
height!: number;
}
export class RotateParameters {
@IsAxisAlignedRotation()
@ApiProperty({ description: 'Rotation angle in degrees' })
angle!: number;
}
export class MirrorParameters {
@IsEnum(MirrorAxis)
@ApiProperty({ enum: MirrorAxis, enumName: 'MirrorAxis', description: 'Axis to mirror along' })
axis!: MirrorAxis;
}
class EditActionBase {
@IsEnum(EditAction)
@ApiProperty({ enum: EditAction, enumName: 'EditAction' })
action!: EditAction;
}
export class EditActionCrop extends EditActionBase {
@ValidateNested()
@Type(() => CropParameters)
@ApiProperty({ type: CropParameters })
parameters!: CropParameters;
}
export class EditActionRotate extends EditActionBase {
@ValidateNested()
@Type(() => RotateParameters)
@ApiProperty({ type: RotateParameters })
parameters!: RotateParameters;
}
export class EditActionMirror extends EditActionBase {
@ValidateNested()
@Type(() => MirrorParameters)
@ApiProperty({ type: MirrorParameters })
parameters!: MirrorParameters;
}
export type EditActionItem =
| {
action: EditAction.Crop;
parameters: CropParameters;
}
| {
action: EditAction.Rotate;
parameters: RotateParameters;
}
| {
action: EditAction.Mirror;
parameters: MirrorParameters;
};
export type EditActionParameter = {
[EditAction.Crop]: CropParameters;
[EditAction.Rotate]: RotateParameters;
[EditAction.Mirror]: MirrorParameters;
};
type EditActions = EditActionCrop | EditActionRotate | EditActionMirror;
const actionToClass: Record<EditAction, ClassConstructor<EditActions>> = {
[EditAction.Crop]: EditActionCrop,
[EditAction.Rotate]: EditActionRotate,
[EditAction.Mirror]: EditActionMirror,
} as const;
const getActionClass = (item: { action: EditAction }): ClassConstructor<EditActions> => actionToClass[item.action];
@ApiExtraModels(EditActionRotate, EditActionMirror, EditActionCrop)
export class EditActionListDto {
/** list of edits */
@ValidateNested({ each: true })
@Transform(({ value: edits }) =>
Array.isArray(edits) ? edits.map((item) => plainToInstance(getActionClass(item), item)) : edits,
)
@ApiProperty({ anyOf: Object.values(actionToClass).map((target) => ({ $ref: getSchemaPath(target) })) })
edits!: EditActionItem[];
}
export class AssetEditsDto extends EditActionListDto {
@ValidateUUID()
@ApiProperty()
assetId!: string;
}

View File

@@ -6,12 +6,9 @@ import { DateTime } from 'luxon';
import { AssetFace, Person } from 'src/database';
import { HistoryBuilder, Property } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { EditActionItem } from 'src/dtos/editing.dto';
import { SourceType } from 'src/enum';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { ImageDimensions } from 'src/types';
import { asDateString } from 'src/utils/date';
import { transformFaceBoundingBox } from 'src/utils/transform';
import {
IsDateStringFormat,
MaxDateString,
@@ -236,37 +233,29 @@ export function mapPerson(person: Person): PersonResponseDto {
};
}
export function mapFacesWithoutPerson(
face: Selectable<AssetFaceTable>,
edits?: EditActionItem[],
assetDimensions?: ImageDimensions,
): AssetFaceWithoutPersonResponseDto {
export function mapFacesWithoutPerson(face: Selectable<AssetFaceTable>): AssetFaceWithoutPersonResponseDto {
return {
id: face.id,
...transformFaceBoundingBox(
{
boundingBoxX1: face.boundingBoxX1,
boundingBoxY1: face.boundingBoxY1,
boundingBoxX2: face.boundingBoxX2,
boundingBoxY2: face.boundingBoxY2,
imageWidth: face.imageWidth,
imageHeight: face.imageHeight,
},
edits ?? [],
assetDimensions ?? { width: face.imageWidth, height: face.imageHeight },
),
imageHeight: face.imageHeight,
imageWidth: face.imageWidth,
boundingBoxX1: face.boundingBoxX1,
boundingBoxX2: face.boundingBoxX2,
boundingBoxY1: face.boundingBoxY1,
boundingBoxY2: face.boundingBoxY2,
sourceType: face.sourceType,
};
}
export function mapFaces(
face: AssetFace,
auth: AuthDto,
edits?: EditActionItem[],
assetDimensions?: ImageDimensions,
): AssetFaceResponseDto {
export function mapFaces(face: AssetFace, auth: AuthDto): AssetFaceResponseDto {
return {
...mapFacesWithoutPerson(face, edits, assetDimensions),
id: face.id,
imageHeight: face.imageHeight,
imageWidth: face.imageWidth,
boundingBoxX1: face.boundingBoxX1,
boundingBoxX2: face.boundingBoxX2,
boundingBoxY1: face.boundingBoxY1,
boundingBoxY2: face.boundingBoxY2,
sourceType: face.sourceType,
person: face.person?.ownerId === auth.user.id ? mapPerson(face.person) : null,
};
}

View File

@@ -118,10 +118,6 @@ export class SyncAssetV1 {
livePhotoVideoId!: string | null;
stackId!: string | null;
libraryId!: string | null;
@ApiProperty({ type: 'integer' })
width!: number | null;
@ApiProperty({ type: 'integer' })
height!: number | null;
}
@ExtraModel()

View File

@@ -45,9 +45,6 @@ export enum AssetFileType {
Preview = 'preview',
Thumbnail = 'thumbnail',
Sidecar = 'sidecar',
EditedFullSize = 'fullsize_edited',
EditedPreview = 'preview_edited',
EditedThumbnail = 'thumbnail_edited',
}
export enum AlbumUserRole {
@@ -109,8 +106,6 @@ export enum Permission {
AssetUpload = 'asset.upload',
AssetReplace = 'asset.replace',
AssetCopy = 'asset.copy',
AssetDerive = 'asset.derive',
AssetEdit = 'asset.edit',
AlbumCreate = 'album.create',
AlbumRead = 'album.read',
@@ -363,9 +358,6 @@ export enum AssetPathType {
Original = 'original',
FullSize = 'fullsize',
Preview = 'preview',
EditedFullSize = 'edited_fullsize',
EditedPreview = 'edited_preview',
EditedThumbnail = 'edited_thumbnail',
Thumbnail = 'thumbnail',
EncodedVideo = 'encoded_video',
Sidecar = 'sidecar',

View File

@@ -1,22 +0,0 @@
-- NOTE: This file is auto generated by ./sql-generator
-- AssetEditRepository.storeEdits
begin
delete from "asset_edit"
where
"assetId" = $1
rollback
-- AssetEditRepository.getEditsForAsset
select
"action",
"parameters"
from
"asset_edit"
where
"assetId" = $1
-- AssetEditRepository.deleteEditsForAsset
delete from "asset_edit"
where
"assetId" = $1

View File

@@ -103,21 +103,7 @@ select
where
"asset_file"."assetId" = "asset"."id"
) as agg
) as "files",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_edit"."action",
"asset_edit"."parameters"
from
"asset_edit"
where
"asset_edit"."assetId" = "asset"."id"
) as agg
) as "edits"
) as "files"
from
"asset"
inner join "asset_job_status" on "asset_job_status"."assetId" = "asset"."id"
@@ -179,20 +165,6 @@ select
"asset_file"."assetId" = "asset"."id"
) as agg
) as "files",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_edit"."action",
"asset_edit"."parameters"
from
"asset_edit"
where
"asset_edit"."assetId" = "asset"."id"
) as agg
) as "edits",
to_json("asset_exif") as "exifInfo"
from
"asset"
@@ -217,8 +189,6 @@ select
"asset"."originalPath",
"asset"."ownerId",
"asset"."type",
"asset"."width",
"asset"."height",
(
select
coalesce(json_agg(agg), '[]')
@@ -231,7 +201,6 @@ select
where
"asset_face"."assetId" = "asset"."id"
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $1
) as agg
) as "faces",
(
@@ -247,13 +216,13 @@ select
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $2
and "asset_file"."type" = $1
) as agg
) as "files"
from
"asset"
where
"asset"."id" = $3
"asset"."id" = $2
-- AssetJobRepository.getAlbumThumbnailFiles
select
@@ -423,7 +392,6 @@ select
where
"asset_face"."assetId" = "asset"."id"
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
) as agg
) as "faces",
(

View File

@@ -144,7 +144,6 @@ select
where
"asset_face"."assetId" = "asset"."id"
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
) as agg
) as "faces",
(
@@ -346,10 +345,14 @@ with
"asset_exif"."projectionType",
coalesce(
case
when asset."height" = 0
or asset."width" = 0 then 1
when asset_exif."exifImageHeight" = 0
or asset_exif."exifImageWidth" = 0 then 1
when "asset_exif"."orientation" in ('5', '6', '7', '8', '-90', '90') then round(
asset_exif."exifImageHeight"::numeric / asset_exif."exifImageWidth"::numeric,
3
)
else round(
asset."width"::numeric / asset."height"::numeric,
asset_exif."exifImageWidth"::numeric / asset_exif."exifImageHeight"::numeric,
3
)
end,

View File

@@ -15,7 +15,6 @@ from
"asset_ocr"
where
"asset_ocr"."assetId" = $1
and "asset_ocr"."isVisible" = $2
-- OcrRepository.upsert
with
@@ -67,10 +66,3 @@ with
)
select
1 as "dummy"
-- OcrRepository.updateOcrVisibilities
update "ocr_search"
set
"text" = $1
where
"assetId" = $2

View File

@@ -35,7 +35,6 @@ from
where
"person"."ownerId" = $1
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
and "person"."isHidden" = $2
group by
"person"."id"
@@ -64,7 +63,6 @@ from
left join "asset_face" on "asset_face"."personId" = "person"."id"
where
"asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
group by
"person"."id"
having
@@ -91,7 +89,6 @@ from
where
"asset_face"."assetId" = $1
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $2
order by
"asset_face"."boundingBoxX1" asc
@@ -232,7 +229,6 @@ from
and "asset"."deletedAt" is null
where
"asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
-- PersonRepository.getNumberOfPeople
select
@@ -254,7 +250,6 @@ where
where
"asset_face"."personId" = "person"."id"
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $2
and exists (
select
from
@@ -265,7 +260,7 @@ where
and "asset"."deletedAt" is null
)
)
and "person"."ownerId" = $3
and "person"."ownerId" = $2
-- PersonRepository.refreshFaces
with
@@ -326,7 +321,6 @@ from
where
"asset_face"."personId" = $1
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
-- PersonRepository.getLatestFaceDate
select

View File

@@ -69,8 +69,6 @@ select
"asset"."livePhotoVideoId",
"asset"."stackId",
"asset"."libraryId",
"asset"."width",
"asset"."height",
"album_asset"."updateId"
from
"album_asset" as "album_asset"
@@ -101,8 +99,6 @@ select
"asset"."livePhotoVideoId",
"asset"."stackId",
"asset"."libraryId",
"asset"."width",
"asset"."height",
"asset"."updateId"
from
"asset" as "asset"
@@ -138,9 +134,7 @@ select
"asset"."duration",
"asset"."livePhotoVideoId",
"asset"."stackId",
"asset"."libraryId",
"asset"."width",
"asset"."height"
"asset"."libraryId"
from
"album_asset" as "album_asset"
inner join "asset" on "asset"."id" = "album_asset"."assetId"
@@ -454,8 +448,6 @@ select
"asset"."livePhotoVideoId",
"asset"."stackId",
"asset"."libraryId",
"asset"."width",
"asset"."height",
"asset"."updateId"
from
"asset" as "asset"
@@ -544,7 +536,6 @@ where
"asset_face"."updateId" < $1
and "asset_face"."updateId" > $2
and "asset"."ownerId" = $3
and "asset_face"."isVisible" = $4
order by
"asset_face"."updateId" asc
@@ -749,8 +740,6 @@ select
"asset"."livePhotoVideoId",
"asset"."stackId",
"asset"."libraryId",
"asset"."width",
"asset"."height",
"asset"."updateId"
from
"asset" as "asset"
@@ -800,8 +789,6 @@ select
"asset"."livePhotoVideoId",
"asset"."stackId",
"asset"."libraryId",
"asset"."width",
"asset"."height",
"asset"."updateId"
from
"asset" as "asset"

View File

@@ -1,45 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Kysely } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DummyValue, GenerateSql } from 'src/decorators';
import { EditActionItem } from 'src/dtos/editing.dto';
import { DB } from 'src/schema';
@Injectable()
export class AssetEditRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({
params: [DummyValue.UUID],
})
async storeEdits(assetId: string, edits: EditActionItem[]): Promise<void> {
await this.db.transaction().execute(async (trx) => {
await trx.deleteFrom('asset_edit').where('assetId', '=', assetId).execute();
if (edits.length > 0) {
await trx
.insertInto('asset_edit')
.values(edits.map((edit) => ({ assetId, ...edit })))
.execute();
}
});
}
@GenerateSql({
params: [DummyValue.UUID],
})
async getEditsForAsset(assetId: string): Promise<EditActionItem[]> {
return this.db
.selectFrom('asset_edit')
.select(['action', 'parameters'])
.where('assetId', '=', assetId)
.execute() as Promise<EditActionItem[]>;
}
@GenerateSql({
params: [DummyValue.UUID],
})
async deleteEditsForAsset(assetId: string): Promise<void> {
await this.db.deleteFrom('asset_edit').where('assetId', '=', assetId).execute();
}
}

View File

@@ -11,7 +11,6 @@ import {
asUuid,
toJson,
withDefaultVisibility,
withEdits,
withExif,
withExifInner,
withFaces,
@@ -72,7 +71,6 @@ export class AssetJobRepository {
.selectFrom('asset')
.select(['asset.id', 'asset.thumbhash'])
.select(withFiles)
.select(withEdits)
.where('asset.deletedAt', 'is', null)
.where('asset.visibility', '!=', AssetVisibility.Hidden)
.$if(!force, (qb) =>
@@ -114,7 +112,6 @@ export class AssetJobRepository {
'asset.type',
])
.select(withFiles)
.select(withEdits)
.$call(withExifInner)
.where('asset.id', '=', id)
.executeTakeFirst();
@@ -192,7 +189,7 @@ export class AssetJobRepository {
.selectFrom('asset')
.select(['asset.id', 'asset.visibility'])
.$call(withExifInner)
.select((eb) => withFaces(eb, true, true))
.select((eb) => withFaces(eb, true))
.select((eb) => withFiles(eb, AssetFileType.Preview))
.where('asset.id', '=', id)
.executeTakeFirst();

View File

@@ -19,7 +19,6 @@ import {
truncatedDate,
unnest,
withDefaultVisibility,
withEdits,
withExif,
withFaces,
withFacesAndPeople,
@@ -112,7 +111,6 @@ interface GetByIdsRelations {
smartSearch?: boolean;
stack?: { assets?: boolean };
tags?: boolean;
edits?: boolean;
}
@Injectable()
@@ -410,10 +408,7 @@ export class AssetRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
getById(
id: string,
{ exifInfo, faces, files, library, owner, smartSearch, stack, tags, edits }: GetByIdsRelations = {},
) {
getById(id: string, { exifInfo, faces, files, library, owner, smartSearch, stack, tags }: GetByIdsRelations = {}) {
return this.db
.selectFrom('asset')
.selectAll('asset')
@@ -450,7 +445,6 @@ export class AssetRepository {
)
.$if(!!files, (qb) => qb.select(withFiles))
.$if(!!tags, (qb) => qb.select(withTags))
.$if(!!edits, (qb) => qb.select(withEdits))
.limit(1)
.executeTakeFirst();
}
@@ -478,11 +472,10 @@ export class AssetRepository {
.selectAll('asset')
.$call(withExif)
.$call((qb) => qb.select(withFacesAndPeople))
.$call((qb) => qb.select(withEdits))
.executeTakeFirst();
}
return this.getById(asset.id, { exifInfo: true, faces: { person: true }, edits: true });
return this.getById(asset.id, { exifInfo: true, faces: { person: true } });
}
async remove(asset: { id: string }): Promise<void> {
@@ -639,9 +632,11 @@ export class AssetRepository {
.coalesce(
eb
.case()
.when(sql`asset."height" = 0 or asset."width" = 0`)
.when(sql`asset_exif."exifImageHeight" = 0 or asset_exif."exifImageWidth" = 0`)
.then(eb.lit(1))
.else(sql`round(asset."width"::numeric / asset."height"::numeric, 3)`)
.when('asset_exif.orientation', 'in', sql<string>`('5', '6', '7', '8', '-90', '90')`)
.then(sql`round(asset_exif."exifImageHeight"::numeric / asset_exif."exifImageWidth"::numeric, 3)`)
.else(sql`round(asset_exif."exifImageWidth"::numeric / asset_exif."exifImageHeight"::numeric, 3)`)
.end(),
eb.lit(1),
)

View File

@@ -4,7 +4,6 @@ import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { AppRepository } from 'src/repositories/app.repository';
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { AuditRepository } from 'src/repositories/audit.repository';
@@ -60,7 +59,6 @@ export const repositories = [
ApiKeyRepository,
AppRepository,
AssetRepository,
AssetEditRepository,
AssetJobRepository,
ConfigRepository,
CronRepository,

View File

@@ -1,711 +0,0 @@
import sharp from 'sharp';
import { AssetFace } from 'src/database';
import { EditAction, EditActionCrop, MirrorAxis } from 'src/dtos/editing.dto';
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
import { SourceType } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { MediaRepository } from 'src/repositories/media.repository';
import { automock } from 'test/utils';
const getPixelColor = async (buffer: Buffer, x: number, y: number) => {
const metadata = await sharp(buffer).metadata();
const width = metadata.width!;
const { data } = await sharp(buffer).raw().toBuffer({ resolveWithObject: true });
const idx = (y * width + x) * 4;
return {
r: data[idx],
g: data[idx + 1],
b: data[idx + 2],
};
};
const buildTestQuadImage = async () => {
// build a 4 quadrant image for testing mirroring
const base = sharp({
create: { width: 1000, height: 1000, channels: 3, background: { r: 0, g: 0, b: 0 } },
}).png();
const tl = await sharp({
create: { width: 500, height: 500, channels: 3, background: { r: 255, g: 0, b: 0 } },
})
.png()
.toBuffer();
const tr = await sharp({
create: { width: 500, height: 500, channels: 3, background: { r: 0, g: 255, b: 0 } },
})
.png()
.toBuffer();
const bl = await sharp({
create: { width: 500, height: 500, channels: 3, background: { r: 0, g: 0, b: 255 } },
})
.png()
.toBuffer();
const br = await sharp({
create: { width: 500, height: 500, channels: 3, background: { r: 255, g: 255, b: 0 } },
})
.png()
.toBuffer();
const image = base.composite([
{ input: tl, left: 0, top: 0 }, // top-left
{ input: tr, left: 500, top: 0 }, // top-right
{ input: bl, left: 0, top: 500 }, // bottom-left
{ input: br, left: 500, top: 500 }, // bottom-right
]);
return image.png().toBuffer();
};
describe(MediaRepository.name, () => {
let sut: MediaRepository;
beforeEach(() => {
// eslint-disable-next-line no-sparse-arrays
sut = new MediaRepository(automock(LoggingRepository, { args: [, { getEnv: () => ({}) }], strict: false }));
});
describe('applyEdits (single actions)', () => {
it('should apply crop edit correctly', async () => {
const result = await sut['applyEdits'](
sharp({
create: {
width: 1000,
height: 1000,
channels: 4,
background: { r: 255, g: 0, b: 0, alpha: 0.5 },
},
}).png(),
[
{
action: EditAction.Crop,
parameters: {
x: 100,
y: 200,
width: 700,
height: 300,
},
},
],
);
const metadata = await result.toBuffer().then((buf) => sharp(buf).metadata());
expect(metadata.width).toBe(700);
expect(metadata.height).toBe(300);
});
it('should apply rotate edit correctly', async () => {
const result = await sut['applyEdits'](
sharp({
create: {
width: 500,
height: 1000,
channels: 4,
background: { r: 255, g: 0, b: 0, alpha: 0.5 },
},
}).png(),
[
{
action: EditAction.Rotate,
parameters: {
angle: 90,
},
},
],
);
const metadata = await result.toBuffer().then((buf) => sharp(buf).metadata());
expect(metadata.width).toBe(1000);
expect(metadata.height).toBe(500);
});
it('should apply mirror edit correctly', async () => {
const resultHorizontal = await sut['applyEdits'](sharp(await buildTestQuadImage()), [
{
action: EditAction.Mirror,
parameters: {
axis: MirrorAxis.Horizontal,
},
},
]);
const bufferHorizontal = await resultHorizontal.toBuffer();
const metadataHorizontal = await resultHorizontal.metadata();
expect(metadataHorizontal.width).toBe(1000);
expect(metadataHorizontal.height).toBe(1000);
expect(await getPixelColor(bufferHorizontal, 10, 10)).toEqual({ r: 0, g: 255, b: 0 });
expect(await getPixelColor(bufferHorizontal, 990, 10)).toEqual({ r: 255, g: 0, b: 0 });
expect(await getPixelColor(bufferHorizontal, 10, 990)).toEqual({ r: 255, g: 255, b: 0 });
expect(await getPixelColor(bufferHorizontal, 990, 990)).toEqual({ r: 0, g: 0, b: 255 });
const resultVertical = await sut['applyEdits'](sharp(await buildTestQuadImage()), [
{
action: EditAction.Mirror,
parameters: {
axis: MirrorAxis.Vertical,
},
},
]);
const bufferVertical = await resultVertical.toBuffer();
const metadataVertical = await resultVertical.metadata();
expect(metadataVertical.width).toBe(1000);
expect(metadataVertical.height).toBe(1000);
// top-left should now be bottom-left (blue)
expect(await getPixelColor(bufferVertical, 10, 10)).toEqual({ r: 0, g: 0, b: 255 });
// top-right should now be bottom-right (yellow)
expect(await getPixelColor(bufferVertical, 990, 10)).toEqual({ r: 255, g: 255, b: 0 });
// bottom-left should now be top-left (red)
expect(await getPixelColor(bufferVertical, 10, 990)).toEqual({ r: 255, g: 0, b: 0 });
// bottom-right should now be top-right (blue)
expect(await getPixelColor(bufferVertical, 990, 990)).toEqual({ r: 0, g: 255, b: 0 });
});
});
describe('applyEdits (multiple sequential edits)', () => {
it('should apply horizontal mirror then vertical mirror (equivalent to 180° rotation)', async () => {
const imageBuffer = await buildTestQuadImage();
const result = await sut['applyEdits'](sharp(imageBuffer), [
{ action: EditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
{ action: EditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } },
]);
const buffer = await result.png().toBuffer();
const metadata = await sharp(buffer).metadata();
expect(metadata.width).toBe(1000);
expect(metadata.height).toBe(1000);
expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 255, g: 255, b: 0 });
expect(await getPixelColor(buffer, 990, 10)).toEqual({ r: 0, g: 0, b: 255 });
expect(await getPixelColor(buffer, 10, 990)).toEqual({ r: 0, g: 255, b: 0 });
expect(await getPixelColor(buffer, 990, 990)).toEqual({ r: 255, g: 0, b: 0 });
});
it('should apply rotate 90° then horizontal mirror', async () => {
const imageBuffer = await buildTestQuadImage();
const result = await sut['applyEdits'](sharp(imageBuffer), [
{ action: EditAction.Rotate, parameters: { angle: 90 } },
{ action: EditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
]);
const buffer = await result.png().toBuffer();
const metadata = await sharp(buffer).metadata();
expect(metadata.width).toBe(1000);
expect(metadata.height).toBe(1000);
expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 255, g: 0, b: 0 });
expect(await getPixelColor(buffer, 990, 10)).toEqual({ r: 0, g: 0, b: 255 });
expect(await getPixelColor(buffer, 10, 990)).toEqual({ r: 0, g: 255, b: 0 });
expect(await getPixelColor(buffer, 990, 990)).toEqual({ r: 255, g: 255, b: 0 });
});
it('should apply 180° rotation', async () => {
const imageBuffer = await buildTestQuadImage();
const result = await sut['applyEdits'](sharp(imageBuffer), [
{ action: EditAction.Rotate, parameters: { angle: 180 } },
]);
const buffer = await result.png().toBuffer();
const metadata = await sharp(buffer).metadata();
expect(metadata.width).toBe(1000);
expect(metadata.height).toBe(1000);
expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 255, g: 255, b: 0 });
expect(await getPixelColor(buffer, 990, 10)).toEqual({ r: 0, g: 0, b: 255 });
expect(await getPixelColor(buffer, 10, 990)).toEqual({ r: 0, g: 255, b: 0 });
expect(await getPixelColor(buffer, 990, 990)).toEqual({ r: 255, g: 0, b: 0 });
});
it('should apply 270° rotations', async () => {
const imageBuffer = await buildTestQuadImage();
const result = await sut['applyEdits'](sharp(imageBuffer), [
{ action: EditAction.Rotate, parameters: { angle: 270 } },
]);
const buffer = await result.png().toBuffer();
const metadata = await sharp(buffer).metadata();
expect(metadata.width).toBe(1000);
expect(metadata.height).toBe(1000);
expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 0, g: 255, b: 0 });
expect(await getPixelColor(buffer, 990, 10)).toEqual({ r: 255, g: 255, b: 0 });
expect(await getPixelColor(buffer, 10, 990)).toEqual({ r: 255, g: 0, b: 0 });
expect(await getPixelColor(buffer, 990, 990)).toEqual({ r: 0, g: 0, b: 255 });
});
it('should apply crop then rotate 90°', async () => {
const imageBuffer = await buildTestQuadImage();
const result = await sut['applyEdits'](sharp(imageBuffer), [
{ action: EditAction.Crop, parameters: { x: 0, y: 0, width: 1000, height: 500 } },
{ action: EditAction.Rotate, parameters: { angle: 90 } },
]);
const buffer = await result.png().toBuffer();
const metadata = await sharp(buffer).metadata();
expect(metadata.width).toBe(500);
expect(metadata.height).toBe(1000);
expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 255, g: 0, b: 0 });
expect(await getPixelColor(buffer, 10, 990)).toEqual({ r: 0, g: 255, b: 0 });
});
it('should apply rotate 90° then crop', async () => {
const imageBuffer = await buildTestQuadImage();
const result = await sut['applyEdits'](sharp(imageBuffer), [
{ action: EditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 1000 } },
{ action: EditAction.Rotate, parameters: { angle: 90 } },
]);
const buffer = await result.png().toBuffer();
const metadata = await sharp(buffer).metadata();
expect(metadata.width).toBe(1000);
expect(metadata.height).toBe(500);
expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 0, g: 0, b: 255 });
expect(await getPixelColor(buffer, 990, 10)).toEqual({ r: 255, g: 0, b: 0 });
});
it('should apply vertical mirror then horizontal mirror then rotate 90°', async () => {
const imageBuffer = await buildTestQuadImage();
const result = await sut['applyEdits'](sharp(imageBuffer), [
{ action: EditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } },
{ action: EditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
{ action: EditAction.Rotate, parameters: { angle: 90 } },
]);
const buffer = await result.png().toBuffer();
const metadata = await sharp(buffer).metadata();
expect(metadata.width).toBe(1000);
expect(metadata.height).toBe(1000);
expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 0, g: 255, b: 0 });
expect(await getPixelColor(buffer, 990, 10)).toEqual({ r: 255, g: 255, b: 0 });
expect(await getPixelColor(buffer, 10, 990)).toEqual({ r: 255, g: 0, b: 0 });
expect(await getPixelColor(buffer, 990, 990)).toEqual({ r: 0, g: 0, b: 255 });
});
it('should apply crop to single quadrant then mirror', async () => {
const imageBuffer = await buildTestQuadImage();
const result = await sut['applyEdits'](sharp(imageBuffer), [
{ action: EditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 500 } },
{ action: EditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
]);
const buffer = await result.png().toBuffer();
const metadata = await sharp(buffer).metadata();
expect(metadata.width).toBe(500);
expect(metadata.height).toBe(500);
expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 255, g: 0, b: 0 });
expect(await getPixelColor(buffer, 490, 10)).toEqual({ r: 255, g: 0, b: 0 });
expect(await getPixelColor(buffer, 10, 490)).toEqual({ r: 255, g: 0, b: 0 });
expect(await getPixelColor(buffer, 490, 490)).toEqual({ r: 255, g: 0, b: 0 });
});
it('should apply all operations: crop, rotate, mirror', async () => {
const imageBuffer = await buildTestQuadImage();
const result = await sut['applyEdits'](sharp(imageBuffer), [
{ action: EditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 1000 } },
{ action: EditAction.Rotate, parameters: { angle: 90 } },
{ action: EditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
]);
const buffer = await result.png().toBuffer();
const metadata = await sharp(buffer).metadata();
expect(metadata.width).toBe(1000);
expect(metadata.height).toBe(500);
expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 255, g: 0, b: 0 });
expect(await getPixelColor(buffer, 990, 10)).toEqual({ r: 0, g: 0, b: 255 });
});
});
describe('checkFaceVisibility', () => {
const baseFace: AssetFace = {
id: 'face-1',
assetId: 'asset-1',
personId: 'person-1',
boundingBoxX1: 100,
boundingBoxY1: 100,
boundingBoxX2: 200,
boundingBoxY2: 200,
imageWidth: 1000,
imageHeight: 800,
sourceType: SourceType.MachineLearning,
isVisible: true,
updatedAt: new Date(),
deletedAt: null,
updateId: '',
};
const assetDimensions = { width: 1000, height: 800 };
describe('with no crop edit', () => {
it('should return all faces as visible when no crop is provided', () => {
const faces = [baseFace];
const result = sut.checkFaceVisibility(faces, assetDimensions);
expect(result.visible).toEqual(faces);
expect(result.hidden).toEqual([]);
});
});
describe('with crop edit', () => {
it('should mark face as visible when fully inside crop area', () => {
const crop: EditActionCrop = {
action: EditAction.Crop,
parameters: { x: 0, y: 0, width: 500, height: 400 },
};
const faces = [baseFace];
const result = sut.checkFaceVisibility(faces, assetDimensions, crop);
expect(result.visible).toEqual(faces);
expect(result.hidden).toEqual([]);
});
it('should mark face as visible when more than 50% inside crop area', () => {
const crop: EditActionCrop = {
action: EditAction.Crop,
parameters: { x: 150, y: 150, width: 500, height: 400 },
};
// Face at (100,100)-(200,200), crop starts at (150,150)
// Overlap: (150,150)-(200,200) = 50x50 = 2500
// Face area: 100x100 = 10000
// Overlap percentage: 25% - should be hidden
const faces = [baseFace];
const result = sut.checkFaceVisibility(faces, assetDimensions, crop);
expect(result.visible).toEqual([]);
expect(result.hidden).toEqual(faces);
});
it('should mark face as hidden when less than 50% inside crop area', () => {
const crop: EditActionCrop = {
action: EditAction.Crop,
parameters: { x: 250, y: 250, width: 500, height: 400 },
};
// Face completely outside crop area
const faces = [baseFace];
const result = sut.checkFaceVisibility(faces, assetDimensions, crop);
expect(result.visible).toEqual([]);
expect(result.hidden).toEqual(faces);
});
it('should mark face as hidden when completely outside crop area', () => {
const crop: EditActionCrop = {
action: EditAction.Crop,
parameters: { x: 500, y: 500, width: 200, height: 200 },
};
const faces = [baseFace];
const result = sut.checkFaceVisibility(faces, assetDimensions, crop);
expect(result.visible).toEqual([]);
expect(result.hidden).toEqual(faces);
});
it('should handle multiple faces with mixed visibility', () => {
const crop: EditActionCrop = {
action: EditAction.Crop,
parameters: { x: 0, y: 0, width: 300, height: 300 },
};
const faceInside: AssetFace = {
...baseFace,
id: 'face-inside',
boundingBoxX1: 50,
boundingBoxY1: 50,
boundingBoxX2: 150,
boundingBoxY2: 150,
};
const faceOutside: AssetFace = {
...baseFace,
id: 'face-outside',
boundingBoxX1: 400,
boundingBoxY1: 400,
boundingBoxX2: 500,
boundingBoxY2: 500,
};
const faces = [faceInside, faceOutside];
const result = sut.checkFaceVisibility(faces, assetDimensions, crop);
expect(result.visible).toEqual([faceInside]);
expect(result.hidden).toEqual([faceOutside]);
});
it('should handle face at exactly 50% overlap threshold', () => {
// Face at (0,0)-(100,100), crop at (50,0)-(150,100)
// Overlap: (50,0)-(100,100) = 50x100 = 5000
// Face area: 100x100 = 10000
// Overlap percentage: 50% - exactly at threshold, should be visible
const faceAtEdge: AssetFace = {
...baseFace,
id: 'face-edge',
boundingBoxX1: 0,
boundingBoxY1: 0,
boundingBoxX2: 100,
boundingBoxY2: 100,
};
const crop: EditActionCrop = {
action: EditAction.Crop,
parameters: { x: 50, y: 0, width: 100, height: 100 },
};
const faces = [faceAtEdge];
const result = sut.checkFaceVisibility(faces, assetDimensions, crop);
expect(result.visible).toEqual([faceAtEdge]);
expect(result.hidden).toEqual([]);
});
});
describe('with scaled dimensions', () => {
it('should handle faces when asset dimensions differ from face image dimensions', () => {
// Face stored at 1000x800 resolution, but displaying at 500x400
const scaledDimensions = { width: 500, height: 400 };
const crop: EditActionCrop = {
action: EditAction.Crop,
parameters: { x: 0, y: 0, width: 250, height: 200 },
};
// Face at (100,100)-(200,200) on 1000x800
// Scaled to 500x400: (50,50)-(100,100)
// Crop at (0,0)-(250,200) - face is fully inside
const faces = [baseFace];
const result = sut.checkFaceVisibility(faces, scaledDimensions, crop);
expect(result.visible).toEqual(faces);
expect(result.hidden).toEqual([]);
});
});
describe('visibility is only affected by crop (not rotate or mirror)', () => {
it('should keep all faces visible when there is no crop regardless of other transforms', () => {
// Rotate and mirror edits don't affect visibility - only crop does
// The visibility functions only take an optional crop parameter
const faces = [baseFace];
// Without any crop, all faces remain visible
const result = sut.checkFaceVisibility(faces, assetDimensions);
expect(result.visible).toEqual(faces);
expect(result.hidden).toEqual([]);
});
it('should only consider crop for visibility calculation', () => {
// Even if the image will be rotated/mirrored, visibility is determined
// solely by whether the face overlaps with the crop area
const crop: EditActionCrop = {
action: EditAction.Crop,
parameters: { x: 0, y: 0, width: 300, height: 300 },
};
const faceInsideCrop: AssetFace = {
...baseFace,
id: 'face-inside',
boundingBoxX1: 50,
boundingBoxY1: 50,
boundingBoxX2: 150,
boundingBoxY2: 150,
};
const faceOutsideCrop: AssetFace = {
...baseFace,
id: 'face-outside',
boundingBoxX1: 400,
boundingBoxY1: 400,
boundingBoxX2: 500,
boundingBoxY2: 500,
};
const faces = [faceInsideCrop, faceOutsideCrop];
const result = sut.checkFaceVisibility(faces, assetDimensions, crop);
// Face inside crop area is visible, face outside is hidden
// This is true regardless of any subsequent rotate/mirror operations
expect(result.visible).toEqual([faceInsideCrop]);
expect(result.hidden).toEqual([faceOutsideCrop]);
});
});
});
describe('checkOcrVisibility', () => {
const baseOcr: AssetOcrResponseDto = {
id: 'ocr-1',
assetId: 'asset-1',
x1: 0.1,
y1: 0.1,
x2: 0.2,
y2: 0.1,
x3: 0.2,
y3: 0.2,
x4: 0.1,
y4: 0.2,
boxScore: 0.9,
textScore: 0.85,
text: 'Test OCR',
};
const assetDimensions = { width: 1000, height: 800 };
describe('with no crop edit', () => {
it('should return all OCR items as visible when no crop is provided', () => {
const ocrs = [baseOcr];
const result = sut.checkOcrVisibility(ocrs, assetDimensions);
expect(result.visible).toEqual(ocrs);
expect(result.hidden).toEqual([]);
});
});
describe('with crop edit', () => {
it('should mark OCR as visible when fully inside crop area', () => {
const crop: EditActionCrop = {
action: EditAction.Crop,
parameters: { x: 0, y: 0, width: 500, height: 400 },
};
// OCR box: (0.1,0.1)-(0.2,0.2) on 1000x800 = (100,80)-(200,160)
// Crop: (0,0)-(500,400) - OCR fully inside
const ocrs = [baseOcr];
const result = sut.checkOcrVisibility(ocrs, assetDimensions, crop);
expect(result.visible).toEqual(ocrs);
expect(result.hidden).toEqual([]);
});
it('should mark OCR as hidden when completely outside crop area', () => {
const crop: EditActionCrop = {
action: EditAction.Crop,
parameters: { x: 500, y: 500, width: 200, height: 200 },
};
// OCR box: (100,80)-(200,160) - completely outside crop
const ocrs = [baseOcr];
const result = sut.checkOcrVisibility(ocrs, assetDimensions, crop);
expect(result.visible).toEqual([]);
expect(result.hidden).toEqual(ocrs);
});
it('should mark OCR as hidden when less than 50% inside crop area', () => {
const crop: EditActionCrop = {
action: EditAction.Crop,
parameters: { x: 150, y: 120, width: 500, height: 400 },
};
// OCR box: (100,80)-(200,160)
// Crop: (150,120)-(650,520)
// Overlap: (150,120)-(200,160) = 50x40 = 2000
// OCR area: 100x80 = 8000
// Overlap percentage: 25% - should be hidden
const ocrs = [baseOcr];
const result = sut.checkOcrVisibility(ocrs, assetDimensions, crop);
expect(result.visible).toEqual([]);
expect(result.hidden).toEqual(ocrs);
});
it('should handle multiple OCR items with mixed visibility', () => {
const crop: EditActionCrop = {
action: EditAction.Crop,
parameters: { x: 0, y: 0, width: 300, height: 300 },
};
const ocrInside: AssetOcrResponseDto = {
...baseOcr,
id: 'ocr-inside',
};
const ocrOutside: AssetOcrResponseDto = {
...baseOcr,
id: 'ocr-outside',
x1: 0.5,
y1: 0.5,
x2: 0.6,
y2: 0.5,
x3: 0.6,
y3: 0.6,
x4: 0.5,
y4: 0.6,
};
const ocrs = [ocrInside, ocrOutside];
const result = sut.checkOcrVisibility(ocrs, assetDimensions, crop);
expect(result.visible).toEqual([ocrInside]);
expect(result.hidden).toEqual([ocrOutside]);
});
it('should handle OCR boxes with rotated/skewed polygons', () => {
// OCR with a rotated bounding box (not axis-aligned)
const rotatedOcr: AssetOcrResponseDto = {
...baseOcr,
id: 'ocr-rotated',
x1: 0.15,
y1: 0.1,
x2: 0.25,
y2: 0.15,
x3: 0.2,
y3: 0.25,
x4: 0.1,
y4: 0.2,
};
const crop: EditActionCrop = {
action: EditAction.Crop,
parameters: { x: 0, y: 0, width: 300, height: 300 },
};
const ocrs = [rotatedOcr];
const result = sut.checkOcrVisibility(ocrs, assetDimensions, crop);
expect(result.visible).toEqual([rotatedOcr]);
expect(result.hidden).toEqual([]);
});
});
describe('visibility is only affected by crop (not rotate or mirror)', () => {
it('should keep all OCR items visible when there is no crop regardless of other transforms', () => {
// Rotate and mirror edits don't affect visibility - only crop does
// The visibility functions only take an optional crop parameter
const ocrs = [baseOcr];
// Without any crop, all OCR items remain visible
const result = sut.checkOcrVisibility(ocrs, assetDimensions);
expect(result.visible).toEqual(ocrs);
expect(result.hidden).toEqual([]);
});
it('should only consider crop for visibility calculation', () => {
// Even if the image will be rotated/mirrored, visibility is determined
// solely by whether the OCR box overlaps with the crop area
const crop: EditActionCrop = {
action: EditAction.Crop,
parameters: { x: 0, y: 0, width: 300, height: 300 },
};
const ocrInsideCrop: AssetOcrResponseDto = {
...baseOcr,
id: 'ocr-inside',
// OCR at (0.1,0.1)-(0.2,0.2) = (100,80)-(200,160) on 1000x800, inside crop
};
const ocrOutsideCrop: AssetOcrResponseDto = {
...baseOcr,
id: 'ocr-outside',
x1: 0.5,
y1: 0.5,
x2: 0.6,
y2: 0.5,
x3: 0.6,
y3: 0.6,
x4: 0.5,
y4: 0.6,
// OCR at (500,400)-(600,480) on 1000x800, outside crop
};
const ocrs = [ocrInsideCrop, ocrOutsideCrop];
const result = sut.checkOcrVisibility(ocrs, assetDimensions, crop);
// OCR inside crop area is visible, OCR outside is hidden
// This is true regardless of any subsequent rotate/mirror operations
expect(result.visible).toEqual([ocrInsideCrop]);
expect(result.hidden).toEqual([ocrOutsideCrop]);
});
});
});
});

View File

@@ -6,9 +6,7 @@ import fs from 'node:fs/promises';
import { Writable } from 'node:stream';
import sharp from 'sharp';
import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants';
import { AssetFace, Exif } from 'src/database';
import { EditActionCrop, EditActionItem } from 'src/dtos/editing.dto';
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
import { Exif } from 'src/database';
import { Colorspace, LogLevel, RawExtractedFormat } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import {
@@ -21,7 +19,6 @@ import {
VideoInfo,
} from 'src/types';
import { handlePromiseError } from 'src/utils/misc';
import { createAffineMatrix } from 'src/utils/transform';
const probe = (input: string, options: string[]): Promise<FfprobeData> =>
new Promise((resolve, reject) =>
@@ -141,48 +138,21 @@ export class MediaRepository {
}
}
async decodeImage(input: string | Buffer, options: DecodeToBufferOptions) {
const pipeline = await this.getImageDecodingPipeline(input, options);
return pipeline.raw().toBuffer({ resolveWithObject: true });
}
private async applyEdits(pipeline: sharp.Sharp, edits: EditActionItem[]): Promise<sharp.Sharp> {
const affineEditOperations = edits.filter((edit) => edit.action !== 'crop');
const matrix = createAffineMatrix(affineEditOperations);
const crop = edits.find((edit) => edit.action === 'crop');
const dimensions = await pipeline.metadata();
if (crop) {
pipeline = pipeline.extract({
left: crop ? Math.round(crop.parameters.x) : 0,
top: crop ? Math.round(crop.parameters.y) : 0,
width: crop ? Math.round(crop.parameters.width) : dimensions.width || 0,
height: crop ? Math.round(crop.parameters.height) : dimensions.height || 0,
});
}
const { a, b, c, d } = matrix;
pipeline = pipeline.affine([
[a, b],
[c, d],
]);
return pipeline;
decodeImage(input: string | Buffer, options: DecodeToBufferOptions) {
return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true });
}
async generateThumbnail(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise<void> {
const pipeline = await this.getImageDecodingPipeline(input, options);
const decoded = pipeline.toFormat(options.format, {
quality: options.quality,
// this is default in libvips (except the threshold is 90), but we need to set it manually in sharp
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
});
await decoded.toFile(output);
await this.getImageDecodingPipeline(input, options)
.toFormat(options.format, {
quality: options.quality,
// this is default in libvips (except the threshold is 90), but we need to set it manually in sharp
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
})
.toFile(output);
}
private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
private getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
let pipeline = sharp(input, {
// some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes
failOn: options.processInvalidImages ? 'none' : 'error',
@@ -205,8 +175,8 @@ export class MediaRepository {
}
}
if (options.edits && options.edits.length > 0) {
pipeline = await this.applyEdits(pipeline, options.edits);
if (options.crop) {
pipeline = pipeline.extract(options.crop);
}
if (options.size !== undefined) {
@@ -216,127 +186,17 @@ export class MediaRepository {
}
async generateThumbhash(input: string | Buffer, options: GenerateThumbhashOptions): Promise<Buffer> {
const [{ rgbaToThumbHash }, decodingPipeline] = await Promise.all([
const [{ rgbaToThumbHash }, { data, info }] = await Promise.all([
import('thumbhash'),
this.getImageDecodingPipeline(input, {
colorspace: options.colorspace,
processInvalidImages: options.processInvalidImages,
raw: options.raw,
edits: options.edits,
}),
sharp(input, options)
.resize(100, 100, { fit: 'inside', withoutEnlargement: true })
.raw()
.ensureAlpha()
.toBuffer({ resolveWithObject: true }),
]);
const pipeline = decodingPipeline.resize(100, 100, { fit: 'inside', withoutEnlargement: true }).raw().ensureAlpha();
const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
return Buffer.from(rgbaToThumbHash(info.width, info.height, data));
}
private boundingBoxOverlap(
boxA: { x1: number; y1: number; x2: number; y2: number },
boxB: { x1: number; y1: number; x2: number; y2: number },
) {
const overlapX1 = Math.max(boxA.x1, boxB.x1);
const overlapY1 = Math.max(boxA.y1, boxB.y1);
const overlapX2 = Math.min(boxA.x2, boxB.x2);
const overlapY2 = Math.min(boxA.y2, boxB.y2);
const overlapArea = Math.max(0, overlapX2 - overlapX1) * Math.max(0, overlapY2 - overlapY1);
const faceArea = (boxA.x2 - boxA.x1) * (boxA.y2 - boxA.y1);
return overlapArea / faceArea;
}
checkFaceVisibility(
faces: AssetFace[],
assetDimensions: ImageDimensions,
crop?: EditActionCrop,
): { visible: AssetFace[]; hidden: AssetFace[] } {
if (!crop) {
return {
visible: faces,
hidden: [],
};
}
const cropArea = {
x1: crop.parameters.x,
y1: crop.parameters.y,
x2: crop.parameters.x + crop.parameters.width,
y2: crop.parameters.y + crop.parameters.height,
};
const status = faces.map((face) => {
const faceArea = {
x1: (face.boundingBoxX1 / face.imageWidth) * assetDimensions.width,
y1: (face.boundingBoxY1 / face.imageHeight) * assetDimensions.height,
x2: (face.boundingBoxX2 / face.imageWidth) * assetDimensions.width,
y2: (face.boundingBoxY2 / face.imageHeight) * assetDimensions.height,
};
const overlapPercentage = this.boundingBoxOverlap(faceArea, cropArea);
return {
face,
isVisible: overlapPercentage >= 0.5,
};
});
return {
visible: status.filter((s) => s.isVisible).map((s) => s.face),
hidden: status.filter((s) => !s.isVisible).map((s) => s.face),
};
}
checkOcrVisibility(
ocrs: AssetOcrResponseDto[],
assetDimensions: ImageDimensions,
crop?: EditActionCrop,
): { visible: AssetOcrResponseDto[]; hidden: AssetOcrResponseDto[] } {
if (!crop) {
return {
visible: ocrs,
hidden: [],
};
}
const cropArea = {
x1: crop.parameters.x,
y1: crop.parameters.y,
x2: crop.parameters.x + crop.parameters.width,
y2: crop.parameters.y + crop.parameters.height,
};
const status = ocrs.map((ocr) => {
// ocr use coordinates of a scaled image for ML
const ocrPolygon = [
{ x: ocr.x1 * assetDimensions.width, y: ocr.y1 * assetDimensions.height },
{ x: ocr.x2 * assetDimensions.width, y: ocr.y2 * assetDimensions.height },
{ x: ocr.x3 * assetDimensions.width, y: ocr.y3 * assetDimensions.height },
{ x: ocr.x4 * assetDimensions.width, y: ocr.y4 * assetDimensions.height },
];
const ocrBox = {
x1: Math.min(ocrPolygon[0].x, ocrPolygon[1].x, ocrPolygon[2].x, ocrPolygon[3].x),
y1: Math.min(ocrPolygon[0].y, ocrPolygon[1].y, ocrPolygon[2].y, ocrPolygon[3].y),
x2: Math.max(ocrPolygon[0].x, ocrPolygon[1].x, ocrPolygon[2].x, ocrPolygon[3].x),
y2: Math.max(ocrPolygon[0].y, ocrPolygon[1].y, ocrPolygon[2].y, ocrPolygon[3].y),
};
const overlapPercentage = this.boundingBoxOverlap(ocrBox, cropArea);
return {
ocr,
isVisible: overlapPercentage >= 0.5,
};
});
return {
visible: status.filter((s) => s.isVisible).map((s) => s.ocr),
hidden: status.filter((s) => !s.isVisible).map((s) => s.ocr),
};
}
async probe(input: string, options?: ProbeOptions): Promise<VideoInfo> {
const results = await probe(input, options?.countFrames ? ['-count_packets'] : []); // gets frame count quickly: https://stackoverflow.com/a/28376817
return {

View File

@@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
import { DB } from 'src/schema';
import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table';
@@ -16,13 +15,8 @@ export class OcrRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
getByAssetId(id: string, { onlyVisible = true }: { onlyVisible?: boolean } = {}) {
return this.db
.selectFrom('asset_ocr')
.selectAll('asset_ocr')
.where('asset_ocr.assetId', '=', id)
.$if(onlyVisible, (qb) => qb.where('asset_ocr.isVisible', '=', true))
.execute();
getByAssetId(id: string) {
return this.db.selectFrom('asset_ocr').selectAll('asset_ocr').where('asset_ocr.assetId', '=', id).execute();
}
deleteAll() {
@@ -71,38 +65,4 @@ export class OcrRepository {
return query.selectNoFrom(sql`1`.as('dummy')).execute();
}
@GenerateSql({ params: [DummyValue.UUID, [], []] })
async updateOcrVisibilities(
assetId: string,
visible: AssetOcrResponseDto[],
hidden: AssetOcrResponseDto[],
): Promise<void> {
if (visible.length > 0) {
await this.db
.updateTable('asset_ocr')
.set({ isVisible: true })
.where(
'asset_ocr.id',
'in',
visible.map((i) => i.id),
)
.execute();
}
if (hidden.length > 0) {
await this.db
.updateTable('asset_ocr')
.set({ isVisible: false })
.where(
'asset_ocr.id',
'in',
hidden.map((i) => i.id),
)
.execute();
}
const searchText = visible.map((item) => item.text.trim()).join(' ');
await this.db.updateTable('ocr_search').set({ text: searchText }).where('assetId', '=', assetId).execute();
}
}

View File

@@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { AssetFace } from 'src/database';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AssetFileType, AssetVisibility, SourceType } from 'src/enum';
import { DB } from 'src/schema';
@@ -122,7 +121,6 @@ export class PersonRepository {
.$if(!!options.sourceType, (qb) => qb.where('asset_face.sourceType', '=', options.sourceType!))
.$if(!!options.assetId, (qb) => qb.where('asset_face.assetId', '=', options.assetId!))
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true)
.stream();
}
@@ -162,7 +160,6 @@ export class PersonRepository {
)
.where('person.ownerId', '=', userId)
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true)
.orderBy('person.isHidden', 'asc')
.orderBy('person.isFavorite', 'desc')
.having((eb) =>
@@ -211,21 +208,19 @@ export class PersonRepository {
.selectAll('person')
.leftJoin('asset_face', 'asset_face.personId', 'person.id')
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true)
.having((eb) => eb.fn.count('asset_face.assetId'), '=', 0)
.groupBy('person.id')
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getFaces(assetId: string, { onlyVisible = true }: { onlyVisible?: boolean } = {}) {
getFaces(assetId: string) {
return this.db
.selectFrom('asset_face')
.selectAll('asset_face')
.select(withPerson)
.where('asset_face.assetId', '=', assetId)
.where('asset_face.deletedAt', 'is', null)
.$if(onlyVisible, (qb) => qb.where('asset_face.isVisible', '=', true))
.orderBy('asset_face.boundingBoxX1', 'asc')
.execute();
}
@@ -355,7 +350,6 @@ export class PersonRepository {
)
.select((eb) => eb.fn.count(eb.fn('distinct', ['asset.id'])).as('count'))
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true)
.executeTakeFirst();
return {
@@ -374,7 +368,6 @@ export class PersonRepository {
.selectFrom('asset_face')
.whereRef('asset_face.personId', '=', 'person.id')
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', '=', true)
.where((eb) =>
eb.exists((eb) =>
eb
@@ -502,7 +495,6 @@ export class PersonRepository {
.selectAll('asset_face')
.where('asset_face.personId', '=', personId)
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true)
.executeTakeFirst();
}
@@ -547,35 +539,4 @@ export class PersonRepository {
}
return this.db.selectFrom('person').select(['id', 'thumbnailPath']).where('id', 'in', ids).execute();
}
@GenerateSql({ params: [[], []] })
async updateFaceVisibilities(visible: AssetFace[], hidden: AssetFace[]): Promise<void> {
if (visible.length === 0 && hidden.length === 0) {
return;
}
if (visible.length > 0) {
await this.db
.updateTable('asset_face')
.set({ isVisible: true })
.where(
'asset_face.id',
'in',
visible.map(({ id }) => id),
)
.execute();
}
if (hidden.length > 0) {
await this.db
.updateTable('asset_face')
.set({ isVisible: false })
.where(
'asset_face.id',
'in',
hidden.map(({ id }) => id),
)
.execute();
}
}
}

View File

@@ -483,7 +483,6 @@ class AssetFaceSync extends BaseSync {
])
.leftJoin('asset', 'asset.id', 'asset_face.assetId')
.where('asset.ownerId', '=', options.userId)
.where('asset_face.isVisible', '=', true)
.stream();
}
}

View File

@@ -28,7 +28,6 @@ import { AlbumUserTable } from 'src/schema/tables/album-user.table';
import { AlbumTable } from 'src/schema/tables/album.table';
import { ApiKeyTable } from 'src/schema/tables/api-key.table';
import { AssetAuditTable } from 'src/schema/tables/asset-audit.table';
import { AssetEditTable } from 'src/schema/tables/asset-edit.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetFaceAuditTable } from 'src/schema/tables/asset-face-audit.table';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
@@ -87,7 +86,6 @@ export class ImmichDatabase {
AlbumTable,
ApiKeyTable,
AssetAuditTable,
AssetEditTable,
AssetFaceTable,
AssetFaceAuditTable,
AssetMetadataTable,
@@ -181,7 +179,6 @@ export interface DB {
asset: AssetTable;
asset_audit: AssetAuditTable;
asset_edit: AssetEditTable;
asset_exif: AssetExifTable;
asset_face: AssetFaceTable;
asset_face_audit: AssetFaceAuditTable;

View File

@@ -1,28 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset" ADD COLUMN "width" integer;`.execute(db);
await sql`ALTER TABLE "asset" ADD COLUMN "height" integer;`.execute(db);
// Populate width and height from exif data with orientation-aware swapping
await sql`
UPDATE "asset"
SET
"width" = CASE
WHEN "asset_exif"."orientation" IN ('5', '6', '7', '8', '-90', '90') THEN "asset_exif"."exifImageHeight"
ELSE "asset_exif"."exifImageWidth"
END,
"height" = CASE
WHEN "asset_exif"."orientation" IN ('5', '6', '7', '8', '-90', '90') THEN "asset_exif"."exifImageWidth"
ELSE "asset_exif"."exifImageHeight"
END
FROM "asset_exif"
WHERE "asset"."id" = "asset_exif"."assetId"
AND ("asset_exif"."exifImageWidth" IS NOT NULL OR "asset_exif"."exifImageHeight" IS NOT NULL)
`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset" DROP COLUMN "width";`.execute(db);
await sql`ALTER TABLE "asset" DROP COLUMN "height";`.execute(db);
}

View File

@@ -1,22 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`
CREATE TABLE "asset_edit" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"assetId" uuid NOT NULL,
"action" varchar NOT NULL,
"parameters" jsonb NOT NULL
);
`.execute(db);
await sql`ALTER TABLE "asset_edit" ADD CONSTRAINT "asset_edit_pkey" PRIMARY KEY ("id");`.execute(db);
await sql`ALTER TABLE "asset_edit" ADD CONSTRAINT "asset_edit_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "asset" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(
db,
);
await sql`CREATE INDEX "asset_edit_assetId_idx" ON "asset_edit" ("assetId")`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TABLE IF EXISTS "asset_edit";`.execute(db);
}

View File

@@ -1,11 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_ocr" ADD COLUMN "isVisible" boolean NOT NULL DEFAULT TRUE`.execute(db);
await sql`ALTER TABLE "asset_face" ADD COLUMN "isVisible" boolean NOT NULL DEFAULT TRUE`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_ocr" DROP COLUMN "isVisible";`.execute(db);
await sql`ALTER TABLE "asset_face" DROP COLUMN "isVisible";`.execute(db);
}

View File

@@ -1,17 +0,0 @@
import { EditAction, EditActionParameter } from 'src/dtos/editing.dto';
import { AssetTable } from 'src/schema/tables/asset.table';
import { Column, ForeignKeyColumn, Generated, PrimaryGeneratedColumn } from 'src/sql-tools';
export class AssetEditTable<T extends EditAction = EditAction> {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false, primary: true })
assetId!: string;
@Column()
action!: T;
@Column({ type: 'jsonb' })
parameters!: EditActionParameter[T];
}

View File

@@ -78,7 +78,4 @@ export class AssetFaceTable {
@UpdateIdColumn()
updateId!: Generated<string>;
@Column({ type: 'boolean', default: true })
isVisible!: Generated<boolean>;
}

View File

@@ -42,7 +42,4 @@ export class AssetOcrTable {
@Column({ type: 'text' })
text!: string;
@Column({ type: 'boolean', default: true })
isVisible!: Generated<boolean>;
}

View File

@@ -137,10 +137,4 @@ export class AssetTable {
@Column({ enum: asset_visibility_enum, default: AssetVisibility.Timeline })
visibility!: Generated<AssetVisibility>;
@Column({ type: 'integer', nullable: true })
width!: number | null;
@Column({ type: 'integer', nullable: true })
height!: number | null;
}

View File

@@ -489,7 +489,7 @@ describe(AssetMediaService.name, () => {
describe('downloadOriginal', () => {
it('should require the asset.download permission', async () => {
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', true)).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id,
@@ -503,16 +503,16 @@ describe(AssetMediaService.name, () => {
it('should throw an error if the asset is not found', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', true)).rejects.toBeInstanceOf(NotFoundException);
await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(NotFoundException);
expect(mocks.asset.getById).toHaveBeenCalledWith('asset-1', { files: true, edits: true });
expect(mocks.asset.getById).toHaveBeenCalledWith('asset-1', { files: true });
});
it('should download a file', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
mocks.asset.getById.mockResolvedValue(assetStub.image);
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', true)).resolves.toEqual(
await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).resolves.toEqual(
new ImmichFileResponse({
path: '/original/path.jpg',
fileName: 'asset-id.jpg',
@@ -521,8 +521,6 @@ describe(AssetMediaService.name, () => {
}),
);
});
// TODO: Edited asset tests
});
describe('viewThumbnail', () => {
@@ -622,8 +620,6 @@ describe(AssetMediaService.name, () => {
}),
);
});
// TODO: Edited asset tests
});
describe('playbackVideo', () => {

View File

@@ -193,24 +193,11 @@ export class AssetMediaService extends BaseService {
}
}
async downloadOriginal(auth: AuthDto, id: string, edited: boolean): Promise<ImmichFileResponse> {
async downloadOriginal(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: [id] });
const asset = await this.findOrFail(id);
if (asset.edits!.length > 0 && edited) {
const { editedFullsizeFile } = getAssetFiles(asset.files ?? []);
if (editedFullsizeFile) {
return new ImmichFileResponse({
path: editedFullsizeFile.path,
fileName: getFileNameWithoutExtension(asset.originalFileName) + getFilenameExtension(editedFullsizeFile.path),
contentType: mimeTypes.lookup(editedFullsizeFile.path),
cacheControl: CacheControl.PrivateWithCache,
});
}
}
return new ImmichFileResponse({
path: asset.originalPath,
fileName: asset.originalFileName,
@@ -229,20 +216,12 @@ export class AssetMediaService extends BaseService {
const asset = await this.findOrFail(id);
const size = dto.size ?? AssetMediaSize.THUMBNAIL;
const files = getAssetFiles(asset.files ?? []);
const requestingEdited = (dto.edited ?? true) && asset.edits!.length > 0;
const { fullsizeFile, previewFile, thumbnailFile } = {
fullsizeFile: requestingEdited ? files.editedFullsizeFile : files.fullsizeFile,
previewFile: requestingEdited ? files.editedPreviewFile : files.previewFile,
thumbnailFile: requestingEdited ? files.editedThumbnailFile : files.thumbnailFile,
};
const { thumbnailFile, previewFile, fullsizeFile } = getAssetFiles(asset.files ?? []);
let filepath = previewFile?.path;
if (size === AssetMediaSize.THUMBNAIL && thumbnailFile) {
filepath = thumbnailFile.path;
} else if (size === AssetMediaSize.FULLSIZE) {
if (mimeTypes.isWebSupportedImage(asset.originalPath) && !dto.edited) {
if (mimeTypes.isWebSupportedImage(asset.originalPath)) {
// use original file for web supported images
return { targetSize: 'original' };
}
@@ -477,7 +456,7 @@ export class AssetMediaService extends BaseService {
}
private async findOrFail(id: string) {
const asset = await this.assetRepository.getById(id, { files: true, edits: true });
const asset = await this.assetRepository.getById(id, { files: true });
if (!asset) {
throw new NotFoundException('Asset not found');
}

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