Merge remote-tracking branch 'origin/main' into img_preload_n_cancel

This commit is contained in:
Min Idzelis 2024-09-18 23:04:38 +00:00
commit ab24f4fe03
No known key found for this signature in database
100 changed files with 1985 additions and 871 deletions

View File

@ -56,6 +56,10 @@ jobs:
run: dart format lib/ --set-exit-if-changed
working-directory: ./mobile
- name: Run dart custom_lint
run: dart run custom_lint
working-directory: ./mobile
# Enable after riverpod generator migration is completed
# - name: Run dart custom lint
# run: dart run custom_lint

View File

@ -34,8 +34,11 @@ describe('/trash', () => {
const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: true }));
const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
const { status, body } = await request(app)
.post('/trash/empty')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ count: 1 });
await utils.waitForWebsocketEvent({ event: 'assetDelete', id: assetId });
@ -51,8 +54,11 @@ describe('/trash', () => {
const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: true, isArchived: true }));
const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
const { status, body } = await request(app)
.post('/trash/empty')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ count: 1 });
await utils.waitForWebsocketEvent({ event: 'assetDelete', id: assetId });
@ -76,8 +82,11 @@ describe('/trash', () => {
const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: true }));
const { status } = await request(app).post('/trash/restore').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
const { status, body } = await request(app)
.post('/trash/restore')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ count: 1 });
const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: false }));
@ -99,11 +108,12 @@ describe('/trash', () => {
const before = await utils.getAssetInfo(admin.accessToken, assetId);
expect(before.isTrashed).toBe(true);
const { status } = await request(app)
const { status, body } = await request(app)
.post('/trash/restore/assets')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ids: [assetId] });
expect(status).toBe(204);
expect(status).toBe(200);
expect(body).toEqual({ count: 1 });
const after = await utils.getAssetInfo(admin.accessToken, assetId);
expect(after.isTrashed).toBe(false);

View File

