mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04:00
refactor sync service
This commit is contained in:
parent
ca42420ca3
commit
c4047fd9b2
@ -55,9 +55,7 @@ custom_lint:
|
||||
restrict: package:photo_manager
|
||||
allowed:
|
||||
# required / wanted
|
||||
- 'lib/domain/interfaces/album_media.interface.dart'
|
||||
- 'lib/infrastructure/repositories/album_media.repository.dart'
|
||||
- 'lib/domain/services/sync.service.dart'
|
||||
- 'lib/repositories/{album,asset,file}_media.repository.dart'
|
||||
# acceptable exceptions for the time being
|
||||
- lib/entities/asset.entity.dart # to provide local AssetEntity for now
|
||||
|
@ -1,10 +1,31 @@
|
||||
import 'package:immich_mobile/domain/models/asset/asset.model.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:immich_mobile/domain/models/local_album.model.dart';
|
||||
|
||||
abstract interface class IAlbumMediaRepository {
|
||||
Future<List<AssetPathEntity>> getAll({PMFilter? filter});
|
||||
Future<List<LocalAlbum>> getAll({
|
||||
bool withModifiedTime = false,
|
||||
bool withAssetCount = false,
|
||||
bool withAssetTitle = false,
|
||||
});
|
||||
|
||||
Future<List<LocalAsset>> getAssetsForAlbum(AssetPathEntity album);
|
||||
Future<List<LocalAsset>> getAssetsForAlbum(
|
||||
String albumId, {
|
||||
bool withModifiedTime = false,
|
||||
bool withAssetTitle = true,
|
||||
DateTimeFilter? updateTimeCond,
|
||||
});
|
||||
|
||||
Future<AssetPathEntity> refresh(String albumId, {PMFilter? filter});
|
||||
Future<LocalAlbum> refresh(
|
||||
String albumId, {
|
||||
bool withModifiedTime = false,
|
||||
bool withAssetCount = false,
|
||||
bool withAssetTitle = false,
|
||||
});
|
||||
}
|
||||
|
||||
class DateTimeFilter {
|
||||
final DateTime min;
|
||||
final DateTime max;
|
||||
|
||||
const DateTimeFilter({required this.min, required this.max});
|
||||
}
|
||||
|
@ -1,17 +1,21 @@
|
||||
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/local_album.model.dart';
|
||||
|
||||
abstract interface class ILocalAlbumRepository implements IDatabaseRepository {
|
||||
Future<void> upsert(LocalAlbum localAlbum);
|
||||
Future<void> insert(LocalAlbum localAlbum, Iterable<LocalAsset> assets);
|
||||
|
||||
Future<void> addAssets(String albumId, Iterable<LocalAsset> assets);
|
||||
|
||||
Future<List<LocalAlbum>> getAll({SortLocalAlbumsBy? sortBy});
|
||||
|
||||
/// Get all asset ids that are only in the album and not in other albums.
|
||||
/// This is used to determine which assets are unique to the album.
|
||||
/// This is useful in cases where the album is a smart album or a user-created album, especially in iOS
|
||||
Future<List<String>> getAssetIdsOnlyInAlbum(String albumId);
|
||||
Future<List<LocalAsset>> getAssetsForAlbum(String albumId);
|
||||
|
||||
Future<void> update(LocalAlbum localAlbum);
|
||||
|
||||
Future<void> delete(String albumId);
|
||||
|
||||
Future<void> removeAssets(String albumId, Iterable<String> assetIds);
|
||||
}
|
||||
|
||||
enum SortLocalAlbumsBy { id }
|
||||
|
@ -1,11 +0,0 @@
|
||||
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/asset.model.dart';
|
||||
|
||||
abstract interface class ILocalAlbumAssetRepository
|
||||
implements IDatabaseRepository {
|
||||
Future<List<LocalAsset>> getAssetsForAlbum(String albumId);
|
||||
|
||||
Future<void> linkAssetsToAlbum(String albumId, Iterable<String> assetIds);
|
||||
|
||||
Future<void> unlinkAssetsFromAlbum(String albumId, Iterable<String> assetIds);
|
||||
}
|
@ -3,8 +3,4 @@ import 'package:immich_mobile/domain/models/asset/asset.model.dart';
|
||||
|
||||
abstract interface class ILocalAssetRepository implements IDatabaseRepository {
|
||||
Future<LocalAsset> get(String assetId);
|
||||
|
||||
Future<void> upsertAll(Iterable<LocalAsset> localAssets);
|
||||
|
||||
Future<void> deleteIds(Iterable<String> ids);
|
||||
}
|
||||
|
@ -30,7 +30,8 @@ class LocalAsset extends Asset {
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant LocalAsset other) {
|
||||
bool operator ==(Object other) {
|
||||
if (other is! LocalAsset) return false;
|
||||
if (identical(this, other)) return true;
|
||||
return super == other && localId == other.localId;
|
||||
}
|
||||
|
@ -33,7 +33,8 @@ class MergedAsset extends Asset {
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant MergedAsset other) {
|
||||
bool operator ==(Object other) {
|
||||
if (other is! MergedAsset) return false;
|
||||
if (identical(this, other)) return true;
|
||||
return super == other &&
|
||||
remoteId == other.remoteId &&
|
||||
|
@ -30,7 +30,8 @@ class RemoteAsset extends Asset {
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant RemoteAsset other) {
|
||||
bool operator ==(Object other) {
|
||||
if (other is! RemoteAsset) return false;
|
||||
if (identical(this, other)) return true;
|
||||
return super == other && remoteId == other.remoteId;
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
import 'package:immich_mobile/utils/nullable_value.dart';
|
||||
|
||||
enum BackupSelection {
|
||||
none,
|
||||
selected,
|
||||
@ -30,7 +32,7 @@ class LocalAlbum {
|
||||
String? name,
|
||||
DateTime? updatedAt,
|
||||
int? assetCount,
|
||||
String? thumbnailId,
|
||||
NullableValue<String>? thumbnailId,
|
||||
BackupSelection? backupSelection,
|
||||
bool? isAll,
|
||||
}) {
|
||||
@ -39,14 +41,15 @@ class LocalAlbum {
|
||||
name: name ?? this.name,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
assetCount: assetCount ?? this.assetCount,
|
||||
thumbnailId: thumbnailId ?? this.thumbnailId,
|
||||
thumbnailId: thumbnailId?.getOrDefault(this.thumbnailId),
|
||||
backupSelection: backupSelection ?? this.backupSelection,
|
||||
isAll: isAll ?? this.isAll,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant LocalAlbum other) {
|
||||
bool operator ==(Object other) {
|
||||
if (other is! LocalAlbum) return false;
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.id == id &&
|
||||
|
@ -3,72 +3,42 @@ import 'dart:async';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/album_media.interface.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/local_album.interface.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/local_album_asset.interface.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/local_asset.interface.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/asset.model.dart'
|
||||
hide AssetType;
|
||||
import 'package:immich_mobile/domain/models/asset/asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/local_album.model.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:immich_mobile/utils/nullable_value.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
class SyncService {
|
||||
final IAlbumMediaRepository _albumMediaRepository;
|
||||
final ILocalAlbumRepository _localAlbumRepository;
|
||||
final ILocalAssetRepository _localAssetRepository;
|
||||
final ILocalAlbumAssetRepository _localAlbumAssetRepository;
|
||||
final Logger _log = Logger("SyncService");
|
||||
|
||||
SyncService({
|
||||
required IAlbumMediaRepository albumMediaRepository,
|
||||
required ILocalAlbumRepository localAlbumRepository,
|
||||
required ILocalAssetRepository localAssetRepository,
|
||||
required ILocalAlbumAssetRepository localAlbumAssetRepository,
|
||||
}) : _albumMediaRepository = albumMediaRepository,
|
||||
_localAlbumRepository = localAlbumRepository,
|
||||
_localAssetRepository = localAssetRepository,
|
||||
_localAlbumAssetRepository = localAlbumAssetRepository;
|
||||
|
||||
late final albumFilter = FilterOptionGroup(
|
||||
imageOption: const FilterOption(
|
||||
// needTitle is expected to be slow on iOS but is required to fetch the asset title
|
||||
needTitle: true,
|
||||
sizeConstraint: SizeConstraint(ignoreSize: true),
|
||||
),
|
||||
videoOption: const FilterOption(
|
||||
needTitle: true,
|
||||
sizeConstraint: SizeConstraint(ignoreSize: true),
|
||||
durationConstraint: DurationConstraint(allowNullable: true),
|
||||
),
|
||||
// This is needed to get the modified time of the album
|
||||
containsPathModified: true,
|
||||
createTimeCond: DateTimeCond.def().copyWith(ignore: true),
|
||||
updateTimeCond: DateTimeCond.def().copyWith(ignore: true),
|
||||
orders: const [
|
||||
// Always sort the result by createdDate.des to update the thumbnail
|
||||
OrderOption(type: OrderOptionType.createDate, asc: false),
|
||||
],
|
||||
);
|
||||
_localAssetRepository = localAssetRepository;
|
||||
|
||||
Future<bool> syncLocalAlbums() async {
|
||||
try {
|
||||
final Stopwatch stopwatch = Stopwatch()..start();
|
||||
|
||||
// Use an AdvancedCustomFilter to get all albums faster
|
||||
final filter = AdvancedCustomFilter(
|
||||
orderBy: [OrderByItem.asc(CustomColumns.base.id)],
|
||||
);
|
||||
final deviceAlbums = await _albumMediaRepository.getAll(filter: filter);
|
||||
// The deviceAlbums will not have the updatedAt field
|
||||
// and the assetCount will be 0. They are refreshed later
|
||||
// after the comparison
|
||||
final deviceAlbums = await _albumMediaRepository.getAll();
|
||||
final dbAlbums =
|
||||
await _localAlbumRepository.getAll(sortBy: SortLocalAlbumsBy.id);
|
||||
|
||||
final hasChange = await diffSortedLists(
|
||||
dbAlbums,
|
||||
await Future.wait(
|
||||
deviceAlbums.map((a) => a.toDto(withAssetCount: false)),
|
||||
),
|
||||
deviceAlbums,
|
||||
compare: (a, b) => a.id.compareTo(b.id),
|
||||
both: diffLocalAlbums,
|
||||
both: syncLocalAlbum,
|
||||
onlyFirst: removeLocalAlbum,
|
||||
onlySecond: addLocalAlbum,
|
||||
);
|
||||
@ -85,31 +55,23 @@ class SyncService {
|
||||
Future<void> addLocalAlbum(LocalAlbum newAlbum) async {
|
||||
try {
|
||||
_log.info("Adding device album ${newAlbum.name}");
|
||||
final deviceAlbum =
|
||||
await _albumMediaRepository.refresh(newAlbum.id, filter: albumFilter);
|
||||
|
||||
final assets = newAlbum.assetCount > 0
|
||||
? (await _albumMediaRepository.getAssetsForAlbum(deviceAlbum))
|
||||
: <LocalAsset>[];
|
||||
final album = (await deviceAlbum.toDto()).copyWith(
|
||||
// The below assumes the list is already sorted by createdDate from the filter
|
||||
thumbnailId: assets.firstOrNull?.localId,
|
||||
final deviceAlbum = await _albumMediaRepository.refresh(
|
||||
newAlbum.id,
|
||||
withModifiedTime: true,
|
||||
withAssetCount: true,
|
||||
);
|
||||
|
||||
await _localAlbumRepository.transaction(() async {
|
||||
if (newAlbum.assetCount > 0) {
|
||||
await _localAssetRepository.upsertAll(assets);
|
||||
}
|
||||
// Needs to be after asset upsert to link the thumbnail
|
||||
await _localAlbumRepository.upsert(album);
|
||||
final assets = deviceAlbum.assetCount > 0
|
||||
? (await _albumMediaRepository.getAssetsForAlbum(deviceAlbum.id))
|
||||
: <LocalAsset>[];
|
||||
|
||||
if (newAlbum.assetCount > 0) {
|
||||
await _localAlbumAssetRepository.linkAssetsToAlbum(
|
||||
album.id,
|
||||
assets.map((a) => a.localId),
|
||||
);
|
||||
}
|
||||
});
|
||||
final album = deviceAlbum.copyWith(
|
||||
// The below assumes the list is already sorted by createdDate from the filter
|
||||
thumbnailId: NullableValue.valueOrEmpty(assets.firstOrNull?.localId),
|
||||
);
|
||||
|
||||
await _localAlbumRepository.insert(album, assets);
|
||||
_log.info("Successfully added device album ${album.name}");
|
||||
} catch (e, s) {
|
||||
_log.warning("Error while adding device album", e, s);
|
||||
}
|
||||
@ -118,55 +80,23 @@ class SyncService {
|
||||
Future<void> removeLocalAlbum(LocalAlbum a) async {
|
||||
_log.info("Removing device album ${a.name}");
|
||||
try {
|
||||
// Do not request title to speed things up on iOS
|
||||
final filter = albumFilter;
|
||||
filter.setOption(
|
||||
AssetType.image,
|
||||
filter.getOption(AssetType.image).copyWith(needTitle: false),
|
||||
);
|
||||
filter.setOption(
|
||||
AssetType.video,
|
||||
filter.getOption(AssetType.video).copyWith(needTitle: false),
|
||||
);
|
||||
final deviceAlbum =
|
||||
await _albumMediaRepository.refresh(a.id, filter: filter);
|
||||
final assetsToDelete =
|
||||
(await _localAlbumAssetRepository.getAssetsForAlbum(deviceAlbum.id))
|
||||
.map((asset) => asset.localId)
|
||||
.toSet();
|
||||
|
||||
// Remove all assets that are only in this particular album
|
||||
// We cannot remove all assets in the album because they might be in other albums in iOS
|
||||
final assetsOnlyInAlbum = assetsToDelete.isEmpty
|
||||
? <String>{}
|
||||
: (await _localAlbumRepository.getAssetIdsOnlyInAlbum(deviceAlbum.id))
|
||||
.toSet();
|
||||
await _localAlbumRepository.transaction(() async {
|
||||
// Delete all assets that are only in this particular album
|
||||
await _localAssetRepository.deleteIds(
|
||||
assetsToDelete.intersection(assetsOnlyInAlbum),
|
||||
);
|
||||
// Unlink the others
|
||||
await _localAlbumAssetRepository.unlinkAssetsFromAlbum(
|
||||
deviceAlbum.id,
|
||||
assetsToDelete.difference(assetsOnlyInAlbum),
|
||||
);
|
||||
await _localAlbumRepository.delete(deviceAlbum.id);
|
||||
});
|
||||
// Asset deletion is handled in the repository
|
||||
await _localAlbumRepository.delete(a.id);
|
||||
} catch (e, s) {
|
||||
_log.warning("Error while removing device album", e, s);
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
// The deviceAlbum is ignored since we are going to refresh it anyways
|
||||
FutureOr<bool> diffLocalAlbums(LocalAlbum dbAlbum, LocalAlbum _) async {
|
||||
FutureOr<bool> syncLocalAlbum(LocalAlbum dbAlbum, LocalAlbum _) async {
|
||||
try {
|
||||
_log.info("Syncing device album ${dbAlbum.name}");
|
||||
|
||||
final albumEntity =
|
||||
await _albumMediaRepository.refresh(dbAlbum.id, filter: albumFilter);
|
||||
final deviceAlbum = await albumEntity.toDto();
|
||||
final deviceAlbum = await _albumMediaRepository.refresh(
|
||||
dbAlbum.id,
|
||||
withModifiedTime: true,
|
||||
withAssetCount: true,
|
||||
);
|
||||
|
||||
// Early return if album hasn't changed
|
||||
if (deviceAlbum.updatedAt.isAtSameMomentAs(dbAlbum.updatedAt) &&
|
||||
@ -179,7 +109,7 @@ class SyncService {
|
||||
|
||||
// Skip empty albums that don't need syncing
|
||||
if (deviceAlbum.assetCount == 0 && dbAlbum.assetCount == 0) {
|
||||
await _localAlbumRepository.upsert(
|
||||
await _localAlbumRepository.update(
|
||||
deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection),
|
||||
);
|
||||
_log.info("Album ${dbAlbum.name} is empty. Only metadata updated.");
|
||||
@ -188,14 +118,14 @@ class SyncService {
|
||||
|
||||
_log.info("Device album ${dbAlbum.name} has changed. Syncing...");
|
||||
|
||||
// Handle the case where assets are only added - fast path
|
||||
if (await handleOnlyAssetsAdded(dbAlbum, deviceAlbum)) {
|
||||
// Faster path - only assets added
|
||||
if (await tryFastSync(dbAlbum, deviceAlbum)) {
|
||||
_log.info("Fast synced device album ${dbAlbum.name}");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Slower path - full sync
|
||||
return await handleAssetUpdate(dbAlbum, deviceAlbum, albumEntity);
|
||||
return await fullSync(dbAlbum, deviceAlbum);
|
||||
} catch (e, s) {
|
||||
_log.warning("Error while diff device album", e, s);
|
||||
}
|
||||
@ -203,10 +133,9 @@ class SyncService {
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<bool> handleOnlyAssetsAdded(
|
||||
LocalAlbum dbAlbum,
|
||||
LocalAlbum deviceAlbum,
|
||||
) async {
|
||||
// The [deviceAlbum] is expected to be refreshed before calling this method
|
||||
// with modified time and asset count
|
||||
Future<bool> tryFastSync(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async {
|
||||
try {
|
||||
_log.info("Fast syncing device album ${dbAlbum.name}");
|
||||
if (!deviceAlbum.updatedAt.isAfter(dbAlbum.updatedAt)) {
|
||||
@ -223,16 +152,13 @@ class SyncService {
|
||||
}
|
||||
|
||||
// Get all assets that are modified after the last known modifiedTime
|
||||
final filter = albumFilter.copyWith(
|
||||
updateTimeCond: DateTimeCond(
|
||||
final newAssets = await _albumMediaRepository.getAssetsForAlbum(
|
||||
deviceAlbum.id,
|
||||
updateTimeCond: DateTimeFilter(
|
||||
min: dbAlbum.updatedAt.add(const Duration(seconds: 1)),
|
||||
max: deviceAlbum.updatedAt,
|
||||
),
|
||||
);
|
||||
final modifiedAlbum =
|
||||
await _albumMediaRepository.refresh(deviceAlbum.id, filter: filter);
|
||||
final newAssets =
|
||||
await _albumMediaRepository.getAssetsForAlbum(modifiedAlbum);
|
||||
|
||||
// Early return if no new assets were found
|
||||
if (newAssets.isEmpty) {
|
||||
@ -262,19 +188,13 @@ class SyncService {
|
||||
}
|
||||
}
|
||||
|
||||
await _localAlbumRepository.transaction(() async {
|
||||
await _localAssetRepository.upsertAll(newAssets);
|
||||
await _localAlbumAssetRepository.linkAssetsToAlbum(
|
||||
deviceAlbum.id,
|
||||
newAssets.map(((a) => a.localId)),
|
||||
);
|
||||
await _localAlbumRepository.upsert(
|
||||
deviceAlbum.copyWith(
|
||||
thumbnailId: thumbnailId,
|
||||
backupSelection: dbAlbum.backupSelection,
|
||||
),
|
||||
);
|
||||
});
|
||||
await _handleUpdate(
|
||||
deviceAlbum.copyWith(
|
||||
thumbnailId: NullableValue.valueOrEmpty(thumbnailId),
|
||||
backupSelection: dbAlbum.backupSelection,
|
||||
),
|
||||
assetsToUpsert: newAssets,
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (e, s) {
|
||||
@ -284,100 +204,123 @@ class SyncService {
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<bool> handleAssetUpdate(
|
||||
LocalAlbum dbAlbum,
|
||||
LocalAlbum deviceAlbum,
|
||||
AssetPathEntity deviceAlbumEntity,
|
||||
) async {
|
||||
// The [deviceAlbum] is expected to be refreshed before calling this method
|
||||
// with modified time and asset count
|
||||
Future<bool> fullSync(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async {
|
||||
try {
|
||||
final assetsInDevice = deviceAlbum.assetCount > 0
|
||||
? await _albumMediaRepository.getAssetsForAlbum(deviceAlbumEntity)
|
||||
? await _albumMediaRepository.getAssetsForAlbum(deviceAlbum.id)
|
||||
: <LocalAsset>[];
|
||||
final assetsInDb = dbAlbum.assetCount > 0
|
||||
? await _localAlbumRepository.getAssetsForAlbum(dbAlbum.id)
|
||||
: <LocalAsset>[];
|
||||
|
||||
final assetsInDb = dbAlbum.assetCount > 0
|
||||
? await _localAlbumAssetRepository.getAssetsForAlbum(dbAlbum.id)
|
||||
: <LocalAsset>[];
|
||||
if (deviceAlbum.assetCount == 0) {
|
||||
_log.fine(
|
||||
"Device album ${deviceAlbum.name} is empty. Removing assets from DB.",
|
||||
);
|
||||
await _handleUpdate(
|
||||
deviceAlbum.copyWith(
|
||||
// Clear thumbnail for empty album
|
||||
thumbnailId: const NullableValue.empty(),
|
||||
backupSelection: dbAlbum.backupSelection,
|
||||
),
|
||||
assetIdsToDelete: assetsInDb.map((a) => a.localId),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The below assumes the list is already sorted by createdDate from the filter
|
||||
String? thumbnailId =
|
||||
assetsInDevice.firstOrNull?.localId ?? dbAlbum.thumbnailId;
|
||||
String? thumbnailId = assetsInDevice.isNotEmpty
|
||||
? assetsInDevice.firstOrNull?.localId
|
||||
: dbAlbum.thumbnailId;
|
||||
|
||||
final assetsToAdd = <LocalAsset>{},
|
||||
assetsToUpsert = <LocalAsset>{},
|
||||
assetsToDelete = <String>{};
|
||||
if (deviceAlbum.assetCount == 0) {
|
||||
assetsToDelete.addAll(assetsInDb.map((asset) => asset.localId));
|
||||
thumbnailId = null;
|
||||
} else if (dbAlbum.assetCount == 0) {
|
||||
assetsToAdd.addAll(assetsInDevice);
|
||||
} else {
|
||||
assetsInDb.sort((a, b) => a.localId.compareTo(b.localId));
|
||||
assetsInDevice.sort((a, b) => a.localId.compareTo(b.localId));
|
||||
diffSortedListsSync(
|
||||
assetsInDb,
|
||||
assetsInDevice,
|
||||
compare: (a, b) => a.localId.compareTo(b.localId),
|
||||
both: (dbAsset, deviceAsset) {
|
||||
if (dbAsset == deviceAsset) {
|
||||
return false;
|
||||
}
|
||||
assetsToUpsert.add(deviceAsset);
|
||||
return true;
|
||||
},
|
||||
onlyFirst: (dbAsset) => assetsToDelete.add(dbAsset.localId),
|
||||
onlySecond: (deviceAsset) => assetsToAdd.add(deviceAsset),
|
||||
);
|
||||
}
|
||||
_log.info(
|
||||
"Syncing ${deviceAlbum.name}. ${assetsToAdd.length} assets to add, ${assetsToUpsert.length} assets to update and ${assetsToDelete.length} assets to delete",
|
||||
);
|
||||
|
||||
// Populate the album meta
|
||||
final updatedAlbum = deviceAlbum.copyWith(
|
||||
thumbnailId: thumbnailId,
|
||||
final updatedDeviceAlbum = deviceAlbum.copyWith(
|
||||
thumbnailId: NullableValue.valueOrEmpty(thumbnailId),
|
||||
backupSelection: dbAlbum.backupSelection,
|
||||
);
|
||||
|
||||
// Remove all assets that are only in this particular album
|
||||
// We cannot remove all assets in the album because they might be in other albums in iOS
|
||||
final assetsOnlyInAlbum = assetsToDelete.isEmpty
|
||||
? <String>{}
|
||||
: (await _localAlbumRepository.getAssetIdsOnlyInAlbum(deviceAlbum.id))
|
||||
.toSet();
|
||||
if (dbAlbum.assetCount == 0) {
|
||||
_log.fine(
|
||||
"Device album ${deviceAlbum.name} is empty. Adding assets to DB.",
|
||||
);
|
||||
await _handleUpdate(updatedDeviceAlbum, assetsToUpsert: assetsInDevice);
|
||||
return true;
|
||||
}
|
||||
|
||||
await _localAlbumRepository.transaction(() async {
|
||||
await _localAssetRepository
|
||||
.upsertAll(assetsToAdd.followedBy(assetsToUpsert));
|
||||
await _localAlbumAssetRepository.linkAssetsToAlbum(
|
||||
dbAlbum.id,
|
||||
assetsToAdd.map((a) => a.localId),
|
||||
// Sort assets by localId for the diffSortedLists function
|
||||
assetsInDb.sort((a, b) => a.localId.compareTo(b.localId));
|
||||
assetsInDevice.sort((a, b) => a.localId.compareTo(b.localId));
|
||||
|
||||
final assetsToAddOrUpdate = <LocalAsset>[];
|
||||
final assetIdsToDelete = <String>[];
|
||||
|
||||
diffSortedListsSync(
|
||||
assetsInDb,
|
||||
assetsInDevice,
|
||||
compare: (a, b) => a.localId.compareTo(b.localId),
|
||||
both: (dbAsset, deviceAsset) {
|
||||
if (!_assetsEqual(dbAsset, deviceAsset)) {
|
||||
assetsToAddOrUpdate.add(deviceAsset);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onlyFirst: (dbAsset) => assetIdsToDelete.add(dbAsset.localId),
|
||||
onlySecond: (deviceAsset) => assetsToAddOrUpdate.add(deviceAsset),
|
||||
);
|
||||
|
||||
_log.info(
|
||||
"Syncing ${deviceAlbum.name}. ${assetsToAddOrUpdate.length} assets to add/update and ${assetIdsToDelete.length} assets to delete",
|
||||
);
|
||||
|
||||
if (assetsToAddOrUpdate.isEmpty && assetIdsToDelete.isEmpty) {
|
||||
_log.fine(
|
||||
"No asset changes detected in album ${deviceAlbum.name}. Updating metadata.",
|
||||
);
|
||||
await _localAlbumRepository.upsert(updatedAlbum);
|
||||
// Delete all assets that are only in this particular album
|
||||
await _localAssetRepository.deleteIds(
|
||||
assetsToDelete.intersection(assetsOnlyInAlbum),
|
||||
);
|
||||
// Unlink the others
|
||||
await _localAlbumAssetRepository.unlinkAssetsFromAlbum(
|
||||
dbAlbum.id,
|
||||
assetsToDelete.difference(assetsOnlyInAlbum),
|
||||
);
|
||||
});
|
||||
_localAlbumRepository.update(updatedDeviceAlbum);
|
||||
return true;
|
||||
}
|
||||
|
||||
await _handleUpdate(
|
||||
updatedDeviceAlbum,
|
||||
assetsToUpsert: assetsToAddOrUpdate,
|
||||
assetIdsToDelete: assetIdsToDelete,
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (e, s) {
|
||||
_log.warning("Error on full syncing local album: ${dbAlbum.name}", e, s);
|
||||
}
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _handleUpdate(
|
||||
LocalAlbum album, {
|
||||
Iterable<LocalAsset>? assetsToUpsert,
|
||||
Iterable<String>? assetIdsToDelete,
|
||||
}) =>
|
||||
_localAlbumRepository.transaction(() async {
|
||||
if (assetsToUpsert != null && assetsToUpsert.isNotEmpty) {
|
||||
await _localAlbumRepository.addAssets(album.id, assetsToUpsert);
|
||||
}
|
||||
|
||||
await _localAlbumRepository.update(album);
|
||||
|
||||
if (assetIdsToDelete != null && assetIdsToDelete.isNotEmpty) {
|
||||
await _localAlbumRepository.removeAssets(
|
||||
album.id,
|
||||
assetIdsToDelete,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Helper method to check if assets are equal without relying on the checksum
|
||||
bool _assetsEqual(LocalAsset a, LocalAsset b) {
|
||||
return a.updatedAt.isAtSameMomentAs(b.updatedAt) &&
|
||||
a.createdAt.isAtSameMomentAs(b.createdAt) &&
|
||||
a.width == b.width &&
|
||||
a.height == b.height &&
|
||||
a.durationInSeconds == b.durationInSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
extension AssetPathEntitySyncX on AssetPathEntity {
|
||||
Future<LocalAlbum> toDto({bool withAssetCount = true}) async => LocalAlbum(
|
||||
id: id,
|
||||
name: name,
|
||||
updatedAt: lastModified ?? DateTime.now(),
|
||||
// the assetCountAsync call is expensive for larger albums with several thousand assets
|
||||
assetCount: withAssetCount ? await assetCountAsync : 0,
|
||||
backupSelection: BackupSelection.none,
|
||||
isAll: isAll,
|
||||
);
|
||||
}
|
||||
|
@ -1,23 +1,79 @@
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/album_media.interface.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/asset.model.dart' as asset;
|
||||
import 'package:immich_mobile/domain/models/local_album.model.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
class AlbumMediaRepository implements IAlbumMediaRepository {
|
||||
const AlbumMediaRepository();
|
||||
|
||||
PMFilter _getAlbumFilter({
|
||||
withAssetTitle = false,
|
||||
withModifiedTime = false,
|
||||
DateTimeFilter? updateTimeCond,
|
||||
}) =>
|
||||
FilterOptionGroup(
|
||||
imageOption: FilterOption(
|
||||
// needTitle is expected to be slow on iOS but is required to fetch the asset title
|
||||
needTitle: withAssetTitle,
|
||||
sizeConstraint: const SizeConstraint(ignoreSize: true),
|
||||
),
|
||||
videoOption: FilterOption(
|
||||
needTitle: withAssetTitle,
|
||||
sizeConstraint: const SizeConstraint(ignoreSize: true),
|
||||
durationConstraint: const DurationConstraint(allowNullable: true),
|
||||
),
|
||||
// This is needed to get the modified time of the album
|
||||
containsPathModified: withModifiedTime,
|
||||
createTimeCond: DateTimeCond.def().copyWith(ignore: true),
|
||||
updateTimeCond: updateTimeCond == null
|
||||
? DateTimeCond.def().copyWith(ignore: true)
|
||||
: DateTimeCond(min: updateTimeCond.min, max: updateTimeCond.max),
|
||||
orders: const [
|
||||
// Always sort the result by createdDate.des to update the thumbnail
|
||||
OrderOption(type: OrderOptionType.createDate, asc: false),
|
||||
],
|
||||
);
|
||||
|
||||
@override
|
||||
Future<List<AssetPathEntity>> getAll({PMFilter? filter}) async {
|
||||
return await PhotoManager.getAssetPathList(
|
||||
Future<List<LocalAlbum>> getAll({
|
||||
withModifiedTime = false,
|
||||
withAssetCount = false,
|
||||
withAssetTitle = false,
|
||||
}) async {
|
||||
final filter = withModifiedTime || withAssetTitle
|
||||
? _getAlbumFilter(
|
||||
withAssetTitle: withAssetTitle,
|
||||
withModifiedTime: withModifiedTime,
|
||||
)
|
||||
|
||||
// Use an AdvancedCustomFilter to get all albums faster
|
||||
: AdvancedCustomFilter(
|
||||
orderBy: [OrderByItem.asc(CustomColumns.base.id)],
|
||||
);
|
||||
|
||||
final entities = await PhotoManager.getAssetPathList(
|
||||
hasAll: true,
|
||||
filterOption: filter,
|
||||
);
|
||||
return entities.toDtoList(withAssetCount: withAssetCount);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<asset.LocalAsset>> getAssetsForAlbum(
|
||||
AssetPathEntity assetPathEntity,
|
||||
) async {
|
||||
String albumId, {
|
||||
withModifiedTime = false,
|
||||
withAssetTitle = true,
|
||||
DateTimeFilter? updateTimeCond,
|
||||
}) async {
|
||||
final assetPathEntity = await AssetPathEntity.obtainPathFromProperties(
|
||||
id: albumId,
|
||||
optionGroup: _getAlbumFilter(
|
||||
withAssetTitle: withAssetTitle,
|
||||
withModifiedTime: withModifiedTime,
|
||||
updateTimeCond: updateTimeCond,
|
||||
),
|
||||
);
|
||||
final assets = <AssetEntity>[];
|
||||
int pageNumber = 0, lastPageCount = 0;
|
||||
do {
|
||||
@ -33,14 +89,23 @@ class AlbumMediaRepository implements IAlbumMediaRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AssetPathEntity> refresh(String albumId, {PMFilter? filter}) =>
|
||||
AssetPathEntity.obtainPathFromProperties(
|
||||
Future<LocalAlbum> refresh(
|
||||
String albumId, {
|
||||
withModifiedTime = false,
|
||||
withAssetCount = false,
|
||||
withAssetTitle = false,
|
||||
}) async =>
|
||||
(await AssetPathEntity.obtainPathFromProperties(
|
||||
id: albumId,
|
||||
optionGroup: filter,
|
||||
);
|
||||
optionGroup: _getAlbumFilter(
|
||||
withAssetTitle: withAssetTitle,
|
||||
withModifiedTime: withModifiedTime,
|
||||
),
|
||||
))
|
||||
.toDto(withAssetCount: withAssetCount);
|
||||
}
|
||||
|
||||
extension AssetEntityMediaRepoX on AssetEntity {
|
||||
extension on AssetEntity {
|
||||
Future<asset.LocalAsset> toDto() async {
|
||||
return asset.LocalAsset(
|
||||
localId: id,
|
||||
@ -60,7 +125,24 @@ extension AssetEntityMediaRepoX on AssetEntity {
|
||||
}
|
||||
}
|
||||
|
||||
extension AssetEntityListMediaRepoX on List<AssetEntity> {
|
||||
extension on List<AssetEntity> {
|
||||
Future<List<asset.LocalAsset>> toDtoList() =>
|
||||
Future.wait(map((a) => a.toDto()));
|
||||
}
|
||||
|
||||
extension on AssetPathEntity {
|
||||
Future<LocalAlbum> toDto({bool withAssetCount = true}) async => LocalAlbum(
|
||||
id: id,
|
||||
name: name,
|
||||
updatedAt: lastModified ?? DateTime.now(),
|
||||
// the assetCountAsync call is expensive for larger albums with several thousand assets
|
||||
assetCount: withAssetCount ? await assetCountAsync : 0,
|
||||
backupSelection: BackupSelection.none,
|
||||
isAll: isAll,
|
||||
);
|
||||
}
|
||||
|
||||
extension on List<AssetPathEntity> {
|
||||
Future<List<LocalAlbum>> toDtoList({bool withAssetCount = true}) =>
|
||||
Future.wait(map((a) => a.toDto(withAssetCount: withAssetCount)));
|
||||
}
|
||||
|
@ -1,17 +1,118 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/local_album.interface.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/local_album.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:platform/platform.dart';
|
||||
|
||||
class DriftLocalAlbumRepository extends DriftDatabaseRepository
|
||||
implements ILocalAlbumRepository {
|
||||
final Drift _db;
|
||||
const DriftLocalAlbumRepository(this._db) : super(_db);
|
||||
final Platform _platform;
|
||||
const DriftLocalAlbumRepository(this._db, {Platform? platform})
|
||||
: _platform = platform ?? const LocalPlatform(),
|
||||
super(_db);
|
||||
|
||||
@override
|
||||
Future<void> upsert(LocalAlbum localAlbum) {
|
||||
Future<List<LocalAlbum>> getAll({SortLocalAlbumsBy? sortBy}) {
|
||||
final query = _db.localAlbumEntity.select();
|
||||
if (sortBy == SortLocalAlbumsBy.id) {
|
||||
query.orderBy([(a) => OrderingTerm.asc(a.id)]);
|
||||
}
|
||||
return query.map((a) => a.toDto()).get();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String albumId) => transaction(() async {
|
||||
// Remove all assets that are only in this particular album
|
||||
// We cannot remove all assets in the album because they might be in other albums in iOS
|
||||
// That is not the case on Android since asset <-> album has one:one mapping
|
||||
final assetsToDelete = _platform.isIOS
|
||||
? await _getUniqueAssetsInAlbum(albumId)
|
||||
: await _getAssetsIdsInAlbum(albumId);
|
||||
if (assetsToDelete.isNotEmpty) {
|
||||
await _deleteAssets(assetsToDelete);
|
||||
}
|
||||
|
||||
// All the other assets that are still associated will be unlinked automatically on-cascade
|
||||
await _db.managers.localAlbumEntity
|
||||
.filter((a) => a.id.equals(albumId))
|
||||
.delete();
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> insert(LocalAlbum localAlbum, Iterable<LocalAsset> assets) =>
|
||||
transaction(() async {
|
||||
if (localAlbum.assetCount > 0) {
|
||||
await _upsertAssets(assets);
|
||||
}
|
||||
// Needs to be after asset upsert to link the thumbnail
|
||||
await _upsertAlbum(localAlbum);
|
||||
|
||||
if (localAlbum.assetCount > 0) {
|
||||
await _linkAssetsToAlbum(localAlbum.id, assets);
|
||||
}
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> addAssets(String albumId, Iterable<LocalAsset> assets) =>
|
||||
transaction(() async {
|
||||
await _upsertAssets(assets);
|
||||
await _linkAssetsToAlbum(albumId, assets);
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> removeAssets(String albumId, Iterable<String> assetIds) async {
|
||||
if (_platform.isAndroid) {
|
||||
await _deleteAssets(assetIds);
|
||||
return;
|
||||
}
|
||||
|
||||
final uniqueAssets = await _getUniqueAssetsInAlbum(albumId);
|
||||
if (uniqueAssets.isEmpty) {
|
||||
await _unlinkAssetsFromAlbum(albumId, assetIds);
|
||||
return;
|
||||
}
|
||||
// Delete unique assets and unlink others
|
||||
final uniqueSet = uniqueAssets.toSet();
|
||||
final assetsToDelete = <String>[];
|
||||
final assetsToUnLink = <String>[];
|
||||
for (final assetId in assetIds) {
|
||||
if (uniqueSet.contains(assetId)) {
|
||||
assetsToDelete.add(assetId);
|
||||
} else {
|
||||
assetsToUnLink.add(assetId);
|
||||
}
|
||||
}
|
||||
await _unlinkAssetsFromAlbum(albumId, assetsToUnLink);
|
||||
await _deleteAssets(assetsToDelete);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> update(LocalAlbum localAlbum) => _upsertAlbum(localAlbum);
|
||||
|
||||
@override
|
||||
Future<List<LocalAsset>> getAssetsForAlbum(String albumId) {
|
||||
final query = _db.localAlbumAssetEntity.select().join(
|
||||
[
|
||||
innerJoin(
|
||||
_db.localAssetEntity,
|
||||
_db.localAlbumAssetEntity.assetId
|
||||
.equalsExp(_db.localAssetEntity.localId),
|
||||
),
|
||||
],
|
||||
)..where(_db.localAlbumAssetEntity.albumId.equals(albumId));
|
||||
return query
|
||||
.map((row) => row.readTable(_db.localAssetEntity).toDto())
|
||||
.get();
|
||||
}
|
||||
|
||||
Future<void> _upsertAlbum(LocalAlbum localAlbum) {
|
||||
final companion = LocalAlbumEntityCompanion.insert(
|
||||
id: localAlbum.id,
|
||||
name: localAlbum.name,
|
||||
@ -26,22 +127,43 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository
|
||||
.insertOne(companion, onConflict: DoUpdate((_) => companion));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<LocalAlbum>> getAll({SortLocalAlbumsBy? sortBy}) {
|
||||
final query = _db.localAlbumEntity.select();
|
||||
if (sortBy == SortLocalAlbumsBy.id) {
|
||||
query.orderBy([(a) => OrderingTerm.asc(a.id)]);
|
||||
}
|
||||
return query.map((a) => a.toDto()).get();
|
||||
Future<void> _linkAssetsToAlbum(
|
||||
String albumId,
|
||||
Iterable<LocalAsset> assets,
|
||||
) =>
|
||||
_db.batch(
|
||||
(batch) => batch.insertAll(
|
||||
_db.localAlbumAssetEntity,
|
||||
assets.map(
|
||||
(a) => LocalAlbumAssetEntityCompanion.insert(
|
||||
assetId: a.localId,
|
||||
albumId: albumId,
|
||||
),
|
||||
),
|
||||
mode: InsertMode.insertOrIgnore,
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> _unlinkAssetsFromAlbum(
|
||||
String albumId,
|
||||
Iterable<String> assetIds,
|
||||
) =>
|
||||
_db.batch(
|
||||
(batch) => batch.deleteWhere(
|
||||
_db.localAlbumAssetEntity,
|
||||
(f) => f.assetId.isIn(assetIds) & f.albumId.equals(albumId),
|
||||
),
|
||||
);
|
||||
|
||||
Future<List<String>> _getAssetsIdsInAlbum(String albumId) {
|
||||
final query = _db.localAlbumAssetEntity.select()
|
||||
..where((row) => row.albumId.equals(albumId));
|
||||
return query.map((row) => row.assetId).get();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String albumId) => _db.managers.localAlbumEntity
|
||||
.filter((a) => a.id.equals(albumId))
|
||||
.delete();
|
||||
|
||||
@override
|
||||
Future<List<String>> getAssetIdsOnlyInAlbum(String albumId) {
|
||||
/// Get all asset ids that are only in this album and not in other albums.
|
||||
/// This is useful in cases where the album is a smart album or a user-created album, especially on iOS
|
||||
Future<List<String>> _getUniqueAssetsInAlbum(String albumId) {
|
||||
final assetId = _db.localAlbumAssetEntity.assetId;
|
||||
final query = _db.localAlbumAssetEntity.selectOnly()
|
||||
..addColumns([assetId])
|
||||
@ -53,4 +175,31 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository
|
||||
|
||||
return query.map((row) => row.read(assetId)!).get();
|
||||
}
|
||||
|
||||
Future<void> _upsertAssets(Iterable<LocalAsset> localAssets) =>
|
||||
_db.batch((batch) async {
|
||||
batch.insertAllOnConflictUpdate(
|
||||
_db.localAssetEntity,
|
||||
localAssets.map(
|
||||
(a) => LocalAssetEntityCompanion.insert(
|
||||
name: a.name,
|
||||
type: a.type,
|
||||
createdAt: Value(a.createdAt),
|
||||
updatedAt: Value(a.updatedAt),
|
||||
width: Value.absentIfNull(a.width),
|
||||
height: Value.absentIfNull(a.height),
|
||||
durationInSeconds: Value.absentIfNull(a.durationInSeconds),
|
||||
localId: a.localId,
|
||||
checksum: Value.absentIfNull(a.checksum),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
Future<void> _deleteAssets(Iterable<String> ids) => _db.batch(
|
||||
(batch) => batch.deleteWhere(
|
||||
_db.localAssetEntity,
|
||||
(f) => f.localId.isIn(ids),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -1,55 +0,0 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/local_album_asset.interface.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
class DriftLocalAlbumAssetRepository extends DriftDatabaseRepository
|
||||
implements ILocalAlbumAssetRepository {
|
||||
final Drift _db;
|
||||
const DriftLocalAlbumAssetRepository(super._db) : _db = _db;
|
||||
|
||||
@override
|
||||
Future<void> linkAssetsToAlbum(String albumId, Iterable<String> assetIds) =>
|
||||
_db.batch(
|
||||
(batch) => batch.insertAll(
|
||||
_db.localAlbumAssetEntity,
|
||||
assetIds.map(
|
||||
(a) => LocalAlbumAssetEntityCompanion.insert(
|
||||
assetId: a,
|
||||
albumId: albumId,
|
||||
),
|
||||
),
|
||||
mode: InsertMode.insertOrIgnore,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<List<LocalAsset>> getAssetsForAlbum(String albumId) {
|
||||
final query = _db.localAlbumAssetEntity.select().join(
|
||||
[
|
||||
innerJoin(
|
||||
_db.localAssetEntity,
|
||||
_db.localAlbumAssetEntity.assetId
|
||||
.equalsExp(_db.localAssetEntity.localId),
|
||||
),
|
||||
],
|
||||
)..where(_db.localAlbumAssetEntity.albumId.equals(albumId));
|
||||
return query
|
||||
.map((row) => row.readTable(_db.localAssetEntity).toDto())
|
||||
.get();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> unlinkAssetsFromAlbum(
|
||||
String albumId,
|
||||
Iterable<String> assetIds,
|
||||
) =>
|
||||
_db.batch(
|
||||
(batch) => batch.deleteWhere(
|
||||
_db.localAlbumAssetEntity,
|
||||
(f) => f.assetId.isIn(assetIds),
|
||||
),
|
||||
);
|
||||
}
|
@ -1,8 +1,6 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/local_asset.interface.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
class DriftLocalAssetRepository extends DriftDatabaseRepository
|
||||
@ -10,35 +8,6 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository
|
||||
final Drift _db;
|
||||
const DriftLocalAssetRepository(this._db) : super(_db);
|
||||
|
||||
@override
|
||||
Future<void> deleteIds(Iterable<String> ids) => _db.batch(
|
||||
(batch) => batch.deleteWhere(
|
||||
_db.localAssetEntity,
|
||||
(f) => f.localId.isIn(ids),
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> upsertAll(Iterable<LocalAsset> localAssets) =>
|
||||
_db.batch((batch) async {
|
||||
batch.insertAllOnConflictUpdate(
|
||||
_db.localAssetEntity,
|
||||
localAssets.map(
|
||||
(a) => LocalAssetEntityCompanion.insert(
|
||||
name: a.name,
|
||||
type: a.type,
|
||||
createdAt: Value(a.createdAt),
|
||||
updatedAt: Value(a.updatedAt),
|
||||
width: Value.absentIfNull(a.width),
|
||||
height: Value.absentIfNull(a.height),
|
||||
durationInSeconds: Value.absentIfNull(a.durationInSeconds),
|
||||
localId: a.localId,
|
||||
checksum: Value.absentIfNull(a.checksum),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@override
|
||||
Future<LocalAsset> get(String assetId) => _db.managers.localAssetEntity
|
||||
.filter((f) => f.localId(assetId))
|
||||
|
23
mobile/lib/utils/nullable_value.dart
Normal file
23
mobile/lib/utils/nullable_value.dart
Normal file
@ -0,0 +1,23 @@
|
||||
/// A utility class to represent a value that can be either present or absent.
|
||||
/// This is useful for cases where you want to distinguish between a value
|
||||
/// being explicitly set to null and a value not being set at all.
|
||||
class NullableValue<T> {
|
||||
final bool _present;
|
||||
final T? _value;
|
||||
|
||||
const NullableValue._(this._value, {bool present = false})
|
||||
: _present = present;
|
||||
|
||||
/// Forces the value to be null
|
||||
const NullableValue.empty() : this._(null, present: true);
|
||||
|
||||
/// Uses the value if it's not null, otherwise null or default value
|
||||
const NullableValue.value(T? value) : this._(value, present: value != null);
|
||||
|
||||
/// Always uses the value even if it's null
|
||||
const NullableValue.valueOrEmpty(T? value) : this._(value, present: true);
|
||||
|
||||
T? get() => _present ? _value : null;
|
||||
|
||||
T? getOrDefault(T? defaultValue) => _present ? _value : defaultValue;
|
||||
}
|
@ -1265,7 +1265,7 @@ packages:
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: platform
|
||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||
|
@ -51,6 +51,7 @@ dependencies:
|
||||
permission_handler: ^11.4.0
|
||||
photo_manager: ^3.6.4
|
||||
photo_manager_image_provider: ^2.2.0
|
||||
platform: ^3.1.6
|
||||
punycode: ^1.0.0
|
||||
riverpod_annotation: ^2.6.1
|
||||
scrollable_positioned_list: ^0.3.8
|
||||
|
@ -12,14 +12,16 @@ import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/services/background.service.dart';
|
||||
import 'package:immich_mobile/services/hash.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
import '../../external.mock.dart';
|
||||
import '../../fixtures/asset.stub.dart';
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
import '../../service.mocks.dart';
|
||||
|
||||
class MockAsset extends Mock implements Asset {}
|
||||
|
||||
class MockAssetEntity extends Mock implements AssetEntity {}
|
||||
|
||||
void main() {
|
||||
late HashService sut;
|
||||
late BackgroundService mockBackgroundService;
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,10 +0,0 @@
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
class MockAssetEntity extends Mock implements AssetEntity {}
|
||||
|
||||
class FakeAssetEntity extends Fake implements AssetEntity {}
|
||||
|
||||
class MockAssetPathEntity extends Mock implements AssetPathEntity {}
|
||||
|
||||
class FakeAssetPathEntity extends Fake implements AssetPathEntity {}
|
10
mobile/test/fixtures/local_album.stub.dart
vendored
10
mobile/test/fixtures/local_album.stub.dart
vendored
@ -12,4 +12,14 @@ abstract final class LocalAlbumStub {
|
||||
backupSelection: BackupSelection.none,
|
||||
isAll: false,
|
||||
);
|
||||
|
||||
static LocalAlbum get album2 => LocalAlbum(
|
||||
id: "album2",
|
||||
name: "Album 2",
|
||||
updatedAt: DateTime(2025),
|
||||
assetCount: 2,
|
||||
thumbnailId: null,
|
||||
backupSelection: BackupSelection.selected,
|
||||
isAll: true,
|
||||
);
|
||||
}
|
||||
|
24
mobile/test/fixtures/local_asset.stub.dart
vendored
24
mobile/test/fixtures/local_asset.stub.dart
vendored
@ -14,4 +14,28 @@ abstract final class LocalAssetStub {
|
||||
height: 1080,
|
||||
durationInSeconds: 0,
|
||||
);
|
||||
|
||||
static LocalAsset get image2 => LocalAsset(
|
||||
localId: "image2",
|
||||
name: "image2.jpg",
|
||||
checksum: "image2-checksum",
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2020),
|
||||
updatedAt: DateTime(2023),
|
||||
width: 300,
|
||||
height: 400,
|
||||
durationInSeconds: 0,
|
||||
);
|
||||
|
||||
static LocalAsset get video1 => LocalAsset(
|
||||
localId: "video1",
|
||||
name: "video1.mov",
|
||||
checksum: "video1-checksum",
|
||||
type: AssetType.video,
|
||||
createdAt: DateTime(2021),
|
||||
updatedAt: DateTime(2025),
|
||||
width: 720,
|
||||
height: 640,
|
||||
durationInSeconds: 120,
|
||||
);
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
import 'package:immich_mobile/domain/interfaces/album_media.interface.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/local_album.interface.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/local_album_asset.interface.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/local_asset.interface.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/log.interface.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/store.interface.dart';
|
||||
@ -28,9 +27,6 @@ class MockLocalAlbumRepository extends Mock implements ILocalAlbumRepository {}
|
||||
|
||||
class MockAlbumMediaRepository extends Mock implements IAlbumMediaRepository {}
|
||||
|
||||
class MockLocalAlbumAssetRepository extends Mock
|
||||
implements ILocalAlbumAssetRepository {}
|
||||
|
||||
// API Repos
|
||||
class MockUserApiRepository extends Mock implements IUserApiRepository {}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user