Compare commits

..

22 Commits

Author SHA1 Message Date
Alex The Bot
ecc894ac82 Version v1.57.1 2023-05-23 09:21:22 +00:00
Michel Heusschen
50b649cd3e fix(web): small fixes for album selection modal (#2527) 2023-05-23 04:15:48 -05:00
Michel Heusschen
99b018cd49 fix(web): loading leaflet in production builds (#2526) 2023-05-23 04:14:00 -05:00
Alex
6aa2800275 chore: post release tasks 2023-05-22 22:43:06 -05:00
Alex The Bot
cd7fc7e026 Version v1.57.0 2023-05-23 02:03:49 +00:00
Alex
b4d312efb6 fix(web): revert justify layout - improve gallery view load time (#2522)
* fix(web): revert justify layout - improve gallery view load time

* Remove package
2023-05-22 21:01:32 -05:00
Mert
e9722710ac feat(server): transcode bitrate and thread settings (#2488)
* support for two-pass transcoding

* added max bitrate and thread to transcode api

* admin page setting desc+bitrate and thread options

* Update web/src/lib/components/admin-page/settings/setting-input-field.svelte

Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>

* Update web/src/lib/components/admin-page/settings/setting-input-field.svelte

Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>

* two-pass slider, `crf` and `threads` as numbers

* updated and added transcode tests

* refactored `getFfmpegOptions`

* default `threads`, `maxBitrate` now 0, more tests

* vp9 constant quality mode

* fixed nullable `crf` and `threads`

* fixed two-pass slider, added apiproperty

* optional `desc` for `SettingSelect`

* disable two-pass if settings are incompatible

* fixed test

* transcode interface

---------

Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
2023-05-22 13:07:43 -05:00
Michel Heusschen
f1384fea58 feat(server): pagination for asset queries in jobs (#2516)
* feat(server): pagination for asset queries in jobs

* default mock value for getAll

* remove live photo name correction

* order paginated results by createdAt

* change log level

* move usePagination to domain
2023-05-22 13:05:06 -05:00
Alex
feadc45e75 chore(server): Remove dist directory in command script (#2518) 2023-05-22 10:27:08 -05:00
Jason Rasmussen
eefe5266a8 chore(server): remove unused filename (#2517) 2023-05-22 10:26:56 -05:00
Jason Rasmussen
74353193f8 feat(web,server): user storage label (#2418)
* feat: user storage label

* chore: open api

* fix: checks

* fix: api update validation and tests

* feat: default admin storage label

* fix: linting

* fix: user create/update dto

* fix: delete library with custom label
2023-05-21 23:18:10 -04:00
Jason Rasmussen
0ccb73cf2b feat(server): add missing thumbnail check to nightly jobs (#2510) 2023-05-21 21:24:21 -05:00
Mert
356f4424df chore(server): queue handlers shouldn't increase concurrency (#2508) 2023-05-21 21:11:26 -05:00
Michel Heusschen
85c6cf4309 fix(web): context menu overlap + outclick types (#2506) 2023-05-21 11:01:08 -05:00
Michel Heusschen
96fb68135e fix(nginx): enable gzip and show error logs (#2504) 2023-05-21 08:23:46 -05:00
Michel Heusschen
a7b9adc692 feat(web+server): map improvements (#2498)
* feat(web+server): map improvements

* add number format double to fix mobile
2023-05-21 01:26:06 -05:00
Jason Rasmussen
e028cf9002 fix(server): reverse geocoding crash loop (#2489) 2023-05-20 21:39:12 -05:00
Jason Rasmussen
f984be8ea0 docs: update contributing pages (#2503) 2023-05-20 20:46:09 -05:00
Jason Rasmussen
3d426b55d3 chore(server): auth request type (#2502) 2023-05-20 20:44:26 -05:00
Fynn Petersen-Frey
02b8b2c125 chore(mobile): remove hive (#2497) 2023-05-20 20:42:19 -05:00
Fynn Petersen-Frey
dc7b0f75bb chore(mobile): use Record instead of custom pair+triple (#2483) 2023-05-20 20:41:34 -05:00
Jason Rasmussen
a089d9891d feat: confirm before deleting all faces and people (#2496) 2023-05-20 20:40:53 -05:00
177 changed files with 2033 additions and 1797 deletions

View File

@@ -1,17 +1,17 @@
dev:
rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --remove-orphans
docker-compose -f ./docker/docker-compose.dev.yml up --remove-orphans
dev-new:
rm -rf ./server/dist && docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
dev-new-update:
rm -rf ./server/dist && docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
dev-update:
rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
dev-scale:
rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
stage:
docker-compose -f ./docker/docker-compose.staging.yml up --build -V --remove-orphans

View File

@@ -1,32 +0,0 @@
# Development Setup
## Lint / format extensions
Setting these in the IDE give a better developer experience auto-formatting code on save and providing instant feedback on lint issues.
### VSCode
Install Prettier, ESLint and Svelte extensions.
in User `settings.json` (`cmd + shift + p` and search for Open User Settings JSON) add the following:
```json
{
"editor.formatOnSave": true,
"[javascript][typescript][css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.tabSize": 2,
"editor.formatOnSave": true
},
"[svelte]": {
"editor.defaultFormatter": "svelte.svelte-vscode",
"editor.tabSize": 2
},
"svelte.enable-ts-plugin": true,
"eslint.validate": ["javascript", "svelte"]
}
```
## Running tests / checks
In both server and web:
`npm run check:all`

View File

@@ -135,8 +135,6 @@ services:
dockerfile: Dockerfile
ports:
- 2283:8080
logging:
driver: none
depends_on:
- immich-server
restart: always

View File

@@ -87,8 +87,6 @@ services:
- IMMICH_WEB_URL
ports:
- 2283:8080
logging:
driver: none
depends_on:
- immich-server
restart: always

View File

@@ -0,0 +1,14 @@
# Database Migrations
After making any changes in the `server/libs/database/src/entities`, a database migration need to run in order to register the changes in the database. Follow the steps below to create a new migration.
1. Run the command
```bash
npm run typeorm:migrations:generate ./libs/infra/src/<migration-name>
```
2. Check if the migration file makes sense.
3. Move the migration file to folder `server/libs/database/src/migrations` in your code editor.
The server will automatically detect `*.ts` file changes and restart. Part of the server start-up process includes running any new migrations, so it will be applied immediately.

View File

@@ -1,7 +1,17 @@
---
sidebar_position: 5
---
# Open API
Immich uses the [Open API](https://swagger.io/specification/) standard to generate API documentation. To view the published docs see [here](/docs/api).
## Generator
OpenAPI is used to generate the client (Typescript, Dart) SDK. `openapi-generator-cli` can be installed [here](https://openapi-generator.tech/docs/installation/). When you add a new or modify an existing endpoint, you must run the command below to update the client SDK.
```bash
npm run api:generate # Run from the `server/` directory
```
You can find the generated client SDK in the `web/src/api` for Typescript SDK and `mobile/openapi` for Dart SDK.
:::tip
This can also be run via `make api` from the project root directory (not in the `server` folder)
:::

View File

@@ -1,16 +1,8 @@
---
sidebar_position: 3
---
# Contributing
Contributions are welcome!
## PR Checklist
# PR Checklist
When contributing code through a pull request, please check the following:
### Web Checks
## Web Checks
- [ ] `npm run lint` (linting via ESLint)
- [ ] `npm run format` (formatting via Prettier)
@@ -21,7 +13,7 @@ When contributing code through a pull request, please check the following:
Run all web checks with `npm run check:all`
:::
### Server Checks
## Server Checks
- [ ] `npm run lint` (linting via ESLint)
- [ ] `npm run format` (formatting via Prettier)
@@ -32,12 +24,10 @@ Run all web checks with `npm run check:all`
Run all server checks with `npm run check:all`
:::
### Open API
## Open API
The Open API client libraries need to be regenerated whenever there are changes to the `immich-openapi-specs.json` file.
The Open API client libraries need to be regenerated whenever there are changes to the `immich-openapi-specs.json` file. See [Open API](/docs/developer/open-api.md) for more details.
- [ ] `npm run api:generate`
## Database Migrations
:::tip
This can also be run via `make api` from the project root directory (not in the `server` folder)
:::
A database migration needs to be generated whenever there are changes to `server/libs/infra/src/entities`. See [Database Migration](/docs/developer/database-migrations.md) for more details.

View File

@@ -92,27 +92,3 @@ in User `settings.json` (`cmd + shift + p` and search for `Open User Settings JS
}
}
```
## OpenAPI generator
OpenAPI is used to generate the client (Typescript, Dart) SDK. `openapi-generator-cli` can be installed [here](https://openapi-generator.tech/docs/installation/). When you add a new or modify an existing endpoint, you must run the command below to update the client SDK.
```bash
npm run api:generate # Run from the `server` directory
```
You can find the generated client SDK in the `web/src/api` for Typescript SDK and `mobile/openapi` for Dart SDK.
## Database migrations
After making any changes in the `server/libs/database/src/entities`, a database migration need to run in order to register the changes in the database. Follow the steps below to create a new migration.
1. Attached to the server container shell.
2. Run
```bash
npm run typeorm:migrations:generate ./libs/infra/src/<migration-name>
```
3. Check if the migration file makes sense.
4. Move the migration file to folder `server/libs/database/src/migrations` in your code editor.

View File

@@ -22,6 +22,7 @@
{ "source": "/docs/features/password-login", "destination": "/docs/administration/password-login" },
{ "source": "/docs/features/server-commands", "destination": "/docs/administration/server-commands" },
{ "source": "/docs/features/storage-template", "destination": "/docs/administration/storage-template" },
{ "source": "/docs/features/user-management", "destination": "/docs/administration/user-management" }
{ "source": "/docs/features/user-management", "destination": "/docs/administration/user-management" },
{ "source": "/docs/developer/contributing", "destination": "/docs/developer/pr-checklist" }
]
}

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 79,
"android.injected.version.name" => "1.56.2",
"android.injected.version.code" => 80,
"android.injected.version.name" => "1.57.1",
}
)
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

@@ -0,0 +1 @@
* Remove Hive box

View File

@@ -5,19 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00032">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000296">
</testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="29.247439">
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="64.042552">
</testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="22.794249">
<failure message="/usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/actions/actions_helper.rb:67:in `execute_action&apos;&#10;/usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/runner.rb:255:in `block in execute_action&apos;&#10;/usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/runner.rb:229:in `chdir&apos;&#10;/usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/runner.rb:229:in `execute_action&apos;&#10;/usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/runner.rb:157:in `trigger_action_by_name&apos;&#10;/usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/fast_file.rb:159:in `method_missing&apos;&#10;Fastfile:42:in `block (2 levels) in parsing_binding&apos;&#10;/usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/lane.rb:33:in `call&apos;&#10;/usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/runner.rb:49:in `block in execute&apos;&#10;/usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/runner.rb:45:in `chdir&apos;&#10;/usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/runner.rb:45:in `execute&apos;&#10;/usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/lane_manager.rb:47:in `cruise_lane&apos;&#10;/usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/command_line_handler.rb:36:in `handle&apos;&#10;/usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/commands_generator.rb:110:in `block (2 levels) in run&apos;&#10;/usr/local/Cellar/fastlane/2.212.2/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in `call&apos;&#10;/usr/local/Cellar/fastlane/2.212.2/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in `run&apos;&#10;/usr/local/Cellar/fastlane/2.212.2/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in `run_active_command&apos;&#10;/usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in `run!&apos;&#10;/usr/local/Cellar/fastlane/2.212.2/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in `run!&apos;&#10;/usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/commands_generator.rb:354:in `run&apos;&#10;/usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/commands_generator.rb:43:in `start&apos;&#10;/usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/cli_tools_distributor.rb:123:in `take_off&apos;&#10;/usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/bin/fastlane:23:in `&lt;top (required)&gt;&apos;&#10;/usr/local/Cellar/fastlane/2.212.2/libexec/bin/fastlane:25:in `load&apos;&#10;/usr/local/Cellar/fastlane/2.212.2/libexec/bin/fastlane:25:in `&lt;main&gt;&apos;&#10;&#10;Google Api Error: Invalid request - APK specifies a version code that has already been used." />
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="29.676557">
</testcase>

View File

@@ -379,7 +379,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 95;
CURRENT_PROJECT_VERSION = 97;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -515,7 +515,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 95;
CURRENT_PROJECT_VERSION = 97;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -543,7 +543,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 95;
CURRENT_PROJECT_VERSION = 97;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -45,11 +45,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.55.0</string>
<string>1.57.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>95</string>
<string>97</string>
<key>FLTEnableImpeller</key>
<true />
<key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.56.2"
version_number: "1.57.1"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -5,29 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000282">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000407">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.815995">
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.988375">
</testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="30.927419">
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="45.42439">
</testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="1.464698">
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="2.381359">
</testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="66.988561">
<testcase classname="fastlane.lanes" name="4: build_app" time="94.653021">
<failure message="/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/actions/actions_helper.rb:67:in `execute_action&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:255:in `block in execute_action&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:229:in `chdir&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:229:in `execute_action&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:157:in `trigger_action_by_name&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/fast_file.rb:159:in `method_missing&apos;&#10;Fastfile:27:in `block (2 levels) in parsing_binding&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/lane.rb:33:in `call&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:49:in `block in execute&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:45:in `chdir&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:45:in `execute&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/lane_manager.rb:47:in `cruise_lane&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/command_line_handler.rb:36:in `handle&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/commands_generator.rb:110:in `block (2 levels) in run&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in `call&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in `run&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in `run_active_command&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in `run!&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in `run!&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/commands_generator.rb:354:in `run&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/commands_generator.rb:43:in `start&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/cli_tools_distributor.rb:123:in `take_off&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/bin/fastlane:23:in `&lt;top (required)&gt;&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/bin/fastlane:25:in `load&apos;&#10;/usr/local/Cellar/fastlane/2.212.1/libexec/bin/fastlane:25:in `&lt;main&gt;&apos;&#10;&#10;Error packaging up the application" />
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="58.237354">
</testcase>

View File

@@ -1,37 +0,0 @@
// Access token
const String userInfoBox = "immichBoxUserInfo"; // Box
const String accessTokenKey = "immichBoxAccessTokenKey"; // Key 1
const String deviceIdKey = 'immichBoxDeviceIdKey'; // Key 2
const String isLoggedInKey = 'immichIsLoggedInKey'; // Key 3
const String serverEndpointKey = 'immichBoxServerEndpoint'; // Key 4
const String assetEtagKey = 'immichAssetEtagKey'; // Key 5
const String userIdKey = 'immichUserIdKey'; // Key 6
// Login Info
const String hiveLoginInfoBox = "immichLoginInfoBox"; // Box
const String savedLoginInfoKey = "immichSavedLoginInfoKey"; // Key 1
// Backup Info
const String hiveBackupInfoBox = "immichBackupAlbumInfoBox"; // Box
const String backupInfoKey = "immichBackupAlbumInfoKey"; // Key 1
// Github Release Info
const String hiveGithubReleaseInfoBox = "immichGithubReleaseInfoBox"; // Box
const String githubReleaseInfoKey = "immichGithubReleaseInfoKey"; // Key 1
// User Setting Info
const String userSettingInfoBox = "immichUserSettingInfoBox";
// Background backup Info
const String backgroundBackupInfoBox = "immichBackgroundBackupInfoBox"; // Box
const String backupFailedSince = "immichBackupFailedSince"; // Key 1
const String backupRequireWifi = "immichBackupRequireWifi"; // Key 2
const String backupRequireCharging = "immichBackupRequireCharging"; // Key 3
const String backupTriggerDelay = "immichBackupTriggerDelay"; // Key 4
// Duplicate asset
const String duplicatedAssetsBox = "immichDuplicatedAssetsBox"; // Box
const String duplicatedAssetsKey = "immichDuplicatedAssetsKey"; // Key 1
// In app logger
const String immichLoggerBox = "immichInAppLogger"; // Box

View File

@@ -6,17 +6,13 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/modules/settings/providers/notification_permission.provider.dart';
@@ -25,7 +21,6 @@ import 'package:immich_mobile/routing/tab_navigation_observer.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
import 'package:immich_mobile/shared/models/logger_message.model.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
@@ -50,18 +45,11 @@ void main() async {
final db = await loadDb();
await initApp();
await migrateHiveToStoreIfNecessary();
await migrateJsonCacheIfNecessary();
await migrateDatabaseIfNeeded(db);
runApp(getMainWidget(db));
}
Future<void> initApp() async {
await Hive.initFlutter();
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
Hive.registerAdapter(HiveBackupAlbumsAdapter());
Hive.registerAdapter(HiveDuplicatedAssetsAdapter());
Hive.registerAdapter(ImmichLoggerMessageAdapter());
await EasyLocalization.ensureInitialized();
if (kReleaseMode && Platform.isAndroid) {

View File

@@ -1,23 +0,0 @@
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/services/json_cache.dart';
@Deprecated("only kept to remove its files after migration")
class _BaseAlbumCacheService extends JsonCache<List<Album>> {
_BaseAlbumCacheService(super.cacheFileName);
@override
void put(List<Album> data) {}
@override
Future<List<Album>?> get() => Future.value(null);
}
@Deprecated("only kept to remove its files after migration")
class AlbumCacheService extends _BaseAlbumCacheService {
AlbumCacheService() : super("album_cache");
}
@Deprecated("only kept to remove its files after migration")
class SharedAlbumCacheService extends _BaseAlbumCacheService {
SharedAlbumCacheService() : super("shared_album_cache");
}

View File

@@ -1,105 +0,0 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:hive/hive.dart';
part 'hive_backup_albums.model.g.dart';
@HiveType(typeId: 1)
class HiveBackupAlbums {
@HiveField(0)
List<String> selectedAlbumIds;
@HiveField(1)
List<String> excludedAlbumsIds;
@HiveField(2, defaultValue: [])
List<DateTime> lastSelectedBackupTime;
@HiveField(3, defaultValue: [])
List<DateTime> lastExcludedBackupTime;
HiveBackupAlbums({
required this.selectedAlbumIds,
required this.excludedAlbumsIds,
required this.lastSelectedBackupTime,
required this.lastExcludedBackupTime,
});
@override
String toString() =>
'HiveBackupAlbums(selectedAlbumIds: $selectedAlbumIds, excludedAlbumsIds: $excludedAlbumsIds)';
HiveBackupAlbums copyWith({
List<String>? selectedAlbumIds,
List<String>? excludedAlbumsIds,
List<DateTime>? lastSelectedBackupTime,
List<DateTime>? lastExcludedBackupTime,
}) {
return HiveBackupAlbums(
selectedAlbumIds: selectedAlbumIds ?? this.selectedAlbumIds,
excludedAlbumsIds: excludedAlbumsIds ?? this.excludedAlbumsIds,
lastSelectedBackupTime:
lastSelectedBackupTime ?? this.lastSelectedBackupTime,
lastExcludedBackupTime:
lastExcludedBackupTime ?? this.lastExcludedBackupTime,
);
}
/// Returns a deep copy to allow safe modification without changing the global
/// state of [HiveBackupAlbums] before actually saving the changes
HiveBackupAlbums deepCopy() {
return HiveBackupAlbums(
selectedAlbumIds: selectedAlbumIds.toList(),
excludedAlbumsIds: excludedAlbumsIds.toList(),
lastSelectedBackupTime: lastSelectedBackupTime.toList(),
lastExcludedBackupTime: lastExcludedBackupTime.toList(),
);
}
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
result.addAll({'selectedAlbumIds': selectedAlbumIds});
result.addAll({'excludedAlbumsIds': excludedAlbumsIds});
result.addAll({'lastSelectedBackupTime': lastSelectedBackupTime});
result.addAll({'lastExcludedBackupTime': lastExcludedBackupTime});
return result;
}
factory HiveBackupAlbums.fromMap(Map<String, dynamic> map) {
return HiveBackupAlbums(
selectedAlbumIds: List<String>.from(map['selectedAlbumIds']),
excludedAlbumsIds: List<String>.from(map['excludedAlbumsIds']),
lastSelectedBackupTime:
List<DateTime>.from(map['lastSelectedBackupTime']),
lastExcludedBackupTime:
List<DateTime>.from(map['lastExcludedBackupTime']),
);
}
String toJson() => json.encode(toMap());
factory HiveBackupAlbums.fromJson(String source) =>
HiveBackupAlbums.fromMap(json.decode(source));
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return other is HiveBackupAlbums &&
listEquals(other.selectedAlbumIds, selectedAlbumIds) &&
listEquals(other.excludedAlbumsIds, excludedAlbumsIds) &&
listEquals(other.lastSelectedBackupTime, lastSelectedBackupTime) &&
listEquals(other.lastExcludedBackupTime, lastExcludedBackupTime);
}
@override
int get hashCode =>
selectedAlbumIds.hashCode ^
excludedAlbumsIds.hashCode ^
lastSelectedBackupTime.hashCode ^
lastExcludedBackupTime.hashCode;
}

View File

@@ -1,52 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'hive_backup_albums.model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class HiveBackupAlbumsAdapter extends TypeAdapter<HiveBackupAlbums> {
@override
final int typeId = 1;
@override
HiveBackupAlbums read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return HiveBackupAlbums(
selectedAlbumIds: (fields[0] as List).cast<String>(),
excludedAlbumsIds: (fields[1] as List).cast<String>(),
lastSelectedBackupTime:
fields[2] == null ? [] : (fields[2] as List).cast<DateTime>(),
lastExcludedBackupTime:
fields[3] == null ? [] : (fields[3] as List).cast<DateTime>(),
);
}
@override
void write(BinaryWriter writer, HiveBackupAlbums obj) {
writer
..writeByte(4)
..writeByte(0)
..write(obj.selectedAlbumIds)
..writeByte(1)
..write(obj.excludedAlbumsIds)
..writeByte(2)
..write(obj.lastSelectedBackupTime)
..writeByte(3)
..write(obj.lastExcludedBackupTime);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is HiveBackupAlbumsAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -1,57 +0,0 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:hive/hive.dart';
part 'hive_duplicated_assets.model.g.dart';
@HiveType(typeId: 2)
class HiveDuplicatedAssets {
@HiveField(0, defaultValue: [])
List<String> duplicatedAssetIds;
HiveDuplicatedAssets({
required this.duplicatedAssetIds,
});
HiveDuplicatedAssets copyWith({
List<String>? duplicatedAssetIds,
}) {
return HiveDuplicatedAssets(
duplicatedAssetIds: duplicatedAssetIds ?? this.duplicatedAssetIds,
);
}
Map<String, dynamic> toMap() {
return {
'duplicatedAssetIds': duplicatedAssetIds,
};
}
factory HiveDuplicatedAssets.fromMap(Map<String, dynamic> map) {
return HiveDuplicatedAssets(
duplicatedAssetIds: List<String>.from(map['duplicatedAssetIds']),
);
}
String toJson() => json.encode(toMap());
factory HiveDuplicatedAssets.fromJson(String source) =>
HiveDuplicatedAssets.fromMap(json.decode(source));
@override
String toString() =>
'HiveDuplicatedAssets(duplicatedAssetIds: $duplicatedAssetIds)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return other is HiveDuplicatedAssets &&
listEquals(other.duplicatedAssetIds, duplicatedAssetIds);
}
@override
int get hashCode => duplicatedAssetIds.hashCode;
}

View File

@@ -1,42 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'hive_duplicated_assets.model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class HiveDuplicatedAssetsAdapter extends TypeAdapter<HiveDuplicatedAssets> {
@override
final int typeId = 2;
@override
HiveDuplicatedAssets read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return HiveDuplicatedAssets(
duplicatedAssetIds:
fields[0] == null ? [] : (fields[0] as List).cast<String>(),
);
}
@override
void write(BinaryWriter writer, HiveDuplicatedAssets obj) {
writer
..writeByte(1)
..writeByte(0)
..write(obj.duplicatedAssetIds);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is HiveDuplicatedAssetsAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -364,7 +364,7 @@ class BackupControllerPage extends HookConsumerWidget {
.read(backgroundServiceProvider)
.getIOSBackgroundAppRefreshEnabled(),
builder: (context, snapshot) {
final enabled = snapshot.data as bool?;
final enabled = snapshot.data;
// If it's not enabled, show them some kind of alert that says
// background refresh is not enabled
if (enabled != null && !enabled) {}

View File

@@ -1,25 +0,0 @@
import 'package:hive/hive.dart';
part 'hive_saved_login_info.model.g.dart';
@HiveType(typeId: 0)
class HiveSavedLoginInfo {
@HiveField(0)
String email; // DEPRECATED
@HiveField(1)
String password; // DEPRECATED
@HiveField(2)
String serverUrl;
@HiveField(4, defaultValue: "")
String accessToken;
HiveSavedLoginInfo({
required this.email,
required this.password,
required this.serverUrl,
required this.accessToken,
});
}

View File

@@ -1,50 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'hive_saved_login_info.model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class HiveSavedLoginInfoAdapter extends TypeAdapter<HiveSavedLoginInfo> {
@override
final int typeId = 0;
@override
HiveSavedLoginInfo read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return HiveSavedLoginInfo(
email: fields[0] as String,
password: fields[1] as String,
serverUrl: fields[2] as String,
accessToken: fields[4] == null ? '' : fields[4] as String,
);
}
@override
void write(BinaryWriter writer, HiveSavedLoginInfo obj) {
writer
..writeByte(4)
..writeByte(0)
..write(obj.email)
..writeByte(1)
..write(obj.password)
..writeByte(2)
..write(obj.serverUrl)
..writeByte(4)
..write(obj.accessToken);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is HiveSavedLoginInfoAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -1,34 +0,0 @@
import 'package:hive/hive.dart';
part 'immich_logger_message.model.g.dart';
@HiveType(typeId: 3)
class ImmichLoggerMessage {
@HiveField(0)
String message;
@HiveField(1, defaultValue: "INFO")
String level;
@HiveField(2)
DateTime createdAt;
@HiveField(3)
String? context1;
@HiveField(4)
String? context2;
ImmichLoggerMessage({
required this.message,
required this.level,
required this.createdAt,
required this.context1,
required this.context2,
});
@override
String toString() {
return 'InAppLoggerMessage(message: $message, level: $level, createdAt: $createdAt)';
}
}

View File

@@ -1,53 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'immich_logger_message.model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ImmichLoggerMessageAdapter extends TypeAdapter<ImmichLoggerMessage> {
@override
final int typeId = 3;
@override
ImmichLoggerMessage read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ImmichLoggerMessage(
message: fields[0] as String,
level: fields[1] == null ? 'INFO' : fields[1] as String,
createdAt: fields[2] as DateTime,
context1: fields[3] as String?,
context2: fields[4] as String?,
);
}
@override
void write(BinaryWriter writer, ImmichLoggerMessage obj) {
writer
..writeByte(5)
..writeByte(0)
..write(obj.message)
..writeByte(1)
..write(obj.level)
..writeByte(2)
..write(obj.createdAt)
..writeByte(3)
..write(obj.context1)
..writeByte(4)
..write(obj.context2);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ImmichLoggerMessageAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -10,7 +10,6 @@ import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:immich_mobile/utils/openapi_extensions.dart';
import 'package:immich_mobile/utils/tuple.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@@ -60,15 +59,14 @@ class AssetService {
}) async {
try {
final etag = hasCache ? Store.tryGet(StoreKey.assetETag) : null;
final Pair<List<AssetResponseDto>, String?>? remote =
final (List<AssetResponseDto>? assets, String? newETag) =
await _apiService.assetApi.getAllAssetsWithETag(eTag: etag);
if (remote == null) {
if (assets == null) {
return null;
} else if (newETag != etag) {
Store.put(StoreKey.assetETag, newETag);
}
if (remote.second != null && remote.second != etag) {
Store.put(StoreKey.assetETag, remote.second);
}
return remote.first;
return assets;
} catch (e, stack) {
log.severe('Error while getting remote assets', e, stack);
return null;

View File

@@ -1,13 +0,0 @@
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/services/json_cache.dart';
@Deprecated("only kept to remove its files after migration")
class AssetCacheService extends JsonCache<List<Asset>> {
AssetCacheService() : super("asset_cache");
@override
void put(List<Asset> data) {}
@override
Future<List<Asset>?> get() => Future.value(null);
}

View File

@@ -1,36 +0,0 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
@Deprecated("only kept to remove its files after migration")
abstract class JsonCache<T> {
final String cacheFileName;
JsonCache(this.cacheFileName);
Future<File> _getCacheFile() async {
final basePath = await getTemporaryDirectory();
final basePathName = basePath.path;
final file = File("$basePathName/$cacheFileName.bin");
return file;
}
Future<bool> isValid() async {
final file = await _getCacheFile();
return await file.exists();
}
Future<void> invalidate() async {
try {
final file = await _getCacheFile();
await file.delete();
} on FileSystemException {
// file is already deleted
}
}
void put(T data);
Future<T?> get();
}

View File

@@ -11,7 +11,6 @@ import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:immich_mobile/utils/builtin_extensions.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:immich_mobile/utils/tuple.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@@ -94,7 +93,7 @@ class SyncService {
deleteCandidates.sort(Asset.compareById);
existing.sort(Asset.compareById);
return _diffAssets(existing, deleteCandidates, compare: Asset.compareById)
.third
.$3
.map((e) => e.id)
.toList();
}
@@ -165,14 +164,14 @@ class SyncService {
.thenByFileModifiedAt()
.findAll();
remote.sort(Asset.compareByOwnerDeviceLocalIdModified);
final diff = _diffAssets(remote, inDb, remote: true);
if (diff.first.isEmpty && diff.second.isEmpty && diff.third.isEmpty) {
final (toAdd, toUpdate, toRemove) = _diffAssets(remote, inDb, remote: true);
if (toAdd.isEmpty && toUpdate.isEmpty && toRemove.isEmpty) {
return false;
}
final idsToDelete = diff.third.map((e) => e.id).toList();
final idsToDelete = toRemove.map((e) => e.id).toList();
try {
await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete));
await upsertAssetsWithExif(diff.first + diff.second);
await upsertAssetsWithExif(toAdd + toUpdate);
} on IsarError catch (e) {
_log.severe("Failed to sync remote assets to db: $e");
}
@@ -252,8 +251,7 @@ class SyncService {
.findAll();
final List<Asset> assetsOnRemote = dto.getAssets();
assetsOnRemote.sort(Asset.compareByOwnerDeviceLocalIdModified);
final d = _diffAssets(assetsOnRemote, assetsInDb);
final List<Asset> toAdd = d.first, toUpdate = d.second, toUnlink = d.third;
final (toAdd, toUpdate, toUnlink) = _diffAssets(assetsOnRemote, assetsInDb);
// update shared users
final List<User> sharedUsers = album.sharedUsers.toList(growable: false);
@@ -271,9 +269,9 @@ class SyncService {
);
// for shared album: put missing album assets into local DB
final resultPair = await _linkWithExistingFromDb(toAdd);
await upsertAssetsWithExif(resultPair.second);
final assetsToLink = resultPair.first + resultPair.second;
final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd);
await upsertAssetsWithExif(updated);
final assetsToLink = existingInDb + updated;
final usersToLink = (await _db.users.getAllById(userIdsToAdd)).cast<User>();
album.name = dto.albumName;
@@ -327,9 +325,10 @@ class SyncService {
if (dto.assetCount == dto.assets.length) {
// in case an album contains assets not yet present in local DB:
// put missing album assets into local DB
final result = await _linkWithExistingFromDb(dto.getAssets());
existing.addAll(result.first);
await upsertAssetsWithExif(result.second);
final (existingInDb, updated) =
await _linkWithExistingFromDb(dto.getAssets());
existing.addAll(existingInDb);
await upsertAssetsWithExif(updated);
final Album a = await Album.remote(dto);
await _db.writeTxn(() => _db.albums.store(a));
@@ -393,18 +392,19 @@ class SyncService {
_log.fine(
"Syncing all local albums almost done. Collected ${deleteCandidates.length} asset candidates to delete",
);
final pair = _handleAssetRemoval(deleteCandidates, existing, remote: false);
final (toDelete, toUpdate) =
_handleAssetRemoval(deleteCandidates, existing, remote: false);
_log.fine(
"${pair.first.length} assets to delete, ${pair.second.length} to update",
"${toDelete.length} assets to delete, ${toUpdate.length} to update",
);
if (pair.first.isNotEmpty || pair.second.isNotEmpty) {
if (toDelete.isNotEmpty || toUpdate.isNotEmpty) {
await _db.writeTxn(() async {
await _db.assets.deleteAll(pair.first);
await _db.exifInfos.deleteAll(pair.first);
await _db.assets.putAll(pair.second);
await _db.assets.deleteAll(toDelete);
await _db.exifInfos.deleteAll(toDelete);
await _db.assets.putAll(toUpdate);
});
_log.info(
"Removed ${pair.first.length} and updated ${pair.second.length} local assets from DB",
"Removed ${toDelete.length} and updated ${toUpdate.length} local assets from DB",
);
}
return anyChanges;
@@ -441,8 +441,8 @@ class SyncService {
final List<Asset> onDevice =
await ape.getAssets(excludedAssets: excludedAssets);
onDevice.sort(Asset.compareByLocalId);
final d = _diffAssets(onDevice, inDb, compare: Asset.compareByLocalId);
final List<Asset> toAdd = d.first, toUpdate = d.second, toDelete = d.third;
final (toAdd, toUpdate, toDelete) =
_diffAssets(onDevice, inDb, compare: Asset.compareByLocalId);
if (toAdd.isEmpty &&
toUpdate.isEmpty &&
toDelete.isEmpty &&
@@ -458,12 +458,12 @@ class SyncService {
_log.fine(
"Syncing local album ${ape.name}. ${toAdd.length} assets to add, ${toUpdate.length} to update, ${toDelete.length} to delete",
);
final result = await _linkWithExistingFromDb(toAdd);
final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd);
_log.fine(
"Linking assets to add with existing from db. ${result.first.length} existing, ${result.second.length} to update",
"Linking assets to add with existing from db. ${existingInDb.length} existing, ${updated.length} to update",
);
deleteCandidates.addAll(toDelete);
existing.addAll(result.first);
existing.addAll(existingInDb);
album.name = ape.name;
album.modifiedAt = ape.lastModified ?? DateTime.now();
if (album.thumbnail.value != null &&
@@ -472,10 +472,10 @@ class SyncService {
}
try {
await _db.writeTxn(() async {
await _db.assets.putAll(result.second);
await _db.assets.putAll(updated);
await _db.assets.putAll(toUpdate);
await album.assets
.update(link: result.first + result.second, unlink: toDelete);
.update(link: existingInDb + updated, unlink: toDelete);
await _db.albums.put(album);
album.thumbnail.value ??= await album.assets.filter().findFirst();
await album.thumbnail.save();
@@ -510,11 +510,11 @@ class SyncService {
return false;
}
album.modifiedAt = ape.lastModified ?? DateTime.now();
final result = await _linkWithExistingFromDb(newAssets);
final (existingInDb, updated) = await _linkWithExistingFromDb(newAssets);
try {
await _db.writeTxn(() async {
await _db.assets.putAll(result.second);
await album.assets.update(link: result.first + result.second);
await _db.assets.putAll(updated);
await album.assets.update(link: existingInDb + updated);
await _db.albums.put(album);
});
_log.info("Fast synced local album ${ape.name} to DB");
@@ -536,15 +536,15 @@ class SyncService {
_log.info("Syncing a new local album to DB: ${ape.name}");
final Album a = Album.local(ape);
final assets = await ape.getAssets(excludedAssets: excludedAssets);
final result = await _linkWithExistingFromDb(assets);
final (existingInDb, updated) = await _linkWithExistingFromDb(assets);
_log.info(
"${result.first.length} assets already existed in DB, to upsert ${result.second.length}",
"${existingInDb.length} assets already existed in DB, to upsert ${updated.length}",
);
await upsertAssetsWithExif(result.second);
existing.addAll(result.first);
a.assets.addAll(result.first);
a.assets.addAll(result.second);
final thumb = result.first.firstOrNull ?? result.second.firstOrNull;
await upsertAssetsWithExif(updated);
existing.addAll(existingInDb);
a.assets.addAll(existingInDb);
a.assets.addAll(updated);
final thumb = existingInDb.firstOrNull ?? updated.firstOrNull;
a.thumbnail.value = thumb;
try {
await _db.writeTxn(() => _db.albums.store(a));
@@ -555,11 +555,11 @@ class SyncService {
}
/// Returns a tuple (existing, updated)
Future<Pair<List<Asset>, List<Asset>>> _linkWithExistingFromDb(
Future<(List<Asset> existing, List<Asset> updated)> _linkWithExistingFromDb(
List<Asset> assets,
) async {
if (assets.isEmpty) {
return const Pair([], []);
return ([].cast<Asset>(), [].cast<Asset>());
}
final List<Asset> inDb = await _db.assets
.where()
@@ -596,7 +596,7 @@ class SyncService {
),
onlySecond: (Asset b) => toUpsert.add(b),
);
return Pair(existing, toUpsert);
return (existing, toUpsert);
}
/// Inserts or updates the assets in the database with their ExifInfo (if any)
@@ -623,7 +623,7 @@ class SyncService {
}
/// Returns a triple(toAdd, toUpdate, toRemove)
Triple<List<Asset>, List<Asset>, List<Asset>> _diffAssets(
(List<Asset> toAdd, List<Asset> toUpdate, List<Asset> toRemove) _diffAssets(
List<Asset> assets,
List<Asset> inDb, {
bool? remote,
@@ -660,30 +660,30 @@ Triple<List<Asset>, List<Asset>, List<Asset>> _diffAssets(
},
onlySecond: (Asset b) => toAdd.add(b),
);
return Triple(toAdd, toUpdate, toRemove);
return (toAdd, toUpdate, toRemove);
}
/// returns a tuple (toDelete toUpdate) when assets are to be deleted
Pair<List<int>, List<Asset>> _handleAssetRemoval(
(List<int> toDelete, List<Asset> toUpdate) _handleAssetRemoval(
List<Asset> deleteCandidates,
List<Asset> existing, {
bool? remote,
}) {
if (deleteCandidates.isEmpty) {
return const Pair([], []);
return const ([], []);
}
deleteCandidates.sort(Asset.compareById);
deleteCandidates.uniqueConsecutive((a) => a.id);
existing.sort(Asset.compareById);
existing.uniqueConsecutive((a) => a.id);
final triple = _diffAssets(
final (tooAdd, toUpdate, toRemove) = _diffAssets(
existing,
deleteCandidates,
compare: Asset.compareById,
remote: remote,
);
assert(triple.first.isEmpty, "toAdd should be empty in _handleAssetRemoval");
return Pair(triple.third.map((e) => e.id).toList(), triple.second);
assert(tooAdd.isEmpty, "toAdd should be empty in _handleAssetRemoval");
return (toRemove.map((e) => e.id).toList(), toUpdate);
}
/// returns `true` if the albums differ on the surface

View File

@@ -1,151 +1,9 @@
// ignore_for_file: deprecated_member_use_from_same_package
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/services/asset_cache.service.dart';
import 'package:immich_mobile/utils/db.dart';
import 'package:isar/isar.dart';
Future<void> migrateHiveToStoreIfNecessary() async {
await _migrateHiveBoxIfNecessary(userInfoBox, _migrateHiveUserInfoBox);
await _migrateHiveBoxIfNecessary(
backgroundBackupInfoBox,
_migrateHiveBackgroundBackupInfoBox,
);
await _migrateHiveBoxIfNecessary(hiveBackupInfoBox, _migrateBackupInfoBox);
await _migrateHiveBoxIfNecessary(
duplicatedAssetsBox,
_migrateDuplicatedAssetsBox,
);
await _migrateHiveBoxIfNecessary(
hiveGithubReleaseInfoBox,
_migrateReleaseInfoBox,
);
await _migrateHiveBoxIfNecessary(hiveLoginInfoBox, _migrateLoginInfoBox);
await _migrateHiveBoxIfNecessary(
immichLoggerBox,
(Box<ImmichLoggerMessage> box) => box.deleteFromDisk(),
);
await _migrateHiveBoxIfNecessary(userSettingInfoBox, _migrateAppSettingsBox);
}
FutureOr<void> _migrateReleaseInfoBox(Box box) =>
_migrateKey(box, githubReleaseInfoKey, StoreKey.githubReleaseInfo);
Future<void> _migrateLoginInfoBox(Box<HiveSavedLoginInfo> box) async {
final HiveSavedLoginInfo? info = box.get(savedLoginInfoKey);
if (info != null) {
await Store.put(StoreKey.serverUrl, info.serverUrl);
await Store.put(StoreKey.accessToken, info.accessToken);
}
}
Future<void> _migrateHiveUserInfoBox(Box box) async {
await _migrateKey(box, assetEtagKey, StoreKey.assetETag);
if (Store.tryGet(StoreKey.deviceId) == null) {
await _migrateKey(box, deviceIdKey, StoreKey.deviceId);
}
await _migrateKey(box, serverEndpointKey, StoreKey.serverEndpoint);
}
Future<void> _migrateHiveBackgroundBackupInfoBox(Box box) async {
await _migrateKey(box, backupFailedSince, StoreKey.backupFailedSince);
await _migrateKey(box, backupRequireWifi, StoreKey.backupRequireWifi);
await _migrateKey(box, backupRequireCharging, StoreKey.backupRequireCharging);
await _migrateKey(box, backupTriggerDelay, StoreKey.backupTriggerDelay);
}
FutureOr<void> _migrateBackupInfoBox(Box<HiveBackupAlbums> box) {
final HiveBackupAlbums? infos = box.get(backupInfoKey);
if (infos != null) {
final Isar? db = Isar.getInstance();
if (db == null) {
throw Exception("_migrateBackupInfoBox could not load database");
}
List<BackupAlbum> albums = [];
for (int i = 0; i < infos.selectedAlbumIds.length; i++) {
final album = BackupAlbum(
infos.selectedAlbumIds[i],
infos.lastSelectedBackupTime[i],
BackupSelection.select,
);
albums.add(album);
}
for (int i = 0; i < infos.excludedAlbumsIds.length; i++) {
final album = BackupAlbum(
infos.excludedAlbumsIds[i],
infos.lastExcludedBackupTime[i],
BackupSelection.exclude,
);
albums.add(album);
}
return db.writeTxn(() => db.backupAlbums.putAll(albums));
}
}
FutureOr<void> _migrateDuplicatedAssetsBox(Box<HiveDuplicatedAssets> box) {
final HiveDuplicatedAssets? duplicatedAssets = box.get(duplicatedAssetsKey);
if (duplicatedAssets != null) {
final Isar? db = Isar.getInstance();
if (db == null) {
throw Exception("_migrateBackupInfoBox could not load database");
}
final duplicatedAssetIds = duplicatedAssets.duplicatedAssetIds
.map((id) => DuplicatedAsset(id))
.toList();
return db.writeTxn(() => db.duplicatedAssets.putAll(duplicatedAssetIds));
}
}
Future<void> _migrateAppSettingsBox(Box box) async {
for (AppSettingsEnum s in AppSettingsEnum.values) {
if (s.hiveKey != null) {
await _migrateKey(box, s.hiveKey!, s.storeKey);
}
}
}
Future<void> _migrateHiveBoxIfNecessary<T>(
String boxName,
FutureOr<void> Function(Box<T>) migrate,
) async {
try {
if (await Hive.boxExists(boxName)) {
final box = await Hive.openBox<T>(boxName);
await migrate(box);
await box.deleteFromDisk();
}
} catch (e) {
debugPrint("Error while migrating $boxName $e");
}
}
FutureOr<void> _migrateKey<T>(Box box, String hiveKey, StoreKey<T> key) {
final T? value = box.get(hiveKey);
if (value != null) {
return Store.put(key, value);
}
}
Future<void> migrateJsonCacheIfNecessary() async {
await AlbumCacheService().invalidate();
await SharedAlbumCacheService().invalidate();
await AssetCacheService().invalidate();
}
Future<void> migrateDatabaseIfNeeded(Isar db) async {
final int version = Store.get(StoreKey.version, 1);
switch (version) {

View File

@@ -4,8 +4,6 @@ import 'dart:io';
import 'package:http/http.dart';
import 'package:openapi/api.dart';
import 'tuple.dart';
/// Extension methods to retrieve ETag together with the API call
extension WithETag on AssetApi {
/// Get all AssetEntity belong to the user
@@ -14,7 +12,7 @@ extension WithETag on AssetApi {
///
/// * [String] eTag:
/// ETag of data already cached on the client
Future<Pair<List<AssetResponseDto>, String?>?> getAllAssetsWithETag({
Future<(List<AssetResponseDto>? assets, String? eTag)> getAllAssetsWithETag({
String? eTag,
}) async {
final response = await getAllAssetsWithHttpInfo(
@@ -36,9 +34,9 @@ extension WithETag on AssetApi {
) as List)
.cast<AssetResponseDto>()
.toList();
return Pair(data, etag);
return (data, etag);
}
return null;
return (null, null);
}
}

View File

@@ -1,18 +0,0 @@
/// An immutable pair or 2-tuple
/// TODO replace with Record once Dart 2.19 is available
class Pair<T1, T2> {
final T1 first;
final T2 second;
const Pair(this.first, this.second);
}
/// An immutable triple or 3-tuple
/// TODO replace with Record once Dart 2.19 is available
class Triple<T1, T2, T3> {
final T1 first;
final T2 second;
final T3 third;
const Triple(this.first, this.second, this.third);
}

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: 1.56.2
- API version: 1.57.1
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements

View File

@@ -1041,12 +1041,10 @@ This endpoint does not need any parameter.
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getMapMarkers**
> List<MapMarkerResponseDto> getMapMarkers(isFavorite, isArchived, skip)
> List<MapMarkerResponseDto> getMapMarkers(isFavorite)
Get all assets that have GPS information embedded
### Example
```dart
import 'package:openapi/api.dart';
@@ -1067,11 +1065,9 @@ import 'package:openapi/api.dart';
final api_instance = AssetApi();
final isFavorite = true; // bool |
final isArchived = true; // bool |
final skip = 8.14; // num |
try {
final result = api_instance.getMapMarkers(isFavorite, isArchived, skip);
final result = api_instance.getMapMarkers(isFavorite);
print(result);
} catch (e) {
print('Exception when calling AssetApi->getMapMarkers: $e\n');
@@ -1083,8 +1079,6 @@ try {
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**isFavorite** | **bool**| | [optional]
**isArchived** | **bool**| | [optional]
**skip** | **num**| | [optional]
### Return type

View File

@@ -12,6 +12,7 @@ Name | Type | Description | Notes
**password** | **String** | |
**firstName** | **String** | |
**lastName** | **String** | |
**storageLabel** | **String** | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -8,10 +8,9 @@ import 'package:openapi/api.dart';
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**type** | [**AssetTypeEnum**](AssetTypeEnum.md) | |
**id** | **String** | |
**lat** | **double** | |
**lon** | **double** | |
**id** | **String** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -8,11 +8,14 @@ import 'package:openapi/api.dart';
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**crf** | **String** | |
**crf** | **int** | |
**threads** | **int** | |
**preset** | **String** | |
**targetVideoCodec** | **String** | |
**targetAudioCodec** | **String** | |
**targetResolution** | **String** | |
**maxBitrate** | **String** | |
**twoPass** | **bool** | |
**transcode** | **String** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -8,11 +8,12 @@ import 'package:openapi/api.dart';
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**id** | **String** | |
**email** | **String** | | [optional]
**password** | **String** | | [optional]
**firstName** | **String** | | [optional]
**lastName** | **String** | | [optional]
**id** | **String** | |
**storageLabel** | **String** | | [optional]
**isAdmin** | **bool** | | [optional]
**shouldChangePassword** | **bool** | | [optional]

View File

@@ -12,6 +12,7 @@ Name | Type | Description | Notes
**email** | **String** | |
**firstName** | **String** | |
**lastName** | **String** | |
**storageLabel** | **String** | |
**createdAt** | **String** | |
**profileImagePath** | **String** | |
**shouldChangePassword** | **bool** | |

View File

@@ -979,18 +979,11 @@ class AssetApi {
return null;
}
/// Get all assets that have GPS information embedded
///
/// Note: This method returns the HTTP [Response].
///
/// Performs an HTTP 'GET /asset/map-marker' operation and returns the [Response].
/// Parameters:
///
/// * [bool] isFavorite:
///
/// * [bool] isArchived:
///
/// * [num] skip:
Future<Response> getMapMarkersWithHttpInfo({ bool? isFavorite, bool? isArchived, num? skip, }) async {
Future<Response> getMapMarkersWithHttpInfo({ bool? isFavorite, }) async {
// ignore: prefer_const_declarations
final path = r'/asset/map-marker';
@@ -1004,12 +997,6 @@ class AssetApi {
if (isFavorite != null) {
queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
}
if (isArchived != null) {
queryParams.addAll(_queryParams('', 'isArchived', isArchived));
}
if (skip != null) {
queryParams.addAll(_queryParams('', 'skip', skip));
}
const contentTypes = <String>[];
@@ -1025,17 +1012,11 @@ class AssetApi {
);
}
/// Get all assets that have GPS information embedded
///
/// Parameters:
///
/// * [bool] isFavorite:
///
/// * [bool] isArchived:
///
/// * [num] skip:
Future<List<MapMarkerResponseDto>?> getMapMarkers({ bool? isFavorite, bool? isArchived, num? skip, }) async {
final response = await getMapMarkersWithHttpInfo( isFavorite: isFavorite, isArchived: isArchived, skip: skip, );
Future<List<MapMarkerResponseDto>?> getMapMarkers({ bool? isFavorite, }) async {
final response = await getMapMarkersWithHttpInfo( isFavorite: isFavorite, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View File

@@ -17,6 +17,7 @@ class CreateUserDto {
required this.password,
required this.firstName,
required this.lastName,
this.storageLabel,
});
String email;
@@ -27,12 +28,15 @@ class CreateUserDto {
String lastName;
String? storageLabel;
@override
bool operator ==(Object other) => identical(this, other) || other is CreateUserDto &&
other.email == email &&
other.password == password &&
other.firstName == firstName &&
other.lastName == lastName;
other.lastName == lastName &&
other.storageLabel == storageLabel;
@override
int get hashCode =>
@@ -40,10 +44,11 @@ class CreateUserDto {
(email.hashCode) +
(password.hashCode) +
(firstName.hashCode) +
(lastName.hashCode);
(lastName.hashCode) +
(storageLabel == null ? 0 : storageLabel!.hashCode);
@override
String toString() => 'CreateUserDto[email=$email, password=$password, firstName=$firstName, lastName=$lastName]';
String toString() => 'CreateUserDto[email=$email, password=$password, firstName=$firstName, lastName=$lastName, storageLabel=$storageLabel]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -51,6 +56,11 @@ class CreateUserDto {
json[r'password'] = this.password;
json[r'firstName'] = this.firstName;
json[r'lastName'] = this.lastName;
if (this.storageLabel != null) {
json[r'storageLabel'] = this.storageLabel;
} else {
// json[r'storageLabel'] = null;
}
return json;
}
@@ -77,6 +87,7 @@ class CreateUserDto {
password: mapValueOfType<String>(json, r'password')!,
firstName: mapValueOfType<String>(json, r'firstName')!,
lastName: mapValueOfType<String>(json, r'lastName')!,
storageLabel: mapValueOfType<String>(json, r'storageLabel'),
);
}
return null;

View File

@@ -13,44 +13,38 @@ part of openapi.api;
class MapMarkerResponseDto {
/// Returns a new [MapMarkerResponseDto] instance.
MapMarkerResponseDto({
required this.type,
required this.id,
required this.lat,
required this.lon,
required this.id,
});
AssetTypeEnum type;
String id;
double lat;
double lon;
String id;
@override
bool operator ==(Object other) => identical(this, other) || other is MapMarkerResponseDto &&
other.type == type &&
other.id == id &&
other.lat == lat &&
other.lon == lon &&
other.id == id;
other.lon == lon;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(type.hashCode) +
(id.hashCode) +
(lat.hashCode) +
(lon.hashCode) +
(id.hashCode);
(lon.hashCode);
@override
String toString() => 'MapMarkerResponseDto[type=$type, lat=$lat, lon=$lon, id=$id]';
String toString() => 'MapMarkerResponseDto[id=$id, lat=$lat, lon=$lon]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'type'] = this.type;
json[r'id'] = this.id;
json[r'lat'] = this.lat;
json[r'lon'] = this.lon;
json[r'id'] = this.id;
return json;
}
@@ -73,10 +67,9 @@ class MapMarkerResponseDto {
}());
return MapMarkerResponseDto(
type: AssetTypeEnum.fromJson(json[r'type'])!,
id: mapValueOfType<String>(json, r'id')!,
lat: mapValueOfType<double>(json, r'lat')!,
lon: mapValueOfType<double>(json, r'lon')!,
id: mapValueOfType<String>(json, r'id')!,
);
}
return null;
@@ -124,10 +117,9 @@ class MapMarkerResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'type',
'id',
'lat',
'lon',
'id',
};
}

View File

@@ -14,14 +14,19 @@ class SystemConfigFFmpegDto {
/// Returns a new [SystemConfigFFmpegDto] instance.
SystemConfigFFmpegDto({
required this.crf,
required this.threads,
required this.preset,
required this.targetVideoCodec,
required this.targetAudioCodec,
required this.targetResolution,
required this.maxBitrate,
required this.twoPass,
required this.transcode,
});
String crf;
int crf;
int threads;
String preset;
@@ -31,37 +36,50 @@ class SystemConfigFFmpegDto {
String targetResolution;
String maxBitrate;
bool twoPass;
SystemConfigFFmpegDtoTranscodeEnum transcode;
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigFFmpegDto &&
other.crf == crf &&
other.threads == threads &&
other.preset == preset &&
other.targetVideoCodec == targetVideoCodec &&
other.targetAudioCodec == targetAudioCodec &&
other.targetResolution == targetResolution &&
other.maxBitrate == maxBitrate &&
other.twoPass == twoPass &&
other.transcode == transcode;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(crf.hashCode) +
(threads.hashCode) +
(preset.hashCode) +
(targetVideoCodec.hashCode) +
(targetAudioCodec.hashCode) +
(targetResolution.hashCode) +
(maxBitrate.hashCode) +
(twoPass.hashCode) +
(transcode.hashCode);
@override
String toString() => 'SystemConfigFFmpegDto[crf=$crf, preset=$preset, targetVideoCodec=$targetVideoCodec, targetAudioCodec=$targetAudioCodec, targetResolution=$targetResolution, transcode=$transcode]';
String toString() => 'SystemConfigFFmpegDto[crf=$crf, threads=$threads, preset=$preset, targetVideoCodec=$targetVideoCodec, targetAudioCodec=$targetAudioCodec, targetResolution=$targetResolution, maxBitrate=$maxBitrate, twoPass=$twoPass, transcode=$transcode]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'crf'] = this.crf;
json[r'threads'] = this.threads;
json[r'preset'] = this.preset;
json[r'targetVideoCodec'] = this.targetVideoCodec;
json[r'targetAudioCodec'] = this.targetAudioCodec;
json[r'targetResolution'] = this.targetResolution;
json[r'maxBitrate'] = this.maxBitrate;
json[r'twoPass'] = this.twoPass;
json[r'transcode'] = this.transcode;
return json;
}
@@ -85,11 +103,14 @@ class SystemConfigFFmpegDto {
}());
return SystemConfigFFmpegDto(
crf: mapValueOfType<String>(json, r'crf')!,
crf: mapValueOfType<int>(json, r'crf')!,
threads: mapValueOfType<int>(json, r'threads')!,
preset: mapValueOfType<String>(json, r'preset')!,
targetVideoCodec: mapValueOfType<String>(json, r'targetVideoCodec')!,
targetAudioCodec: mapValueOfType<String>(json, r'targetAudioCodec')!,
targetResolution: mapValueOfType<String>(json, r'targetResolution')!,
maxBitrate: mapValueOfType<String>(json, r'maxBitrate')!,
twoPass: mapValueOfType<bool>(json, r'twoPass')!,
transcode: SystemConfigFFmpegDtoTranscodeEnum.fromJson(json[r'transcode'])!,
);
}
@@ -139,10 +160,13 @@ class SystemConfigFFmpegDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'crf',
'threads',
'preset',
'targetVideoCodec',
'targetAudioCodec',
'targetResolution',
'maxBitrate',
'twoPass',
'transcode',
};
}

View File

@@ -13,15 +13,18 @@ part of openapi.api;
class UpdateUserDto {
/// Returns a new [UpdateUserDto] instance.
UpdateUserDto({
required this.id,
this.email,
this.password,
this.firstName,
this.lastName,
required this.id,
this.storageLabel,
this.isAdmin,
this.shouldChangePassword,
});
String id;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
@@ -54,7 +57,13 @@ class UpdateUserDto {
///
String? lastName;
String id;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? storageLabel;
///
/// Please note: This property should have been non-nullable! Since the specification file
@@ -74,30 +83,33 @@ class UpdateUserDto {
@override
bool operator ==(Object other) => identical(this, other) || other is UpdateUserDto &&
other.id == id &&
other.email == email &&
other.password == password &&
other.firstName == firstName &&
other.lastName == lastName &&
other.id == id &&
other.storageLabel == storageLabel &&
other.isAdmin == isAdmin &&
other.shouldChangePassword == shouldChangePassword;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(id.hashCode) +
(email == null ? 0 : email!.hashCode) +
(password == null ? 0 : password!.hashCode) +
(firstName == null ? 0 : firstName!.hashCode) +
(lastName == null ? 0 : lastName!.hashCode) +
(id.hashCode) +
(storageLabel == null ? 0 : storageLabel!.hashCode) +
(isAdmin == null ? 0 : isAdmin!.hashCode) +
(shouldChangePassword == null ? 0 : shouldChangePassword!.hashCode);
@override
String toString() => 'UpdateUserDto[email=$email, password=$password, firstName=$firstName, lastName=$lastName, id=$id, isAdmin=$isAdmin, shouldChangePassword=$shouldChangePassword]';
String toString() => 'UpdateUserDto[id=$id, email=$email, password=$password, firstName=$firstName, lastName=$lastName, storageLabel=$storageLabel, isAdmin=$isAdmin, shouldChangePassword=$shouldChangePassword]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'id'] = this.id;
if (this.email != null) {
json[r'email'] = this.email;
} else {
@@ -118,7 +130,11 @@ class UpdateUserDto {
} else {
// json[r'lastName'] = null;
}
json[r'id'] = this.id;
if (this.storageLabel != null) {
json[r'storageLabel'] = this.storageLabel;
} else {
// json[r'storageLabel'] = null;
}
if (this.isAdmin != null) {
json[r'isAdmin'] = this.isAdmin;
} else {
@@ -151,11 +167,12 @@ class UpdateUserDto {
}());
return UpdateUserDto(
id: mapValueOfType<String>(json, r'id')!,
email: mapValueOfType<String>(json, r'email'),
password: mapValueOfType<String>(json, r'password'),
firstName: mapValueOfType<String>(json, r'firstName'),
lastName: mapValueOfType<String>(json, r'lastName'),
id: mapValueOfType<String>(json, r'id')!,
storageLabel: mapValueOfType<String>(json, r'storageLabel'),
isAdmin: mapValueOfType<bool>(json, r'isAdmin'),
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword'),
);

View File

@@ -17,6 +17,7 @@ class UserResponseDto {
required this.email,
required this.firstName,
required this.lastName,
required this.storageLabel,
required this.createdAt,
required this.profileImagePath,
required this.shouldChangePassword,
@@ -34,6 +35,8 @@ class UserResponseDto {
String lastName;
String? storageLabel;
String createdAt;
String profileImagePath;
@@ -66,6 +69,7 @@ class UserResponseDto {
other.email == email &&
other.firstName == firstName &&
other.lastName == lastName &&
other.storageLabel == storageLabel &&
other.createdAt == createdAt &&
other.profileImagePath == profileImagePath &&
other.shouldChangePassword == shouldChangePassword &&
@@ -81,6 +85,7 @@ class UserResponseDto {
(email.hashCode) +
(firstName.hashCode) +
(lastName.hashCode) +
(storageLabel == null ? 0 : storageLabel!.hashCode) +
(createdAt.hashCode) +
(profileImagePath.hashCode) +
(shouldChangePassword.hashCode) +
@@ -90,7 +95,7 @@ class UserResponseDto {
(oauthId.hashCode);
@override
String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt, updatedAt=$updatedAt, oauthId=$oauthId]';
String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, storageLabel=$storageLabel, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt, updatedAt=$updatedAt, oauthId=$oauthId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -98,6 +103,11 @@ class UserResponseDto {
json[r'email'] = this.email;
json[r'firstName'] = this.firstName;
json[r'lastName'] = this.lastName;
if (this.storageLabel != null) {
json[r'storageLabel'] = this.storageLabel;
} else {
// json[r'storageLabel'] = null;
}
json[r'createdAt'] = this.createdAt;
json[r'profileImagePath'] = this.profileImagePath;
json[r'shouldChangePassword'] = this.shouldChangePassword;
@@ -139,6 +149,7 @@ class UserResponseDto {
email: mapValueOfType<String>(json, r'email')!,
firstName: mapValueOfType<String>(json, r'firstName')!,
lastName: mapValueOfType<String>(json, r'lastName')!,
storageLabel: mapValueOfType<String>(json, r'storageLabel'),
createdAt: mapValueOfType<String>(json, r'createdAt')!,
profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
@@ -197,6 +208,7 @@ class UserResponseDto {
'email',
'firstName',
'lastName',
'storageLabel',
'createdAt',
'profileImagePath',
'shouldChangePassword',

View File

@@ -117,9 +117,7 @@ void main() {
// TODO
});
// Get all assets that have GPS information embedded
//
//Future<List<MapMarkerResponseDto>> getMapMarkers({ bool isFavorite, bool isArchived, num skip }) async
//Future<List<MapMarkerResponseDto>> getMapMarkers({ bool isFavorite }) async
test('test getMapMarkers', () async {
// TODO
});

View File

@@ -36,6 +36,11 @@ void main() {
// TODO
});
// String storageLabel
test('to test the property `storageLabel`', () async {
// TODO
});
});

View File

@@ -16,8 +16,8 @@ void main() {
// final instance = MapMarkerResponseDto();
group('test MapMarkerResponseDto', () {
// AssetTypeEnum type
test('to test the property `type`', () async {
// String id
test('to test the property `id`', () async {
// TODO
});
@@ -31,11 +31,6 @@ void main() {
// TODO
});
// String id
test('to test the property `id`', () async {
// TODO
});
});

View File

@@ -16,11 +16,16 @@ void main() {
// final instance = SystemConfigFFmpegDto();
group('test SystemConfigFFmpegDto', () {
// String crf
// int crf
test('to test the property `crf`', () async {
// TODO
});
// int threads
test('to test the property `threads`', () async {
// TODO
});
// String preset
test('to test the property `preset`', () async {
// TODO
@@ -41,6 +46,16 @@ void main() {
// TODO
});
// String maxBitrate
test('to test the property `maxBitrate`', () async {
// TODO
});
// bool twoPass
test('to test the property `twoPass`', () async {
// TODO
});
// String transcode
test('to test the property `transcode`', () async {
// TODO

View File

@@ -16,6 +16,11 @@ void main() {
// final instance = UpdateUserDto();
group('test UpdateUserDto', () {
// String id
test('to test the property `id`', () async {
// TODO
});
// String email
test('to test the property `email`', () async {
// TODO
@@ -36,8 +41,8 @@ void main() {
// TODO
});
// String id
test('to test the property `id`', () async {
// String storageLabel
test('to test the property `storageLabel`', () async {
// TODO
});

View File

@@ -36,6 +36,11 @@ void main() {
// TODO
});
// String storageLabel
test('to test the property `storageLabel`', () async {
// TODO
});
// String createdAt
test('to test the property `createdAt`', () async {
// TODO

View File

@@ -511,30 +511,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
hive:
dependency: "direct main"
description:
name: hive
sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941"
url: "https://pub.dev"
source: hosted
version: "2.2.3"
hive_flutter:
dependency: "direct main"
description:
name: hive_flutter
sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc
url: "https://pub.dev"
source: hosted
version: "1.1.0"
hive_generator:
dependency: "direct dev"
description:
name: hive_generator
sha256: "65998cc4d2cd9680a3d9709d893d2f6bb15e6c1f92626c3f1fa650b4b3281521"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
hooks_riverpod:
dependency: "direct main"
description:
@@ -1152,14 +1128,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.6"
source_helper:
dependency: transitive
description:
name: source_helper
sha256: "3b67aade1d52416149c633ba1bb36df44d97c6b51830c2198e934e3fca87ca1f"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
source_span:
dependency: transitive
description:

View File

@@ -2,11 +2,11 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.56.2+79
version: 1.57.1+80
isar_version: &isar_version 3.0.5
environment:
sdk: ">=2.17.0 <3.0.0"
sdk: ">=3.0.0-0 <4.0.0"
dependencies:
flutter:
@@ -16,8 +16,6 @@ dependencies:
photo_manager: ^2.5.0
flutter_hooks: ^0.18.6
hooks_riverpod: ^2.2.0
hive: ^2.2.1
hive_flutter: ^1.1.0
cached_network_image: ^3.2.2
intl: ^0.18.0
auto_route: ^5.0.1
@@ -59,7 +57,6 @@ dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.1
hive_generator: ^2.0.0
build_runner: ^2.2.1
auto_route_generator: ^5.0.2
flutter_launcher_icons: "^0.9.2"

View File

@@ -28,7 +28,7 @@ server {
client_max_body_size 50000M;
# Compression
gzip off;
gzip on;
gzip_comp_level 2;
gzip_min_length 1000;
gzip_proxied any;

View File

@@ -1,6 +0,0 @@
## Public sharing
### Albums
- [ ] Add asset to shared link when new asset is added to shared album
- [ ] Prevent public user to delete asset from shared album

View File

@@ -39,6 +39,7 @@ describe('Album service', () => {
oauthId: '',
tags: [],
assets: [],
storageLabel: null,
});
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
const sharedAlbumOwnerId = '2222';

View File

@@ -31,7 +31,7 @@ import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { AssetResponseDto, ImmichReadStream, MapMarkerResponseDto } from '@app/domain';
import { AssetResponseDto, ImmichReadStream } from '@app/domain';
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.dto';
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
@@ -260,18 +260,6 @@ export class AssetController {
return await this.assetService.getAssetByTimeBucket(authUser, getAssetByTimeBucketDto);
}
/**
* Get all assets that have GPS information embedded
*/
@Authenticated()
@Get('/map-marker')
getMapMarkers(
@GetAuthUser() authUser: AuthUserDto,
@Query(new ValidationPipe({ transform: true })) dto: AssetSearchDto,
): Promise<MapMarkerResponseDto[]> {
return this.assetService.getMapMarkers(authUser, dto);
}
/**
* Get all asset of a device that are in the database, ID only.
*/

View File

@@ -41,7 +41,7 @@ export class AssetCore {
faces: [],
});
await this.jobRepository.queue({ name: JobName.ASSET_UPLOADED, data: { asset, fileName: file.originalName } });
await this.jobRepository.queue({ name: JobName.ASSET_UPLOADED, data: { asset } });
return asset;
}

View File

@@ -328,18 +328,8 @@ describe('AssetService', () => {
});
expect(jobMock.queue.mock.calls).toEqual([
[
{
name: JobName.ASSET_UPLOADED,
data: { asset: assetEntityStub.livePhotoMotionAsset, fileName: 'asset_1.mp4' },
},
],
[
{
name: JobName.ASSET_UPLOADED,
data: { asset: assetEntityStub.livePhotoStillAsset, fileName: 'asset_1.jpeg' },
},
],
[{ name: JobName.ASSET_UPLOADED, data: { asset: assetEntityStub.livePhotoMotionAsset } }],
[{ name: JobName.ASSET_UPLOADED, data: { asset: assetEntityStub.livePhotoStillAsset } }],
]);
});
});
@@ -504,17 +494,4 @@ describe('AssetService', () => {
expect(storageMock.createReadStream).toHaveBeenCalledWith('fake_path/asset_1.jpeg', 'image/jpeg');
});
});
describe('get map markers', () => {
it('should get geo information of assets', async () => {
assetRepositoryMock.getAllByUserId.mockResolvedValue(_getAssets());
const markers = await sut.getMapMarkers(authStub.admin, {});
expect(markers).toHaveLength(1);
expect(markers[0].lat).toBe(_getAsset_1().exifInfo?.latitude);
expect(markers[0].lon).toBe(_getAsset_1().exifInfo?.longitude);
expect(markers[0].id).toBe(_getAsset_1().id);
});
});
});

View File

@@ -30,8 +30,6 @@ import {
JobName,
mapAsset,
mapAssetWithoutExif,
MapMarkerResponseDto,
mapAssetMapMarker,
PartnerCore,
} from '@app/domain';
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
@@ -149,12 +147,6 @@ export class AssetService {
return assets.map((asset) => mapAsset(asset));
}
public async getMapMarkers(authUser: AuthUserDto, dto: AssetSearchDto): Promise<MapMarkerResponseDto[]> {
const assets = await this._assetRepository.getAllByUserId(authUser.id, dto);
return assets.map((asset) => mapAssetMapMarker(asset)).filter((marker) => marker != null) as MapMarkerResponseDto[];
}
public async getAssetByTimeBucket(
authUser: AuthUserDto,
getAssetByTimeBucketDto: GetAssetByTimeBucketDto,

View File

@@ -27,6 +27,7 @@ describe('TagService', () => {
tags: [],
assets: [],
oauthId: 'oauth-id-1',
storageLabel: null,
});
// const user2: UserEntity = Object.freeze({

View File

@@ -9,6 +9,7 @@ import { InfraModule } from '@app/infra';
import {
AlbumController,
APIKeyController,
AssetController,
AuthController,
PersonController,
JobController,
@@ -36,6 +37,7 @@ import { AppCronJobs } from './app.cron-jobs';
AppController,
AlbumController,
APIKeyController,
AssetController,
AuthController,
JobController,
OAuthController,

View File

@@ -1,5 +1,6 @@
import { Request } from 'express';
import * as fs from 'fs';
import { AuthRequest } from '../decorators/auth-user.decorator';
import { multerUtils } from './asset-upload.config';
const { fileFilter, destination, filename } = multerUtils;
@@ -14,7 +15,7 @@ const mock = {
deviceId: 'test-device',
fileExtension: '.jpg',
},
} as Request,
} as AuthRequest,
file: { originalname: 'test.jpg' } as Express.Multer.File,
};

View File

@@ -2,12 +2,11 @@ import { StorageCore, StorageFolder } from '@app/domain/storage';
import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common';
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
import { createHash, randomUUID } from 'crypto';
import { Request } from 'express';
import { existsSync, mkdirSync } from 'fs';
import { diskStorage, StorageEngine } from 'multer';
import { extname } from 'path';
import sanitize from 'sanitize-filename';
import { AuthUserDto } from '../decorators/auth-user.decorator';
import { AuthRequest, AuthUserDto } from '../decorators/auth-user.decorator';
import { patchFormData } from '../utils/path-form-data.util';
export interface ImmichFile extends Express.Multer.File {
@@ -50,7 +49,7 @@ export const multerUtils = { fileFilter, filename, destination };
const logger = new Logger('AssetUploadConfig');
function fileFilter(req: Request, file: any, cb: any) {
function fileFilter(req: AuthRequest, file: any, cb: any) {
if (!req.user || (req.user.isPublicUser && !req.user.isAllowUpload)) {
return cb(new UnauthorizedException());
}
@@ -66,7 +65,7 @@ function fileFilter(req: Request, file: any, cb: any) {
}
}
function destination(req: Request, file: Express.Multer.File, cb: any) {
function destination(req: AuthRequest, file: Express.Multer.File, cb: any) {
if (!req.user || (req.user.isPublicUser && !req.user.isAllowUpload)) {
return cb(new UnauthorizedException());
}
@@ -82,7 +81,7 @@ function destination(req: Request, file: Express.Multer.File, cb: any) {
cb(null, uploadFolder);
}
function filename(req: Request, file: Express.Multer.File, cb: any) {
function filename(req: AuthRequest, file: Express.Multer.File, cb: any) {
if (!req.user || (req.user.isPublicUser && !req.user.isAllowUpload)) {
return cb(new UnauthorizedException());
}

View File

@@ -1,5 +1,6 @@
import { Request } from 'express';
import * as fs from 'fs';
import { AuthRequest } from '../decorators/auth-user.decorator';
import { multerUtils } from './profile-image-upload.config';
const { fileFilter, destination, filename } = multerUtils;
@@ -10,7 +11,7 @@ const mock = {
user: {
id: 'test-user',
},
} as Request,
} as AuthRequest,
file: { originalname: 'test.jpg' } as Express.Multer.File,
};

View File

@@ -1,12 +1,11 @@
import { StorageCore, StorageFolder } from '@app/domain/storage';
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
import { Request } from 'express';
import { existsSync, mkdirSync } from 'fs';
import { diskStorage } from 'multer';
import { extname } from 'path';
import sanitize from 'sanitize-filename';
import { AuthUserDto } from '../decorators/auth-user.decorator';
import { AuthRequest, AuthUserDto } from '../decorators/auth-user.decorator';
import { patchFormData } from '../utils/path-form-data.util';
export const profileImageUploadOption: MulterOptions = {
@@ -21,7 +20,7 @@ export const multerUtils = { fileFilter, filename, destination };
const storageCore = new StorageCore();
function fileFilter(req: Request, file: any, cb: any) {
function fileFilter(req: AuthRequest, file: any, cb: any) {
if (!req.user) {
return cb(new UnauthorizedException());
}
@@ -33,7 +32,7 @@ function fileFilter(req: Request, file: any, cb: any) {
}
}
function destination(req: Request, file: Express.Multer.File, cb: any) {
function destination(req: AuthRequest, file: Express.Multer.File, cb: any) {
if (!req.user) {
return cb(new UnauthorizedException());
}
@@ -48,7 +47,7 @@ function destination(req: Request, file: Express.Multer.File, cb: any) {
cb(null, profileImageLocation);
}
function filename(req: Request, file: Express.Multer.File, cb: any) {
function filename(req: AuthRequest, file: Express.Multer.File, cb: any) {
if (!req.user) {
return cb(new UnauthorizedException());
}

View File

@@ -0,0 +1,20 @@
import { AssetService, AuthUserDto, MapMarkerResponseDto } from '@app/domain';
import { MapMarkerDto } from '@app/domain/asset/dto/map-marker.dto';
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { GetAuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
@ApiTags('Asset')
@Controller('asset')
@Authenticated()
@UseValidation()
export class AssetController {
constructor(private service: AssetService) {}
@Get('/map-marker')
getMapMarkers(@GetAuthUser() authUser: AuthUserDto, @Query() options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
return this.service.getMapMarkers(authUser, options);
}
}

View File

@@ -1,5 +1,6 @@
export * from './album.controller';
export * from './api-key.controller';
export * from './asset.controller';
export * from './auth.controller';
export * from './job.controller';
export * from './oauth.controller';

View File

@@ -1,8 +1,13 @@
export { AuthUserDto } from '@app/domain';
import { AuthUserDto, LoginDetails } from '@app/domain';
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Request } from 'express';
import { UAParser } from 'ua-parser-js';
export interface AuthRequest extends Request {
user?: AuthUserDto;
}
export const GetAuthUser = createParamDecorator((data, ctx: ExecutionContext): AuthUserDto => {
return ctx.switchToHttp().getRequest<{ user: AuthUserDto }>().user;
});

View File

@@ -1,11 +0,0 @@
import { AuthUserDto } from './decorators/auth-user.decorator';
declare global {
namespace Express {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface User extends AuthUserDto {}
export interface Request {
user: AuthUserDto;
}
}
}

View File

@@ -1,23 +1,23 @@
import {
getLogLevels,
IMMICH_ACCESS_COOKIE,
IMMICH_API_KEY_HEADER,
IMMICH_API_KEY_NAME,
MACHINE_LEARNING_ENABLED,
SearchService,
SERVER_VERSION,
} from '@app/domain';
import { RedisIoAdapter } from '@app/infra';
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerDocumentOptions, SwaggerModule } from '@nestjs/swagger';
import { json } from 'body-parser';
import cookieParser from 'cookie-parser';
import { writeFileSync } from 'fs';
import path from 'path';
import { AppModule } from './app.module';
import { RedisIoAdapter } from '@app/infra';
import { json } from 'body-parser';
import { patchOpenAPI } from './utils/patch-open-api.util';
import {
getLogLevels,
MACHINE_LEARNING_ENABLED,
SERVER_VERSION,
IMMICH_ACCESS_COOKIE,
SearchService,
IMMICH_API_KEY_HEADER,
IMMICH_API_KEY_NAME,
} from '@app/domain';
const logger = new Logger('ImmichServer');

View File

@@ -1,7 +1,7 @@
import { AuthService } from '@app/domain';
import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { AuthRequest } from '../decorators/auth-user.decorator';
import { Metadata } from '../decorators/authenticated.decorator';
@Injectable()
@@ -21,7 +21,7 @@ export class AuthGuard implements CanActivate {
return true;
}
const req = context.switchToHttp().getRequest<Request>();
const req = context.switchToHttp().getRequest<AuthRequest>();
const authDto = await this.authService.validate(req.headers, req.query as Record<string, string>);
if (!authDto) {

View File

@@ -1,4 +1,10 @@
export const toBoolean = ({ value }: { value: string }) => {
import sanitize from 'sanitize-filename';
interface IValue {
value?: string;
}
export const toBoolean = ({ value }: IValue) => {
if (value == 'true') {
return true;
} else if (value == 'false') {
@@ -6,3 +12,7 @@ export const toBoolean = ({ value }: { value: string }) => {
}
return value;
};
export const toEmail = ({ value }: IValue) => value?.toLowerCase();
export const toSanitized = ({ value }: IValue) => sanitize((value || '').replace(/\./g, ''));

View File

@@ -87,10 +87,10 @@ describe('User', () => {
]);
});
it('fetches the user collection excluding the auth user', async () => {
it('fetches the user collection including the auth user', async () => {
const { status, body } = await request(app.getHttpServer()).get('/user?isAll=false');
expect(status).toEqual(200);
expect(body).toHaveLength(2);
expect(body).toHaveLength(3);
expect(body).toEqual(
expect.arrayContaining([
{
@@ -105,6 +105,7 @@ describe('User', () => {
deletedAt: null,
updatedAt: expect.anything(),
oauthId: '',
storageLabel: null,
},
{
email: userTwoEmail,
@@ -118,10 +119,24 @@ describe('User', () => {
deletedAt: null,
updatedAt: expect.anything(),
oauthId: '',
storageLabel: null,
},
{
email: authUserEmail,
firstName: 'auth-user',
lastName: 'test',
id: expect.anything(),
createdAt: expect.anything(),
isAdmin: true,
shouldChangePassword: true,
profileImagePath: '',
deletedAt: null,
updatedAt: expect.anything(),
oauthId: '',
storageLabel: 'admin',
},
]),
);
expect(body).toEqual(expect.not.arrayContaining([expect.objectContaining({ email: authUserEmail })]));
});
it('disallows admin user from creating a second admin account', async () => {

View File

@@ -4,6 +4,7 @@ import { SERVER_VERSION } from '@app/domain';
import { getLogLevels } from '@app/domain';
import { RedisIoAdapter } from '@app/infra';
import { MicroservicesModule } from './microservices.module';
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
const logger = new Logger('ImmichMicroservice');
@@ -16,6 +17,20 @@ async function bootstrap() {
app.useWebSocketAdapter(new RedisIoAdapter(app));
const metadataService = app.get(MetadataExtractionProcessor);
process.on('uncaughtException', (error: Error | any) => {
const isCsvError = error.code === 'CSV_RECORD_INCONSISTENT_FIELDS_LENGTH';
if (!isCsvError) {
throw error;
}
logger.warn('Geocoding csv parse error, trying again without cache...');
metadataService.init(true);
});
await metadataService.init();
await app.listen(listeningPort, () => {
const envName = (process.env.NODE_ENV || 'development').toUpperCase();
logger.log(
@@ -23,4 +38,5 @@ async function bootstrap() {
);
});
}
bootstrap();

View File

@@ -3,7 +3,6 @@ import {
FacialRecognitionService,
IAssetFaceJob,
IAssetJob,
IAssetUploadedJob,
IBaseJob,
IBulkEntityJob,
IDeleteFilesJob,
@@ -34,7 +33,7 @@ export class BackgroundTaskProcessor {
) {}
@Process(JobName.ASSET_UPLOADED)
async onAssetUpload(job: Job<IAssetUploadedJob>) {
async onAssetUpload(job: Job<IAssetJob>) {
await this.assetService.handleAssetUpload(job.data);
}
@@ -68,7 +67,7 @@ export class BackgroundTaskProcessor {
export class ObjectTaggingProcessor {
constructor(private smartInfoService: SmartInfoService) {}
@Process({ name: JobName.QUEUE_OBJECT_TAGGING, concurrency: 1 })
@Process({ name: JobName.QUEUE_OBJECT_TAGGING, concurrency: 0 })
async onQueueObjectTagging(job: Job<IBaseJob>) {
await this.smartInfoService.handleQueueObjectTagging(job.data);
}
@@ -88,7 +87,7 @@ export class ObjectTaggingProcessor {
export class FacialRecognitionProcessor {
constructor(private facialRecognitionService: FacialRecognitionService) {}
@Process({ name: JobName.QUEUE_RECOGNIZE_FACES, concurrency: 1 })
@Process({ name: JobName.QUEUE_RECOGNIZE_FACES, concurrency: 0 })
async onQueueRecognizeFaces(job: Job<IBaseJob>) {
await this.facialRecognitionService.handleQueueRecognizeFaces(job.data);
}
@@ -108,7 +107,7 @@ export class FacialRecognitionProcessor {
export class ClipEncodingProcessor {
constructor(private smartInfoService: SmartInfoService) {}
@Process({ name: JobName.QUEUE_ENCODE_CLIP, concurrency: 1 })
@Process({ name: JobName.QUEUE_ENCODE_CLIP, concurrency: 0 })
async onQueueClipEncoding(job: Job<IBaseJob>) {
await this.smartInfoService.handleQueueEncodeClip(job.data);
}
@@ -188,7 +187,7 @@ export class StorageTemplateMigrationProcessor {
export class ThumbnailGeneratorProcessor {
constructor(private mediaService: MediaService) {}
@Process({ name: JobName.QUEUE_GENERATE_THUMBNAILS, concurrency: 1 })
@Process({ name: JobName.QUEUE_GENERATE_THUMBNAILS, concurrency: 0 })
async onQueueGenerateThumbnails(job: Job<IBaseJob>) {
await this.mediaService.handleQueueGenerateThumbnails(job.data);
}
@@ -208,7 +207,7 @@ export class ThumbnailGeneratorProcessor {
export class VideoTranscodeProcessor {
constructor(private mediaService: MediaService) {}
@Process({ name: JobName.QUEUE_VIDEO_CONVERSION, concurrency: 1 })
@Process({ name: JobName.QUEUE_VIDEO_CONVERSION, concurrency: 0 })
async onQueueVideoConversion(job: Job<IBaseJob>): Promise<void> {
await this.mediaService.handleQueueVideoConversion(job.data);
}

View File

@@ -1,12 +1,14 @@
import {
AssetCore,
IAssetJob,
IAssetRepository,
IAssetUploadedJob,
IBaseJob,
IGeocodingRepository,
IJobRepository,
JobName,
JOBS_ASSET_PAGINATION_SIZE,
QueueName,
usePagination,
WithoutProperty,
} from '@app/domain';
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
@@ -46,16 +48,18 @@ export class MetadataExtractionProcessor {
) {
this.assetCore = new AssetCore(assetRepository, jobRepository);
this.reverseGeocodingEnabled = !configService.get('DISABLE_REVERSE_GEOCODING');
this.init();
}
private async init() {
async init(skipCache = false) {
this.logger.warn(`Reverse geocoding is ${this.reverseGeocodingEnabled ? 'enabled' : 'disabled'}`);
if (!this.reverseGeocodingEnabled) {
return;
}
try {
if (!skipCache) {
await this.geocodingRepository.deleteCache();
}
this.logger.log('Initializing Reverse Geocoding');
await this.jobRepository.pause(QueueName.METADATA_EXTRACTION);
@@ -72,14 +76,17 @@ export class MetadataExtractionProcessor {
async handleQueueMetadataExtraction(job: Job<IBaseJob>) {
try {
const { force } = job.data;
const assets = force
? await this.assetRepository.getAll()
: await this.assetRepository.getWithout(WithoutProperty.EXIF);
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force
? this.assetRepository.getAll(pagination)
: this.assetRepository.getWithout(pagination, WithoutProperty.EXIF);
});
for (const asset of assets) {
const fileName = asset.originalFileName;
const name = asset.type === AssetType.VIDEO ? JobName.EXTRACT_VIDEO_METADATA : JobName.EXIF_EXTRACTION;
await this.jobRepository.queue({ name, data: { asset, fileName } });
for await (const assets of assetPagination) {
for (const asset of assets) {
const name = asset.type === AssetType.VIDEO ? JobName.EXTRACT_VIDEO_METADATA : JobName.EXIF_EXTRACTION;
await this.jobRepository.queue({ name, data: { asset } });
}
}
} catch (error: any) {
this.logger.error(`Unable to queue metadata extraction`, error?.stack);
@@ -87,7 +94,7 @@ export class MetadataExtractionProcessor {
}
@Process(JobName.EXIF_EXTRACTION)
async extractExifInfo(job: Job<IAssetUploadedJob>) {
async extractExifInfo(job: Job<IAssetJob>) {
let asset = job.data.asset;
try {
@@ -192,7 +199,7 @@ export class MetadataExtractionProcessor {
}
@Process({ name: JobName.EXTRACT_VIDEO_METADATA, concurrency: 2 })
async extractVideoMetadata(job: Job<IAssetUploadedJob>) {
async extractVideoMetadata(job: Job<IAssetJob>) {
let asset = job.data.asset;
if (!asset.isVisible) {

View File

@@ -295,6 +295,50 @@
]
}
},
"/asset/map-marker": {
"get": {
"operationId": "getMapMarkers",
"parameters": [
{
"name": "isFavorite",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MapMarkerResponseDto"
}
}
}
}
}
},
"tags": [
"Asset"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/auth/login": {
"post": {
"operationId": "login",
@@ -2962,67 +3006,6 @@
]
}
},
"/asset/map-marker": {
"get": {
"operationId": "getMapMarkers",
"description": "Get all assets that have GPS information embedded",
"parameters": [
{
"name": "isFavorite",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isArchived",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "skip",
"required": false,
"in": "query",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MapMarkerResponseDto"
}
}
}
}
}
},
"tags": [
"Asset"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/asset/{deviceId}": {
"get": {
"operationId": "getUserAssetsByDeviceId",
@@ -4095,7 +4078,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.56.2",
"version": "1.57.1",
"contact": {}
},
"tags": [],
@@ -4139,6 +4122,10 @@
"lastName": {
"type": "string"
},
"storageLabel": {
"type": "string",
"nullable": true
},
"createdAt": {
"type": "string"
},
@@ -4167,6 +4154,7 @@
"email",
"firstName",
"lastName",
"storageLabel",
"createdAt",
"profileImagePath",
"shouldChangePassword",
@@ -4579,6 +4567,27 @@
"name"
]
},
"MapMarkerResponseDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"lat": {
"type": "number",
"format": "double"
},
"lon": {
"type": "number",
"format": "double"
}
},
"required": [
"id",
"lat",
"lon"
]
},
"LoginCredentialDto": {
"type": "object",
"properties": {
@@ -5340,7 +5349,10 @@
"type": "object",
"properties": {
"crf": {
"type": "string"
"type": "integer"
},
"threads": {
"type": "integer"
},
"preset": {
"type": "string"
@@ -5354,6 +5366,12 @@
"targetResolution": {
"type": "string"
},
"maxBitrate": {
"type": "string"
},
"twoPass": {
"type": "boolean"
},
"transcode": {
"type": "string",
"enum": [
@@ -5366,10 +5384,13 @@
},
"required": [
"crf",
"threads",
"preset",
"targetVideoCodec",
"targetAudioCodec",
"targetResolution",
"maxBitrate",
"twoPass",
"transcode"
]
},
@@ -5525,20 +5546,20 @@
"type": "object",
"properties": {
"email": {
"type": "string",
"example": "testuser@email.com"
"type": "string"
},
"password": {
"type": "string",
"example": "password"
"type": "string"
},
"firstName": {
"type": "string",
"example": "John"
"type": "string"
},
"lastName": {
"type": "string"
},
"storageLabel": {
"type": "string",
"example": "Doe"
"nullable": true
}
},
"required": [
@@ -5562,26 +5583,25 @@
"UpdateUserDto": {
"type": "object",
"properties": {
"email": {
"type": "string",
"example": "testuser@email.com"
},
"password": {
"type": "string",
"example": "password"
},
"firstName": {
"type": "string",
"example": "John"
},
"lastName": {
"type": "string",
"example": "Doe"
},
"id": {
"type": "string",
"format": "uuid"
},
"email": {
"type": "string"
},
"password": {
"type": "string"
},
"firstName": {
"type": "string"
},
"lastName": {
"type": "string"
},
"storageLabel": {
"type": "string"
},
"isAdmin": {
"type": "boolean"
},
@@ -5897,31 +5917,6 @@
"timeBucket"
]
},
"MapMarkerResponseDto": {
"type": "object",
"properties": {
"type": {
"$ref": "#/components/schemas/AssetTypeEnum"
},
"lat": {
"type": "number",
"format": "double"
},
"lon": {
"type": "number",
"format": "double"
},
"id": {
"type": "string"
}
},
"required": [
"type",
"lat",
"lon",
"id"
]
},
"UpdateAssetDto": {
"type": "object",
"properties": {

View File

@@ -1,14 +1,10 @@
import { AssetEntity } from '@app/infra/entities';
import { IJobRepository, JobName } from '../job';
import { AssetSearchOptions, IAssetRepository, LivePhotoSearchOptions } from './asset.repository';
import { IAssetRepository, LivePhotoSearchOptions } from './asset.repository';
export class AssetCore {
constructor(private assetRepository: IAssetRepository, private jobRepository: IJobRepository) {}
getAll(options: AssetSearchOptions) {
return this.assetRepository.getAll(options);
}
async save(asset: Partial<AssetEntity>) {
const _asset = await this.assetRepository.save(asset);
await this.jobRepository.queue({

View File

@@ -1,4 +1,5 @@
import { AssetEntity, AssetType } from '@app/infra/entities';
import { Paginated, PaginationOptions } from '../domain.util';
export interface AssetSearchOptions {
isVisible?: boolean;
@@ -12,6 +13,16 @@ export interface LivePhotoSearchOptions {
type: AssetType;
}
export interface MapMarkerSearchOptions {
isFavorite?: boolean;
}
export interface MapMarker {
id: string;
lat: number;
lon: number;
}
export enum WithoutProperty {
THUMBNAIL = 'thumbnail',
ENCODED_VIDEO = 'encoded-video',
@@ -25,10 +36,11 @@ export const IAssetRepository = 'IAssetRepository';
export interface IAssetRepository {
getByIds(ids: string[]): Promise<AssetEntity[]>;
getWithout(property: WithoutProperty): Promise<AssetEntity[]>;
getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity>;
getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
deleteAll(ownerId: string): Promise<void>;
getAll(options?: AssetSearchOptions): Promise<AssetEntity[]>;
getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>;
getMapMarkers(ownerId: string, options?: MapMarkerSearchOptions): Promise<MapMarker[]>;
}

View File

@@ -1,5 +1,5 @@
import { AssetEntity, AssetType } from '@app/infra/entities';
import { assetEntityStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test';
import { assetEntityStub, authStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test';
import { AssetService, IAssetRepository } from '../asset';
import { IJobRepository, JobName } from '../job';
@@ -20,7 +20,7 @@ describe(AssetService.name, () => {
describe(`handle asset upload`, () => {
it('should process an uploaded video', async () => {
const data = { asset: { type: AssetType.VIDEO } as AssetEntity, fileName: 'video.mp4' };
const data = { asset: { type: AssetType.VIDEO } as AssetEntity };
await expect(sut.handleAssetUpload(data)).resolves.toBeUndefined();
@@ -33,7 +33,7 @@ describe(AssetService.name, () => {
});
it('should process an uploaded image', async () => {
const data = { asset: { type: AssetType.IMAGE } as AssetEntity, fileName: 'image.jpg' };
const data = { asset: { type: AssetType.IMAGE } as AssetEntity };
await sut.handleAssetUpload(data);
@@ -58,4 +58,29 @@ describe(AssetService.name, () => {
});
});
});
describe('get map markers', () => {
it('should get geo information of assets', async () => {
assetMock.getMapMarkers.mockResolvedValue(
[assetEntityStub.withLocation].map((asset) => ({
id: asset.id,
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
lat: asset.exifInfo!.latitude!,
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
lon: asset.exifInfo!.longitude!,
})),
);
const markers = await sut.getMapMarkers(authStub.user1, {});
expect(markers).toHaveLength(1);
expect(markers[0]).toEqual({
id: assetEntityStub.withLocation.id,
lat: 100,
lon: 100,
});
});
});
});

View File

@@ -1,20 +1,23 @@
import { AssetEntity, AssetType } from '@app/infra/entities';
import { Inject } from '@nestjs/common';
import { IAssetUploadedJob, IJobRepository, JobName } from '../job';
import { AuthUserDto } from '../auth';
import { IAssetJob, IJobRepository, JobName } from '../job';
import { AssetCore } from './asset.core';
import { IAssetRepository } from './asset.repository';
import { MapMarkerDto } from './dto/map-marker.dto';
import { MapMarkerResponseDto } from './response-dto';
export class AssetService {
private assetCore: AssetCore;
constructor(
@Inject(IAssetRepository) assetRepository: IAssetRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
) {
this.assetCore = new AssetCore(assetRepository, jobRepository);
}
async handleAssetUpload(data: IAssetUploadedJob) {
async handleAssetUpload(data: IAssetJob) {
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data });
if (data.asset.type == AssetType.VIDEO) {
@@ -28,4 +31,8 @@ export class AssetService {
save(asset: Partial<AssetEntity>) {
return this.assetCore.save(asset);
}
getMapMarkers(authUser: AuthUserDto, options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
return this.assetRepository.getMapMarkers(authUser.id, options);
}
}

View File

@@ -0,0 +1,10 @@
import { toBoolean } from 'apps/immich/src/utils/transform.util';
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional } from 'class-validator';
export class MapMarkerDto {
@IsOptional()
@IsBoolean()
@Transform(toBoolean)
isFavorite?: boolean;
}

View File

@@ -1,35 +1,12 @@
import { AssetEntity, AssetType } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
export class MapMarkerResponseDto {
@ApiProperty()
id!: string;
@ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
type!: AssetType;
@ApiProperty({ type: 'number', format: 'double' })
@ApiProperty({ format: 'double' })
lat!: number;
@ApiProperty({ type: 'number', format: 'double' })
@ApiProperty({ format: 'double' })
lon!: number;
}
export function mapAssetMapMarker(entity: AssetEntity): MapMarkerResponseDto | null {
if (!entity.exifInfo) {
return null;
}
const lat = entity.exifInfo.latitude;
const lon = entity.exifInfo.longitude;
if (!lat || !lon) {
return null;
}
return {
id: entity.id,
type: entity.type,
lon,
lat,
};
}

View File

@@ -306,7 +306,7 @@ describe('AuthService', () => {
expect(userTokenMock.save.mock.calls[0][0]).toMatchObject({
id: 'not_active',
token: 'auth_token',
userId: 'immich_id',
userId: 'user-id',
createdAt: new Date('2021-01-01'),
updatedAt: expect.any(Date),
deviceOS: 'Android',

View File

@@ -122,6 +122,7 @@ export class AuthService {
firstName: dto.firstName,
lastName: dto.lastName,
password: dto.password,
storageLabel: 'admin',
});
return mapAdminSignupResponse(admin);

View File

@@ -32,3 +32,28 @@ export function asHumanReadable(bytes: number, precision = 1): string {
return `${remainder.toFixed(magnitude == 0 ? 0 : precision)} ${units[magnitude]}`;
}
export interface PaginationOptions {
take: number;
skip?: number;
}
export interface PaginationResult<T> {
items: T[];
hasNextPage: boolean;
}
export type Paginated<T> = Promise<PaginationResult<T>>;
export async function* usePagination<T>(
pageSize: number,
getNextPage: (pagination: PaginationOptions) => Paginated<T>,
) {
let hasNextPage = true;
for (let skip = 0; hasNextPage; skip += pageSize) {
const result = await getNextPage({ take: pageSize, skip });
hasNextPage = result.hasNextPage;
yield result.items;
}
}

View File

@@ -132,10 +132,13 @@ describe(FacialRecognitionService.name, () => {
describe('handleQueueRecognizeFaces', () => {
it('should queue missing assets', async () => {
assetMock.getWithout.mockResolvedValue([assetEntityStub.image]);
assetMock.getWithout.mockResolvedValue({
items: [assetEntityStub.image],
hasNextPage: false,
});
await sut.handleQueueRecognizeFaces({});
expect(assetMock.getWithout).toHaveBeenCalledWith(WithoutProperty.FACES);
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.FACES);
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.RECOGNIZE_FACES,
data: { asset: assetEntityStub.image },
@@ -143,7 +146,10 @@ describe(FacialRecognitionService.name, () => {
});
it('should queue all assets', async () => {
assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
assetMock.getAll.mockResolvedValue({
items: [assetEntityStub.image],
hasNextPage: false,
});
personMock.deleteAll.mockResolvedValue(5);
searchMock.deleteAllFaces.mockResolvedValue(100);

View File

@@ -2,7 +2,8 @@ import { Inject, Logger } from '@nestjs/common';
import { join } from 'path';
import { IAssetRepository, WithoutProperty } from '../asset';
import { MACHINE_LEARNING_ENABLED } from '../domain.constant';
import { IAssetJob, IBaseJob, IFaceThumbnailJob, IJobRepository, JobName } from '../job';
import { usePagination } from '../domain.util';
import { IAssetJob, IBaseJob, IFaceThumbnailJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job';
import { CropOptions, FACE_THUMBNAIL_SIZE, IMediaRepository } from '../media';
import { IPersonRepository } from '../person/person.repository';
import { ISearchRepository } from '../search/search.repository';
@@ -27,17 +28,21 @@ export class FacialRecognitionService {
async handleQueueRecognizeFaces({ force }: IBaseJob) {
try {
const assets = force
? await this.assetRepository.getAll()
: await this.assetRepository.getWithout(WithoutProperty.FACES);
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force
? this.assetRepository.getAll(pagination)
: this.assetRepository.getWithout(pagination, WithoutProperty.FACES);
});
if (force) {
const people = await this.personRepository.deleteAll();
const faces = await this.searchRepository.deleteAllFaces();
this.logger.debug(`Deleted ${people} people and ${faces} faces`);
}
for (const asset of assets) {
await this.jobRepository.queue({ name: JobName.RECOGNIZE_FACES, data: { asset } });
for await (const assets of assetPagination) {
for (const asset of assets) {
await this.jobRepository.queue({ name: JobName.RECOGNIZE_FACES, data: { asset } });
}
}
} catch (error: any) {
this.logger.error(`Unable to queue recognize faces`, error?.stack);

View File

@@ -73,3 +73,5 @@ export enum JobName {
QUEUE_ENCODE_CLIP = 'queue-clip-encode',
ENCODE_CLIP = 'clip-encode',
}
export const JOBS_ASSET_PAGINATION_SIZE = 1000;

View File

@@ -30,11 +30,6 @@ export interface IBulkEntityJob extends IBaseJob {
ids: string[];
}
export interface IAssetUploadedJob extends IBaseJob {
asset: AssetEntity;
fileName: string;
}
export interface IDeleteFilesJob extends IBaseJob {
files: Array<string | null | undefined>;
}

View File

@@ -2,7 +2,6 @@ import { JobName, QueueName } from './job.constants';
import {
IAssetFaceJob,
IAssetJob,
IAssetUploadedJob,
IBaseJob,
IBulkEntityJob,
IDeleteFilesJob,
@@ -26,7 +25,7 @@ export interface QueueStatus {
export type JobItem =
// Asset Upload
| { name: JobName.ASSET_UPLOADED; data: IAssetUploadedJob }
| { name: JobName.ASSET_UPLOADED; data: IAssetJob }
// Transcoding
| { name: JobName.QUEUE_VIDEO_CONVERSION; data: IBaseJob }
@@ -48,8 +47,8 @@ export type JobItem =
// Metadata Extraction
| { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob }
| { name: JobName.EXIF_EXTRACTION; data: IAssetUploadedJob }
| { name: JobName.EXTRACT_VIDEO_METADATA; data: IAssetUploadedJob }
| { name: JobName.EXIF_EXTRACTION; data: IAssetJob }
| { name: JobName.EXTRACT_VIDEO_METADATA; data: IAssetJob }
// Object Tagging
| { name: JobName.QUEUE_OBJECT_TAGGING; data: IBaseJob }

View File

@@ -22,6 +22,7 @@ describe(JobService.name, () => {
expect(jobMock.queue.mock.calls).toEqual([
[{ name: JobName.USER_DELETE_CHECK }],
[{ name: JobName.PERSON_CLEANUP }],
[{ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }],
]);
});
});

View File

@@ -14,6 +14,7 @@ export class JobService {
async handleNightlyJobs() {
await this.jobRepository.queue({ name: JobName.USER_DELETE_CHECK });
await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP });
await this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } });
}
handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<void> {

View File

@@ -38,6 +38,11 @@ export interface CropOptions {
height: number;
}
export interface TranscodeOptions {
outputOptions: string[];
twoPass: boolean;
}
export interface IMediaRepository {
// image
extractThumbnailFromExif(input: string, output: string): Promise<void>;
@@ -47,5 +52,5 @@ export interface IMediaRepository {
// video
extractVideoThumbnail(input: string, output: string, size: number): Promise<void>;
probe(input: string): Promise<VideoInfo>;
transcode(input: string, output: string, options: any): Promise<void>;
transcode(input: string, output: string, options: TranscodeOptions): Promise<void>;
}

View File

@@ -44,7 +44,10 @@ describe(MediaService.name, () => {
describe('handleQueueGenerateThumbnails', () => {
it('should queue all assets', async () => {
assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
assetMock.getAll.mockResolvedValue({
items: [assetEntityStub.image],
hasNextPage: false,
});
await sut.handleQueueGenerateThumbnails({ force: true });
@@ -57,12 +60,15 @@ describe(MediaService.name, () => {
});
it('should queue all assets with missing thumbnails', async () => {
assetMock.getWithout.mockResolvedValue([assetEntityStub.image]);
assetMock.getWithout.mockResolvedValue({
items: [assetEntityStub.image],
hasNextPage: false,
});
await sut.handleQueueGenerateThumbnails({ force: false });
expect(assetMock.getAll).not.toHaveBeenCalled();
expect(assetMock.getWithout).toHaveBeenCalledWith(WithoutProperty.THUMBNAIL);
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.GENERATE_JPEG_THUMBNAIL,
data: { asset: assetEntityStub.image },
@@ -183,11 +189,14 @@ describe(MediaService.name, () => {
describe('handleQueueVideoConversion', () => {
it('should queue all video assets', async () => {
assetMock.getAll.mockResolvedValue([assetEntityStub.video]);
assetMock.getAll.mockResolvedValue({
items: [assetEntityStub.video],
hasNextPage: false,
});
await sut.handleQueueVideoConversion({ force: true });
expect(assetMock.getAll).toHaveBeenCalledWith({ type: AssetType.VIDEO });
expect(assetMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { type: AssetType.VIDEO });
expect(assetMock.getWithout).not.toHaveBeenCalled();
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.VIDEO_CONVERSION,
@@ -196,12 +205,15 @@ describe(MediaService.name, () => {
});
it('should queue all video assets without encoded videos', async () => {
assetMock.getWithout.mockResolvedValue([assetEntityStub.video]);
assetMock.getWithout.mockResolvedValue({
items: [assetEntityStub.video],
hasNextPage: false,
});
await sut.handleQueueVideoConversion({});
expect(assetMock.getAll).not.toHaveBeenCalled();
expect(assetMock.getWithout).toHaveBeenCalledWith(WithoutProperty.ENCODED_VIDEO);
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.ENCODED_VIDEO);
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.VIDEO_CONVERSION,
data: { asset: assetEntityStub.video },
@@ -241,7 +253,10 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
['-crf 23', '-preset ultrafast', '-vcodec h264', '-acodec aac', '-movflags faststart'],
{
outputOptions: ['-vcodec h264', '-acodec aac', '-movflags faststart', '-preset ultrafast', '-crf 23'],
twoPass: false,
},
);
});
@@ -264,7 +279,10 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
['-crf 23', '-preset ultrafast', '-vcodec h264', '-acodec aac', '-movflags faststart'],
{
outputOptions: ['-vcodec h264', '-acodec aac', '-movflags faststart', '-preset ultrafast', '-crf 23'],
twoPass: false,
},
);
});
@@ -275,7 +293,17 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
['-crf 23', '-preset ultrafast', '-vcodec h264', '-acodec aac', '-movflags faststart', '-vf scale=-2:720'],
{
outputOptions: [
'-vcodec h264',
'-acodec aac',
'-movflags faststart',
'-vf scale=-2:720',
'-preset ultrafast',
'-crf 23',
],
twoPass: false,
},
);
});
@@ -286,7 +314,17 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
['-crf 23', '-preset ultrafast', '-vcodec h264', '-acodec aac', '-movflags faststart', '-vf scale=720:-2'],
{
outputOptions: [
'-vcodec h264',
'-acodec aac',
'-movflags faststart',
'-vf scale=720:-2',
'-preset ultrafast',
'-crf 23',
],
twoPass: false,
},
);
});
@@ -297,7 +335,17 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
['-crf 23', '-preset ultrafast', '-vcodec h264', '-acodec aac', '-movflags faststart', '-vf scale=-2:720'],
{
outputOptions: [
'-vcodec h264',
'-acodec aac',
'-movflags faststart',
'-vf scale=-2:720',
'-preset ultrafast',
'-crf 23',
],
twoPass: false,
},
);
});
@@ -308,7 +356,17 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
['-crf 23', '-preset ultrafast', '-vcodec h264', '-acodec aac', '-movflags faststart', '-vf scale=-2:720'],
{
outputOptions: [
'-vcodec h264',
'-acodec aac',
'-movflags faststart',
'-vf scale=-2:720',
'-preset ultrafast',
'-crf 23',
],
twoPass: false,
},
);
});
@@ -318,5 +376,152 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ asset: assetEntityStub.video });
expect(mediaMock.transcode).not.toHaveBeenCalled();
});
it('should set max bitrate if above 0', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }]);
await sut.handleVideoConversion({ asset: assetEntityStub.video });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
{
outputOptions: [
'-vcodec h264',
'-acodec aac',
'-movflags faststart',
'-vf scale=-2:720',
'-preset ultrafast',
'-crf 23',
'-maxrate 4500k',
],
twoPass: false,
},
);
});
it('should transcode in two passes for h264/h265 when enabled and max bitrate is above 0', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' },
{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true },
]);
await sut.handleVideoConversion({ asset: assetEntityStub.video });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
{
outputOptions: [
'-vcodec h264',
'-acodec aac',
'-movflags faststart',
'-vf scale=-2:720',
'-preset ultrafast',
'-b:v 3104k',
'-minrate 1552k',
'-maxrate 4500k',
],
twoPass: true,
},
);
});
it('should fallback to one pass for h264/h265 if two-pass is enabled but no max bitrate is set', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }]);
await sut.handleVideoConversion({ asset: assetEntityStub.video });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
{
outputOptions: [
'-vcodec h264',
'-acodec aac',
'-movflags faststart',
'-vf scale=-2:720',
'-preset ultrafast',
'-crf 23',
],
twoPass: false,
},
);
});
it('should configure preset for vp9', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'vp9' },
{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
]);
await sut.handleVideoConversion({ asset: assetEntityStub.video });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
{
outputOptions: [
'-vcodec vp9',
'-acodec aac',
'-movflags faststart',
'-vf scale=-2:720',
'-cpu-used 5',
'-row-mt 1',
'-threads 2',
'-crf 23',
'-b:v 0',
],
twoPass: false,
},
);
});
it('should configure threads if above 0', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'vp9' },
{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
]);
await sut.handleVideoConversion({ asset: assetEntityStub.video });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
{
outputOptions: [
'-vcodec vp9',
'-acodec aac',
'-movflags faststart',
'-vf scale=-2:720',
'-cpu-used 5',
'-row-mt 1',
'-threads 2',
'-crf 23',
'-b:v 0',
],
twoPass: false,
},
);
});
it('should disable thread pooling for x264/x265 if thread limit is above 0', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 }]);
await sut.handleVideoConversion({ asset: assetEntityStub.video });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/asset-id.mp4',
{
outputOptions: [
'-vcodec h264',
'-acodec aac',
'-movflags faststart',
'-vf scale=-2:720',
'-preset ultrafast',
'-threads 2',
'-x264-params "pools=none"',
'-x264-params "frame-threads=2"',
'-crf 23',
],
twoPass: false,
},
);
});
});
});

View File

@@ -3,7 +3,8 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
import { join } from 'path';
import { IAssetRepository, mapAsset, WithoutProperty } from '../asset';
import { CommunicationEvent, ICommunicationRepository } from '../communication';
import { IAssetJob, IBaseJob, IJobRepository, JobName } from '../job';
import { usePagination } from '../domain.util';
import { IAssetJob, IBaseJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job';
import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config';
import { SystemConfigCore } from '../system-config/system-config.core';
@@ -31,12 +32,16 @@ export class MediaService {
try {
const { force } = job;
const assets = force
? await this.assetRepository.getAll()
: await this.assetRepository.getWithout(WithoutProperty.THUMBNAIL);
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force
? this.assetRepository.getAll(pagination)
: this.assetRepository.getWithout(pagination, WithoutProperty.THUMBNAIL);
});
for (const asset of assets) {
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset } });
for await (const assets of assetPagination) {
for (const asset of assets) {
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset } });
}
}
} catch (error: any) {
this.logger.error('Failed to queue generate thumbnail jobs', error.stack);
@@ -115,11 +120,16 @@ export class MediaService {
const { force } = job;
try {
const assets = force
? await this.assetRepository.getAll({ type: AssetType.VIDEO })
: await this.assetRepository.getWithout(WithoutProperty.ENCODED_VIDEO);
for (const asset of assets) {
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { asset } });
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force
? this.assetRepository.getAll(pagination, { type: AssetType.VIDEO })
: this.assetRepository.getWithout(pagination, WithoutProperty.ENCODED_VIDEO);
});
for await (const assets of assetPagination) {
for (const asset of assets) {
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { asset } });
}
}
} catch (error: any) {
this.logger.error('Failed to queue video conversions', error.stack);
@@ -155,10 +165,11 @@ export class MediaService {
return;
}
const options = this.getFfmpegOptions(mainVideoStream, config);
const outputOptions = this.getFfmpegOptions(mainVideoStream, config);
const twoPass = this.eligibleForTwoPass(config);
this.logger.log(`Start encoding video ${asset.id} ${options}`);
await this.mediaRepository.transcode(input, output, options);
this.logger.log(`Start encoding video ${asset.id} ${outputOptions}`);
await this.mediaRepository.transcode(input, output, { outputOptions, twoPass });
this.logger.log(`Encoding success ${asset.id}`);
@@ -221,8 +232,6 @@ export class MediaService {
private getFfmpegOptions(stream: VideoStreamInfo, ffmpeg: SystemConfigFFmpegDto) {
const options = [
`-crf ${ffmpeg.crf}`,
`-preset ${ffmpeg.preset}`,
`-vcodec ${ffmpeg.targetVideoCodec}`,
`-acodec ${ffmpeg.targetAudioCodec}`,
// Makes a second pass moving the moov atom to the beginning of
@@ -230,17 +239,81 @@ export class MediaService {
`-movflags faststart`,
];
// video dimensions
const videoIsRotated = Math.abs(stream.rotation) === 90;
const targetResolution = Number.parseInt(ffmpeg.targetResolution);
const isVideoVertical = stream.height > stream.width || videoIsRotated;
const scaling = isVideoVertical ? `${targetResolution}:-2` : `-2:${targetResolution}`;
const shouldScale = Math.min(stream.height, stream.width) > targetResolution;
// video codec
const isVP9 = ffmpeg.targetVideoCodec === 'vp9';
const isH264 = ffmpeg.targetVideoCodec === 'h264';
const isH265 = ffmpeg.targetVideoCodec === 'hevc';
// transcode efficiency
const limitThreads = ffmpeg.threads > 0;
const maxBitrateValue = Number.parseInt(ffmpeg.maxBitrate) || 0;
const constrainMaximumBitrate = maxBitrateValue > 0;
const bitrateUnit = ffmpeg.maxBitrate.trim().substring(maxBitrateValue.toString().length); // use inputted unit if provided
if (shouldScale) {
options.push(`-vf scale=${scaling}`);
}
if (isH264 || isH265) {
options.push(`-preset ${ffmpeg.preset}`);
}
if (isVP9) {
// vp9 doesn't have presets, but does have a similar setting -cpu-used, from 0-5, 0 being the slowest
const presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast'];
const speed = Math.min(presets.indexOf(ffmpeg.preset), 5); // values over 5 require realtime mode, which is its own can of worms since it overrides -crf and -threads
if (speed >= 0) {
options.push(`-cpu-used ${speed}`);
}
options.push('-row-mt 1'); // better multithreading
}
if (limitThreads) {
options.push(`-threads ${ffmpeg.threads}`);
// x264 and x265 handle threads differently than one might expect
// https://x265.readthedocs.io/en/latest/cli.html#cmdoption-pools
if (isH264 || isH265) {
options.push(`-${isH265 ? 'x265' : 'x264'}-params "pools=none"`);
options.push(`-${isH265 ? 'x265' : 'x264'}-params "frame-threads=${ffmpeg.threads}"`);
}
}
// two-pass mode for x264/x265 uses bitrate ranges, so it requires a max bitrate from which to derive a target and min bitrate
if (constrainMaximumBitrate && ffmpeg.twoPass) {
const targetBitrateValue = Math.ceil(maxBitrateValue / 1.45); // recommended by https://developers.google.com/media/vp9/settings/vod
const minBitrateValue = targetBitrateValue / 2;
options.push(`-b:v ${targetBitrateValue}${bitrateUnit}`);
options.push(`-minrate ${minBitrateValue}${bitrateUnit}`);
options.push(`-maxrate ${maxBitrateValue}${bitrateUnit}`);
} else if (constrainMaximumBitrate || isVP9) {
// for vp9, these flags work for both one-pass and two-pass
options.push(`-crf ${ffmpeg.crf}`);
options.push(`${isVP9 ? '-b:v' : '-maxrate'} ${maxBitrateValue}${bitrateUnit}`);
} else {
options.push(`-crf ${ffmpeg.crf}`);
}
return options;
}
private eligibleForTwoPass(ffmpeg: SystemConfigFFmpegDto) {
if (!ffmpeg.twoPass) {
return false;
}
const isVP9 = ffmpeg.targetVideoCodec === 'vp9';
const maxBitrateValue = Number.parseInt(ffmpeg.maxBitrate) || 0;
const constrainMaximumBitrate = maxBitrateValue > 0;
return constrainMaximumBitrate || isVP9;
}
}

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