@ -36,8 +36,31 @@ analyzer:
- openapi/**
- lib/generated_plugin_registrant.dart
plugins:
- custom_lint
plugins:
- custom_lint
custom_lint:
debug: true
rules:
- avoid_build_context_in_providers: false
- avoid_public_notifier_properties: false
- avoid_manual_providers_as_generated_provider_dependency: false
- unsupported_provider_value: false
- photo_manager:
exclude:
# required / wanted
- album_media.repository.dart
- asset_media.repository.dart
- file_media.repository.dart
# acceptable exceptions for the time being
- asset.entity.dart # to provide local AssetEntity for now
- immich_local_image_provider.dart # accesses thumbnails via PhotoManager
- immich_local_thumbnail_provider.dart # accesses thumbnails via PhotoManager
# refactor to make the providers and services testable
- backup.provider.dart # uses only PMProgressHandler
- manual_upload.provider.dart # uses only PMProgressHandler
- background.service.dart # uses only PMProgressHandler
- backup.service.dart # uses only PMProgressHandler
dart_code_metrics:
metrics:

View File

@ -0,0 +1 @@
include: package:lints/recommended.yaml

View File

@ -0,0 +1,49 @@
import 'dart:collection';
import 'package:analyzer/error/listener.dart';
import 'package:analyzer/error/error.dart' show ErrorSeverity;
import 'package:custom_lint_builder/custom_lint_builder.dart';
PluginBase createPlugin() => ImmichLinter();
class ImmichLinter extends PluginBase {
@override
List<LintRule> getLintRules(CustomLintConfigs configs) => [
PhotoManagerRule(configs.rules[PhotoManagerRule._code.name]),
];
}
class PhotoManagerRule extends DartLintRule {
PhotoManagerRule(LintOptions? options) : super(code: _code) {
final excludeOption = options?.json["exclude"];
if (excludeOption is String) {
_excludePaths.add(excludeOption);
} else if (excludeOption is List) {
_excludePaths.addAll(excludeOption.map((option) => option));
}
}
final Set<String> _excludePaths = HashSet();
static const _code = LintCode(
name: 'photo_manager',
problemMessage:
'photo_manager library must only be used in MediaRepository',
errorSeverity: ErrorSeverity.WARNING,
);
@override
void run(
CustomLintResolver resolver,
ErrorReporter reporter,
CustomLintContext context,
) {
if (_excludePaths.contains(resolver.source.shortName)) return;
context.registry.addImportDirective((node) {
if (node.uri.stringValue?.startsWith("package:photo_manager") == true) {
reporter.atNode(node, code);
}
});
}
}

View File

@ -0,0 +1,370 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "45cfa8471b89fb6643fe9bf51bd7931a76b8f5ec2d65de4fb176dba8d4f22c77"
url: "https://pub.dev"
source: hosted
version: "73.0.0"
_macros:
dependency: transitive
description: dart
source: sdk
version: "0.3.2"
analyzer:
dependency: "direct main"
description:
name: analyzer
sha256: "4959fec185fe70cce007c57e9ab6983101dbe593d2bf8bbfb4453aaec0cf470a"
url: "https://pub.dev"
source: hosted
version: "6.8.0"
analyzer_plugin:
dependency: "direct main"
description:
name: analyzer_plugin
sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161"
url: "https://pub.dev"
source: hosted
version: "0.11.3"
args:
dependency: transitive
description:
name: args
sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a"
url: "https://pub.dev"
source: hosted
version: "2.5.0"
async:
dependency: transitive
description:
name: async
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
url: "https://pub.dev"
source: hosted
version: "2.11.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff
url: "https://pub.dev"
source: hosted
version: "2.0.3"
ci:
dependency: transitive
description:
name: ci
sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19
url: "https://pub.dev"
source: hosted
version: "0.4.1"
collection:
dependency: transitive
description:
name: collection
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
url: "https://pub.dev"
source: hosted
version: "1.19.0"
convert:
dependency: transitive
description:
name: convert
sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27
url: "https://pub.dev"
source: hosted
version: "3.0.5"
custom_lint:
dependency: transitive
description:
name: custom_lint
sha256: "6e1ec47427ca968f22bce734d00028ae7084361999b41673291138945c5baca0"
url: "https://pub.dev"
source: hosted
version: "0.6.7"
custom_lint_builder:
dependency: "direct main"
description:
name: custom_lint_builder
sha256: ba2f90fff4eff71d202d097eb14b14f87087eaaef742e956208c0eb9d3a40a21
url: "https://pub.dev"
source: hosted
version: "0.6.7"
custom_lint_core:
dependency: transitive
description:
name: custom_lint_core
sha256: "4ddbbdaa774265de44c97054dcec058a83d9081d071785ece601e348c18c267d"
url: "https://pub.dev"
source: hosted
version: "0.6.5"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab"
url: "https://pub.dev"
source: hosted
version: "2.3.7"
file:
dependency: transitive
description:
name: file
sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
freezed_annotation:
dependency: transitive
description:
name: freezed_annotation
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
url: "https://pub.dev"
source: hosted
version: "2.4.4"
glob:
dependency: transitive
description:
name: glob
sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
hotreloader:
dependency: transitive
description:
name: hotreloader
sha256: ed56fdc1f3a8ac924e717257621d09e9ec20e308ab6352a73a50a1d7a4d9158e
url: "https://pub.dev"
source: hosted
version: "4.2.0"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
lints:
dependency: "direct dev"
description:
name: lints
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
logging:
dependency: transitive
description:
name: logging
sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
macros:
dependency: transitive
description:
name: macros
sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536"
url: "https://pub.dev"
source: hosted
version: "0.1.2-main.4"
matcher:
dependency: transitive
description:
name: matcher
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
url: "https://pub.dev"
source: hosted
version: "0.12.16+1"
meta:
dependency: transitive
description:
name: meta
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
url: "https://pub.dev"
source: hosted
version: "1.15.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
path:
dependency: transitive
description:
name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
url: "https://pub.dev"
source: hosted
version: "1.9.0"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8
url: "https://pub.dev"
source: hosted
version: "1.3.0"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
url: "https://pub.dev"
source: hosted
version: "1.10.0"
sprintf:
dependency: transitive
description:
name: sprintf
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
url: "https://pub.dev"
source: hosted
version: "1.11.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
url: "https://pub.dev"
source: hosted
version: "2.1.2"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
url: "https://pub.dev"
source: hosted
version: "1.2.1"
test_api:
dependency: transitive
description:
name: test_api
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
url: "https://pub.dev"
source: hosted
version: "0.7.3"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c
url: "https://pub.dev"
source: hosted
version: "1.3.2"
uuid:
dependency: transitive
description:
name: uuid
sha256: f33d6bb662f0e4f79dcd7ada2e6170f3b3a2530c28fc41f49a411ddedd576a77
url: "https://pub.dev"
source: hosted
version: "4.5.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
url: "https://pub.dev"
source: hosted
version: "14.2.5"
watcher:
dependency: transitive
description:
name: watcher
sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5"
url: "https://pub.dev"
source: hosted
version: "3.1.2"
sdks:
dart: ">=3.4.0 <4.0.0"

View File

@ -0,0 +1,13 @@
name: immich_mobile_immich_lint
publish_to: none
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
analyzer: ^6.8.0
analyzer_plugin: ^0.11.3
custom_lint_builder: ^0.6.4
dev_dependencies:
lints: ^4.0.0

View File

@ -1,11 +1,9 @@
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/utils/datetime_comparison.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
part 'album.entity.g.dart';
@ -43,6 +41,9 @@ class Album {
final IsarLinks<User> sharedUsers = IsarLinks<User>();
final IsarLinks<Asset> assets = IsarLinks<Asset>();
@ignore
bool isAll = false;
@ignore
bool get isRemote => remoteId != null;
@ -70,6 +71,9 @@ class Album {
return name.join(' ');
}
@ignore
String get eTagKeyAssetCount => "device-album-$localId-asset-count";
@override
bool operator ==(other) {
if (other is! Album) return false;
@ -112,19 +116,6 @@ class Album {
sharedUsers.length.hashCode ^
assets.length.hashCode;
static Album local(AssetPathEntity ape) {
final Album a = Album(
name: ape.name,
createdAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
modifiedAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
shared: false,
activityEnabled: false,
);
a.owner.value = Store.get(StoreKey.currentUser);
a.localId = ape.id;
return a;
}
static Future<Album> remote(AlbumResponseDto dto) async {
final Isar db = Isar.getInstance()!;
final Album a = Album(
@ -177,7 +168,3 @@ extension AssetsHelper on IsarCollection<Album> {
extension AlbumResponseDtoHelper on AlbumResponseDto {
List<Asset> getAssets() => assets.map(Asset.remote).toList();
}
extension AssetPathEntityHelper on AssetPathEntity {
String get eTagKeyAssetCount => "device-album-$id-asset-count";
}

View File

@ -1,11 +1,10 @@
import 'dart:convert';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:photo_manager/photo_manager.dart' show AssetEntity;
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:path/path.dart' as p;
@ -42,33 +41,6 @@ class Asset {
stackId = remote.stack?.id,
thumbhash = remote.thumbhash;
Asset.local(AssetEntity local, List<int> hash)
: localId = local.id,
checksum = base64.encode(hash),
durationInSeconds = local.duration,
type = AssetType.values[local.typeInt],
height = local.height,
width = local.width,
fileName = local.title!,
ownerId = Store.get(StoreKey.currentUser).isarId,
fileModifiedAt = local.modifiedDateTime,
updatedAt = local.modifiedDateTime,
isFavorite = local.isFavorite,
isArchived = false,
isTrashed = false,
isOffline = false,
stackCount = 0,
fileCreatedAt = local.createDateTime {
if (fileCreatedAt.year == 1970) {
fileCreatedAt = fileModifiedAt;
}
if (local.latitude != null) {
exifInfo = ExifInfo(lat: local.latitude, long: local.longitude);
}
_local = local;
assert(hash.length == 20, "invalid SHA1 hash");
}
Asset({
this.id = Isar.autoIncrement,
required this.checksum,
@ -115,6 +87,8 @@ class Asset {
return _local;
}
set local(AssetEntity? assetEntity) => _local = assetEntity;
Id id = Isar.autoIncrement;
/// stores the raw SHA1 bytes as a base64 String
@ -210,6 +184,10 @@ class Asset {
@ignore
Duration get duration => Duration(seconds: durationInSeconds);
// ignore: invalid_annotation_target
@ignore
set byteHash(List<int> hash) => checksum = base64.encode(hash);
@override
bool operator ==(other) {
if (other is! Asset) return false;

View File

@ -0,0 +1,21 @@
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
abstract interface class IAlbumMediaRepository {
Future<List<Album>> getAll();
Future<List<String>> getAssetIds(String albumId);
Future<int> getAssetCount(String albumId);
Future<List<Asset>> getAssets(
String albumId, {
int start = 0,
int end = 0x7fffffffffffffff,
DateTime? modifiedFrom,
DateTime? modifiedUntil,
bool orderByModificationDate = false,
});
Future<Album> get(String id);
}

View File

@ -0,0 +1,7 @@
import 'package:immich_mobile/entities/asset.entity.dart';
abstract interface class IAssetMediaRepository {
Future<List<String>> deleteAll(List<String> ids);
Future<Asset?> get(String id);
}

View File

@ -0,0 +1,30 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:immich_mobile/entities/asset.entity.dart';
abstract interface class IFileMediaRepository {
Future<Asset?> saveImage(
Uint8List data, {
required String title,
String? relativePath,
});
Future<Asset?> saveVideo(
File file, {
required String title,
String? relativePath,
});
Future<Asset?> saveLivePhoto({
required File image,
required File video,
required String title,
});
Future<void> clearFileCache();
Future<void> enableBackgroundAccess();
Future<void> requestExtendedPermissions();
}

View File

@ -1,45 +1,47 @@
import 'dart:typed_data';
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/entities/album.entity.dart';
class AvailableAlbum {
final AssetPathEntity albumEntity;
final Album album;
final int assetCount;
final DateTime? lastBackup;
AvailableAlbum({
required this.albumEntity,
required this.album,
required this.assetCount,
this.lastBackup,
});
AvailableAlbum copyWith({
AssetPathEntity? albumEntity,
Album? album,
int? assetCount,
DateTime? lastBackup,
Uint8List? thumbnailData,
}) {
return AvailableAlbum(
albumEntity: albumEntity ?? this.albumEntity,
album: album ?? this.album,
assetCount: assetCount ?? this.assetCount,
lastBackup: lastBackup ?? this.lastBackup,
);
}
String get name => albumEntity.name;
String get name => album.name;
Future<int> get assetCount => albumEntity.assetCountAsync;
String get id => album.localId!;
String get id => albumEntity.id;
bool get isAll => albumEntity.isAll;
bool get isAll => album.isAll;
@override
String toString() =>
'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup)';
'AvailableAlbum(albumEntity: $album, lastBackup: $lastBackup)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is AvailableAlbum && other.albumEntity == albumEntity;
return other is AvailableAlbum && other.album == album;
}
@override
int get hashCode => albumEntity.hashCode;
int get hashCode => album.hashCode;
}

View File

@ -1,9 +1,9 @@
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
class BackupCandidate {
BackupCandidate({required this.asset, required this.albumNames});
AssetEntity asset;
Asset asset;
List<String> albumNames;
@override

View File

@ -1,11 +1,11 @@
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
class ErrorUploadAsset {
final String id;
final DateTime fileCreatedAt;
final String fileName;
final String fileType;
final AssetEntity asset;
final Asset asset;
final String errorMessage;
const ErrorUploadAsset({
@ -22,7 +22,7 @@ class ErrorUploadAsset {
DateTime? fileCreatedAt,
String? fileName,
String? fileType,
AssetEntity? asset,
Asset? asset,
String? errorMessage,
}) {
return ErrorUploadAsset(

View File

@ -1,28 +1,27 @@
import 'dart:typed_data';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
@RoutePage()
class AlbumPreviewPage extends HookConsumerWidget {
final AssetPathEntity album;
final Album album;
const AlbumPreviewPage({super.key, required this.album});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assets = useState<List<AssetEntity>>([]);
final assets = useState<List<Asset>>([]);
getAssetsInAlbum() async {
assets.value = await album.getAssetListRange(
start: 0,
end: await album.assetCountAsync,
);
assets.value = await ref
.read(albumMediaRepositoryProvider)
.getAssets(album.localId!);
}
useEffect(
@ -68,30 +67,10 @@ class AlbumPreviewPage extends HookConsumerWidget {
),
itemCount: assets.value.length,
itemBuilder: (context, index) {
Future<Uint8List?> thumbData =
assets.value[index].thumbnailDataWithSize(
const ThumbnailSize(200, 200),
quality: 50,
);
return FutureBuilder<Uint8List?>(
future: thumbData,
builder: ((context, snapshot) {
if (snapshot.hasData && snapshot.data != null) {
return Image.memory(
snapshot.data!,
width: 100,
height: 100,
fit: BoxFit.cover,
);
}
return const SizedBox(
width: 100,
height: 100,
child: ImmichLoadingIndicator(),
);
}),
return ImmichThumbnail(
asset: assets.value[index],
width: 100,
height: 100,
);
},
),

View File

@ -3,9 +3,8 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart';
import 'package:intl/intl.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:photo_manager_image_provider/photo_manager_image_provider.dart';
@RoutePage()
class FailedBackupStatusPage extends HookConsumerWidget {
@ -70,11 +69,10 @@ class FailedBackupStatusPage extends HookConsumerWidget {
clipBehavior: Clip.hardEdge,
child: Image(
fit: BoxFit.cover,
image: AssetEntityImageProvider(
errorAsset.asset,
isOriginal: false,
thumbnailSize: const ThumbnailSize.square(512),
thumbnailFormat: ThumbnailFormat.jpeg,
image: ImmichLocalThumbnailProvider(
asset: errorAsset.asset,
height: 512,
width: 512,
),
),
),

View File

@ -8,11 +8,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/widgets/common/immich_image.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:auto_route/auto_route.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:path/path.dart' as p;
@ -67,10 +67,10 @@ class EditImagePage extends ConsumerWidget {
) async {
try {
final Uint8List imageData = await _imageToUint8List(image);
await PhotoManager.editor.saveImage(
imageData,
title: "${p.withoutExtension(asset.fileName)}_edited.jpg",
);
await ref.read(fileMediaRepositoryProvider).saveImage(
imageData,
title: "${p.withoutExtension(asset.fileName)}_edited.jpg",
);
await ref.read(albumProvider.notifier).getDeviceAlbums();
Navigator.of(context).popUntil((route) => route.isFirst);
ImmichToast.show(

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/memory.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@ -15,7 +16,6 @@ import 'package:immich_mobile/utils/db.dart';
import 'package:immich_mobile/utils/renderlist_generator.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
class AssetNotifier extends StateNotifier<bool> {
final AssetService _assetService;
@ -257,7 +257,7 @@ class AssetNotifier extends StateNotifier<bool> {
// Delete asset from device
if (local.isNotEmpty) {
try {
return await PhotoManager.editor.deleteWithIds(local);
return await _ref.read(assetMediaRepositoryProvider).deleteAll(local);
} catch (e, stack) {
log.severe("Failed to delete asset from device", e, stack);
}

View File

@ -5,6 +5,9 @@ import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/models/backup/available_album.model.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
@ -13,6 +16,8 @@ import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/models/authentication/authentication_state.model.dart';
@ -28,7 +33,7 @@ import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
class BackupNotifier extends StateNotifier<BackUpState> {
BackupNotifier(
@ -38,6 +43,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
this._backgroundService,
this._galleryPermissionNotifier,
this._db,
this._albumMediaRepository,
this._fileMediaRepository,
this.ref,
) : super(
BackUpState(
@ -86,6 +93,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final BackgroundService _backgroundService;
final GalleryPermissionNotifier _galleryPermissionNotifier;
final Isar _db;
final IAlbumMediaRepository _albumMediaRepository;
final IFileMediaRepository _fileMediaRepository;
final Ref ref;
///
@ -224,22 +233,24 @@ class BackupNotifier extends StateNotifier<BackUpState> {
Stopwatch stopwatch = Stopwatch()..start();
// Get all albums on the device
List<AvailableAlbum> availableAlbums = [];
List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(
hasAll: true,
type: RequestType.common,
);
List<Album> albums = await _albumMediaRepository.getAll();
// Map of id -> album for quick album lookup later on.
Map<String, AssetPathEntity> albumMap = {};
Map<String, Album> albumMap = {};
log.info('Found ${albums.length} local albums');
for (AssetPathEntity album in albums) {
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
for (Album album in albums) {
AvailableAlbum availableAlbum = AvailableAlbum(
album: album,
assetCount: await ref
.read(albumMediaRepositoryProvider)
.getAssetCount(album.localId!),
);
availableAlbums.add(availableAlbum);
albumMap[album.id] = album;
albumMap[album.localId!] = album;
}
state = state.copyWith(availableAlbums: availableAlbums);
@ -248,14 +259,18 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final List<BackupAlbum> selectedBackupAlbums =
await _backupService.selectedAlbumsQuery().findAll();
// Generate AssetPathEntity from id to add to local state
final Set<AvailableAlbum> selectedAlbums = {};
for (final BackupAlbum ba in selectedBackupAlbums) {
final albumAsset = albumMap[ba.id];
if (albumAsset != null) {
selectedAlbums.add(
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
AvailableAlbum(
album: albumAsset,
assetCount:
await _albumMediaRepository.getAssetCount(albumAsset.localId!),
lastBackup: ba.lastBackup,
),
);
} else {
log.severe('Selected album not found');
@ -268,7 +283,13 @@ class BackupNotifier extends StateNotifier<BackUpState> {
if (albumAsset != null) {
excludedAlbums.add(
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
AvailableAlbum(
album: albumAsset,
assetCount: await ref
.read(albumMediaRepositoryProvider)
.getAssetCount(albumAsset.localId!),
lastBackup: ba.lastBackup,
),
);
} else {
log.severe('Excluded album not found');
@ -297,23 +318,24 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final Set<BackupCandidate> assetsFromExcludedAlbums = {};
for (final album in state.selectedBackupAlbums) {
final assetCount = await album.albumEntity.assetCountAsync;
final assetCount = await ref
.read(albumMediaRepositoryProvider)
.getAssetCount(album.album.localId!);
if (assetCount == 0) {
continue;
}
final assets = await album.albumEntity.getAssetListRange(
start: 0,
end: assetCount,
);
final assets = await ref
.read(albumMediaRepositoryProvider)
.getAssets(album.album.localId!);
// Add album's name to the asset info
for (final asset in assets) {
List<String> albumNames = [album.name];
final existingAsset = assetsFromSelectedAlbums.firstWhereOrNull(
(a) => a.asset.id == asset.id,
(a) => a.asset.localId == asset.localId,
);
if (existingAsset != null) {
@ -331,16 +353,17 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
for (final album in state.excludedBackupAlbums) {
final assetCount = await album.albumEntity.assetCountAsync;
final assetCount = await ref
.read(albumMediaRepositoryProvider)
.getAssetCount(album.album.localId!);
if (assetCount == 0) {
continue;
}
final assets = await album.albumEntity.getAssetListRange(
start: 0,
end: assetCount,
);
final assets = await ref
.read(albumMediaRepositoryProvider)
.getAssets(album.album.localId!);
for (final asset in assets) {
assetsFromExcludedAlbums.add(
@ -360,14 +383,14 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Find asset that were backup from selected albums
final Set<String> selectedAlbumsBackupAssets =
Set.from(allUniqueAssets.map((e) => e.asset.id));
Set.from(allUniqueAssets.map((e) => e.asset.localId));
selectedAlbumsBackupAssets
.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
// Remove duplicated asset from all unique assets
allUniqueAssets.removeWhere(
(candidate) => duplicatedAssetIds.contains(candidate.asset.id),
(candidate) => duplicatedAssetIds.contains(candidate.asset.localId),
);
if (allUniqueAssets.isEmpty) {
@ -454,7 +477,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final hasPermission = _galleryPermissionNotifier.hasPermission;
if (hasPermission) {
await PhotoManager.clearFileCache();
await _fileMediaRepository.clearFileCache();
if (state.allUniqueAssets.isEmpty) {
log.info("No Asset On Device - Abort Backup Process");
@ -465,7 +488,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
Set<BackupCandidate> assetsWillBeBackup = Set.from(state.allUniqueAssets);
// Remove item that has already been backed up
for (final assetId in state.allAssetsInDatabase) {
assetsWillBeBackup.removeWhere((e) => e.asset.id == assetId);
assetsWillBeBackup.removeWhere((e) => e.asset.localId == assetId);
}
if (assetsWillBeBackup.isEmpty) {
@ -531,7 +554,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
state = state.copyWith(
allUniqueAssets: state.allUniqueAssets
.where(
(candidate) => candidate.asset.id != result.candidate.asset.id,
(candidate) =>
candidate.asset.localId != result.candidate.asset.localId,
)
.toSet(),
);
@ -539,11 +563,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
state = state.copyWith(
selectedAlbumsBackupAssetsIds: {
...state.selectedAlbumsBackupAssetsIds,
result.candidate.asset.id,
result.candidate.asset.localId!,
},
allAssetsInDatabase: [
...state.allAssetsInDatabase,
result.candidate.asset.id,
result.candidate.asset.localId!,
],
);
}
@ -552,7 +576,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
state.selectedAlbumsBackupAssetsIds.length ==
0) {
final latestAssetBackup = state.allUniqueAssets
.map((candidate) => candidate.asset.modifiedDateTime)
.map((candidate) => candidate.asset.fileModifiedAt)
.reduce(
(v, e) => e.isAfter(v) ? e : v,
);
@ -741,6 +765,8 @@ final backupProvider =
ref.watch(backgroundServiceProvider),
ref.watch(galleryPermissionNotifier.notifier),
ref.watch(dbProvider),
ref.watch(albumMediaRepositoryProvider),
ref.watch(fileMediaRepositoryProvider),
ref,
);
});

View File

@ -8,6 +8,7 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
@ -27,7 +28,7 @@ import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
final manualUploadProvider =
StateNotifierProvider<ManualUploadNotifier, ManualUploadState>((ref) {
@ -193,17 +194,10 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
_backupProvider.updateBackupProgress(BackUpProgressEnum.manualInProgress);
if (ref.read(galleryPermissionNotifier.notifier).hasPermission) {
await PhotoManager.clearFileCache();
await ref.read(fileMediaRepositoryProvider).clearFileCache();
// We do not have 1:1 mapping of all AssetEntity fields to Asset. This results in cases
// where platform specific fields such as `subtype` used to detect platform specific assets such as
// LivePhoto in iOS is lost when we directly fetch the local asset from Asset using Asset.local
List<AssetEntity?> allAssetsFromDevice = await Future.wait(
allManualUploads
// Filter local only assets
.where((e) => e.isLocal && !e.isRemote)
.map((e) => e.local!.obtainForNewProperties()),
);
final allAssetsFromDevice =
allManualUploads.where((e) => e.isLocal && !e.isRemote).toList();
if (allAssetsFromDevice.length != allManualUploads.length) {
_log.warning(
@ -221,11 +215,17 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
await _backupService.buildUploadCandidates(
selectedBackupAlbums,
excludedBackupAlbums,
useTimeFilter: false,
);
// Extrack candidate from allAssetsFromDevice.nonNulls
final uploadAssets = candidates
.where((e) => allAssetsFromDevice.nonNulls.contains(e.asset));
// Extrack candidate from allAssetsFromDevice
final uploadAssets = candidates.where(
(candidate) =>
allAssetsFromDevice.firstWhereOrNull(
(asset) => asset.localId == candidate.asset.localId,
) !=
null,
);
if (uploadAssets.isEmpty) {
debugPrint("[_startUpload] No Assets to upload - Abort Process");

View File

@ -9,7 +9,7 @@ import 'package:flutter/painting.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:photo_manager/photo_manager.dart' show ThumbnailSize;
/// The local image provider for an asset
class ImmichLocalImageProvider extends ImageProvider<ImmichLocalImageProvider> {

View File

@ -6,7 +6,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:photo_manager/photo_manager.dart' show ThumbnailSize;
/// The local image provider for an asset
/// Only viable

View File

@ -0,0 +1,93 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:photo_manager/photo_manager.dart' hide AssetType;
final albumMediaRepositoryProvider = Provider((ref) => AlbumMediaRepository());
class AlbumMediaRepository implements IAlbumMediaRepository {
@override
Future<List<Album>> getAll() async {
final List<AssetPathEntity> assetPathEntities =
await PhotoManager.getAssetPathList(
hasAll: true,
filterOption: FilterOptionGroup(containsPathModified: true),
);
return assetPathEntities.map(_toAlbum).toList();
}
@override
Future<List<String>> getAssetIds(String albumId) async {
final album = await AssetPathEntity.fromId(albumId);
final List<AssetEntity> assets =
await album.getAssetListRange(start: 0, end: 0x7fffffffffffffff);
return assets.map((e) => e.id).toList();
}
@override
Future<int> getAssetCount(String albumId) async {
final album = await AssetPathEntity.fromId(albumId);
return album.assetCountAsync;
}
@override
Future<List<Asset>> getAssets(
String albumId, {
int start = 0,
int end = 0x7fffffffffffffff,
DateTime? modifiedFrom,
DateTime? modifiedUntil,
bool orderByModificationDate = false,
}) async {
final onDevice = await AssetPathEntity.fromId(
albumId,
filterOption: FilterOptionGroup(
containsPathModified: true,
orders: orderByModificationDate
? [const OrderOption(type: OrderOptionType.updateDate)]
: [],
imageOption: const FilterOption(needTitle: true),
videoOption: const FilterOption(needTitle: true),
updateTimeCond: modifiedFrom == null && modifiedUntil == null
? null
: DateTimeCond(
min: modifiedFrom ?? DateTime.utc(-271820),
max: modifiedUntil ?? DateTime.utc(275760),
),
),
);
final List<AssetEntity> assets =
await onDevice.getAssetListRange(start: start, end: end);
return assets.map(AssetMediaRepository.toAsset).toList().cast();
}
@override
Future<Album> get(
String id, {
DateTime? modifiedFrom,
DateTime? modifiedUntil,
}) async {
final assetPathEntity = await AssetPathEntity.fromId(id);
return _toAlbum(assetPathEntity);
}
static Album _toAlbum(AssetPathEntity assetPathEntity) {
final Album album = Album(
name: assetPathEntity.name,
createdAt:
assetPathEntity.lastModified?.toUtc() ?? DateTime.now().toUtc(),
modifiedAt:
assetPathEntity.lastModified?.toUtc() ?? DateTime.now().toUtc(),
shared: false,
activityEnabled: false,
);
album.owner.value = Store.get(StoreKey.currentUser);
album.localId = assetPathEntity.id;
album.isAll = assetPathEntity.isAll;
return album;
}
}

View File

@ -0,0 +1,46 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/interfaces/asset_media.interface.dart';
import 'package:photo_manager/photo_manager.dart' hide AssetType;
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository());
class AssetMediaRepository implements IAssetMediaRepository {
@override
Future<List<String>> deleteAll(List<String> ids) =>
PhotoManager.editor.deleteWithIds(ids);
@override
Future<Asset?> get(String id) async {
final entity = await AssetEntity.fromId(id);
return toAsset(entity);
}
static Asset? toAsset(AssetEntity? local) {
if (local == null) return null;
final Asset asset = Asset(
checksum: "",
localId: local.id,
ownerId: Store.get(StoreKey.currentUser).isarId,
fileCreatedAt: local.createDateTime,
fileModifiedAt: local.modifiedDateTime,
updatedAt: local.modifiedDateTime,
durationInSeconds: local.duration,
type: AssetType.values[local.typeInt],
fileName: local.title!,
width: local.width,
height: local.height,
isFavorite: local.isFavorite,
);
if (asset.fileCreatedAt.year == 1970) {
asset.fileCreatedAt = asset.fileModifiedAt;
}
if (local.latitude != null) {
asset.exifInfo = ExifInfo(lat: local.latitude, long: local.longitude);
}
asset.local = local;
return asset;
}
}

View File

@ -0,0 +1,62 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:photo_manager/photo_manager.dart' hide AssetType;
final fileMediaRepositoryProvider = Provider((ref) => FileMediaRepository());
class FileMediaRepository implements IFileMediaRepository {
@override
Future<Asset?> saveImage(
Uint8List data, {
required String title,
String? relativePath,
}) async {
final entity = await PhotoManager.editor
.saveImage(data, title: title, relativePath: relativePath);
return AssetMediaRepository.toAsset(entity);
}
@override
Future<Asset?> saveLivePhoto({
required File image,
required File video,
required String title,
}) async {
final entity = await PhotoManager.editor.darwin.saveLivePhoto(
imageFile: image,
videoFile: video,
title: title,
);
return AssetMediaRepository.toAsset(entity);
}
@override
Future<Asset?> saveVideo(
File file, {
required String title,
String? relativePath,
}) async {
final entity = await PhotoManager.editor.saveVideo(
file,
title: title,
relativePath: relativePath,
);
return AssetMediaRepository.toAsset(entity);
}
@override
Future<void> clearFileCache() => PhotoManager.clearFileCache();
@override
Future<void> enableBackgroundAccess() =>
PhotoManager.setIgnorePermissionCheck(true);
@override
Future<void> requestExtendedPermissions() =>
PhotoManager.requestPermissionExtend();
}

View File

@ -63,7 +63,6 @@ import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:isar/isar.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:photo_manager/photo_manager.dart' hide LatLng;
part 'router.gr.dart';

View File

@ -185,7 +185,7 @@ class AlbumOptionsRouteArgs {
class AlbumPreviewRoute extends PageRouteInfo<AlbumPreviewRouteArgs> {
AlbumPreviewRoute({
Key? key,
required AssetPathEntity album,
required Album album,
List<PageRouteInfo>? children,
}) : super(
AlbumPreviewRoute.name,
@ -218,7 +218,7 @@ class AlbumPreviewRouteArgs {
final Key? key;
final AssetPathEntity album;
final Album album;
@override
String toString() {

View File

@ -6,6 +6,7 @@ import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/interfaces/album.interface.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/backup.interface.dart';
import 'package:immich_mobile/interfaces/user.interface.dart';
@ -19,13 +20,13 @@ import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/album.repository.dart';
import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/backup.repository.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/user.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/sync.service.dart';
import 'package:immich_mobile/services/user.service.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
final albumServiceProvider = Provider(
(ref) => AlbumService(
@ -36,6 +37,7 @@ final albumServiceProvider = Provider(
ref.watch(assetRepositoryProvider),
ref.watch(userRepositoryProvider),
ref.watch(backupRepositoryProvider),
ref.watch(albumMediaRepositoryProvider),
),
);
@ -47,6 +49,7 @@ class AlbumService {
final IAssetRepository _assetRepository;
final IUserRepository _userRepository;
final IBackupRepository _backupAlbumRepository;
final IAlbumMediaRepository _albumMediaRepository;
final Logger _log = Logger('AlbumService');
Completer<bool> _localCompleter = Completer()..complete(false);
Completer<bool> _remoteCompleter = Completer()..complete(false);
@ -59,6 +62,7 @@ class AlbumService {
this._assetRepository,
this._userRepository,
this._backupAlbumRepository,
this._albumMediaRepository,
);
/// Checks all selected device albums for changes of albums and their assets
@ -84,11 +88,7 @@ class AlbumService {
}
return false;
}
final List<AssetPathEntity> onDevice =
await PhotoManager.getAssetPathList(
hasAll: true,
filterOption: FilterOptionGroup(containsPathModified: true),
);
final List<Album> onDevice = await _albumMediaRepository.getAll();
_log.info("Found ${onDevice.length} device albums");
Set<String>? excludedAssets;
if (excludedIds.isNotEmpty) {
@ -104,13 +104,15 @@ class AlbumService {
_log.info("Found ${excludedAssets.length} assets to exclude");
}
// remove all excluded albums
onDevice.removeWhere((e) => excludedIds.contains(e.id));
onDevice.removeWhere((e) => excludedIds.contains(e.localId));
_log.info(
"Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums",
);
}
final hasAll = selectedIds
.map((id) => onDevice.firstWhereOrNull((a) => a.id == id))
.map(
(id) => onDevice.firstWhereOrNull((album) => album.localId == id),
)
.whereNotNull()
.any((a) => a.isAll);
if (hasAll) {
@ -122,7 +124,7 @@ class AlbumService {
}
} else {
// keep only the explicitly selected albums
onDevice.removeWhere((e) => !selectedIds.contains(e.id));
onDevice.removeWhere((e) => !selectedIds.contains(e.localId));
_log.info("'Recents' is not selected, keeping only selected albums");
}
changes =
@ -136,15 +138,15 @@ class AlbumService {
}
Future<Set<String>> _loadExcludedAssetIds(
List<AssetPathEntity> albums,
List<Album> albums,
List<String> excludedAlbumIds,
) async {
final Set<String> result = HashSet<String>();
for (AssetPathEntity a in albums) {
if (excludedAlbumIds.contains(a.id)) {
final List<AssetEntity> assets =
await a.getAssetListRange(start: 0, end: 0x7fffffffffffffff);
result.addAll(assets.map((e) => e.id));
for (Album album in albums) {
if (excludedAlbumIds.contains(album.localId)) {
final assetIds =
await _albumMediaRepository.getAssetIds(album.localId!);
result.addAll(assetIds);
}
}
return result;

View File

@ -321,7 +321,7 @@ class AssetService {
for (BackupCandidate candidate in candidates) {
final asset = remoteAssets.firstWhereOrNull(
(a) => a.localId == candidate.asset.id,
(a) => a.localId == candidate.asset.localId,
);
if (asset != null) {

View File

@ -15,6 +15,8 @@ import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
import 'package:immich_mobile/repositories/album.repository.dart';
import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/backup.repository.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/repositories/user.repository.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/hash.service.dart';
@ -34,7 +36,7 @@ import 'package:immich_mobile/utils/diff.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:isar/isar.dart';
import 'package:path_provider_ios/path_provider_ios.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
final backgroundServiceProvider = Provider(
(ref) => BackgroundService(),
@ -363,8 +365,10 @@ class BackgroundService {
AssetRepository assetRepository = AssetRepository(db);
UserRepository userRepository = UserRepository(db);
BackupRepository backupAlbumRepository = BackupRepository(db);
HashService hashService = HashService(db, this);
SyncService syncSerive = SyncService(db, hashService);
AlbumMediaRepository albumMediaRepository = AlbumMediaRepository();
FileMediaRepository fileMediaRepository = FileMediaRepository();
HashService hashService = HashService(db, this, albumMediaRepository);
SyncService syncSerive = SyncService(db, hashService, albumMediaRepository);
UserService userService =
UserService(apiService, db, syncSerive, partnerService);
AlbumService albumService = AlbumService(
@ -375,9 +379,16 @@ class BackgroundService {
assetRepository,
userRepository,
backupAlbumRepository,
albumMediaRepository,
);
BackupService backupService = BackupService(
apiService,
db,
settingService,
albumService,
albumMediaRepository,
fileMediaRepository,
);
BackupService backupService =
BackupService(apiService, db, settingService, albumService);
final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync();
final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync();
@ -385,7 +396,7 @@ class BackgroundService {
return true;
}
await PhotoManager.setIgnorePermissionCheck(true);
await fileMediaRepository.enableBackgroundAccess();
do {
final bool backupOk = await _runBackup(

View File

@ -6,9 +6,13 @@ import 'package:cancellation_token_http/http.dart' as http;
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
@ -16,6 +20,8 @@ import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
@ -24,7 +30,7 @@ import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:path/path.dart' as p;
import 'package:permission_handler/permission_handler.dart' as pm;
import 'package:photo_manager/photo_manager.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
final backupServiceProvider = Provider(
(ref) => BackupService(
@ -32,6 +38,8 @@ final backupServiceProvider = Provider(
ref.watch(dbProvider),
ref.watch(appSettingsServiceProvider),
ref.watch(albumServiceProvider),
ref.watch(albumMediaRepositoryProvider),
ref.watch(fileMediaRepositoryProvider),
),
);
@ -42,12 +50,16 @@ class BackupService {
final Logger _log = Logger("BackupService");
final AppSettingsService _appSetting;
final AlbumService _albumService;
final IAlbumMediaRepository _albumMediaRepository;
final IFileMediaRepository _fileMediaRepository;
BackupService(
this._apiService,
this._db,
this._appSetting,
this._albumService,
this._albumMediaRepository,
this._fileMediaRepository,
);
Future<List<String>?> getDeviceBackupAsset() async {
@ -86,44 +98,17 @@ class BackupService {
List<BackupAlbum> excludedBackupAlbums, {
bool useTimeFilter = true,
}) async {
final filter = FilterOptionGroup(
containsPathModified: true,
orders: [const OrderOption(type: OrderOptionType.updateDate)],
// title is needed to create Assets
imageOption: const FilterOption(needTitle: true),
videoOption: const FilterOption(needTitle: true),
);
final now = DateTime.now();
final List<AssetPathEntity?> selectedAlbums =
await _loadAlbumsWithTimeFilter(
selectedBackupAlbums,
filter,
now,
useTimeFilter: useTimeFilter,
);
if (selectedAlbums.every((e) => e == null)) {
return {};
}
final List<AssetPathEntity?> excludedAlbums =
await _loadAlbumsWithTimeFilter(
excludedBackupAlbums,
filter,
now,
useTimeFilter: useTimeFilter,
);
final Set<BackupCandidate> toAdd = await _fetchAssetsAndUpdateLastBackup(
selectedAlbums,
selectedBackupAlbums,
now,
useTimeFilter: useTimeFilter,
);
if (toAdd.isEmpty) return {};
final Set<BackupCandidate> toRemove = await _fetchAssetsAndUpdateLastBackup(
excludedAlbums,
excludedBackupAlbums,
now,
useTimeFilter: useTimeFilter,
@ -132,92 +117,62 @@ class BackupService {
return toAdd.difference(toRemove);
}
Future<List<AssetPathEntity?>> _loadAlbumsWithTimeFilter(
List<BackupAlbum> albums,
FilterOptionGroup filter,
DateTime now, {
bool useTimeFilter = true,
}) async {
List<AssetPathEntity?> result = [];
for (BackupAlbum backupAlbum in albums) {
try {
final optionGroup = useTimeFilter
? filter.copyWith(
updateTimeCond: DateTimeCond(
// subtract 2 seconds to prevent missing assets due to rounding issues
min: backupAlbum.lastBackup
.subtract(const Duration(seconds: 2)),
max: now,
),
)
: filter;
final AssetPathEntity album =
await AssetPathEntity.obtainPathFromProperties(
id: backupAlbum.id,
optionGroup: optionGroup,
maxDateTimeToNow: false,
);
result.add(album);
} on StateError {
// either there are no assets matching the filter criteria OR the album no longer exists
}
}
return result;
}
Future<Set<BackupCandidate>> _fetchAssetsAndUpdateLastBackup(
List<AssetPathEntity?> localAlbums,
List<BackupAlbum> backupAlbums,
DateTime now, {
bool useTimeFilter = true,
}) async {
Set<BackupCandidate> candidate = {};
Set<BackupCandidate> candidates = {};
for (int i = 0; i < localAlbums.length; i++) {
final localAlbum = localAlbums[i];
if (localAlbum == null) {
for (final BackupAlbum backupAlbum in backupAlbums) {
final Album localAlbum;
try {
localAlbum = await _albumMediaRepository.get(backupAlbum.id);
} on StateError {
// the album no longer exists
continue;
}
if (useTimeFilter &&
localAlbum.lastModified?.isBefore(backupAlbums[i].lastBackup) ==
true) {
localAlbum.modifiedAt.isBefore(backupAlbum.lastBackup)) {
continue;
}
final List<Asset> assets;
try {
assets = await _albumMediaRepository.getAssets(
backupAlbum.id,
modifiedFrom: useTimeFilter
?
// subtract 2 seconds to prevent missing assets due to rounding issues
backupAlbum.lastBackup.subtract(const Duration(seconds: 2))
: null,
modifiedUntil: useTimeFilter ? now : null,
);
} on StateError {
// either there are no assets matching the filter criteria OR the album no longer exists
continue;
}
final assets = await localAlbum.getAssetListRange(
start: 0,
end: await localAlbum.assetCountAsync,
);
// Add album's name to the asset info
for (final asset in assets) {
List<String> albumNames = [localAlbum.name];
final existingAsset = candidate.firstWhereOrNull(
(a) => a.asset.id == asset.id,
final existingAsset = candidates.firstWhereOrNull(
(candidate) => candidate.asset.localId == asset.localId,
);
if (existingAsset != null) {
albumNames.addAll(existingAsset.albumNames);
candidate.remove(existingAsset);
candidates.remove(existingAsset);
}
candidate.add(
BackupCandidate(
asset: asset,
albumNames: albumNames,
),
);
candidates.add(BackupCandidate(asset: asset, albumNames: albumNames));
}
backupAlbums[i].lastBackup = now;
backupAlbum.lastBackup = now;
}
return candidate;
return candidates;
}
/// Returns a new list of assets not yet uploaded
@ -230,7 +185,7 @@ class BackupService {
final Set<String> duplicatedAssetIds = await getDuplicatedAssetIds();
candidates.removeWhere(
(candidate) => duplicatedAssetIds.contains(candidate.asset.id),
(candidate) => duplicatedAssetIds.contains(candidate.asset.localId),
);
if (candidates.isEmpty) {
@ -243,7 +198,7 @@ class BackupService {
final CheckExistingAssetsResponseDto? duplicates =
await _apiService.assetsApi.checkExistingAssets(
CheckExistingAssetsDto(
deviceAssetIds: candidates.map((c) => c.asset.id).toList(),
deviceAssetIds: candidates.map((c) => c.asset.localId!).toList(),
deviceId: deviceId,
),
);
@ -259,7 +214,7 @@ class BackupService {
}
if (existing.isNotEmpty) {
candidates.removeWhere((c) => existing.contains(c.asset.id));
candidates.removeWhere((c) => existing.contains(c.asset.localId));
}
return candidates;
@ -278,7 +233,7 @@ class BackupService {
// DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS
if (Platform.isIOS) {
await PhotoManager.requestPermissionExtend();
await _fileMediaRepository.requestExtendedPermissions();
}
return true;
@ -289,9 +244,9 @@ class BackupService {
List<BackupCandidate> _sortPhotosFirst(List<BackupCandidate> candidates) {
return candidates.sorted(
(a, b) {
final cmp = a.asset.typeInt - b.asset.typeInt;
final cmp = a.asset.type.index - b.asset.type.index;
if (cmp != 0) return cmp;
return a.asset.createDateTime.compareTo(b.asset.createDateTime);
return a.asset.fileCreatedAt.compareTo(b.asset.fileCreatedAt);
},
);
}
@ -325,13 +280,13 @@ class BackupService {
}
for (final candidate in candidates) {
final AssetEntity entity = candidate.asset;
final Asset asset = candidate.asset;
File? file;
File? livePhotoFile;
try {
final isAvailableLocally =
await entity.isLocallyAvailable(isOrigin: true);
await asset.local!.isLocallyAvailable(isOrigin: true);
// Handle getting files from iCloud
if (!isAvailableLocally && Platform.isIOS) {
@ -342,39 +297,41 @@ class BackupService {
onCurrentAsset(
CurrentUploadAsset(
id: entity.id,
fileCreatedAt: entity.createDateTime.year == 1970
? entity.modifiedDateTime
: entity.createDateTime,
fileName: await entity.titleAsync,
fileType: _getAssetType(entity.type),
id: asset.localId!,
fileCreatedAt: asset.fileCreatedAt.year == 1970
? asset.fileModifiedAt
: asset.fileCreatedAt,
fileName: asset.fileName,
fileType: _getAssetType(asset.type),
iCloudAsset: true,
),
);
file = await entity.loadFile(progressHandler: pmProgressHandler);
if (entity.isLivePhoto) {
livePhotoFile = await entity.loadFile(
file =
await asset.local!.loadFile(progressHandler: pmProgressHandler);
if (asset.local!.isLivePhoto) {
livePhotoFile = await asset.local!.loadFile(
withSubtype: true,
progressHandler: pmProgressHandler,
);
}
} else {
if (entity.type == AssetType.video) {
file = await entity.originFile;
if (asset.type == AssetType.video) {
file = await asset.local!.originFile;
} else {
file = await entity.originFile.timeout(const Duration(seconds: 5));
if (entity.isLivePhoto) {
livePhotoFile = await entity.originFileWithSubtype
file = await asset.local!.originFile
.timeout(const Duration(seconds: 5));
if (asset.local!.isLivePhoto) {
livePhotoFile = await asset.local!.originFileWithSubtype
.timeout(const Duration(seconds: 5));
}
}
}
if (file != null) {
String originalFileName = await entity.titleAsync;
String originalFileName = asset.fileName;
if (entity.isLivePhoto) {
if (asset.local!.isLivePhoto) {
if (livePhotoFile == null) {
_log.warning(
"Failed to obtain motion part of the livePhoto - $originalFileName",
@ -398,31 +355,31 @@ class BackupService {
baseRequest.headers.addAll(ApiService.getRequestHeaders());
baseRequest.headers["Transfer-Encoding"] = "chunked";
baseRequest.fields['deviceAssetId'] = entity.id;
baseRequest.fields['deviceAssetId'] = asset.localId!;
baseRequest.fields['deviceId'] = deviceId;
baseRequest.fields['fileCreatedAt'] =
entity.createDateTime.toUtc().toIso8601String();
asset.fileCreatedAt.toUtc().toIso8601String();
baseRequest.fields['fileModifiedAt'] =
entity.modifiedDateTime.toUtc().toIso8601String();
baseRequest.fields['isFavorite'] = entity.isFavorite.toString();
baseRequest.fields['duration'] = entity.videoDuration.toString();
asset.fileModifiedAt.toUtc().toIso8601String();
baseRequest.fields['isFavorite'] = asset.isFavorite.toString();
baseRequest.fields['duration'] = asset.duration.toString();
baseRequest.files.add(assetRawUploadData);
onCurrentAsset(
CurrentUploadAsset(
id: entity.id,
fileCreatedAt: entity.createDateTime.year == 1970
? entity.modifiedDateTime
: entity.createDateTime,
id: asset.localId!,
fileCreatedAt: asset.fileCreatedAt.year == 1970
? asset.fileModifiedAt
: asset.fileCreatedAt,
fileName: originalFileName,
fileType: _getAssetType(entity.type),
fileType: _getAssetType(asset.type),
fileSize: file.lengthSync(),
iCloudAsset: false,
),
);
String? livePhotoVideoId;
if (entity.isLivePhoto && livePhotoFile != null) {
if (asset.local!.isLivePhoto && livePhotoFile != null) {
livePhotoVideoId = await uploadLivePhotoVideo(
originalFileName,
livePhotoFile,
@ -448,16 +405,16 @@ class BackupService {
final errorMessage = error['message'] ?? error['error'];
debugPrint(
"Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}",
"Error(${error['statusCode']}) uploading ${asset.localId} | $originalFileName | Created on ${asset.fileCreatedAt} | ${error['error']}",
);
onError(
ErrorUploadAsset(
asset: entity,
id: entity.id,
fileCreatedAt: entity.createDateTime,
asset: asset,
id: asset.localId!,
fileCreatedAt: asset.fileCreatedAt,
fileName: originalFileName,
fileType: _getAssetType(entity.type),
fileType: _getAssetType(candidate.asset.type),
errorMessage: errorMessage,
),
);
@ -473,7 +430,7 @@ class BackupService {
bool isDuplicate = false;
if (response.statusCode == 200) {
isDuplicate = true;
duplicatedAssetIds.add(entity.id);
duplicatedAssetIds.add(asset.localId!);
}
onSuccess(

View File

@ -8,17 +8,19 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:photo_manager/photo_manager.dart' show PhotoManager;
/// Finds duplicates originating from missing EXIF information
class BackupVerificationService {
final Isar _db;
final IFileMediaRepository _fileMediaRepository;
BackupVerificationService(this._db);
BackupVerificationService(this._db, this._fileMediaRepository);
/// Returns at most [limit] assets that were backed up without exif
Future<List<Asset>> findWronglyBackedUpAssets({int limit = 100}) async {
@ -71,6 +73,7 @@ class BackupVerificationService {
auth: Store.get(StoreKey.accessToken),
endpoint: Store.get(StoreKey.serverEndpoint),
rootIsolateToken: isolateToken,
fileMediaRepository: _fileMediaRepository,
),
);
final upper = compute(
@ -81,6 +84,7 @@ class BackupVerificationService {
auth: Store.get(StoreKey.accessToken),
endpoint: Store.get(StoreKey.serverEndpoint),
rootIsolateToken: isolateToken,
fileMediaRepository: _fileMediaRepository,
),
);
toDelete = await lower + await upper;
@ -93,6 +97,7 @@ class BackupVerificationService {
auth: Store.get(StoreKey.accessToken),
endpoint: Store.get(StoreKey.serverEndpoint),
rootIsolateToken: isolateToken,
fileMediaRepository: _fileMediaRepository,
),
);
}
@ -106,12 +111,13 @@ class BackupVerificationService {
String auth,
String endpoint,
RootIsolateToken rootIsolateToken,
IFileMediaRepository fileMediaRepository,
}) tuple,
) async {
assert(tuple.deleteCandidates.length == tuple.originals.length);
final List<Asset> result = [];
BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken);
await PhotoManager.setIgnorePermissionCheck(true);
await tuple.fileMediaRepository.enableBackgroundAccess();
final ApiService apiService = ApiService();
apiService.setEndpoint(tuple.endpoint);
apiService.setAccessToken(tuple.auth);
@ -228,5 +234,6 @@ class BackupVerificationService {
final backupVerificationServiceProvider = Provider(
(ref) => BackupVerificationService(
ref.watch(dbProvider),
ref.watch(fileMediaRepositoryProvider),
),
);

View File

@ -2,6 +2,9 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
@ -11,38 +14,46 @@ import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
class HashService {
HashService(this._db, this._backgroundService);
HashService(this._db, this._backgroundService, this._albumMediaRepository);
final Isar _db;
final BackgroundService _backgroundService;
final IAlbumMediaRepository _albumMediaRepository;
final _log = Logger('HashService');
/// Returns all assets that were successfully hashed
Future<List<Asset>> getHashedAssets(
AssetPathEntity album, {
Album album, {
int start = 0,
int end = 0x7fffffffffffffff,
DateTime? modifiedFrom,
DateTime? modifiedUntil,
Set<String>? excludedAssets,
}) async {
final entities = await album.getAssetListRange(start: start, end: end);
final entities = await _albumMediaRepository.getAssets(
album.localId!,
start: start,
end: end,
modifiedFrom: modifiedFrom,
modifiedUntil: modifiedUntil,
);
final filtered = excludedAssets == null
? entities
: entities.where((e) => !excludedAssets.contains(e.id)).toList();
: entities.where((e) => !excludedAssets.contains(e.localId!)).toList();
return _hashAssets(filtered);
}
/// Converts a list of [AssetEntity]s to [Asset]s including only those
/// Processes a list of local [Asset]s, storing their hash and returning only those
/// that were successfully hashed. Hashes are looked up in a DB table
/// [AndroidDeviceAsset] / [IOSDeviceAsset] by local id. Only missing
/// entries are newly hashed and added to the DB table.
Future<List<Asset>> _hashAssets(List<AssetEntity> assetEntities) async {
Future<List<Asset>> _hashAssets(List<Asset> assets) async {
const int batchFileCount = 128;
const int batchDataSize = 1024 * 1024 * 1024; // 1GB
final ids = assetEntities
.map(Platform.isAndroid ? (a) => a.id.toInt() : (a) => a.id)
final ids = assets
.map(Platform.isAndroid ? (a) => a.localId!.toInt() : (a) => a.localId!)
.toList();
final List<DeviceAsset?> hashes = await _lookupHashes(ids);
final List<DeviceAsset> toAdd = [];
@ -50,22 +61,16 @@ class HashService {
int bytes = 0;
for (int i = 0; i < assetEntities.length; i++) {
for (int i = 0; i < assets.length; i++) {
if (hashes[i] != null) {
continue;
}
final file = await assetEntities[i].originFile;
final file = await assets[i].local!.originFile;
if (file == null) {
final fileName = await assetEntities[i].titleAsync.catchError((error) {
_log.warning(
"Failed to get title for asset ${assetEntities[i].id}",
);
return "";
});
final fileName = assets[i].fileName;
_log.warning(
"Failed to get file for asset ${assetEntities[i].id}, name: $fileName, created on: ${assetEntities[i].createDateTime}, skipping",
"Failed to get file for asset ${assets[i].localId}, name: $fileName, created on: ${assets[i].fileCreatedAt}, skipping",
);
continue;
}
@ -86,7 +91,7 @@ class HashService {
if (toHash.isNotEmpty) {
await _processBatch(toHash, toAdd);
}
return _mapAllHashedAssets(assetEntities, hashes);
return _getHashedAssets(assets, hashes);
}
/// Lookup hashes of assets by their local ID
@ -133,15 +138,16 @@ class HashService {
return hashes;
}
/// Converts [AssetEntity]s that were successfully hashed to [Asset]s
List<Asset> _mapAllHashedAssets(
List<AssetEntity> assets,
/// Returns all successfully hashed [Asset]s with their hash value set
List<Asset> _getHashedAssets(
List<Asset> assets,
List<DeviceAsset?> hashes,
) {
final List<Asset> result = [];
for (int i = 0; i < assets.length; i++) {
if (hashes[i] != null && hashes[i]!.hash.isNotEmpty) {
result.add(Asset.local(assets[i], hashes[i]!.hash));
assets[i].byteHash = hashes[i]!.hash;
result.add(assets[i]);
}
}
return result;
@ -152,5 +158,6 @@ final hashServiceProvider = Provider(
(ref) => HashService(
ref.watch(dbProvider),
ref.watch(backgroundServiceProvider),
ref.watch(albumMediaRepositoryProvider),
),
);

View File

@ -3,21 +3,27 @@ import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:path_provider/path_provider.dart';
final imageViewerServiceProvider =
Provider((ref) => ImageViewerService(ref.watch(apiServiceProvider)));
final imageViewerServiceProvider = Provider(
(ref) => ImageViewerService(
ref.watch(apiServiceProvider),
ref.watch(fileMediaRepositoryProvider),
),
);
class ImageViewerService {
final ApiService _apiService;
final IFileMediaRepository _fileMediaRepository;
final Logger _log = Logger("ImageViewerService");
ImageViewerService(this._apiService);
ImageViewerService(this._apiService, this._fileMediaRepository);
Future<bool> downloadAsset(Asset asset) async {
File? imageFile;
@ -46,7 +52,7 @@ class ImageViewerService {
return false;
}
AssetEntity? entity;
Asset? resultAsset;
final tempDir = await getTemporaryDirectory();
videoFile = await File('${tempDir.path}/livephoto.mov').create();
@ -54,24 +60,21 @@ class ImageViewerService {
videoFile.writeAsBytesSync(motionResponse.bodyBytes);
imageFile.writeAsBytesSync(imageResponse.bodyBytes);
entity = await PhotoManager.editor.darwin.saveLivePhoto(
imageFile: imageFile,
videoFile: videoFile,
resultAsset = await _fileMediaRepository.saveLivePhoto(
image: imageFile,
video: videoFile,
title: asset.fileName,
);
if (entity == null) {
if (resultAsset == null) {
_log.warning(
"Asset cannot be saved as a live photo. This is most likely a motion photo. Saving only the image file",
);
entity = await PhotoManager.editor.saveImage(
imageResponse.bodyBytes,
title: asset.fileName,
);
resultAsset = await _fileMediaRepository
.saveImage(imageResponse.bodyBytes, title: asset.fileName);
}
return entity != null;
return resultAsset != null;
} else {
var res = await _apiService.assetsApi
.downloadAssetWithHttpInfo(asset.remoteId!);
@ -81,11 +84,11 @@ class ImageViewerService {
return false;
}
final AssetEntity? entity;
final Asset? resultAsset;
final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null;
if (asset.isImage) {
entity = await PhotoManager.editor.saveImage(
resultAsset = await _fileMediaRepository.saveImage(
res.bodyBytes,
title: asset.fileName,
relativePath: relativePath,
@ -94,13 +97,13 @@ class ImageViewerService {
final tempDir = await getTemporaryDirectory();
videoFile = await File('${tempDir.path}/${asset.fileName}').create();
videoFile.writeAsBytesSync(res.bodyBytes);
entity = await PhotoManager.editor.saveVideo(
resultAsset = await _fileMediaRepository.saveVideo(
videoFile,
title: asset.fileName,
relativePath: relativePath,
);
}
return entity != null;
return resultAsset != null;
}
} catch (error, stack) {
_log.severe("Error saving downloaded asset", error, stack);

View File

@ -8,7 +8,9 @@ import 'package:immich_mobile/entities/etag.entity.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/services/hash.service.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart';
@ -17,19 +19,23 @@ import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
final syncServiceProvider = Provider(
(ref) => SyncService(ref.watch(dbProvider), ref.watch(hashServiceProvider)),
(ref) => SyncService(
ref.watch(dbProvider),
ref.watch(hashServiceProvider),
ref.watch(albumMediaRepositoryProvider),
),
);
class SyncService {
final Isar _db;
final HashService _hashService;
final IAlbumMediaRepository _albumMediaRepository;
final AsyncMutex _lock = AsyncMutex();
final Logger _log = Logger('SyncService');
SyncService(this._db, this._hashService);
SyncService(this._db, this._hashService, this._albumMediaRepository);
// public methods:
@ -68,7 +74,7 @@ class SyncService {
/// Syncs all device albums and their assets to the database
/// Returns `true` if there were any changes
Future<bool> syncLocalAlbumAssetsToDb(
List<AssetPathEntity> onDevice, [
List<Album> onDevice, [
Set<String>? excludedAssets,
]) =>
_lock.run(() => _syncLocalAlbumAssetsToDb(onDevice, excludedAssets));
@ -492,7 +498,7 @@ class SyncService {
/// Syncs all device albums and their assets to the database
/// Returns `true` if there were any changes
Future<bool> _syncLocalAlbumAssetsToDb(
List<AssetPathEntity> onDevice, [
List<Album> onDevice, [
Set<String>? excludedAssets,
]) async {
onDevice.sort((a, b) => a.id.compareTo(b.id));
@ -504,16 +510,15 @@ class SyncService {
final bool anyChanges = await diffSortedLists(
onDevice,
inDb,
compare: (AssetPathEntity a, Album b) => a.id.compareTo(b.localId!),
both: (AssetPathEntity ape, Album album) => _syncAlbumInDbAndOnDevice(
ape,
album,
compare: (Album a, Album b) => a.localId!.compareTo(b.localId!),
both: (Album a, Album b) => _syncAlbumInDbAndOnDevice(
a,
b,
deleteCandidates,
existing,
excludedAssets,
),
onlyFirst: (AssetPathEntity ape) =>
_addAlbumFromDevice(ape, existing, excludedAssets),
onlyFirst: (Album a) => _addAlbumFromDevice(a, existing, excludedAssets),
onlySecond: (Album a) => _removeAlbumFromDb(a, deleteCandidates),
);
_log.fine(
@ -541,58 +546,65 @@ class SyncService {
/// returns `true` if there were any changes
/// Accumulates asset candidates to delete and those already existing in DB
Future<bool> _syncAlbumInDbAndOnDevice(
AssetPathEntity ape,
Album album,
Album deviceAlbum,
Album dbAlbum,
List<Asset> deleteCandidates,
List<Asset> existing, [
Set<String>? excludedAssets,
bool forceRefresh = false,
]) async {
if (!forceRefresh && !await _hasAssetPathEntityChanged(ape, album)) {
_log.fine("Local album ${ape.name} has not changed. Skipping sync.");
if (!forceRefresh && !await _hasAlbumChangeOnDevice(deviceAlbum, dbAlbum)) {
_log.fine(
"Local album ${deviceAlbum.name} has not changed. Skipping sync.",
);
return false;
}
if (!forceRefresh &&
excludedAssets == null &&
await _syncDeviceAlbumFast(ape, album)) {
await _syncDeviceAlbumFast(deviceAlbum, dbAlbum)) {
return true;
}
// general case, e.g. some assets have been deleted or there are excluded albums on iOS
final inDb = await album.assets
final inDb = await dbAlbum.assets
.filter()
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
.sortByChecksum()
.findAll();
assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!");
final int assetCountOnDevice = await ape.assetCountAsync;
final List<Asset> onDevice =
await _hashService.getHashedAssets(ape, excludedAssets: excludedAssets);
final int assetCountOnDevice =
await _albumMediaRepository.getAssetCount(deviceAlbum.localId!);
final List<Asset> onDevice = await _hashService.getHashedAssets(
deviceAlbum,
excludedAssets: excludedAssets,
);
_removeDuplicates(onDevice);
// _removeDuplicates sorts `onDevice` by checksum
final (toAdd, toUpdate, toDelete) = _diffAssets(onDevice, inDb);
if (toAdd.isEmpty &&
toUpdate.isEmpty &&
toDelete.isEmpty &&
album.name == ape.name &&
ape.lastModified != null &&
album.modifiedAt.isAtSameMomentAs(ape.lastModified!)) {
dbAlbum.name == deviceAlbum.name &&
dbAlbum.modifiedAt.isAtSameMomentAs(deviceAlbum.modifiedAt)) {
// changes only affeted excluded albums
_log.fine(
"Only excluded assets in local album ${ape.name} changed. Stopping sync.",
"Only excluded assets in local album ${deviceAlbum.name} changed. Stopping sync.",
);
if (assetCountOnDevice !=
_db.eTags.getByIdSync(ape.eTagKeyAssetCount)?.assetCount) {
_db.eTags.getByIdSync(deviceAlbum.eTagKeyAssetCount)?.assetCount) {
await _db.writeTxn(
() => _db.eTags.put(
ETag(id: ape.eTagKeyAssetCount, assetCount: assetCountOnDevice),
ETag(
id: deviceAlbum.eTagKeyAssetCount,
assetCount: assetCountOnDevice,
),
),
);
}
return false;
}
_log.fine(
"Syncing local album ${ape.name}. ${toAdd.length} assets to add, ${toUpdate.length} to update, ${toDelete.length} to delete",
"Syncing local album ${deviceAlbum.name}. ${toAdd.length} assets to add, ${toUpdate.length} to update, ${toDelete.length} to delete",
);
final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd);
_log.fine(
@ -600,28 +612,31 @@ class SyncService {
);
deleteCandidates.addAll(toDelete);
existing.addAll(existingInDb);
album.name = ape.name;
album.modifiedAt = ape.lastModified ?? DateTime.now();
if (album.thumbnail.value != null &&
toDelete.contains(album.thumbnail.value)) {
album.thumbnail.value = null;
dbAlbum.name = deviceAlbum.name;
dbAlbum.modifiedAt = deviceAlbum.modifiedAt;
if (dbAlbum.thumbnail.value != null &&
toDelete.contains(dbAlbum.thumbnail.value)) {
dbAlbum.thumbnail.value = null;
}
try {
await _db.writeTxn(() async {
await _db.assets.putAll(updated);
await _db.assets.putAll(toUpdate);
await album.assets
await dbAlbum.assets
.update(link: existingInDb + updated, unlink: toDelete);
await _db.albums.put(album);
album.thumbnail.value ??= await album.assets.filter().findFirst();
await album.thumbnail.save();
await _db.albums.put(dbAlbum);
dbAlbum.thumbnail.value ??= await dbAlbum.assets.filter().findFirst();
await dbAlbum.thumbnail.save();
await _db.eTags.put(
ETag(id: ape.eTagKeyAssetCount, assetCount: assetCountOnDevice),
ETag(
id: deviceAlbum.eTagKeyAssetCount,
assetCount: assetCountOnDevice,
),
);
});
_log.info("Synced changes of local album ${ape.name} to DB");
_log.info("Synced changes of local album ${deviceAlbum.name} to DB");
} on IsarError catch (e) {
_log.severe("Failed to update synced album ${ape.name} in DB", e);
_log.severe("Failed to update synced album ${deviceAlbum.name} in DB", e);
}
return true;
@ -629,45 +644,45 @@ class SyncService {
/// fast path for common case: only new assets were added to device album
/// returns `true` if successfull, else `false`
Future<bool> _syncDeviceAlbumFast(AssetPathEntity ape, Album album) async {
if (!(ape.lastModified ?? DateTime.now()).isAfter(album.modifiedAt)) {
Future<bool> _syncDeviceAlbumFast(Album deviceAlbum, Album dbAlbum) async {
if (!deviceAlbum.modifiedAt.isAfter(dbAlbum.modifiedAt)) {
return false;
}
final int totalOnDevice = await ape.assetCountAsync;
final int totalOnDevice =
await _albumMediaRepository.getAssetCount(deviceAlbum.localId!);
final int lastKnownTotal =
(await _db.eTags.getById(ape.eTagKeyAssetCount))?.assetCount ?? 0;
final AssetPathEntity? modified = totalOnDevice > lastKnownTotal
? await ape.fetchPathProperties(
filterOptionGroup: FilterOptionGroup(
updateTimeCond: DateTimeCond(
min: album.modifiedAt.add(const Duration(seconds: 1)),
max: ape.lastModified ?? DateTime.now(),
),
),
)
: null;
if (modified == null) {
(await _db.eTags.getById(deviceAlbum.eTagKeyAssetCount))?.assetCount ??
0;
if (totalOnDevice <= lastKnownTotal) {
return false;
}
final List<Asset> newAssets = await _hashService.getHashedAssets(modified);
final List<Asset> newAssets = await _hashService.getHashedAssets(
deviceAlbum,
modifiedFrom: dbAlbum.modifiedAt.add(const Duration(seconds: 1)),
modifiedUntil: deviceAlbum.modifiedAt,
);
if (totalOnDevice != lastKnownTotal + newAssets.length) {
return false;
}
album.modifiedAt = ape.lastModified ?? DateTime.now();
dbAlbum.modifiedAt = deviceAlbum.modifiedAt;
_removeDuplicates(newAssets);
final (existingInDb, updated) = await _linkWithExistingFromDb(newAssets);
try {
await _db.writeTxn(() async {
await _db.assets.putAll(updated);
await album.assets.update(link: existingInDb + updated);
await _db.albums.put(album);
await _db.eTags
.put(ETag(id: ape.eTagKeyAssetCount, assetCount: totalOnDevice));
await dbAlbum.assets.update(link: existingInDb + updated);
await _db.albums.put(dbAlbum);
await _db.eTags.put(
ETag(id: deviceAlbum.eTagKeyAssetCount, assetCount: totalOnDevice),
);
});
_log.info("Fast synced local album ${ape.name} to DB");
_log.info("Fast synced local album ${deviceAlbum.name} to DB");
} on IsarError catch (e) {
_log.severe("Failed to fast sync local album ${ape.name} to DB", e);
_log.severe(
"Failed to fast sync local album ${deviceAlbum.name} to DB",
e,
);
return false;
}
@ -677,14 +692,15 @@ class SyncService {
/// Adds a new album from the device to the database and Accumulates all
/// assets already existing in the database to the list of `existing` assets
Future<void> _addAlbumFromDevice(
AssetPathEntity ape,
Album album,
List<Asset> existing, [
Set<String>? excludedAssets,
]) async {
_log.info("Syncing a new local album to DB: ${ape.name}");
final Album a = Album.local(ape);
final assets =
await _hashService.getHashedAssets(ape, excludedAssets: excludedAssets);
_log.info("Syncing a new local album to DB: ${album.name}");
final assets = await _hashService.getHashedAssets(
album,
excludedAssets: excludedAssets,
);
_removeDuplicates(assets);
final (existingInDb, updated) = await _linkWithExistingFromDb(assets);
_log.info(
@ -692,15 +708,15 @@ class SyncService {
);
await upsertAssetsWithExif(updated);
existing.addAll(existingInDb);
a.assets.addAll(existingInDb);
a.assets.addAll(updated);
album.assets.addAll(existingInDb);
album.assets.addAll(updated);
final thumb = existingInDb.firstOrNull ?? updated.firstOrNull;
a.thumbnail.value = thumb;
album.thumbnail.value = thumb;
try {
await _db.writeTxn(() => _db.albums.store(a));
_log.info("Added a new local album to DB: ${ape.name}");
await _db.writeTxn(() => _db.albums.store(album));
_log.info("Added a new local album to DB: ${album.name}");
} on IsarError catch (e) {
_log.severe("Failed to add new local album ${ape.name} to DB", e);
_log.severe("Failed to add new local album ${album.name} to DB", e);
}
}
@ -798,12 +814,15 @@ class SyncService {
}
/// returns `true` if the albums differ on the surface
Future<bool> _hasAssetPathEntityChanged(AssetPathEntity a, Album b) async {
return a.name != b.name ||
a.lastModified == null ||
!a.lastModified!.isAtSameMomentAs(b.modifiedAt) ||
await a.assetCountAsync !=
(await _db.eTags.getById(a.eTagKeyAssetCount))?.assetCount;
Future<bool> _hasAlbumChangeOnDevice(
Album deviceAlbum,
Album dbAlbum,
) async {
return deviceAlbum.name != dbAlbum.name ||
!deviceAlbum.modifiedAt.isAtSameMomentAs(dbAlbum.modifiedAt) ||
await _albumMediaRepository.getAssetCount(deviceAlbum.localId!) !=
(await _db.eTags.getById(deviceAlbum.eTagKeyAssetCount))
?.assetCount;
}
Future<bool> _removeAllLocalAlbumsAndAssets() async {

View File

@ -12,6 +12,16 @@ dynamic upgradeDto(dynamic value, String targetType) {
addDefault(value, 'tags', TagsResponse().toJson());
}
break;
case 'UserResponseDto':
if (value is Map) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
}
break;
case 'UserAdminResponseDto':
if (value is Map) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
}
break;
}
}

View File

@ -183,23 +183,13 @@ class AlbumInfoCard extends HookConsumerWidget {
),
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: FutureBuilder(
builder: ((context, snapshot) {
if (snapshot.hasData) {
return Text(
snapshot.data.toString() +
(album.isAll
? " (${'backup_all'.tr()})"
: ""),
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
);
}
return const Text("0");
}),
future: album.assetCount,
child: Text(
album.assetCount.toString() +
(album.isAll ? " (${'backup_all'.tr()})" : ""),
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
),
],
@ -208,7 +198,7 @@ class AlbumInfoCard extends HookConsumerWidget {
IconButton(
onPressed: () {
context.pushRoute(
AlbumPreviewRoute(album: album.albumEntity),
AlbumPreviewRoute(album: album.album),
);
},
icon: Icon(

View File

@ -1,6 +1,5 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@ -24,19 +23,10 @@ class AlbumInfoListTile extends HookConsumerWidget {
ref.watch(backupProvider).selectedBackupAlbums.contains(album);
final bool isExcluded =
ref.watch(backupProvider).excludedBackupAlbums.contains(album);
final assetCount = useState(0);
final syncAlbum = ref
.watch(appSettingsServiceProvider)
.getSetting(AppSettingsEnum.syncAlbums);
useEffect(
() {
album.assetCount.then((value) => assetCount.value = value);
return null;
},
[album],
);
buildTileColor() {
if (isSelected) {
return context.isDarkTheme
@ -117,11 +107,11 @@ class AlbumInfoListTile extends HookConsumerWidget {
fontWeight: FontWeight.bold,
),
),
subtitle: Text(assetCount.value.toString()),
subtitle: Text(album.assetCount.toString()),
trailing: IconButton(
onPressed: () {
context.pushRoute(
AlbumPreviewRoute(album: album.albumEntity),
AlbumPreviewRoute(album: album.album),
);
},
icon: Icon(

View File

@ -2,18 +2,19 @@ import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
const CurrentUploadingAssetInfoBox({super.key});
@ -148,17 +149,6 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
);
}
buildAssetThumbnail() async {
var assetEntity = await AssetEntity.fromId(asset.id);
if (assetEntity != null) {
return assetEntity.thumbnailDataWithSize(
const ThumbnailSize(500, 500),
quality: 100,
);
}
}
buildiCloudDownloadProgerssBar() {
if (asset.iCloudAsset != null && asset.iCloudAsset!) {
return Padding(
@ -239,8 +229,8 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
);
}
return FutureBuilder<Uint8List?>(
future: buildAssetThumbnail(),
return FutureBuilder<Asset?>(
future: ref.read(assetMediaRepositoryProvider).get(asset.id),
builder: (context, thumbnail) => ListTile(
isThreeLine: true,
leading: AnimatedCrossFade(
@ -250,9 +240,8 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
child: thumbnail.hasData
? ClipRRect(
borderRadius: BorderRadius.circular(5),
child: Image.memory(
thumbnail.data!,
fit: BoxFit.cover,
child: ImmichThumbnail(
asset: thumbnail.data,
width: 50,
height: 50,
),

View File

@ -452,6 +452,7 @@ Class | Method | HTTP request | Description
- [ToneMapping](doc//ToneMapping.md)
- [TranscodeHWAccel](doc//TranscodeHWAccel.md)
- [TranscodePolicy](doc//TranscodePolicy.md)
- [TrashResponseDto](doc//TrashResponseDto.md)
- [UpdateAlbumDto](doc//UpdateAlbumDto.md)
- [UpdateAlbumUserDto](doc//UpdateAlbumUserDto.md)
- [UpdateAssetDto](doc//UpdateAssetDto.md)

View File

@ -265,6 +265,7 @@ part 'model/time_bucket_size.dart';
part 'model/tone_mapping.dart';
part 'model/transcode_hw_accel.dart';
part 'model/transcode_policy.dart';
part 'model/trash_response_dto.dart';
part 'model/update_album_dto.dart';
part 'model/update_album_user_dto.dart';
part 'model/update_asset_dto.dart';

View File

@ -42,11 +42,19 @@ class TrashApi {
);
}
Future<void> emptyTrash() async {
Future<TrashResponseDto?> emptyTrash() async {
final response = await emptyTrashWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TrashResponseDto',) as TrashResponseDto;
}
return null;
}
/// Performs an HTTP 'POST /trash/restore/assets' operation and returns the [Response].
@ -81,11 +89,19 @@ class TrashApi {
/// Parameters:
///
/// * [BulkIdsDto] bulkIdsDto (required):
Future<void> restoreAssets(BulkIdsDto bulkIdsDto,) async {
Future<TrashResponseDto?> restoreAssets(BulkIdsDto bulkIdsDto,) async {
final response = await restoreAssetsWithHttpInfo(bulkIdsDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TrashResponseDto',) as TrashResponseDto;
}
return null;
}
/// Performs an HTTP 'POST /trash/restore' operation and returns the [Response].
@ -114,10 +130,18 @@ class TrashApi {
);
}
Future<void> restoreTrash() async {
Future<TrashResponseDto?> restoreTrash() async {
final response = await restoreTrashWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TrashResponseDto',) as TrashResponseDto;
}
return null;
}
}

View File

@ -585,6 +585,8 @@ class ApiClient {
return TranscodeHWAccelTypeTransformer().decode(value);
case 'TranscodePolicy':
return TranscodePolicyTypeTransformer().decode(value);
case 'TrashResponseDto':
return TrashResponseDto.fromJson(value);
case 'UpdateAlbumDto':
return UpdateAlbumDto.fromJson(value);
case 'UpdateAlbumUserDto':

View File

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

View File

@ -836,6 +836,13 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.1+1"
immich_mobile_immich_lint:
dependency: "direct dev"
description:
path: immich_lint
relative: true
source: path
version: "0.0.0"
integration_test:
dependency: "direct dev"
description: flutter

View File

@ -94,10 +94,12 @@ dev_dependencies:
isar_generator: ^3.1.0+1
integration_test:
sdk: flutter
custom_lint: ^0.6.0
custom_lint: ^0.6.4
riverpod_lint: ^2.3.7
riverpod_generator: ^2.3.9
mocktail: ^1.0.3
immich_mobile_immich_lint:
path: './immich_lint'
flutter:
uses-material-design: true

View File

@ -1,11 +1,8 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/hash.service.dart';
import 'package:mocktail/mocktail.dart';
class MockHashService extends Mock implements HashService {}
class MockCurrentUserProvider extends StateNotifier<User?>
with Mock
implements CurrentUserProvider {

View File

@ -7,8 +7,9 @@ import 'package:immich_mobile/services/immich_logger.service.dart';
import 'package:immich_mobile/services/sync.service.dart';
import 'package:isar/isar.dart';
import '../../repository.mocks.dart';
import '../../service.mocks.dart';
import '../../test_utils.dart';
import 'shared_mocks.dart';
void main() {
Asset makeAsset({
@ -38,6 +39,8 @@ void main() {
group('Test SyncService grouped', () {
late final Isar db;
final MockHashService hs = MockHashService();
final MockAlbumMediaRepository albumMediaRepository =
MockAlbumMediaRepository();
final owner = User(
id: "1",
updatedAt: DateTime.now(),
@ -67,7 +70,7 @@ void main() {
});
});
test('test inserting existing assets', () async {
SyncService s = SyncService(db, hs);
SyncService s = SyncService(db, hs, albumMediaRepository);
final List<Asset> remoteAssets = [
makeAsset(checksum: "a", remoteId: "0-1"),
makeAsset(checksum: "b", remoteId: "2-1"),
@ -85,7 +88,7 @@ void main() {
});
test('test inserting new assets', () async {
SyncService s = SyncService(db, hs);
SyncService s = SyncService(db, hs, albumMediaRepository);
final List<Asset> remoteAssets = [
makeAsset(checksum: "a", remoteId: "0-1"),
makeAsset(checksum: "b", remoteId: "2-1"),
@ -106,7 +109,7 @@ void main() {
});
test('test syncing duplicate assets', () async {
SyncService s = SyncService(db, hs);
SyncService s = SyncService(db, hs, albumMediaRepository);
final List<Asset> remoteAssets = [
makeAsset(checksum: "a", remoteId: "0-1"),
makeAsset(checksum: "b", remoteId: "1-1"),
@ -154,7 +157,7 @@ void main() {
});
test('test efficient sync', () async {
SyncService s = SyncService(db, hs);
SyncService s = SyncService(db, hs, albumMediaRepository);
final List<Asset> toUpsert = [
makeAsset(checksum: "a", remoteId: "0-1"), // changed
makeAsset(checksum: "f", remoteId: "0-2"), // new

View File

@ -1,6 +1,9 @@
import 'package:immich_mobile/interfaces/album.interface.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/asset_media.interface.dart';
import 'package:immich_mobile/interfaces/backup.interface.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/interfaces/user.interface.dart';
import 'package:mocktail/mocktail.dart';
@ -11,3 +14,9 @@ class MockAssetRepository extends Mock implements IAssetRepository {}
class MockUserRepository extends Mock implements IUserRepository {}
class MockBackupRepository extends Mock implements IBackupRepository {}
class MockAlbumMediaRepository extends Mock implements IAlbumMediaRepository {}
class MockAssetMediaRepository extends Mock implements IAssetMediaRepository {}
class MockFileMediaRepository extends Mock implements IFileMediaRepository {}

View File

@ -1,4 +1,5 @@
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/hash.service.dart';
import 'package:immich_mobile/services/sync.service.dart';
import 'package:immich_mobile/services/user.service.dart';
import 'package:mocktail/mocktail.dart';
@ -8,3 +9,5 @@ class MockApiService extends Mock implements ApiService {}
class MockUserService extends Mock implements UserService {}
class MockSyncService extends Mock implements SyncService {}
class MockHashService extends Mock implements HashService {}

View File

@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:mocktail/mocktail.dart';
import '../fixtures/album.stub.dart';
import '../repository.mocks.dart';
import '../service.mocks.dart';
@ -14,6 +15,7 @@ void main() {
late MockAssetRepository assetRepository;
late MockUserRepository userRepository;
late MockBackupRepository backupRepository;
late MockAlbumMediaRepository albumMediaRepository;
setUp(() {
apiService = MockApiService();
@ -23,6 +25,7 @@ void main() {
assetRepository = MockAssetRepository();
userRepository = MockUserRepository();
backupRepository = MockBackupRepository();
albumMediaRepository = MockAlbumMediaRepository();
sut = AlbumService(
apiService,
@ -32,6 +35,7 @@ void main() {
assetRepository,
userRepository,
backupRepository,
albumMediaRepository,
);
});
@ -48,5 +52,22 @@ void main() {
expect(result, false);
verify(() => syncService.removeAllLocalAlbumsAndAssets());
});
test('one selected albums, two on device', () async {
when(() => backupRepository.getIdsBySelection(BackupSelection.exclude))
.thenAnswer((_) async => []);
when(() => backupRepository.getIdsBySelection(BackupSelection.select))
.thenAnswer((_) async => [AlbumStub.oneAsset.localId!]);
when(() => albumMediaRepository.getAll())
.thenAnswer((_) async => [AlbumStub.oneAsset, AlbumStub.twoAsset]);
when(() => syncService.syncLocalAlbumAssetsToDb(any(), any()))
.thenAnswer((_) async => true);
final result = await sut.refreshDeviceAlbums();
expect(result, true);
verify(
() => syncService.syncLocalAlbumAssetsToDb([AlbumStub.oneAsset], null),
).called(1);
verifyNoMoreInteractions(syncService);
});
});
}

View File

@ -6839,7 +6839,14 @@
"operationId": "emptyTrash",
"parameters": [],
"responses": {
"204": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TrashResponseDto"
}
}
},
"description": ""
}
},
@ -6864,7 +6871,14 @@
"operationId": "restoreTrash",
"parameters": [],
"responses": {
"204": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TrashResponseDto"
}
}
},
"description": ""
}
},
@ -6899,7 +6913,14 @@
"required": true
},
"responses": {
"204": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TrashResponseDto"
}
}
},
"description": ""
}
},
@ -12254,6 +12275,17 @@
],
"type": "string"
},
"TrashResponseDto": {
"properties": {
"count": {
"type": "integer"
}
},
"required": [
"count"
],
"type": "object"
},
"UpdateAlbumDto": {
"properties": {
"albumName": {

View File

@ -1246,6 +1246,9 @@ export type TimeBucketResponseDto = {
count: number;
timeBucket: string;
};
export type TrashResponseDto = {
count: number;
};
export type UserUpdateMeDto = {
email?: string;
name?: string;
@ -3073,13 +3076,19 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key
}));
}
export function emptyTrash(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/trash/empty", {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: TrashResponseDto;
}>("/trash/empty", {
...opts,
method: "POST"
}));
}
export function restoreTrash(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/trash/restore", {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: TrashResponseDto;
}>("/trash/restore", {
...opts,
method: "POST"
}));
@ -3087,7 +3096,10 @@ export function restoreTrash(opts?: Oazapfts.RequestOpts) {
export function restoreAssets({ bulkIdsDto }: {
bulkIdsDto: BulkIdsDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/trash/restore/assets", oazapfts.json({
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: TrashResponseDto;
}>("/trash/restore/assets", oazapfts.json({
...opts,
method: "POST",
body: bulkIdsDto

View File

@ -2,6 +2,7 @@ import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { TrashResponseDto } from 'src/dtos/trash.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { TrashService } from 'src/services/trash.service';
@ -12,23 +13,23 @@ export class TrashController {
constructor(private service: TrashService) {}
@Post('empty')
@HttpCode(HttpStatus.NO_CONTENT)
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.ASSET_DELETE })
emptyTrash(@Auth() auth: AuthDto): Promise<void> {
emptyTrash(@Auth() auth: AuthDto): Promise<TrashResponseDto> {
return this.service.empty(auth);
}
@Post('restore')
@HttpCode(HttpStatus.NO_CONTENT)
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.ASSET_DELETE })
restoreTrash(@Auth() auth: AuthDto): Promise<void> {
restoreTrash(@Auth() auth: AuthDto): Promise<TrashResponseDto> {
return this.service.restore(auth);
}
@Post('restore/assets')
@HttpCode(HttpStatus.NO_CONTENT)
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.ASSET_DELETE })
restoreAssets(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<void> {
restoreAssets(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<TrashResponseDto> {
return this.service.restoreAssets(auth, dto);
}
}

View File

@ -0,0 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
export class TrashResponseDto {
@ApiProperty({ type: 'integer' })
count!: number;
}

View File

@ -10,7 +10,7 @@ import { SmartSearchEntity } from 'src/entities/smart-search.entity';
import { StackEntity } from 'src/entities/stack.entity';
import { TagEntity } from 'src/entities/tag.entity';
import { UserEntity } from 'src/entities/user.entity';
import { AssetType } from 'src/enum';
import { AssetStatus, AssetType } from 'src/enum';
import {
Column,
CreateDateColumn,
@ -70,6 +70,9 @@ export class AssetEntity {
@Column()
type!: AssetType;
@Column({ type: 'enum', enum: AssetStatus, default: AssetStatus.ACTIVE })
status!: AssetStatus;
@Column()
originalPath!: string;

View File

@ -182,6 +182,12 @@ export enum UserStatus {
DELETED = 'deleted',
}
export enum AssetStatus {
ACTIVE = 'active',
TRASHED = 'trashed',
DELETED = 'deleted',
}
export enum SourceType {
MACHINE_LEARNING = 'machine-learning',
EXIF = 'exif',

View File

@ -1,7 +1,7 @@
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity';
import { AssetFileType, AssetOrder, AssetType } from 'src/enum';
import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum';
import { AssetSearchOptions, SearchExploreItem } from 'src/interfaces/search.interface';
import { Paginated, PaginationOptions } from 'src/utils/pagination';
import { FindOptionsOrder, FindOptionsRelations, FindOptionsSelect } from 'typeorm';
@ -56,6 +56,7 @@ export interface AssetBuilderOptions {
userIds?: string[];
withStacked?: boolean;
exifInfo?: boolean;
status?: AssetStatus;
assetType?: AssetType;
}
@ -185,8 +186,6 @@ export interface IAssetRepository {
updateDuplicates(options: AssetUpdateDuplicateOptions): Promise<void>;
update(asset: AssetUpdateOptions): Promise<void>;
remove(asset: AssetEntity): Promise<void>;
softDeleteAll(ids: string[]): Promise<void>;
restoreAll(ids: string[]): Promise<void>;
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>;
getStatistics(ownerId: string, options: AssetStatsOptions): Promise<AssetStats>;
getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]>;

View File

@ -27,6 +27,7 @@ type EmitEventMap = {
// asset bulk events
'assets.trash': [{ assetIds: string[]; userId: string }];
'assets.delete': [{ assetIds: string[]; userId: string }];
'assets.restore': [{ assetIds: string[]; userId: string }];
// session events

View File

@ -93,6 +93,8 @@ export enum JobName {
QUEUE_SMART_SEARCH = 'queue-smart-search',
SMART_SEARCH = 'smart-search',
QUEUE_TRASH_EMPTY = 'queue-trash-empty',
// duplicate detection
QUEUE_DUPLICATE_DETECTION = 'queue-duplicate-detection',
DUPLICATE_DETECTION = 'duplicate-detection',
@ -253,6 +255,7 @@ export type JobItem =
// Smart Search
| { name: JobName.QUEUE_SMART_SEARCH; data: IBaseJob }
| { name: JobName.SMART_SEARCH; data: IEntityJob }
| { name: JobName.QUEUE_TRASH_EMPTY; data?: IBaseJob }
// Duplicate Detection
| { name: JobName.QUEUE_DUPLICATE_DETECTION; data: IBaseJob }

View File

@ -53,9 +53,4 @@ export interface IMetadataRepository {
readTags(path: string): Promise<ImmichTags>;
writeTags(path: string, tags: Partial<Tags>): Promise<void>;
extractBinaryTag(tagName: string, path: string): Promise<Buffer>;
getCountries(userIds: string[]): Promise<Array<string | null>>;
getStates(userIds: string[], country?: string): Promise<Array<string | null>>;
getCities(userIds: string[], country?: string, state?: string): Promise<Array<string | null>>;
getCameraMakes(userIds: string[], model?: string): Promise<Array<string | null>>;
getCameraModels(userIds: string[], make?: string): Promise<Array<string | null>>;
}

View File

@ -1,7 +1,7 @@
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity';
import { AssetType } from 'src/enum';
import { AssetStatus, AssetType } from 'src/enum';
import { Paginated } from 'src/utils/pagination';
export const ISearchRepository = 'ISearchRepository';
@ -61,6 +61,7 @@ export interface SearchStatusOptions {
isVisible?: boolean;
isNotInAlbum?: boolean;
type?: AssetType;
status?: AssetStatus;
withArchived?: boolean;
withDeleted?: boolean;
}
@ -181,4 +182,9 @@ export interface ISearchRepository {
deleteAllSearchEmbeddings(): Promise<void>;
getDimensionSize(): Promise<number>;
setDimensionSize(dimSize: number): Promise<void>;
getCountries(userIds: string[]): Promise<Array<string | null>>;
getStates(userIds: string[], country?: string): Promise<Array<string | null>>;
getCities(userIds: string[], country?: string, state?: string): Promise<Array<string | null>>;
getCameraMakes(userIds: string[], model?: string): Promise<Array<string | null>>;
getCameraModels(userIds: string[], make?: string): Promise<Array<string | null>>;
}

View File

@ -0,0 +1,10 @@
import { Paginated, PaginationOptions } from 'src/utils/pagination';
export const ITrashRepository = 'ITrashRepository';
export interface ITrashRepository {
empty(userId: string): Promise<number>;
restore(userId: string): Promise<number>;
restoreAll(assetIds: string[]): Promise<number>;
getDeletedIds(pagination: PaginationOptions): Paginated<string>;
}

View File

@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddAssetStatus1726593009549 implements MigrationInterface {
name = 'AddAssetStatus1726593009549'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TYPE "assets_status_enum" AS ENUM('active', 'trashed', 'deleted')`);
await queryRunner.query(`ALTER TABLE "assets" ADD "status" "assets_status_enum" NOT NULL DEFAULT 'active'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "status"`);
await queryRunner.query(`DROP TYPE "assets_status_enum"`);
}
}

View File

@ -8,6 +8,7 @@ SELECT
"entity"."libraryId" AS "entity_libraryId",
"entity"."deviceId" AS "entity_deviceId",
"entity"."type" AS "entity_type",
"entity"."status" AS "entity_status",
"entity"."originalPath" AS "entity_originalPath",
"entity"."thumbhash" AS "entity_thumbhash",
"entity"."encodedVideoPath" AS "entity_encodedVideoPath",
@ -96,6 +97,7 @@ SELECT
"AssetEntity"."libraryId" AS "AssetEntity_libraryId",
"AssetEntity"."deviceId" AS "AssetEntity_deviceId",
"AssetEntity"."type" AS "AssetEntity_type",
"AssetEntity"."status" AS "AssetEntity_status",
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
"AssetEntity"."thumbhash" AS "AssetEntity_thumbhash",
"AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath",
@ -130,6 +132,7 @@ SELECT
"AssetEntity"."libraryId" AS "AssetEntity_libraryId",
"AssetEntity"."deviceId" AS "AssetEntity_deviceId",
"AssetEntity"."type" AS "AssetEntity_type",
"AssetEntity"."status" AS "AssetEntity_status",
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
"AssetEntity"."thumbhash" AS "AssetEntity_thumbhash",
"AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath",
@ -218,6 +221,7 @@ SELECT
"bd93d5747511a4dad4923546c51365bf1a803774"."libraryId" AS "bd93d5747511a4dad4923546c51365bf1a803774_libraryId",
"bd93d5747511a4dad4923546c51365bf1a803774"."deviceId" AS "bd93d5747511a4dad4923546c51365bf1a803774_deviceId",
"bd93d5747511a4dad4923546c51365bf1a803774"."type" AS "bd93d5747511a4dad4923546c51365bf1a803774_type",
"bd93d5747511a4dad4923546c51365bf1a803774"."status" AS "bd93d5747511a4dad4923546c51365bf1a803774_status",
"bd93d5747511a4dad4923546c51365bf1a803774"."originalPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_originalPath",
"bd93d5747511a4dad4923546c51365bf1a803774"."thumbhash" AS "bd93d5747511a4dad4923546c51365bf1a803774_thumbhash",
"bd93d5747511a4dad4923546c51365bf1a803774"."encodedVideoPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_encodedVideoPath",
@ -305,6 +309,7 @@ FROM
"AssetEntity"."libraryId" AS "AssetEntity_libraryId",
"AssetEntity"."deviceId" AS "AssetEntity_deviceId",
"AssetEntity"."type" AS "AssetEntity_type",
"AssetEntity"."status" AS "AssetEntity_status",
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
"AssetEntity"."thumbhash" AS "AssetEntity_thumbhash",
"AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath",
@ -402,6 +407,7 @@ SELECT
"AssetEntity"."libraryId" AS "AssetEntity_libraryId",
"AssetEntity"."deviceId" AS "AssetEntity_deviceId",
"AssetEntity"."type" AS "AssetEntity_type",
"AssetEntity"."status" AS "AssetEntity_status",
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
"AssetEntity"."thumbhash" AS "AssetEntity_thumbhash",
"AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath",
@ -455,6 +461,7 @@ SELECT
"AssetEntity"."libraryId" AS "AssetEntity_libraryId",
"AssetEntity"."deviceId" AS "AssetEntity_deviceId",
"AssetEntity"."type" AS "AssetEntity_type",
"AssetEntity"."status" AS "AssetEntity_status",
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
"AssetEntity"."thumbhash" AS "AssetEntity_thumbhash",
"AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath",
@ -527,6 +534,7 @@ SELECT
"AssetEntity"."libraryId" AS "AssetEntity_libraryId",
"AssetEntity"."deviceId" AS "AssetEntity_deviceId",
"AssetEntity"."type" AS "AssetEntity_type",
"AssetEntity"."status" AS "AssetEntity_status",
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
"AssetEntity"."thumbhash" AS "AssetEntity_thumbhash",
"AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath",
@ -581,6 +589,7 @@ SELECT
"asset"."libraryId" AS "asset_libraryId",
"asset"."deviceId" AS "asset_deviceId",
"asset"."type" AS "asset_type",
"asset"."status" AS "asset_status",
"asset"."originalPath" AS "asset_originalPath",
"asset"."thumbhash" AS "asset_thumbhash",
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
@ -640,6 +649,7 @@ SELECT
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
"stackedAssets"."type" AS "stackedAssets_type",
"stackedAssets"."status" AS "stackedAssets_status",
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
@ -719,6 +729,7 @@ SELECT
"asset"."libraryId" AS "asset_libraryId",
"asset"."deviceId" AS "asset_deviceId",
"asset"."type" AS "asset_type",
"asset"."status" AS "asset_status",
"asset"."originalPath" AS "asset_originalPath",
"asset"."thumbhash" AS "asset_thumbhash",
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
@ -778,6 +789,7 @@ SELECT
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
"stackedAssets"."type" AS "stackedAssets_type",
"stackedAssets"."status" AS "stackedAssets_status",
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
@ -833,6 +845,7 @@ SELECT
"asset"."libraryId" AS "asset_libraryId",
"asset"."deviceId" AS "asset_deviceId",
"asset"."type" AS "asset_type",
"asset"."status" AS "asset_status",
"asset"."originalPath" AS "asset_originalPath",
"asset"."thumbhash" AS "asset_thumbhash",
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
@ -892,6 +905,7 @@ SELECT
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
"stackedAssets"."type" AS "stackedAssets_type",
"stackedAssets"."status" AS "stackedAssets_status",
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
@ -997,6 +1011,7 @@ SELECT
"asset"."libraryId" AS "asset_libraryId",
"asset"."deviceId" AS "asset_deviceId",
"asset"."type" AS "asset_type",
"asset"."status" AS "asset_status",
"asset"."originalPath" AS "asset_originalPath",
"asset"."thumbhash" AS "asset_thumbhash",
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
@ -1072,6 +1087,7 @@ SELECT
"asset"."libraryId" AS "asset_libraryId",
"asset"."deviceId" AS "asset_deviceId",
"asset"."type" AS "asset_type",
"asset"."status" AS "asset_status",
"asset"."originalPath" AS "asset_originalPath",
"asset"."thumbhash" AS "asset_thumbhash",
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",

View File

@ -1,56 +0,0 @@
-- NOTE: This file is auto generated by ./sql-generator
-- MetadataRepository.getCountries
SELECT DISTINCT
ON ("exif"."country") "exif"."country" AS "country"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" IN ($1)
-- MetadataRepository.getStates
SELECT DISTINCT
ON ("exif"."state") "exif"."state" AS "state"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" IN ($1)
AND "exif"."country" = $2
-- MetadataRepository.getCities
SELECT DISTINCT
ON ("exif"."city") "exif"."city" AS "city"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" IN ($1)
AND "exif"."country" = $2
AND "exif"."state" = $3
-- MetadataRepository.getCameraMakes
SELECT DISTINCT
ON ("exif"."make") "exif"."make" AS "make"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" IN ($1)
AND "exif"."model" = $2
-- MetadataRepository.getCameraModels
SELECT DISTINCT
ON ("exif"."model") "exif"."model" AS "model"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" IN ($1)
AND "exif"."make" = $2

View File

@ -159,6 +159,7 @@ FROM
"AssetFaceEntity__AssetFaceEntity_asset"."libraryId" AS "AssetFaceEntity__AssetFaceEntity_asset_libraryId",
"AssetFaceEntity__AssetFaceEntity_asset"."deviceId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceId",
"AssetFaceEntity__AssetFaceEntity_asset"."type" AS "AssetFaceEntity__AssetFaceEntity_asset_type",
"AssetFaceEntity__AssetFaceEntity_asset"."status" AS "AssetFaceEntity__AssetFaceEntity_asset_status",
"AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath",
"AssetFaceEntity__AssetFaceEntity_asset"."thumbhash" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbhash",
"AssetFaceEntity__AssetFaceEntity_asset"."encodedVideoPath" AS "AssetFaceEntity__AssetFaceEntity_asset_encodedVideoPath",
@ -260,6 +261,7 @@ FROM
"AssetEntity"."libraryId" AS "AssetEntity_libraryId",
"AssetEntity"."deviceId" AS "AssetEntity_deviceId",
"AssetEntity"."type" AS "AssetEntity_type",
"AssetEntity"."status" AS "AssetEntity_status",
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
"AssetEntity"."thumbhash" AS "AssetEntity_thumbhash",
"AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath",
@ -391,6 +393,7 @@ SELECT
"AssetFaceEntity__AssetFaceEntity_asset"."libraryId" AS "AssetFaceEntity__AssetFaceEntity_asset_libraryId",
"AssetFaceEntity__AssetFaceEntity_asset"."deviceId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceId",
"AssetFaceEntity__AssetFaceEntity_asset"."type" AS "AssetFaceEntity__AssetFaceEntity_asset_type",
"AssetFaceEntity__AssetFaceEntity_asset"."status" AS "AssetFaceEntity__AssetFaceEntity_asset_status",
"AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath",
"AssetFaceEntity__AssetFaceEntity_asset"."thumbhash" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbhash",
"AssetFaceEntity__AssetFaceEntity_asset"."encodedVideoPath" AS "AssetFaceEntity__AssetFaceEntity_asset_encodedVideoPath",

View File

@ -13,6 +13,7 @@ FROM
"asset"."libraryId" AS "asset_libraryId",
"asset"."deviceId" AS "asset_deviceId",
"asset"."type" AS "asset_type",
"asset"."status" AS "asset_status",
"asset"."originalPath" AS "asset_originalPath",
"asset"."thumbhash" AS "asset_thumbhash",
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
@ -43,6 +44,7 @@ FROM
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
"stackedAssets"."type" AS "stackedAssets_type",
"stackedAssets"."status" AS "stackedAssets_status",
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
@ -106,6 +108,7 @@ SELECT
"asset"."libraryId" AS "asset_libraryId",
"asset"."deviceId" AS "asset_deviceId",
"asset"."type" AS "asset_type",
"asset"."status" AS "asset_status",
"asset"."originalPath" AS "asset_originalPath",
"asset"."thumbhash" AS "asset_thumbhash",
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
@ -136,6 +139,7 @@ SELECT
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
"stackedAssets"."type" AS "stackedAssets_type",
"stackedAssets"."status" AS "stackedAssets_status",
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
@ -345,6 +349,7 @@ SELECT
"asset"."libraryId" AS "asset_libraryId",
"asset"."deviceId" AS "asset_deviceId",
"asset"."type" AS "asset_type",
"asset"."status" AS "asset_status",
"asset"."originalPath" AS "asset_originalPath",
"asset"."thumbhash" AS "asset_thumbhash",
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
@ -401,3 +406,58 @@ FROM
INNER JOIN cte ON asset.id = cte."assetId"
ORDER BY
exif.city
-- SearchRepository.getCountries
SELECT DISTINCT
ON ("exif"."country") "exif"."country" AS "country"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" IN ($1)
-- SearchRepository.getStates
SELECT DISTINCT
ON ("exif"."state") "exif"."state" AS "state"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" IN ($1)
AND "exif"."country" = $2
-- SearchRepository.getCities
SELECT DISTINCT
ON ("exif"."city") "exif"."city" AS "city"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" IN ($1)
AND "exif"."country" = $2
AND "exif"."state" = $3
-- SearchRepository.getCameraMakes
SELECT DISTINCT
ON ("exif"."make") "exif"."make" AS "make"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" IN ($1)
AND "exif"."model" = $2
-- SearchRepository.getCameraModels
SELECT DISTINCT
ON ("exif"."model") "exif"."model" AS "model"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" IN ($1)
AND "exif"."make" = $2

View File

@ -27,6 +27,7 @@ FROM
"SharedLinkEntity__SharedLinkEntity_assets"."libraryId" AS "SharedLinkEntity__SharedLinkEntity_assets_libraryId",
"SharedLinkEntity__SharedLinkEntity_assets"."deviceId" AS "SharedLinkEntity__SharedLinkEntity_assets_deviceId",
"SharedLinkEntity__SharedLinkEntity_assets"."type" AS "SharedLinkEntity__SharedLinkEntity_assets_type",
"SharedLinkEntity__SharedLinkEntity_assets"."status" AS "SharedLinkEntity__SharedLinkEntity_assets_status",
"SharedLinkEntity__SharedLinkEntity_assets"."originalPath" AS "SharedLinkEntity__SharedLinkEntity_assets_originalPath",
"SharedLinkEntity__SharedLinkEntity_assets"."thumbhash" AS "SharedLinkEntity__SharedLinkEntity_assets_thumbhash",
"SharedLinkEntity__SharedLinkEntity_assets"."encodedVideoPath" AS "SharedLinkEntity__SharedLinkEntity_assets_encodedVideoPath",
@ -93,6 +94,7 @@ FROM
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."libraryId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_libraryId",
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."deviceId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_deviceId",
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."type" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_type",
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."status" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_status",
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."originalPath" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_originalPath",
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."thumbhash" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_thumbhash",
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."encodedVideoPath" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_encodedVideoPath",
@ -214,6 +216,7 @@ SELECT
"SharedLinkEntity__SharedLinkEntity_assets"."libraryId" AS "SharedLinkEntity__SharedLinkEntity_assets_libraryId",
"SharedLinkEntity__SharedLinkEntity_assets"."deviceId" AS "SharedLinkEntity__SharedLinkEntity_assets_deviceId",
"SharedLinkEntity__SharedLinkEntity_assets"."type" AS "SharedLinkEntity__SharedLinkEntity_assets_type",
"SharedLinkEntity__SharedLinkEntity_assets"."status" AS "SharedLinkEntity__SharedLinkEntity_assets_status",
"SharedLinkEntity__SharedLinkEntity_assets"."originalPath" AS "SharedLinkEntity__SharedLinkEntity_assets_originalPath",
"SharedLinkEntity__SharedLinkEntity_assets"."thumbhash" AS "SharedLinkEntity__SharedLinkEntity_assets_thumbhash",
"SharedLinkEntity__SharedLinkEntity_assets"."encodedVideoPath" AS "SharedLinkEntity__SharedLinkEntity_assets_encodedVideoPath",

View File

@ -8,6 +8,7 @@ SELECT
"asset"."libraryId" AS "asset_libraryId",
"asset"."deviceId" AS "asset_deviceId",
"asset"."type" AS "asset_type",
"asset"."status" AS "asset_status",
"asset"."originalPath" AS "asset_originalPath",
"asset"."thumbhash" AS "asset_thumbhash",
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",

View File

@ -6,7 +6,7 @@ import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity';
import { SmartInfoEntity } from 'src/entities/smart-info.entity';
import { AssetFileType, AssetOrder, AssetType } from 'src/enum';
import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum';
import {
AssetBuilderOptions,
AssetCreate,
@ -295,16 +295,6 @@ export class AssetRepository implements IAssetRepository {
.execute();
}
@Chunked()
async softDeleteAll(ids: string[]): Promise<void> {
await this.repository.softDelete({ id: In(ids) });
}
@Chunked()
async restoreAll(ids: string[]): Promise<void> {
await this.repository.restore({ id: In(ids) });
}
async update(asset: AssetUpdateOptions): Promise<void> {
await this.repository.update(asset.id, asset);
}
@ -597,7 +587,10 @@ export class AssetRepository implements IAssetRepository {
}
if (isTrashed !== undefined) {
builder.withDeleted().andWhere(`asset.deletedAt is not null`);
builder
.withDeleted()
.andWhere(`asset.deletedAt is not null`)
.andWhere('asset.status = :status', { status: AssetStatus.TRASHED });
}
const items = await builder.getRawMany();
@ -755,6 +748,10 @@ export class AssetRepository implements IAssetRepository {
if (options.isTrashed !== undefined) {
builder.andWhere(`asset.deletedAt ${options.isTrashed ? 'IS NOT NULL' : 'IS NULL'}`).withDeleted();
if (options.isTrashed) {
builder.andWhere('asset.status = :status', { status: AssetStatus.TRASHED });
}
}
if (options.isDuplicate !== undefined) {

View File

@ -29,6 +29,7 @@ import { IStackRepository } from 'src/interfaces/stack.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { ITrashRepository } from 'src/interfaces/trash.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { IViewRepository } from 'src/interfaces/view.interface';
import { AccessRepository } from 'src/repositories/access.repository';
@ -62,6 +63,7 @@ import { StackRepository } from 'src/repositories/stack.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TagRepository } from 'src/repositories/tag.repository';
import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { ViewRepository } from 'src/repositories/view-repository';
@ -97,6 +99,7 @@ export const repositories = [
{ provide: IStorageRepository, useClass: StorageRepository },
{ provide: ISystemMetadataRepository, useClass: SystemMetadataRepository },
{ provide: ITagRepository, useClass: TagRepository },
{ provide: ITrashRepository, useClass: TrashRepository },
{ provide: IUserRepository, useClass: UserRepository },
{ provide: IViewRepository, useClass: ViewRepository },
];

View File

@ -95,6 +95,9 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
// Version check
[JobName.VERSION_CHECK]: QueueName.BACKGROUND_TASK,
// Trash
[JobName.QUEUE_TRASH_EMPTY]: QueueName.BACKGROUND_TASK,
};
@Instrumentation()

View File

@ -2,7 +2,6 @@ import { Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored';
import geotz from 'geo-tz';
import { DummyValue, GenerateSql } from 'src/decorators';
import { ExifEntity } from 'src/entities/exif.entity';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface';
@ -54,91 +53,4 @@ export class MetadataRepository implements IMetadataRepository {
this.logger.warn(`Error writing exif data (${path}): ${error}`);
}
}
@GenerateSql({ params: [[DummyValue.UUID]] })
async getCountries(userIds: string[]): Promise<string[]> {
const results = await this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId IN (:...userIds )', { userIds })
.select('exif.country', 'country')
.distinctOn(['exif.country'])
.getRawMany<{ country: string }>();
return results.map(({ country }) => country).filter((item) => item !== '');
}
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
async getStates(userIds: string[], country: string | undefined): Promise<string[]> {
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId IN (:...userIds )', { userIds })
.select('exif.state', 'state')
.distinctOn(['exif.state']);
if (country) {
query.andWhere('exif.country = :country', { country });
}
const result = await query.getRawMany<{ state: string }>();
return result.map(({ state }) => state).filter((item) => item !== '');
}
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING, DummyValue.STRING] })
async getCities(userIds: string[], country: string | undefined, state: string | undefined): Promise<string[]> {
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId IN (:...userIds )', { userIds })
.select('exif.city', 'city')
.distinctOn(['exif.city']);
if (country) {
query.andWhere('exif.country = :country', { country });
}
if (state) {
query.andWhere('exif.state = :state', { state });
}
const results = await query.getRawMany<{ city: string }>();
return results.map(({ city }) => city).filter((item) => item !== '');
}
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
async getCameraMakes(userIds: string[], model: string | undefined): Promise<string[]> {
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId IN (:...userIds )', { userIds })
.select('exif.make', 'make')
.distinctOn(['exif.make']);
if (model) {
query.andWhere('exif.model = :model', { model });
}
const results = await query.getRawMany<{ make: string }>();
return results.map(({ make }) => make).filter((item) => item !== '');
}
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
async getCameraModels(userIds: string[], make: string | undefined): Promise<string[]> {
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId IN (:...userIds )', { userIds })
.select('exif.model', 'model')
.distinctOn(['exif.model']);
if (make) {
query.andWhere('exif.make = :make', { make });
}
const results = await query.getRawMany<{ model: string }>();
return results.map(({ model }) => model).filter((item) => item !== '');
}
}

View File

@ -4,6 +4,7 @@ import { getVectorExtension } from 'src/database.config';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity';
import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity';
import { SmartInfoEntity } from 'src/entities/smart-info.entity';
import { SmartSearchEntity } from 'src/entities/smart-search.entity';
@ -35,6 +36,7 @@ export class SearchRepository implements ISearchRepository {
constructor(
@InjectRepository(SmartInfoEntity) private repository: Repository<SmartInfoEntity>,
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
@InjectRepository(SmartSearchEntity) private smartSearchRepository: Repository<SmartSearchEntity>,
@InjectRepository(GeodataPlacesEntity) private geodataPlacesRepository: Repository<GeodataPlacesEntity>,
@ -322,6 +324,93 @@ export class SearchRepository implements ISearchRepository {
return this.smartSearchRepository.clear();
}
@GenerateSql({ params: [[DummyValue.UUID]] })
async getCountries(userIds: string[]): Promise<string[]> {
const results = await this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId IN (:...userIds )', { userIds })
.select('exif.country', 'country')
.distinctOn(['exif.country'])
.getRawMany<{ country: string }>();
return results.map(({ country }) => country).filter((item) => item !== '');
}
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
async getStates(userIds: string[], country: string | undefined): Promise<string[]> {
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId IN (:...userIds )', { userIds })
.select('exif.state', 'state')
.distinctOn(['exif.state']);
if (country) {
query.andWhere('exif.country = :country', { country });
}
const result = await query.getRawMany<{ state: string }>();
return result.map(({ state }) => state).filter((item) => item !== '');
}
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING, DummyValue.STRING] })
async getCities(userIds: string[], country: string | undefined, state: string | undefined): Promise<string[]> {
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId IN (:...userIds )', { userIds })
.select('exif.city', 'city')
.distinctOn(['exif.city']);
if (country) {
query.andWhere('exif.country = :country', { country });
}
if (state) {
query.andWhere('exif.state = :state', { state });
}
const results = await query.getRawMany<{ city: string }>();
return results.map(({ city }) => city).filter((item) => item !== '');
}
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
async getCameraMakes(userIds: string[], model: string | undefined): Promise<string[]> {
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId IN (:...userIds )', { userIds })
.select('exif.make', 'make')
.distinctOn(['exif.make']);
if (model) {
query.andWhere('exif.model = :model', { model });
}
const results = await query.getRawMany<{ make: string }>();
return results.map(({ make }) => make).filter((item) => item !== '');
}
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
async getCameraModels(userIds: string[], make: string | undefined): Promise<string[]> {
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId IN (:...userIds )', { userIds })
.select('exif.model', 'model')
.distinctOn(['exif.model']);
if (make) {
query.andWhere('exif.make = :make', { make });
}
const results = await query.getRawMany<{ model: string }>();
return results.map(({ model }) => model).filter((item) => item !== '');
}
private getRuntimeConfig(numResults?: number): string {
if (getVectorExtension() === DatabaseExtension.VECTOR) {
return 'SET LOCAL hnsw.ef_search = 1000;'; // mitigate post-filter recall

View File

@ -0,0 +1,49 @@
import { InjectRepository } from '@nestjs/typeorm';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus } from 'src/enum';
import { ITrashRepository } from 'src/interfaces/trash.interface';
import { Paginated, paginatedBuilder, PaginationOptions } from 'src/utils/pagination';
import { In, IsNull, Not, Repository } from 'typeorm';
export class TrashRepository implements ITrashRepository {
constructor(@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>) {}
async getDeletedIds(pagination: PaginationOptions): Paginated<string> {
const { hasNextPage, items } = await paginatedBuilder(
this.assetRepository
.createQueryBuilder('asset')
.select('asset.id')
.where({ status: AssetStatus.DELETED })
.withDeleted(),
pagination,
);
return {
hasNextPage,
items: items.map((asset) => asset.id),
};
}
async restore(userId: string): Promise<number> {
const result = await this.assetRepository.update(
{ ownerId: userId, deletedAt: Not(IsNull()) },
{ status: AssetStatus.ACTIVE, deletedAt: null },
);
return result.affected || 0;
}
async empty(userId: string): Promise<number> {
const result = await this.assetRepository.update(
{ ownerId: userId, deletedAt: Not(IsNull()), status: AssetStatus.TRASHED },
{ status: AssetStatus.DELETED },
);
return result.affected || 0;
}
async restoreAll(ids: string[]): Promise<number> {
const result = await this.assetRepository.update({ id: In(ids) }, { status: AssetStatus.ACTIVE, deletedAt: null });
return result.affected ?? 0;
}
}

View File

@ -4,7 +4,7 @@ import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos
import { AssetMediaCreateDto, AssetMediaReplaceDto, UploadFieldName } from 'src/dtos/asset-media.dto';
import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
import { AssetType } from 'src/enum';
import { AssetStatus, AssetType } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
@ -478,7 +478,10 @@ describe(AssetMediaService.name, () => {
}),
);
expect(assetMock.softDeleteAll).toHaveBeenCalledWith([copiedAsset.id]);
expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
deletedAt: expect.any(Date),
status: AssetStatus.TRASHED,
});
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
expect(storageMock.utimes).toHaveBeenCalledWith(
updatedFile.originalPath,
@ -506,7 +509,10 @@ describe(AssetMediaService.name, () => {
id: 'copied-asset',
});
expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['copied-asset']);
expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
deletedAt: expect.any(Date),
status: AssetStatus.TRASHED,
});
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
expect(storageMock.utimes).toHaveBeenCalledWith(
updatedFile.originalPath,
@ -532,7 +538,10 @@ describe(AssetMediaService.name, () => {
id: 'copied-asset',
});
expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['copied-asset']);
expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
deletedAt: expect.any(Date),
status: AssetStatus.TRASHED,
});
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
expect(storageMock.utimes).toHaveBeenCalledWith(
updatedFile.originalPath,
@ -561,7 +570,7 @@ describe(AssetMediaService.name, () => {
});
expect(assetMock.create).not.toHaveBeenCalled();
expect(assetMock.softDeleteAll).not.toHaveBeenCalled();
expect(assetMock.updateAll).not.toHaveBeenCalled();
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.DELETE_FILES,
data: { files: [updatedFile.originalPath, undefined] },

View File

@ -27,7 +27,7 @@ import {
} from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
import { AssetType, Permission } from 'src/enum';
import { AssetStatus, AssetType, Permission } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
@ -193,7 +193,7 @@ export class AssetMediaService {
// but the local variable holds the original file data paths.
const copiedPhoto = await this.createCopy(asset);
// and immediate trash it
await this.assetRepository.softDeleteAll([copiedPhoto.id]);
await this.assetRepository.updateAll([copiedPhoto.id], { deletedAt: new Date(), status: AssetStatus.TRASHED });
await this.eventRepository.emit('asset.trash', { assetId: copiedPhoto.id, userId: auth.user.id });
await this.userRepository.updateUsage(auth.user.id, file.size);

View File

@ -2,7 +2,7 @@ import { BadRequestException } from '@nestjs/common';
import { mapAsset } from 'src/dtos/asset-response.dto';
import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetType } from 'src/enum';
import { AssetStatus, AssetType } from 'src/enum';
import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
@ -269,10 +269,10 @@ describe(AssetService.name, () => {
await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: true });
expect(jobMock.queueAll).toHaveBeenCalledWith([
{ name: JobName.ASSET_DELETION, data: { id: 'asset1', deleteOnDisk: true } },
{ name: JobName.ASSET_DELETION, data: { id: 'asset2', deleteOnDisk: true } },
]);
expect(eventMock.emit).toHaveBeenCalledWith('assets.delete', {
assetIds: ['asset1', 'asset2'],
userId: 'user-id',
});
});
it('should soft delete a batch of assets', async () => {
@ -280,7 +280,10 @@ describe(AssetService.name, () => {
await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: false });
expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['asset1', 'asset2']);
expect(assetMock.updateAll).toHaveBeenCalledWith(['asset1', 'asset2'], {
deletedAt: expect.any(Date),
status: AssetStatus.TRASHED,
});
expect(jobMock.queue.mock.calls).toEqual([]);
});
});

View File

@ -20,7 +20,7 @@ import {
import { AuthDto } from 'src/dtos/auth.dto';
import { MemoryLaneDto } from 'src/dtos/search.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { Permission } from 'src/enum';
import { AssetStatus, Permission } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
@ -302,18 +302,11 @@ export class AssetService {
const { ids, force } = dto;
await requireAccess(this.access, { auth, permission: Permission.ASSET_DELETE, ids });
if (force) {
await this.jobRepository.queueAll(
ids.map((id) => ({
name: JobName.ASSET_DELETION,
data: { id, deleteOnDisk: true },
})),
);
} else {
await this.assetRepository.softDeleteAll(ids);
await this.eventRepository.emit('assets.trash', { assetIds: ids, userId: auth.user.id });
}
await this.assetRepository.updateAll(ids, {
deletedAt: new Date(),
status: force ? AssetStatus.DELETED : AssetStatus.TRASHED,
});
await this.eventRepository.emit(force ? 'assets.delete' : 'assets.trash', { assetIds: ids, userId: auth.user.id });
}
async run(auth: AuthDto, dto: AssetJobsDto) {

View File

@ -16,6 +16,7 @@ import { SmartInfoService } from 'src/services/smart-info.service';
import { StorageTemplateService } from 'src/services/storage-template.service';
import { StorageService } from 'src/services/storage.service';
import { TagService } from 'src/services/tag.service';
import { TrashService } from 'src/services/trash.service';
import { UserService } from 'src/services/user.service';
import { VersionService } from 'src/services/version.service';
import { otelShutdown } from 'src/utils/instrumentation';
@ -36,6 +37,7 @@ export class MicroservicesService {
private storageTemplateService: StorageTemplateService,
private storageService: StorageService,
private tagService: TagService,
private trashService: TrashService,
private userService: UserService,
private duplicateService: DuplicateService,
private versionService: VersionService,
@ -97,6 +99,7 @@ export class MicroservicesService {
[JobName.NOTIFY_SIGNUP]: (data) => this.notificationService.handleUserSignup(data),
[JobName.TAG_CLEANUP]: () => this.tagService.handleTagCleanup(),
[JobName.VERSION_CHECK]: () => this.versionService.handleVersionCheck(),
[JobName.QUEUE_TRASH_EMPTY]: () => this.trashService.handleQueueEmptyTrash(),
});
}

View File

@ -3,7 +3,6 @@ import { SearchSuggestionType } from 'src/dtos/search.dto';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISearchRepository } from 'src/interfaces/search.interface';
@ -15,7 +14,6 @@ import { personStub } from 'test/fixtures/person.stub';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock';
import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock';
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock';
@ -32,7 +30,6 @@ describe(SearchService.name, () => {
let personMock: Mocked<IPersonRepository>;
let searchMock: Mocked<ISearchRepository>;
let partnerMock: Mocked<IPartnerRepository>;
let metadataMock: Mocked<IMetadataRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => {
@ -42,19 +39,9 @@ describe(SearchService.name, () => {
personMock = newPersonRepositoryMock();
searchMock = newSearchRepositoryMock();
partnerMock = newPartnerRepositoryMock();
metadataMock = newMetadataRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new SearchService(
systemMock,
machineMock,
personMock,
searchMock,
assetMock,
partnerMock,
metadataMock,
loggerMock,
);
sut = new SearchService(systemMock, machineMock, personMock, searchMock, assetMock, partnerMock, loggerMock);
});
it('should work', () => {
@ -99,19 +86,19 @@ describe(SearchService.name, () => {
describe('getSearchSuggestions', () => {
it('should return search suggestions (including null)', async () => {
metadataMock.getCountries.mockResolvedValue(['USA', null]);
searchMock.getCountries.mockResolvedValue(['USA', null]);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.COUNTRY }),
).resolves.toEqual(['USA', null]);
expect(metadataMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]);
expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]);
});
it('should return search suggestions (without null)', async () => {
metadataMock.getCountries.mockResolvedValue(['USA', null]);
searchMock.getCountries.mockResolvedValue(['USA', null]);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.COUNTRY }),
).resolves.toEqual(['USA']);
expect(metadataMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]);
expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]);
});
});
});

View File

@ -19,7 +19,6 @@ import { AssetOrder } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISearchRepository, SearchExploreItem } from 'src/interfaces/search.interface';
@ -38,7 +37,6 @@ export class SearchService {
@Inject(ISearchRepository) private searchRepository: ISearchRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
@Inject(IMetadataRepository) private metadataRepository: IMetadataRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.logger.setContext(SearchService.name);
@ -129,19 +127,19 @@ export class SearchService {
private getSuggestions(userIds: string[], dto: SearchSuggestionRequestDto) {
switch (dto.type) {
case SearchSuggestionType.COUNTRY: {
return this.metadataRepository.getCountries(userIds);
return this.searchRepository.getCountries(userIds);
}
case SearchSuggestionType.STATE: {
return this.metadataRepository.getStates(userIds, dto.country);
return this.searchRepository.getStates(userIds, dto.country);
}
case SearchSuggestionType.CITY: {
return this.metadataRepository.getCities(userIds, dto.country, dto.state);
return this.searchRepository.getCities(userIds, dto.country, dto.state);
}
case SearchSuggestionType.CAMERA_MAKE: {
return this.metadataRepository.getCameraMakes(userIds, dto.model);
return this.searchRepository.getCameraMakes(userIds, dto.model);
}
case SearchSuggestionType.CAMERA_MODEL: {
return this.metadataRepository.getCameraModels(userIds, dto.make);
return this.searchRepository.getCameraModels(userIds, dto.make);
}
default: {
return [];

View File

@ -140,6 +140,23 @@ describe(TagService.name, () => {
parent: expect.objectContaining({ id: 'tag-parent' }),
});
});
it('should upsert a tag and ignore leading and trailing slashes', async () => {
tagMock.getByValue.mockResolvedValueOnce(null);
tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent);
tagMock.upsertValue.mockResolvedValueOnce(tagStub.child);
await expect(sut.upsert(authStub.admin, { tags: ['/Parent/Child/'] })).resolves.toBeDefined();
expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, {
value: 'Parent',
userId: 'admin_id',
parent: undefined,
});
expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, {
value: 'Parent/Child',
userId: 'admin_id',
parent: expect.objectContaining({ id: 'tag-parent' }),
});
});
});
describe('remove', () => {

View File

@ -1,22 +1,24 @@
import { BadRequestException } from '@nestjs/common';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ITrashRepository } from 'src/interfaces/trash.interface';
import { TrashService } from 'src/services/trash.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newTrashRepositoryMock } from 'test/repositories/trash.repository.mock';
import { Mocked } from 'vitest';
describe(TrashService.name, () => {
let sut: TrashService;
let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>;
let jobMock: Mocked<IJobRepository>;
let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>;
let trashMock: Mocked<ITrashRepository>;
let loggerMock: Mocked<ILoggerRepository>;
it('should work', () => {
expect(sut).toBeDefined();
@ -24,11 +26,12 @@ describe(TrashService.name, () => {
beforeEach(() => {
accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock();
eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock();
trashMock = newTrashRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new TrashService(accessMock, assetMock, jobMock, eventMock);
sut = new TrashService(accessMock, eventMock, jobMock, trashMock, loggerMock);
});
describe('restoreAssets', () => {
@ -40,44 +43,70 @@ describe(TrashService.name, () => {
).rejects.toBeInstanceOf(BadRequestException);
});
it('should handle an empty list', async () => {
await expect(sut.restoreAssets(authStub.user1, { ids: [] })).resolves.toEqual({ count: 0 });
expect(accessMock.asset.checkOwnerAccess).not.toHaveBeenCalled();
});
it('should restore a batch of assets', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2']));
await sut.restoreAssets(authStub.user1, { ids: ['asset1', 'asset2'] });
expect(assetMock.restoreAll).toHaveBeenCalledWith(['asset1', 'asset2']);
expect(trashMock.restoreAll).toHaveBeenCalledWith(['asset1', 'asset2']);
expect(jobMock.queue.mock.calls).toEqual([]);
});
});
describe('restore', () => {
it('should handle an empty trash', async () => {
assetMock.getByUserId.mockResolvedValue({ items: [], hasNextPage: false });
await expect(sut.restore(authStub.user1)).resolves.toBeUndefined();
expect(assetMock.restoreAll).not.toHaveBeenCalled();
expect(eventMock.clientSend).not.toHaveBeenCalled();
trashMock.getDeletedIds.mockResolvedValue({ items: [], hasNextPage: false });
trashMock.restore.mockResolvedValue(0);
await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 0 });
expect(trashMock.restore).toHaveBeenCalledWith('user-id');
});
it('should restore and notify', async () => {
assetMock.getByUserId.mockResolvedValue({ items: [assetStub.image], hasNextPage: false });
await expect(sut.restore(authStub.user1)).resolves.toBeUndefined();
expect(assetMock.restoreAll).toHaveBeenCalledWith([assetStub.image.id]);
expect(eventMock.emit).toHaveBeenCalledWith('assets.restore', { assetIds: ['asset-id'], userId: 'user-id' });
it('should restore', async () => {
trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-id'], hasNextPage: false });
trashMock.restore.mockResolvedValue(1);
await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 1 });
expect(trashMock.restore).toHaveBeenCalledWith('user-id');
});
});
describe('empty', () => {
it('should handle an empty trash', async () => {
assetMock.getByUserId.mockResolvedValue({ items: [], hasNextPage: false });
await expect(sut.empty(authStub.user1)).resolves.toBeUndefined();
expect(jobMock.queueAll).toHaveBeenCalledWith([]);
trashMock.getDeletedIds.mockResolvedValue({ items: [], hasNextPage: false });
trashMock.empty.mockResolvedValue(0);
await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 0 });
expect(jobMock.queue).not.toHaveBeenCalled();
});
it('should empty the trash', async () => {
assetMock.getByUserId.mockResolvedValue({ items: [assetStub.image], hasNextPage: false });
await expect(sut.empty(authStub.user1)).resolves.toBeUndefined();
trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-id'], hasNextPage: false });
trashMock.empty.mockResolvedValue(1);
await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 1 });
expect(trashMock.empty).toHaveBeenCalledWith('user-id');
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} });
});
});
describe('onAssetsDelete', () => {
it('should queue the empty trash job', async () => {
await expect(sut.onAssetsDelete()).resolves.toBeUndefined();
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} });
});
});
describe('handleQueueEmptyTrash', () => {
it('should queue asset delete jobs', async () => {
trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-1'], hasNextPage: false });
await expect(sut.handleQueueEmptyTrash()).resolves.toEqual(JobStatus.SUCCESS);
expect(jobMock.queueAll).toHaveBeenCalledWith([
{ name: JobName.ASSET_DELETION, data: { id: assetStub.image.id, deleteOnDisk: true } },
{
name: JobName.ASSET_DELETION,
data: { id: 'asset-1', deleteOnDisk: true },
},
]);
});
});

View File

@ -1,69 +1,86 @@
import { Inject } from '@nestjs/common';
import { DateTime } from 'luxon';
import { OnEmit } from 'src/decorators';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { TrashResponseDto } from 'src/dtos/trash.dto';
import { Permission } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName } from 'src/interfaces/job.interface';
import { IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ITrashRepository } from 'src/interfaces/trash.interface';
import { requireAccess } from 'src/utils/access';
import { usePagination } from 'src/utils/pagination';
export class TrashService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
) {}
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ITrashRepository) private trashRepository: ITrashRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.logger.setContext(TrashService.name);
}
async restoreAssets(auth: AuthDto, dto: BulkIdsDto): Promise<void> {
async restoreAssets(auth: AuthDto, dto: BulkIdsDto): Promise<TrashResponseDto> {
const { ids } = dto;
await requireAccess(this.access, { auth, permission: Permission.ASSET_DELETE, ids });
await this.restoreAndSend(auth, ids);
}
async restore(auth: AuthDto): Promise<void> {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
this.assetRepository.getByUserId(pagination, auth.user.id, {
trashedBefore: DateTime.now().toJSDate(),
}),
);
for await (const assets of assetPagination) {
const ids = assets.map((a) => a.id);
await this.restoreAndSend(auth, ids);
if (ids.length === 0) {
return { count: 0 };
}
await requireAccess(this.access, { auth, permission: Permission.ASSET_DELETE, ids });
await this.trashRepository.restoreAll(ids);
await this.eventRepository.emit('assets.restore', { assetIds: ids, userId: auth.user.id });
this.logger.log(`Restored ${ids.length} assets from trash`);
return { count: ids.length };
}
async empty(auth: AuthDto): Promise<void> {
async restore(auth: AuthDto): Promise<TrashResponseDto> {
const count = await this.trashRepository.restore(auth.user.id);
if (count > 0) {
this.logger.log(`Restored ${count} assets from trash`);
}
return { count };
}
async empty(auth: AuthDto): Promise<TrashResponseDto> {
const count = await this.trashRepository.empty(auth.user.id);
if (count > 0) {
await this.jobRepository.queue({ name: JobName.QUEUE_TRASH_EMPTY, data: {} });
}
return { count };
}
@OnEmit({ event: 'assets.delete' })
async onAssetsDelete() {
await this.jobRepository.queue({ name: JobName.QUEUE_TRASH_EMPTY, data: {} });
}
async handleQueueEmptyTrash() {
let count = 0;
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
this.assetRepository.getByUserId(pagination, auth.user.id, {
trashedBefore: DateTime.now().toJSDate(),
withArchived: true,
}),
this.trashRepository.getDeletedIds(pagination),
);
for await (const assets of assetPagination) {
for await (const assetIds of assetPagination) {
this.logger.debug(`Queueing ${assetIds.length} assets for deletion from the trash`);
count += assetIds.length;
await this.jobRepository.queueAll(
assets.map((asset) => ({
assetIds.map((assetId) => ({
name: JobName.ASSET_DELETION,
data: {
id: asset.id,
id: assetId,
deleteOnDisk: true,
},
})),
);
}
}
private async restoreAndSend(auth: AuthDto, ids: string[]) {
if (ids.length === 0) {
return;
}
this.logger.log(`Queued ${count} assets for deletion from the trash`);
await this.assetRepository.restoreAll(ids);
await this.eventRepository.emit('assets.restore', { assetIds: ids, userId: auth.user.id });
return JobStatus.SUCCESS;
}
}

View File

@ -8,7 +8,7 @@ export const upsertTags = async (repository: ITagRepository, { userId, tags }: U
const results: TagEntity[] = [];
for (const tag of tags) {
const parts = tag.split('/');
const parts = tag.split('/').filter(Boolean);
let parent: TagEntity | undefined;
for (const part of parts) {

View File

@ -2,7 +2,7 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity';
import { StackEntity } from 'src/entities/stack.entity';
import { AssetFileType, AssetType } from 'src/enum';
import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
import { authStub } from 'test/fixtures/auth.stub';
import { fileStub } from 'test/fixtures/file.stub';
import { libraryStub } from 'test/fixtures/library.stub';
@ -42,6 +42,7 @@ export const stackStub = (stackId: string, assets: AssetEntity[]): StackEntity =
export const assetStub = {
noResizePath: Object.freeze<AssetEntity>({
id: 'asset-id',
status: AssetStatus.ACTIVE,
originalFileName: 'IMG_123.jpg',
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -76,6 +77,7 @@ export const assetStub = {
noWebpPath: Object.freeze<AssetEntity>({
id: 'asset-id',
status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -83,7 +85,6 @@ export const assetStub = {
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: 'upload/library/IMG_456.jpg',
files: [previewFile],
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.IMAGE,
@ -114,6 +115,7 @@ export const assetStub = {
noThumbhash: Object.freeze<AssetEntity>({
id: 'asset-id',
status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -148,6 +150,7 @@ export const assetStub = {
primaryImage: Object.freeze<AssetEntity>({
id: 'primary-asset-id',
status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -192,6 +195,7 @@ export const assetStub = {
image: Object.freeze<AssetEntity>({
id: 'asset-id',
status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -231,6 +235,7 @@ export const assetStub = {
trashed: Object.freeze<AssetEntity>({
id: 'asset-id',
status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -270,6 +275,7 @@ export const assetStub = {
archived: Object.freeze<AssetEntity>({
id: 'asset-id',
status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -309,6 +315,7 @@ export const assetStub = {
external: Object.freeze<AssetEntity>({
id: 'asset-id',
status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -348,6 +355,7 @@ export const assetStub = {
offline: Object.freeze<AssetEntity>({
id: 'asset-id',
status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -385,6 +393,7 @@ export const assetStub = {
externalOffline: Object.freeze<AssetEntity>({
id: 'asset-id',
status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -424,6 +433,7 @@ export const assetStub = {
image1: Object.freeze<AssetEntity>({
id: 'asset-id-1',
status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -461,6 +471,7 @@ export const assetStub = {
imageFrom2015: Object.freeze<AssetEntity>({
id: 'asset-id-1',
status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2015-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2015-02-23T05:06:29.716Z'),
@ -498,6 +509,7 @@ export const assetStub = {
video: Object.freeze<AssetEntity>({
id: 'asset-id',
status: AssetStatus.ACTIVE,
originalFileName: 'asset-id.ext',
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -536,6 +548,7 @@ export const assetStub = {
}),
livePhotoMotionAsset: Object.freeze({
status: AssetStatus.ACTIVE,
id: fileStub.livePhotoMotion.uuid,
originalPath: fileStub.livePhotoMotion.originalPath,
ownerId: authStub.user1.user.id,
@ -551,6 +564,7 @@ export const assetStub = {
liveMotionWithThumb: Object.freeze({
id: fileStub.livePhotoMotion.uuid,
status: AssetStatus.ACTIVE,
originalPath: fileStub.livePhotoMotion.originalPath,
ownerId: authStub.user1.user.id,
type: AssetType.VIDEO,
@ -581,6 +595,7 @@ export const assetStub = {
livePhotoStillAsset: Object.freeze({
id: 'live-photo-still-asset',
status: AssetStatus.ACTIVE,
originalPath: fileStub.livePhotoStill.originalPath,
ownerId: authStub.user1.user.id,
type: AssetType.IMAGE,
@ -596,6 +611,7 @@ export const assetStub = {
livePhotoStillAssetWithTheSameLivePhotoMotionAsset: Object.freeze({
id: 'live-photo-still-asset-1',
status: AssetStatus.ACTIVE,
originalPath: fileStub.livePhotoStill.originalPath,
ownerId: authStub.user1.user.id,
type: AssetType.IMAGE,
@ -611,6 +627,7 @@ export const assetStub = {
livePhotoWithOriginalFileName: Object.freeze({
id: 'live-photo-still-asset',
status: AssetStatus.ACTIVE,
originalPath: fileStub.livePhotoStill.originalPath,
originalFileName: fileStub.livePhotoStill.originalName,
ownerId: authStub.user1.user.id,
@ -627,6 +644,7 @@ export const assetStub = {
withLocation: Object.freeze<AssetEntity>({
id: 'asset-with-favorite-id',
status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-22T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-22T05:06:29.716Z'),
@ -668,6 +686,7 @@ export const assetStub = {
}),
sidecar: Object.freeze<AssetEntity>({
id: 'asset-id',
status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -701,6 +720,7 @@ export const assetStub = {
}),
sidecarWithoutExt: Object.freeze<AssetEntity>({
id: 'asset-id',
status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -735,6 +755,7 @@ export const assetStub = {
readOnly: Object.freeze<AssetEntity>({
id: 'read-only-asset',
status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -769,6 +790,7 @@ export const assetStub = {
hasEncodedVideo: Object.freeze<AssetEntity>({
id: 'asset-id',
status: AssetStatus.ACTIVE,
originalFileName: 'asset-id.ext',
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -805,6 +827,7 @@ export const assetStub = {
}),
missingFileExtension: Object.freeze<AssetEntity>({
id: 'asset-id',
status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -843,6 +866,7 @@ export const assetStub = {
}),
hasFileExtension: Object.freeze<AssetEntity>({
id: 'asset-id',
status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -881,6 +905,7 @@ export const assetStub = {
}),
imageDng: Object.freeze<AssetEntity>({
id: 'asset-id',
status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -919,6 +944,7 @@ export const assetStub = {
}),
hasEmbedding: Object.freeze<AssetEntity>({
id: 'asset-id-embedding',
status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -959,6 +985,7 @@ export const assetStub = {
}),
hasDupe: Object.freeze<AssetEntity>({
id: 'asset-id-dupe',
status: AssetStatus.ACTIVE,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),

View File

@ -5,7 +5,7 @@ import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto';
import { mapUser } from 'src/dtos/user.dto';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { UserEntity } from 'src/entities/user.entity';
import { AssetOrder, AssetType, SharedLinkType } from 'src/enum';
import { AssetOrder, AssetStatus, AssetType, SharedLinkType } from 'src/enum';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { userStub } from 'test/fixtures/user.stub';
@ -188,6 +188,7 @@ export const sharedLinkStub = {
assets: [
{
id: 'id_1',
status: AssetStatus.ACTIVE,
owner: undefined as unknown as UserEntity,
ownerId: 'user_id_1',
deviceAssetId: 'device_asset_id_1',

View File

@ -34,8 +34,6 @@ export const newAssetRepositoryMock = (): Mocked<IAssetRepository> => {
getStatistics: vitest.fn(),
getTimeBucket: vitest.fn(),
getTimeBuckets: vitest.fn(),
restoreAll: vitest.fn(),
softDeleteAll: vitest.fn(),
getAssetIdByCity: vitest.fn(),
getAssetIdByTag: vitest.fn(),
getAllForUserFullSync: vitest.fn(),

View File

@ -7,10 +7,5 @@ export const newMetadataRepositoryMock = (): Mocked<IMetadataRepository> => {
readTags: vitest.fn(),
writeTags: vitest.fn(),
extractBinaryTag: vitest.fn(),
getCameraMakes: vitest.fn(),
getCameraModels: vitest.fn(),
getCities: vitest.fn(),
getCountries: vitest.fn(),
getStates: vitest.fn(),
};
};

View File

@ -13,5 +13,10 @@ export const newSearchRepositoryMock = (): Mocked<ISearchRepository> => {
deleteAllSearchEmbeddings: vitest.fn(),
getDimensionSize: vitest.fn(),
setDimensionSize: vitest.fn(),
getCameraMakes: vitest.fn(),
getCameraModels: vitest.fn(),
getCities: vitest.fn(),
getCountries: vitest.fn(),
getStates: vitest.fn(),
};
};

View File

@ -0,0 +1,11 @@
import { ITrashRepository } from 'src/interfaces/trash.interface';
import { Mocked, vitest } from 'vitest';
export const newTrashRepositoryMock = (): Mocked<ITrashRepository> => {
return {
empty: vitest.fn(),
restore: vitest.fn(),
restoreAll: vitest.fn(),
getDeletedIds: vitest.fn(),
};
};

View File

@ -74,7 +74,7 @@
if (!theme) {
theme = { value: 'light', system: true };
} else if (theme === 'dark' || theme === 'light') {
theme = { value: item, system: false };
theme = { value: theme, system: false };
localStorage.setItem(colorThemeKeyName, JSON.stringify(theme));
} else {
theme = JSON.parse(theme);

View File

@ -1,5 +1,9 @@
<script lang="ts">
import { goto } from '$app/navigation';
import DetailPanelDescription from '$lib/components/asset-viewer/detail-panel-description.svelte';
import DetailPanelLocation from '$lib/components/asset-viewer/detail-panel-location.svelte';
import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte';
import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import ChangeDate from '$lib/components/shared-components/change-date.svelte';
import { AppRoute, QueryParameter, timeToLoadTheMap } from '$lib/constants';
@ -9,6 +13,9 @@
import { preferences, user } from '$lib/stores/user.store';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl, handlePromiseError, isSharedLink } from '$lib/utils';
import { delay, isFlipped } from '$lib/utils/asset-utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
import { fromDateTimeOriginal, fromLocalDateTime } from '$lib/utils/timeline-util';
import {
AssetMediaSize,
getAssetInfo,
@ -18,6 +25,7 @@
type ExifResponseDto,
} from '@immich/sdk';
import {
mdiAccountOff,
mdiCalendar,
mdiCameraIris,
mdiClose,
@ -26,24 +34,17 @@
mdiImageOutline,
mdiInformationOutline,
mdiPencil,
mdiAccountOff,
} from '@mdi/js';
import { DateTime } from 'luxon';
import { createEventDispatcher } from 'svelte';
import { t } from 'svelte-i18n';
import { slide } from 'svelte/transition';
import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import PersonSidePanel from '../faces-page/person-side-panel.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte';
import AlbumListItemDetails from './album-list-item-details.svelte';
import DetailPanelDescription from '$lib/components/asset-viewer/detail-panel-description.svelte';
import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte';
import { t } from 'svelte-i18n';
import { goto } from '$app/navigation';
import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte';
export let asset: AssetResponseDto;
export let albums: AlbumResponseDto[] = [];
@ -99,6 +100,12 @@
$: unassignedFaces = asset.unassignedFaces || [];
$: timeZone = asset.exifInfo?.timeZone;
$: dateTime =
timeZone && asset.exifInfo?.dateTimeOriginal
? fromDateTimeOriginal(asset.exifInfo.dateTimeOriginal, timeZone)
: fromLocalDateTime(asset.localDateTime);
const dispatch = createEventDispatcher<{
close: void;
}>();
@ -261,10 +268,7 @@
<p class="text-sm">{$t('no_exif_info_available').toUpperCase()}</p>
{/if}
{#if asset.exifInfo?.dateTimeOriginal}
{@const assetDateTimeOriginal = DateTime.fromISO(asset.exifInfo.dateTimeOriginal, {
zone: asset.exifInfo.timeZone ?? undefined,
})}
{#if dateTime}
<button
type="button"
class="flex w-full text-left justify-between place-items-start gap-4 py-4"
@ -280,7 +284,7 @@
<div>
<p>
{assetDateTimeOriginal.toLocaleString(
{dateTime.toLocaleString(
{
month: 'short',
day: 'numeric',
@ -291,12 +295,12 @@
</p>
<div class="flex gap-2 text-sm">
<p>
{assetDateTimeOriginal.toLocaleString(
{dateTime.toLocaleString(
{
weekday: 'short',
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'longOffset',
timeZoneName: timeZone ? 'longOffset' : undefined,
},
{ locale: $locale },
)}
@ -325,16 +329,9 @@
{/if}
{#if isShowChangeDate}
{@const assetDateTimeOriginal = asset.exifInfo?.dateTimeOriginal
? DateTime.fromISO(asset.exifInfo.dateTimeOriginal, {
zone: asset.exifInfo.timeZone ?? undefined,
locale: $locale,
})
: DateTime.now()}
{@const assetTimeZoneOriginal = asset.exifInfo?.timeZone ?? ''}
<ChangeDate
initialDate={assetDateTimeOriginal}
initialTimeZone={assetTimeZoneOriginal}
initialDate={dateTime}
initialTimeZone={timeZone ?? ''}
on:confirm={({ detail: date }) => handleConfirmChangeDate(date)}
on:cancel={() => (isShowChangeDate = false)}
/>

View File

@ -638,9 +638,7 @@ export class AssetStore {
this.options.userId ||
this.options.personId ||
this.options.albumId ||
isMismatched(this.options.isArchived, asset.isArchived) ||
isMismatched(this.options.isFavorite, asset.isFavorite) ||
isMismatched(this.options.isTrashed, asset.isTrashed)
this.isExcluded(asset)
) {
// If asset is already in the bucket we don't need to recalculate
// asset store containers
@ -699,26 +697,22 @@ export class AssetStore {
async findAndLoadBucketAsPending(id: string) {
const bucketInfo = this.assetToBucket[id];
if (bucketInfo) {
const bucket = bucketInfo.bucket;
let bucket: AssetBucket | null = bucketInfo?.bucket ?? null;
if (!bucket) {
const asset = await getAssetInfo({ id });
if (!asset || this.isExcluded(asset)) {
return;
}
bucket = await this.loadBucketAtTime(asset.localDateTime, { preventCancel: true, pending: true });
}
if (bucket && bucket.assets.some((a) => a.id === id)) {
this.pendingScrollBucket = bucket;
this.pendingScrollAssetId = id;
this.emit(false);
return bucket;
}
const asset = await getAssetInfo({ id });
if (asset) {
if (this.options.isArchived !== asset.isArchived) {
return;
}
const bucket = await this.loadBucketAtTime(asset.localDateTime, { preventCancel: true, pending: true });
if (bucket) {
this.pendingScrollBucket = bucket;
this.pendingScrollAssetId = asset.id;
this.emit(false);
}
return bucket;
}
}
/* Must be paired with matching clearPendingScroll() call */
@ -905,6 +899,14 @@ export class AssetStore {
}
this.store$.set(this);
}
private isExcluded(asset: AssetResponseDto) {
return (
isMismatched(this.options.isArchived ?? false, asset.isArchived) ||
isMismatched(this.options.isFavorite, asset.isFavorite) ||
isMismatched(this.options.isTrashed ?? false, asset.isTrashed)
);
}
}
export const isSelectingAllAssets = writable(false);

