Compare commits

...

21 Commits

Author SHA1 Message Date
Alex The Bot
cd7fc7e026 Version v1.57.0 2023-05-23 02:03:49 +00:00
Alex
b4d312efb6 fix(web): revert justify layout - improve gallery view load time (#2522)
* fix(web): revert justify layout - improve gallery view load time

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

* added max bitrate and thread to transcode api

* admin page setting desc+bitrate and thread options

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

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

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

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

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

* updated and added transcode tests

* refactored `getFfmpegOptions`

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

* vp9 constant quality mode

* fixed nullable `crf` and `threads`

* fixed two-pass slider, added apiproperty

* optional `desc` for `SettingSelect`

* disable two-pass if settings are incompatible

* fixed test

* transcode interface

---------

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

* default mock value for getAll

* remove live photo name correction

* order paginated results by createdAt

* change log level

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

* chore: open api

* fix: checks

* fix: api update validation and tests

* feat: default admin storage label

* fix: linting

* fix: user create/update dto

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

* add number format double to fix mobile
2023-05-21 01:26:06 -05:00
Jason Rasmussen
e028cf9002 fix(server): reverse geocoding crash loop (#2489) 2023-05-20 21:39:12 -05:00
Jason Rasmussen
f984be8ea0 docs: update contributing pages (#2503) 2023-05-20 20:46:09 -05:00
Jason Rasmussen
3d426b55d3 chore(server): auth request type (#2502) 2023-05-20 20:44:26 -05:00
Fynn Petersen-Frey
02b8b2c125 chore(mobile): remove hive (#2497) 2023-05-20 20:42:19 -05:00
Fynn Petersen-Frey
dc7b0f75bb chore(mobile): use Record instead of custom pair+triple (#2483) 2023-05-20 20:41:34 -05:00
Jason Rasmussen
a089d9891d feat: confirm before deleting all faces and people (#2496) 2023-05-20 20:40:53 -05:00
Alex The Bot
a1183f4b4b Version v1.56.2 2023-05-20 03:53:45 +00:00
Alex
84cfa38510 chore(ml): load models on start up (#2487)
* chore(ml): load models on start up

* Download correct model
2023-05-19 22:37:01 -05:00
Fynn Petersen-Frey
89edbcacfa chore(mobile): remove obsolete files (#2482) 2023-05-19 22:06:39 -05:00
180 changed files with 2048 additions and 2600 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import uvicorn
from insightface.app import FaceAnalysis
from transformers import pipeline
from sentence_transformers import SentenceTransformer, util
from sentence_transformers import SentenceTransformer
from PIL import Image
from fastapi import FastAPI
from pydantic import BaseModel
@@ -20,22 +20,32 @@ class ClipRequestBody(BaseModel):
classification_model = os.getenv(
'MACHINE_LEARNING_CLASSIFICATION_MODEL', 'microsoft/resnet-50')
object_model = os.getenv('MACHINE_LEARNING_OBJECT_MODEL', 'hustvl/yolos-tiny')
clip_image_model = os.getenv(
'MACHINE_LEARNING_CLIP_IMAGE_MODEL', 'clip-ViT-B-32')
clip_text_model = os.getenv(
'MACHINE_LEARNING_CLIP_TEXT_MODEL', 'clip-ViT-B-32')
"MACHINE_LEARNING_CLASSIFICATION_MODEL", "microsoft/resnet-50"
)
object_model = os.getenv("MACHINE_LEARNING_OBJECT_MODEL", "hustvl/yolos-tiny")
clip_image_model = os.getenv("MACHINE_LEARNING_CLIP_IMAGE_MODEL", "clip-ViT-B-32")
clip_text_model = os.getenv("MACHINE_LEARNING_CLIP_TEXT_MODEL", "clip-ViT-B-32")
facial_recognition_model = os.getenv(
'MACHINE_LEARNING_FACIAL_RECOGNITION_MODEL', 'buffalo_l')
"MACHINE_LEARNING_FACIAL_RECOGNITION_MODEL", "buffalo_l"
)
cache_folder = os.getenv('MACHINE_LEARNING_CACHE_FOLDER', '/cache')
cache_folder = os.getenv("MACHINE_LEARNING_CACHE_FOLDER", "/cache")
_model_cache = {}
app = FastAPI()
@app.on_event("startup")
async def startup_event():
# Get all models
_get_model(object_model, "object-detection")
_get_model(classification_model, "image-classification")
_get_model(clip_image_model)
_get_model(clip_text_model)
_get_model(facial_recognition_model, "facial-recognition")
@app.get("/")
async def root():
return {"message": "Immich ML"}
@@ -48,14 +58,14 @@ def ping():
@app.post("/object-detection/detect-object", status_code=200)
def object_detection(payload: MlRequestBody):
model = _get_model(object_model, 'object-detection')
model = _get_model(object_model, "object-detection")
assetPath = payload.thumbnailPath
return run_engine(model, assetPath)
@app.post("/image-classifier/tag-image", status_code=200)
def image_classification(payload: MlRequestBody):
model = _get_model(classification_model, 'image-classification')
model = _get_model(classification_model, "image-classification")
assetPath = payload.thumbnailPath
return run_engine(model, assetPath)
@@ -76,31 +86,32 @@ def clip_encode_text(payload: ClipRequestBody):
@app.post("/facial-recognition/detect-faces", status_code=200)
def facial_recognition(payload: MlRequestBody):
model = _get_model(facial_recognition_model, 'facial-recognition')
model = _get_model(facial_recognition_model, "facial-recognition")
assetPath = payload.thumbnailPath
img = cv.imread(assetPath)
height, width, _ = img.shape
results = []
faces = model.get(img)
for face in faces:
if face.det_score < 0.7:
continue
x1, y1, x2, y2 = face.bbox
# min face size as percent of original image
# if (x2 - x1) / width < 0.03 or (y2 - y1) / height < 0.05:
# continue
results.append({
"imageWidth": width,
"imageHeight": height,
"boundingBox": {
"x1": round(x1),
"y1": round(y1),
"x2": round(x2),
"y2": round(y2),
},
"score": face.det_score.item(),
"embedding": face.normed_embedding.tolist()
})
results.append(
{
"imageWidth": width,
"imageHeight": height,
"boundingBox": {
"x1": round(x1),
"y1": round(y1),
"x2": round(x2),
"y2": round(y2),
},
"score": face.det_score.item(),
"embedding": face.normed_embedding.tolist(),
}
)
return results
@@ -109,11 +120,11 @@ def run_engine(engine, path):
predictions = engine(path)
for index, pred in enumerate(predictions):
tags = pred['label'].split(', ')
if (pred['score'] > 0.9):
tags = pred["label"].split(", ")
if pred["score"] > 0.9:
result = [*result, *tags]
if (len(result) > 1):
if len(result) > 1:
result = list(set(result))
return result
@@ -121,25 +132,27 @@ def run_engine(engine, path):
def _get_model(model, task=None):
global _model_cache
key = '|'.join([model, str(task)])
key = "|".join([model, str(task)])
if key not in _model_cache:
if task:
if task == 'facial-recognition':
if task == "facial-recognition":
face_model = FaceAnalysis(
name=model, root=cache_folder, allowed_modules=["detection", "recognition"])
name=model,
root=cache_folder,
allowed_modules=["detection", "recognition"],
)
face_model.prepare(ctx_id=0, det_size=(640, 640))
_model_cache[key] = face_model
else:
_model_cache[key] = pipeline(model=model, task=task)
else:
_model_cache[key] = SentenceTransformer(
model, cache_folder=cache_folder)
_model_cache[key] = SentenceTransformer(model, cache_folder=cache_folder)
return _model_cache[key]
if __name__ == "__main__":
host = os.getenv('MACHINE_LEARNING_HOST', '0.0.0.0')
port = int(os.getenv('MACHINE_LEARNING_PORT', 3003))
is_dev = os.getenv('NODE_ENV') == 'development'
host = os.getenv("MACHINE_LEARNING_HOST", "0.0.0.0")
port = int(os.getenv("MACHINE_LEARNING_PORT", 3003))
is_dev = os.getenv("NODE_ENV") == "development"
uvicorn.run("main:app", host=host, port=port, reload=is_dev, workers=1)

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,77 +0,0 @@
import 'package:collection/collection.dart';
import 'package:immich_mobile/shared/models/asset.dart';
class AssetSelectionState {
final Set<String> selectedMonths;
final Set<Asset> selectedNewAssetsForAlbum;
final Set<Asset> selectedAdditionalAssetsForAlbum;
final Set<Asset> selectedAssetsInAlbumViewer;
final bool isMultiselectEnable;
/// Indicate the asset selection page is navigated from existing album
final bool isAlbumExist;
AssetSelectionState({
required this.selectedMonths,
required this.selectedNewAssetsForAlbum,
required this.selectedAdditionalAssetsForAlbum,
required this.selectedAssetsInAlbumViewer,
required this.isMultiselectEnable,
required this.isAlbumExist,
});
AssetSelectionState copyWith({
Set<String>? selectedMonths,
Set<Asset>? selectedNewAssetsForAlbum,
Set<Asset>? selectedAdditionalAssetsForAlbum,
Set<Asset>? selectedAssetsInAlbumViewer,
bool? isMultiselectEnable,
bool? isAlbumExist,
}) {
return AssetSelectionState(
selectedMonths: selectedMonths ?? this.selectedMonths,
selectedNewAssetsForAlbum:
selectedNewAssetsForAlbum ?? this.selectedNewAssetsForAlbum,
selectedAdditionalAssetsForAlbum: selectedAdditionalAssetsForAlbum ??
this.selectedAdditionalAssetsForAlbum,
selectedAssetsInAlbumViewer:
selectedAssetsInAlbumViewer ?? this.selectedAssetsInAlbumViewer,
isMultiselectEnable: isMultiselectEnable ?? this.isMultiselectEnable,
isAlbumExist: isAlbumExist ?? this.isAlbumExist,
);
}
@override
String toString() {
return 'AssetSelectionState(selectedMonths: $selectedMonths, selectedNewAssetsForAlbum: $selectedNewAssetsForAlbum, selectedAdditionalAssetsForAlbum: $selectedAdditionalAssetsForAlbum, selectedAssetsInAlbumViewer: $selectedAssetsInAlbumViewer, isMultiselectEnable: $isMultiselectEnable, isAlbumExist: $isAlbumExist)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
final setEquals = const DeepCollectionEquality().equals;
return other is AssetSelectionState &&
setEquals(other.selectedMonths, selectedMonths) &&
setEquals(other.selectedNewAssetsForAlbum, selectedNewAssetsForAlbum) &&
setEquals(
other.selectedAdditionalAssetsForAlbum,
selectedAdditionalAssetsForAlbum,
) &&
setEquals(
other.selectedAssetsInAlbumViewer,
selectedAssetsInAlbumViewer,
) &&
other.isMultiselectEnable == isMultiselectEnable &&
other.isAlbumExist == isAlbumExist;
}
@override
int get hashCode {
return selectedMonths.hashCode ^
selectedNewAssetsForAlbum.hashCode ^
selectedAdditionalAssetsForAlbum.hashCode ^
selectedAssetsInAlbumViewer.hashCode ^
isMultiselectEnable.hashCode ^
isAlbumExist.hashCode;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,110 +0,0 @@
import 'dart:convert';
class LogInReponse {
final String accessToken;
final String userId;
final String userEmail;
final String firstName;
final String lastName;
final String profileImagePath;
final bool isAdmin;
final bool shouldChangePassword;
LogInReponse({
required this.accessToken,
required this.userId,
required this.userEmail,
required this.firstName,
required this.lastName,
required this.profileImagePath,
required this.isAdmin,
required this.shouldChangePassword,
});
LogInReponse copyWith({
String? accessToken,
String? userId,
String? userEmail,
String? firstName,
String? lastName,
String? profileImagePath,
bool? isAdmin,
bool? shouldChangePassword,
}) {
return LogInReponse(
accessToken: accessToken ?? this.accessToken,
userId: userId ?? this.userId,
userEmail: userEmail ?? this.userEmail,
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
profileImagePath: profileImagePath ?? this.profileImagePath,
isAdmin: isAdmin ?? this.isAdmin,
shouldChangePassword: shouldChangePassword ?? this.shouldChangePassword,
);
}
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
result.addAll({'accessToken': accessToken});
result.addAll({'userId': userId});
result.addAll({'userEmail': userEmail});
result.addAll({'firstName': firstName});
result.addAll({'lastName': lastName});
result.addAll({'profileImagePath': profileImagePath});
result.addAll({'isAdmin': isAdmin});
result.addAll({'shouldChangePassword': shouldChangePassword});
return result;
}
factory LogInReponse.fromMap(Map<String, dynamic> map) {
return LogInReponse(
accessToken: map['accessToken'] ?? '',
userId: map['userId'] ?? '',
userEmail: map['userEmail'] ?? '',
firstName: map['firstName'] ?? '',
lastName: map['lastName'] ?? '',
profileImagePath: map['profileImagePath'] ?? '',
isAdmin: map['isAdmin'] ?? false,
shouldChangePassword: map['shouldChangePassword'] ?? false,
);
}
String toJson() => json.encode(toMap());
factory LogInReponse.fromJson(String source) =>
LogInReponse.fromMap(json.decode(source));
@override
String toString() {
return 'LogInReponse(accessToken: $accessToken, userId: $userId, userEmail: $userEmail, firstName: $firstName, lastName: $lastName, profileImagePath: $profileImagePath, isAdmin: $isAdmin, shouldChangePassword: $shouldChangePassword)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is LogInReponse &&
other.accessToken == accessToken &&
other.userId == userId &&
other.userEmail == userEmail &&
other.firstName == firstName &&
other.lastName == lastName &&
other.profileImagePath == profileImagePath &&
other.isAdmin == isAdmin &&
other.shouldChangePassword == shouldChangePassword;
}
@override
int get hashCode {
return accessToken.hashCode ^
userId.hashCode ^
userEmail.hashCode ^
firstName.hashCode ^
lastName.hashCode ^
profileImagePath.hashCode ^
isAdmin.hashCode ^
shouldChangePassword.hashCode;
}
}

View File

@@ -1,150 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/modules/settings/ui/cache_settings/cache_settings_slider_pref.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
class CacheSettings extends HookConsumerWidget {
const CacheSettings({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final CacheService cacheService = ref.watch(cacheServiceProvider);
final clearCacheState = useState(false);
Future<void> clearCache() async {
await cacheService.emptyAllCaches();
clearCacheState.value = true;
}
Widget cacheStatisticsRow(String name, CacheType type) {
final cacheSize = useState(0);
final cacheAssets = useState(0);
if (!clearCacheState.value) {
final repo = cacheService.getCacheRepo(type);
repo.open().then((_) {
cacheSize.value = repo.getCacheSize();
cacheAssets.value = repo.getNumberOfCachedObjects();
});
} else {
cacheSize.value = 0;
cacheAssets.value = 0;
}
return Container(
margin: const EdgeInsets.only(left: 20, bottom: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
const Text(
"cache_settings_statistics_assets",
style: TextStyle(color: Colors.grey),
).tr(
args: ["${cacheAssets.value}", formatBytes(cacheSize.value)],
),
],
),
);
}
return ExpansionTile(
expandedCrossAxisAlignment: CrossAxisAlignment.start,
textColor: Theme.of(context).primaryColor,
title: const Text(
'cache_settings_title',
style: TextStyle(
fontWeight: FontWeight.bold,
),
).tr(),
subtitle: const Text(
'cache_settings_subtitle',
style: TextStyle(
fontSize: 13,
),
).tr(),
children: [
const CacheSettingsSliderPref(
setting: AppSettingsEnum.thumbnailCacheSize,
translationKey: "cache_settings_thumbnail_size",
min: 1000,
max: 20000,
divisions: 19,
),
const CacheSettingsSliderPref(
setting: AppSettingsEnum.imageCacheSize,
translationKey: "cache_settings_image_cache_size",
min: 0,
max: 1000,
divisions: 20,
),
const CacheSettingsSliderPref(
setting: AppSettingsEnum.albumThumbnailCacheSize,
translationKey: "cache_settings_album_thumbnails",
min: 0,
max: 1000,
divisions: 20,
),
ListTile(
title: const Text(
"cache_settings_statistics_title",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
).tr(),
),
cacheStatisticsRow(
"cache_settings_statistics_thumbnail".tr(),
CacheType.thumbnail,
),
cacheStatisticsRow(
"cache_settings_statistics_album".tr(),
CacheType.albumThumbnail,
),
cacheStatisticsRow(
"cache_settings_statistics_shared".tr(),
CacheType.sharedAlbumThumbnail,
),
cacheStatisticsRow(
"cache_settings_statistics_full".tr(),
CacheType.imageViewerFull,
),
ListTile(
title: const Text(
"cache_settings_clear_cache_button_title",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
).tr(),
),
Container(
alignment: Alignment.center,
child: ElevatedButton(
onPressed: clearCache,
child: const Text(
"cache_settings_clear_cache_button",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
).tr(),
),
)
],
);
}
}

View File

@@ -1,63 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
class CacheSettingsSliderPref extends HookConsumerWidget {
final AppSettingsEnum<int> setting;
final String translationKey;
final int min;
final int max;
final int divisions;
const CacheSettingsSliderPref({
Key? key,
required this.setting,
required this.translationKey,
required this.min,
required this.max,
required this.divisions,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final appSettingService = ref.watch(appSettingsServiceProvider);
final itemsValue = useState(appSettingService.getSetting<int>(setting));
void sliderChanged(double value) {
itemsValue.value = value.toInt();
}
void sliderChangedEnd(double value) {
appSettingService.setSetting(setting, value.toInt());
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
title: Text(
translationKey,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
).tr(args: ["${itemsValue.value.toInt()}"]),
),
Slider(
onChangeEnd: sliderChangedEnd,
onChanged: sliderChanged,
value: itemsValue.value.toDouble(),
min: min.toDouble(),
max: max.toDouble(),
divisions: divisions,
label: "${itemsValue.value.toInt()}",
activeColor: Theme.of(context).primaryColor,
),
],
);
}
}

View File

@@ -1,48 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class ExperimentalSettings extends HookConsumerWidget {
const ExperimentalSettings({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return ExpansionTile(
textColor: Theme.of(context).primaryColor,
title: const Text(
'experimental_settings_title',
style: TextStyle(
fontWeight: FontWeight.bold,
),
).tr(),
subtitle: const Text(
'experimental_settings_subtitle',
style: TextStyle(
fontSize: 13,
),
).tr(),
children: const [
// SwitchListTile.adaptive(
// activeColor: Theme.of(context).primaryColor,
// title: const Text(
// "experimental_settings_new_asset_list_title",
// style: TextStyle(
// fontSize: 12,
// fontWeight: FontWeight.bold,
// ),
// ).tr(),
// subtitle: const Text(
// "experimental_settings_new_asset_list_subtitle",
// style: TextStyle(
// fontSize: 12,
// ),
// ).tr(),
// value: useExperimentalAssetGrid.value,
// onChanged: changeUseExperimentalAssetGrid,
// ),
],
);
}
}

View File

@@ -1,11 +0,0 @@
class ImageViewerPageData {
final String heroTag;
final String imageUrl;
final String thumbnailUrl;
ImageViewerPageData({
required this.heroTag,
required this.imageUrl,
required this.thumbnailUrl,
});
}

View File

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

View File

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

View File

@@ -1,57 +0,0 @@
import 'dart:convert';
class UploadProfileImageResponse {
final String userId;
final String profileImagePath;
UploadProfileImageResponse({
required this.userId,
required this.profileImagePath,
});
UploadProfileImageResponse copyWith({
String? userId,
String? profileImagePath,
}) {
return UploadProfileImageResponse(
userId: userId ?? this.userId,
profileImagePath: profileImagePath ?? this.profileImagePath,
);
}
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
result.addAll({'userId': userId});
result.addAll({'profileImagePath': profileImagePath});
return result;
}
factory UploadProfileImageResponse.fromMap(Map<String, dynamic> map) {
return UploadProfileImageResponse(
userId: map['userId'] ?? '',
profileImagePath: map['profileImagePath'] ?? '',
);
}
String toJson() => json.encode(toMap());
factory UploadProfileImageResponse.fromJson(String source) =>
UploadProfileImageResponse.fromMap(json.decode(source));
@override
String toString() =>
'UploadProfileImageReponse(userId: $userId, profileImagePath: $profileImagePath)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is UploadProfileImageResponse &&
other.userId == userId &&
other.profileImagePath == profileImagePath;
}
@override
int get hashCode => userId.hashCode ^ profileImagePath.hashCode;
}

View File

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

View File

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

View File

@@ -1,82 +0,0 @@
// ignore: depend_on_referenced_packages
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/utils/immich_cache_info_repository.dart';
enum CacheType {
// Shared cache for asset thumbnails in various modules
thumbnail,
imageViewerPreview,
imageViewerFull,
albumThumbnail,
sharedAlbumThumbnail;
}
final cacheServiceProvider = Provider(
(ref) => CacheService(ref.watch(appSettingsServiceProvider)),
);
class CacheService {
final AppSettingsService _settingsService;
final _cacheRepositoryInstances = <CacheType, ImmichCacheRepository>{};
CacheService(this._settingsService);
BaseCacheManager getCache(CacheType type) {
return _getDefaultCache(
type.name,
_getCacheSize(type) + 1,
getCacheRepo(type),
);
}
ImmichCacheRepository getCacheRepo(CacheType type) {
if (!_cacheRepositoryInstances.containsKey(type)) {
final repo = ImmichCacheInfoRepository(
"cache_${type.name}",
"cacheKeys_${type.name}",
);
_cacheRepositoryInstances[type] = repo;
}
return _cacheRepositoryInstances[type]!;
}
Future<void> emptyAllCaches() async {
for (var type in CacheType.values) {
await getCache(type).emptyCache();
}
}
int _getCacheSize(CacheType type) {
switch (type) {
case CacheType.thumbnail:
return _settingsService.getSetting(AppSettingsEnum.thumbnailCacheSize);
case CacheType.imageViewerPreview:
case CacheType.imageViewerFull:
return _settingsService.getSetting(AppSettingsEnum.imageCacheSize);
case CacheType.sharedAlbumThumbnail:
case CacheType.albumThumbnail:
return _settingsService
.getSetting(AppSettingsEnum.albumThumbnailCacheSize);
default:
return 200;
}
}
BaseCacheManager _getDefaultCache(
String cacheName,
int size,
CacheInfoRepository repo,
) {
return CacheManager(
Config(
cacheName,
maxNrOfCacheObjects: size,
repo: repo,
),
);
}
}

View File

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

View File

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

View File

@@ -1,210 +0,0 @@
// ignore_for_file: depend_on_referenced_packages, implementation_imports
import 'dart:io';
import 'dart:math';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_cache_manager/src/storage/cache_object.dart';
import 'package:hive/hive.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
// Implementation of a CacheInfoRepository based on Hive
abstract class ImmichCacheRepository extends CacheInfoRepository {
int getNumberOfCachedObjects();
int getCacheSize();
}
class ImmichCacheInfoRepository extends ImmichCacheRepository {
final String hiveBoxName;
final String keyLookupHiveBoxName;
// To circumvent some of the limitations of a non-relational key-value database,
// we use two hive boxes per cache.
// [cacheObjectLookupBox] maps ids to cache objects.
// [keyLookupHiveBox] maps keys to ids.
// The lookup of a cache object by key therefore involves two steps:
// id = keyLookupHiveBox[key]
// object = cacheObjectLookupBox[id]
late Box<Map<dynamic, dynamic>> cacheObjectLookupBox;
late Box<int> keyLookupHiveBox;
ImmichCacheInfoRepository(this.hiveBoxName, this.keyLookupHiveBoxName);
@override
Future<bool> close() async {
await cacheObjectLookupBox.close();
return true;
}
@override
Future<int> delete(int id) async {
if (cacheObjectLookupBox.containsKey(id)) {
await cacheObjectLookupBox.delete(id);
return 1;
}
return 0;
}
@override
Future<int> deleteAll(Iterable<int> ids) async {
int deleted = 0;
for (var id in ids) {
if (cacheObjectLookupBox.containsKey(id)) {
deleted++;
await cacheObjectLookupBox.delete(id);
}
}
return deleted;
}
@override
Future<void> deleteDataFile() async {
await cacheObjectLookupBox.clear();
await keyLookupHiveBox.clear();
}
@override
Future<bool> exists() async {
return cacheObjectLookupBox.isNotEmpty && keyLookupHiveBox.isNotEmpty;
}
@override
Future<CacheObject?> get(String key) async {
if (!keyLookupHiveBox.containsKey(key)) {
return null;
}
int id = keyLookupHiveBox.get(key)!;
if (!cacheObjectLookupBox.containsKey(id)) {
keyLookupHiveBox.delete(key);
return null;
}
return _deserialize(cacheObjectLookupBox.get(id)!);
}
@override
Future<List<CacheObject>> getAllObjects() async {
return cacheObjectLookupBox.values.map(_deserialize).toList();
}
@override
Future<List<CacheObject>> getObjectsOverCapacity(int capacity) async {
if (cacheObjectLookupBox.length <= capacity) {
return List.empty();
}
var values = cacheObjectLookupBox.values.map(_deserialize).toList();
values.sort((CacheObject a, CacheObject b) {
final aTouched = a.touched ?? DateTime.fromMicrosecondsSinceEpoch(0);
final bTouched = b.touched ?? DateTime.fromMicrosecondsSinceEpoch(0);
return aTouched.compareTo(bTouched);
});
return values.skip(capacity).take(10).toList();
}
@override
Future<List<CacheObject>> getOldObjects(Duration maxAge) async {
return cacheObjectLookupBox.values
.map(_deserialize)
.where((CacheObject element) {
DateTime touched =
element.touched ?? DateTime.fromMicrosecondsSinceEpoch(0);
return touched.isBefore(DateTime.now().subtract(maxAge));
}).toList();
}
@override
Future<CacheObject> insert(
CacheObject cacheObject, {
bool setTouchedToNow = true,
}) async {
int newId = keyLookupHiveBox.length == 0
? 0
: keyLookupHiveBox.values.reduce(max) + 1;
cacheObject = cacheObject.copyWith(id: newId);
keyLookupHiveBox.put(cacheObject.key, newId);
cacheObjectLookupBox.put(newId, cacheObject.toMap());
return cacheObject;
}
@override
Future<bool> open() async {
cacheObjectLookupBox = await Hive.openBox(hiveBoxName);
keyLookupHiveBox = await Hive.openBox(keyLookupHiveBoxName);
// The cache might have cleared by the operating system.
// This could create inconsistencies between the file system cache and database.
// To check whether the cache was cleared, a file within the cache directory
// is created for each database. If the file is absent, the cache was cleared and therefore
// the database has to be cleared as well.
if (!await _checkAndCreateAnchorFile()) {
await cacheObjectLookupBox.clear();
await keyLookupHiveBox.clear();
}
return cacheObjectLookupBox.isOpen;
}
@override
Future<int> update(
CacheObject cacheObject, {
bool setTouchedToNow = true,
}) async {
if (cacheObject.id != null) {
cacheObjectLookupBox.put(cacheObject.id, cacheObject.toMap());
return 1;
}
return 0;
}
@override
Future updateOrInsert(CacheObject cacheObject) {
if (cacheObject.id == null) {
return insert(cacheObject);
} else {
return update(cacheObject);
}
}
@override
int getNumberOfCachedObjects() {
return cacheObjectLookupBox.length;
}
@override
int getCacheSize() {
final cacheElementsWithSize =
cacheObjectLookupBox.values.map(_deserialize).map((e) => e.length ?? 0);
if (cacheElementsWithSize.isEmpty) {
return 0;
}
return cacheElementsWithSize.reduce((value, element) => value + element);
}
CacheObject _deserialize(Map serData) {
Map<String, dynamic> converted = {};
serData.forEach((key, value) {
converted[key.toString()] = value;
});
return CacheObject.fromMap(converted);
}
Future<bool> _checkAndCreateAnchorFile() async {
final tmpDir = await getTemporaryDirectory();
final cacheFile = File(p.join(tmpDir.path, "$hiveBoxName.tmp"));
if (await cacheFile.exists()) {
return true;
}
await cacheFile.create();
return false;
}
}

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.56.1
- API version: 1.57.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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