Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecc894ac82 | ||
|
|
50b649cd3e | ||
|
|
99b018cd49 | ||
|
|
6aa2800275 | ||
|
|
cd7fc7e026 | ||
|
|
b4d312efb6 | ||
|
|
e9722710ac | ||
|
|
f1384fea58 | ||
|
|
feadc45e75 | ||
|
|
eefe5266a8 | ||
|
|
74353193f8 | ||
|
|
0ccb73cf2b | ||
|
|
356f4424df | ||
|
|
85c6cf4309 | ||
|
|
96fb68135e | ||
|
|
a7b9adc692 | ||
|
|
e028cf9002 | ||
|
|
f984be8ea0 | ||
|
|
3d426b55d3 | ||
|
|
02b8b2c125 | ||
|
|
dc7b0f75bb | ||
|
|
a089d9891d |
10
Makefile
10
Makefile
@@ -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
|
||||
|
||||
32
dev-setup.md
32
dev-setup.md
@@ -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`
|
||||
@@ -135,8 +135,6 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- 2283:8080
|
||||
logging:
|
||||
driver: none
|
||||
depends_on:
|
||||
- immich-server
|
||||
restart: always
|
||||
|
||||
@@ -87,8 +87,6 @@ services:
|
||||
- IMMICH_WEB_URL
|
||||
ports:
|
||||
- 2283:8080
|
||||
logging:
|
||||
driver: none
|
||||
depends_on:
|
||||
- immich-server
|
||||
restart: always
|
||||
|
||||
14
docs/docs/developer/database-migrations.md
Normal file
14
docs/docs/developer/database-migrations.md
Normal 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.
|
||||
@@ -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)
|
||||
:::
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
* Remove Hive box
|
||||
@@ -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' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/runner.rb:255:in `block in execute_action' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/runner.rb:229:in `chdir' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/runner.rb:229:in `execute_action' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/runner.rb:157:in `trigger_action_by_name' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/fast_file.rb:159:in `method_missing' Fastfile:42:in `block (2 levels) in parsing_binding' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/lane.rb:33:in `call' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/runner.rb:49:in `block in execute' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/runner.rb:45:in `chdir' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/runner.rb:45:in `execute' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/lane_manager.rb:47:in `cruise_lane' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/command_line_handler.rb:36:in `handle' /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' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in `call' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in `run' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in `run_active_command' /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!' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in `run!' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/commands_generator.rb:354:in `run' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/commands_generator.rb:43:in `start' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/fastlane/lib/fastlane/cli_tools_distributor.rb:123:in `take_off' /usr/local/Cellar/fastlane/2.212.2/libexec/gems/fastlane-2.212.2/bin/fastlane:23:in `<top (required)>' /usr/local/Cellar/fastlane/2.212.2/libexec/bin/fastlane:25:in `load' /usr/local/Cellar/fastlane/2.212.2/libexec/bin/fastlane:25:in `<main>' 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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:255:in `block in execute_action' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:229:in `chdir' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:229:in `execute_action' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:157:in `trigger_action_by_name' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/fast_file.rb:159:in `method_missing' Fastfile:27:in `block (2 levels) in parsing_binding' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/lane.rb:33:in `call' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:49:in `block in execute' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:45:in `chdir' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:45:in `execute' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/lane_manager.rb:47:in `cruise_lane' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/command_line_handler.rb:36:in `handle' /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' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in `call' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in `run' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in `run_active_command' /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!' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in `run!' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/commands_generator.rb:354:in `run' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/commands_generator.rb:43:in `start' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/cli_tools_distributor.rb:123:in `take_off' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/bin/fastlane:23:in `<top (required)>' /usr/local/Cellar/fastlane/2.212.1/libexec/bin/fastlane:25:in `load' /usr/local/Cellar/fastlane/2.212.1/libexec/bin/fastlane:25:in `<main>' Error packaging up the application" />
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="58.237354">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 1.56.2
|
||||
- API version: 1.57.1
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
## Requirements
|
||||
|
||||
10
mobile/openapi/doc/AssetApi.md
generated
10
mobile/openapi/doc/AssetApi.md
generated
@@ -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
|
||||
|
||||
|
||||
1
mobile/openapi/doc/CreateUserDto.md
generated
1
mobile/openapi/doc/CreateUserDto.md
generated
@@ -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)
|
||||
|
||||
|
||||
3
mobile/openapi/doc/MapMarkerResponseDto.md
generated
3
mobile/openapi/doc/MapMarkerResponseDto.md
generated
@@ -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)
|
||||
|
||||
|
||||
5
mobile/openapi/doc/SystemConfigFFmpegDto.md
generated
5
mobile/openapi/doc/SystemConfigFFmpegDto.md
generated
@@ -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)
|
||||
|
||||
3
mobile/openapi/doc/UpdateUserDto.md
generated
3
mobile/openapi/doc/UpdateUserDto.md
generated
@@ -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]
|
||||
|
||||
|
||||
1
mobile/openapi/doc/UserResponseDto.md
generated
1
mobile/openapi/doc/UserResponseDto.md
generated
@@ -12,6 +12,7 @@ Name | Type | Description | Notes
|
||||
**email** | **String** | |
|
||||
**firstName** | **String** | |
|
||||
**lastName** | **String** | |
|
||||
**storageLabel** | **String** | |
|
||||
**createdAt** | **String** | |
|
||||
**profileImagePath** | **String** | |
|
||||
**shouldChangePassword** | **bool** | |
|
||||
|
||||
27
mobile/openapi/lib/api/asset_api.dart
generated
27
mobile/openapi/lib/api/asset_api.dart
generated
@@ -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));
|
||||
}
|
||||
|
||||
17
mobile/openapi/lib/model/create_user_dto.dart
generated
17
mobile/openapi/lib/model/create_user_dto.dart
generated
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
31
mobile/openapi/lib/model/update_user_dto.dart
generated
31
mobile/openapi/lib/model/update_user_dto.dart
generated
@@ -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'),
|
||||
);
|
||||
|
||||
14
mobile/openapi/lib/model/user_response_dto.dart
generated
14
mobile/openapi/lib/model/user_response_dto.dart
generated
@@ -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',
|
||||
|
||||
4
mobile/openapi/test/asset_api_test.dart
generated
4
mobile/openapi/test/asset_api_test.dart
generated
@@ -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
|
||||
});
|
||||
|
||||
5
mobile/openapi/test/create_user_dto_test.dart
generated
5
mobile/openapi/test/create_user_dto_test.dart
generated
@@ -36,6 +36,11 @@ void main() {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String storageLabel
|
||||
test('to test the property `storageLabel`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
9
mobile/openapi/test/update_user_dto_test.dart
generated
9
mobile/openapi/test/update_user_dto_test.dart
generated
@@ -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
|
||||
});
|
||||
|
||||
|
||||
5
mobile/openapi/test/user_response_dto_test.dart
generated
5
mobile/openapi/test/user_response_dto_test.dart
generated
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
6
notes.md
6
notes.md
@@ -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
|
||||
@@ -39,6 +39,7 @@ describe('Album service', () => {
|
||||
oauthId: '',
|
||||
tags: [],
|
||||
assets: [],
|
||||
storageLabel: null,
|
||||
});
|
||||
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
|
||||
const sharedAlbumOwnerId = '2222';
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('TagService', () => {
|
||||
tags: [],
|
||||
assets: [],
|
||||
oauthId: 'oauth-id-1',
|
||||
storageLabel: null,
|
||||
});
|
||||
|
||||
// const user2: UserEntity = Object.freeze({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
20
server/apps/immich/src/controllers/asset.controller.ts
Normal file
20
server/apps/immich/src/controllers/asset.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
11
server/apps/immich/src/global.d.ts
vendored
11
server/apps/immich/src/global.d.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, ''));
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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[]>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
10
server/libs/domain/src/asset/dto/map-marker.dto.ts
Normal file
10
server/libs/domain/src/asset/dto/map-marker.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -122,6 +122,7 @@ export class AuthService {
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
password: dto.password,
|
||||
storageLabel: 'admin',
|
||||
});
|
||||
|
||||
return mapAdminSignupResponse(admin);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -73,3 +73,5 @@ export enum JobName {
|
||||
QUEUE_ENCODE_CLIP = 'queue-clip-encode',
|
||||
ENCODE_CLIP = 'clip-encode',
|
||||
}
|
||||
|
||||
export const JOBS_ASSET_PAGINATION_SIZE = 1000;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 } }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user