View File

@ -36,6 +36,9 @@ export type ScrollTargetListener = ({
export const fromLocalDateTime = (localDateTime: string) =>
DateTime.fromISO(localDateTime, { zone: 'UTC', locale: get(locale) });
export const fromDateTimeOriginal = (dateTimeOriginal: string, timeZone: string) =>
DateTime.fromISO(dateTimeOriginal, { zone: timeZone });
export const groupDateFormat: Intl.DateTimeFormatOptions = {
weekday: 'short',
month: 'short',

View File

@ -33,7 +33,8 @@
handlePromiseError(goto(AppRoute.PHOTOS));
}
const assetStore = new AssetStore({ isTrashed: true });
const options = { isTrashed: true };
const assetStore = new AssetStore(options);
const assetInteractionStore = createAssetInteractionStore();
const { isMultiSelectState, selectedAssets } = assetInteractionStore;
@ -47,16 +48,15 @@
}
try {
await emptyTrash();
const deletedAssetIds = assetStore.assets.map((a) => a.id);
const numberOfAssets = deletedAssetIds.length;
assetStore.removeAssets(deletedAssetIds);
const { count } = await emptyTrash();
notificationController.show({
message: $t('assets_permanently_deleted_count', { values: { count: numberOfAssets } }),
message: $t('assets_permanently_deleted_count', { values: { count } }),
type: NotificationType.Info,
});
// reset asset grid (TODO fix in asset store that it should reset when it is empty)
await assetStore.updateOptions(options);
} catch (error) {
handleError(error, $t('errors.unable_to_empty_trash'));
}
@ -71,16 +71,14 @@
return;
}
try {
await restoreTrash();
const restoredAssetIds = assetStore.assets.map((a) => a.id);
const numberOfAssets = restoredAssetIds.length;
assetStore.removeAssets(restoredAssetIds);
const { count } = await restoreTrash();
notificationController.show({
message: $t('assets_restored_count', { values: { count: numberOfAssets } }),
message: $t('assets_restored_count', { values: { count } }),
type: NotificationType.Info,
});
// reset asset grid (TODO fix in asset store that it should reset when it is empty)
await assetStore.updateOptions(options);
} catch (error) {
handleError(error, $t('errors.unable_to_restore_trash'));
}

View File

@ -18,8 +18,8 @@ export const assetFactory = Sync.makeFactory<AssetResponseDto>({
localDateTime: Sync.each(() => faker.date.past().toISOString()),
updatedAt: Sync.each(() => faker.date.past().toISOString()),
isFavorite: Sync.each(() => faker.datatype.boolean()),
isArchived: Sync.each(() => faker.datatype.boolean()),
isTrashed: Sync.each(() => faker.datatype.boolean()),
isArchived: false,
isTrashed: false,
duration: '0:00:00.00000',
checksum: Sync.each(() => faker.string.alphanumeric(28)),
isOffline: Sync.each(() => faker.datatype.boolean()),