mirror of
https://github.com/immich-app/immich.git
synced 2025-05-30 19:54:52 -04:00
refactor sync service
This commit is contained in:
parent
ca42420ca3
commit
c4047fd9b2
@ -55,9 +55,7 @@ custom_lint:
|
|||||||
restrict: package:photo_manager
|
restrict: package:photo_manager
|
||||||
allowed:
|
allowed:
|
||||||
# required / wanted
|
# required / wanted
|
||||||
- 'lib/domain/interfaces/album_media.interface.dart'
|
|
||||||
- 'lib/infrastructure/repositories/album_media.repository.dart'
|
- 'lib/infrastructure/repositories/album_media.repository.dart'
|
||||||
- 'lib/domain/services/sync.service.dart'
|
|
||||||
- 'lib/repositories/{album,asset,file}_media.repository.dart'
|
- 'lib/repositories/{album,asset,file}_media.repository.dart'
|
||||||
# acceptable exceptions for the time being
|
# acceptable exceptions for the time being
|
||||||
- lib/entities/asset.entity.dart # to provide local AssetEntity for now
|
- 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: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 {
|
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/interfaces/db.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/domain/models/local_album.model.dart';
|
||||||
|
|
||||||
abstract interface class ILocalAlbumRepository implements IDatabaseRepository {
|
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});
|
Future<List<LocalAlbum>> getAll({SortLocalAlbumsBy? sortBy});
|
||||||
|
|
||||||
/// Get all asset ids that are only in the album and not in other albums.
|
Future<List<LocalAsset>> getAssetsForAlbum(String albumId);
|
||||||
/// 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<void> update(LocalAlbum localAlbum);
|
||||||
Future<List<String>> getAssetIdsOnlyInAlbum(String albumId);
|
|
||||||
|
|
||||||
Future<void> delete(String albumId);
|
Future<void> delete(String albumId);
|
||||||
|
|
||||||
|
Future<void> removeAssets(String albumId, Iterable<String> assetIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SortLocalAlbumsBy { id }
|
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 {
|
abstract interface class ILocalAssetRepository implements IDatabaseRepository {
|
||||||
Future<LocalAsset> get(String assetId);
|
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
|
@override
|
||||||
bool operator ==(covariant LocalAsset other) {
|
bool operator ==(Object other) {
|
||||||
|
if (other is! LocalAsset) return false;
|
||||||
if (identical(this, other)) return true;
|
if (identical(this, other)) return true;
|
||||||
return super == other && localId == other.localId;
|
return super == other && localId == other.localId;
|
||||||
}
|
}
|
||||||
|
@ -33,7 +33,8 @@ class MergedAsset extends Asset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(covariant MergedAsset other) {
|
bool operator ==(Object other) {
|
||||||
|
if (other is! MergedAsset) return false;
|
||||||
if (identical(this, other)) return true;
|
if (identical(this, other)) return true;
|
||||||
return super == other &&
|
return super == other &&
|
||||||
remoteId == other.remoteId &&
|
remoteId == other.remoteId &&
|
||||||
|
@ -30,7 +30,8 @@ class RemoteAsset extends Asset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(covariant RemoteAsset other) {
|
bool operator ==(Object other) {
|
||||||
|
if (other is! RemoteAsset) return false;
|
||||||
if (identical(this, other)) return true;
|
if (identical(this, other)) return true;
|
||||||
return super == other && remoteId == other.remoteId;
|
return super == other && remoteId == other.remoteId;
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import 'package:immich_mobile/utils/nullable_value.dart';
|
||||||
|
|
||||||
enum BackupSelection {
|
enum BackupSelection {
|
||||||
none,
|
none,
|
||||||
selected,
|
selected,
|
||||||
@ -30,7 +32,7 @@ class LocalAlbum {
|
|||||||
String? name,
|
String? name,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
int? assetCount,
|
int? assetCount,
|
||||||
String? thumbnailId,
|
NullableValue<String>? thumbnailId,
|
||||||
BackupSelection? backupSelection,
|
BackupSelection? backupSelection,
|
||||||
bool? isAll,
|
bool? isAll,
|
||||||
}) {
|
}) {
|
||||||
@ -39,14 +41,15 @@ class LocalAlbum {
|
|||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
updatedAt: updatedAt ?? this.updatedAt,
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
assetCount: assetCount ?? this.assetCount,
|
assetCount: assetCount ?? this.assetCount,
|
||||||
thumbnailId: thumbnailId ?? this.thumbnailId,
|
thumbnailId: thumbnailId?.getOrDefault(this.thumbnailId),
|
||||||
backupSelection: backupSelection ?? this.backupSelection,
|
backupSelection: backupSelection ?? this.backupSelection,
|
||||||
isAll: isAll ?? this.isAll,
|
isAll: isAll ?? this.isAll,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(covariant LocalAlbum other) {
|
bool operator ==(Object other) {
|
||||||
|
if (other is! LocalAlbum) return false;
|
||||||
if (identical(this, other)) return true;
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
return other.id == id &&
|
return other.id == id &&
|
||||||
|
@ -3,72 +3,42 @@ import 'dart:async';
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/album_media.interface.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.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/local_asset.interface.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/asset.model.dart'
|
import 'package:immich_mobile/domain/models/asset/asset.model.dart';
|
||||||
hide AssetType;
|
|
||||||
import 'package:immich_mobile/domain/models/local_album.model.dart';
|
import 'package:immich_mobile/domain/models/local_album.model.dart';
|
||||||
import 'package:immich_mobile/utils/diff.dart';
|
import 'package:immich_mobile/utils/diff.dart';
|
||||||
|
import 'package:immich_mobile/utils/nullable_value.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
|
||||||
|
|
||||||
class SyncService {
|
class SyncService {
|
||||||
final IAlbumMediaRepository _albumMediaRepository;
|
final IAlbumMediaRepository _albumMediaRepository;
|
||||||
final ILocalAlbumRepository _localAlbumRepository;
|
final ILocalAlbumRepository _localAlbumRepository;
|
||||||
final ILocalAssetRepository _localAssetRepository;
|
final ILocalAssetRepository _localAssetRepository;
|
||||||
final ILocalAlbumAssetRepository _localAlbumAssetRepository;
|
|
||||||
final Logger _log = Logger("SyncService");
|
final Logger _log = Logger("SyncService");
|
||||||
|
|
||||||
SyncService({
|
SyncService({
|
||||||
required IAlbumMediaRepository albumMediaRepository,
|
required IAlbumMediaRepository albumMediaRepository,
|
||||||
required ILocalAlbumRepository localAlbumRepository,
|
required ILocalAlbumRepository localAlbumRepository,
|
||||||
required ILocalAssetRepository localAssetRepository,
|
required ILocalAssetRepository localAssetRepository,
|
||||||
required ILocalAlbumAssetRepository localAlbumAssetRepository,
|
|
||||||
}) : _albumMediaRepository = albumMediaRepository,
|
}) : _albumMediaRepository = albumMediaRepository,
|
||||||
_localAlbumRepository = localAlbumRepository,
|
_localAlbumRepository = localAlbumRepository,
|
||||||
_localAssetRepository = localAssetRepository,
|
_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),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<bool> syncLocalAlbums() async {
|
Future<bool> syncLocalAlbums() async {
|
||||||
try {
|
try {
|
||||||
final Stopwatch stopwatch = Stopwatch()..start();
|
final Stopwatch stopwatch = Stopwatch()..start();
|
||||||
|
// The deviceAlbums will not have the updatedAt field
|
||||||
// Use an AdvancedCustomFilter to get all albums faster
|
// and the assetCount will be 0. They are refreshed later
|
||||||
final filter = AdvancedCustomFilter(
|
// after the comparison
|
||||||
orderBy: [OrderByItem.asc(CustomColumns.base.id)],
|
final deviceAlbums = await _albumMediaRepository.getAll();
|
||||||
);
|
|
||||||
final deviceAlbums = await _albumMediaRepository.getAll(filter: filter);
|
|
||||||
final dbAlbums =
|
final dbAlbums =
|
||||||
await _localAlbumRepository.getAll(sortBy: SortLocalAlbumsBy.id);
|
await _localAlbumRepository.getAll(sortBy: SortLocalAlbumsBy.id);
|
||||||
|
|
||||||
final hasChange = await diffSortedLists(
|
final hasChange = await diffSortedLists(
|
||||||
dbAlbums,
|
dbAlbums,
|
||||||
await Future.wait(
|
deviceAlbums,
|
||||||
deviceAlbums.map((a) => a.toDto(withAssetCount: false)),
|
|
||||||
),
|
|
||||||
compare: (a, b) => a.id.compareTo(b.id),
|
compare: (a, b) => a.id.compareTo(b.id),
|
||||||
both: diffLocalAlbums,
|
both: syncLocalAlbum,
|
||||||
onlyFirst: removeLocalAlbum,
|
onlyFirst: removeLocalAlbum,
|
||||||
onlySecond: addLocalAlbum,
|
onlySecond: addLocalAlbum,
|
||||||
);
|
);
|
||||||
@ -85,31 +55,23 @@ class SyncService {
|
|||||||
Future<void> addLocalAlbum(LocalAlbum newAlbum) async {
|
Future<void> addLocalAlbum(LocalAlbum newAlbum) async {
|
||||||
try {
|
try {
|
||||||
_log.info("Adding device album ${newAlbum.name}");
|
_log.info("Adding device album ${newAlbum.name}");
|
||||||
final deviceAlbum =
|
final deviceAlbum = await _albumMediaRepository.refresh(
|
||||||
await _albumMediaRepository.refresh(newAlbum.id, filter: albumFilter);
|
newAlbum.id,
|
||||||
|
withModifiedTime: true,
|
||||||
final assets = newAlbum.assetCount > 0
|
withAssetCount: true,
|
||||||
? (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,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await _localAlbumRepository.transaction(() async {
|
final assets = deviceAlbum.assetCount > 0
|
||||||
if (newAlbum.assetCount > 0) {
|
? (await _albumMediaRepository.getAssetsForAlbum(deviceAlbum.id))
|
||||||
await _localAssetRepository.upsertAll(assets);
|
: <LocalAsset>[];
|
||||||
}
|
|
||||||
// Needs to be after asset upsert to link the thumbnail
|
|
||||||
await _localAlbumRepository.upsert(album);
|
|
||||||
|
|
||||||
if (newAlbum.assetCount > 0) {
|
final album = deviceAlbum.copyWith(
|
||||||
await _localAlbumAssetRepository.linkAssetsToAlbum(
|
// The below assumes the list is already sorted by createdDate from the filter
|
||||||
album.id,
|
thumbnailId: NullableValue.valueOrEmpty(assets.firstOrNull?.localId),
|
||||||
assets.map((a) => a.localId),
|
);
|
||||||
);
|
|
||||||
}
|
await _localAlbumRepository.insert(album, assets);
|
||||||
});
|
_log.info("Successfully added device album ${album.name}");
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
_log.warning("Error while adding device album", e, s);
|
_log.warning("Error while adding device album", e, s);
|
||||||
}
|
}
|
||||||
@ -118,55 +80,23 @@ class SyncService {
|
|||||||
Future<void> removeLocalAlbum(LocalAlbum a) async {
|
Future<void> removeLocalAlbum(LocalAlbum a) async {
|
||||||
_log.info("Removing device album ${a.name}");
|
_log.info("Removing device album ${a.name}");
|
||||||
try {
|
try {
|
||||||
// Do not request title to speed things up on iOS
|
// Asset deletion is handled in the repository
|
||||||
final filter = albumFilter;
|
await _localAlbumRepository.delete(a.id);
|
||||||
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);
|
|
||||||
});
|
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
_log.warning("Error while removing device album", e, s);
|
_log.warning("Error while removing device album", e, s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@visibleForTesting
|
|
||||||
// The deviceAlbum is ignored since we are going to refresh it anyways
|
// 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 {
|
try {
|
||||||
_log.info("Syncing device album ${dbAlbum.name}");
|
_log.info("Syncing device album ${dbAlbum.name}");
|
||||||
|
|
||||||
final albumEntity =
|
final deviceAlbum = await _albumMediaRepository.refresh(
|
||||||
await _albumMediaRepository.refresh(dbAlbum.id, filter: albumFilter);
|
dbAlbum.id,
|
||||||
final deviceAlbum = await albumEntity.toDto();
|
withModifiedTime: true,
|
||||||
|
withAssetCount: true,
|
||||||
|
);
|
||||||
|
|
||||||
// Early return if album hasn't changed
|
// Early return if album hasn't changed
|
||||||
if (deviceAlbum.updatedAt.isAtSameMomentAs(dbAlbum.updatedAt) &&
|
if (deviceAlbum.updatedAt.isAtSameMomentAs(dbAlbum.updatedAt) &&
|
||||||
@ -179,7 +109,7 @@ class SyncService {
|
|||||||
|
|
||||||
// Skip empty albums that don't need syncing
|
// Skip empty albums that don't need syncing
|
||||||
if (deviceAlbum.assetCount == 0 && dbAlbum.assetCount == 0) {
|
if (deviceAlbum.assetCount == 0 && dbAlbum.assetCount == 0) {
|
||||||
await _localAlbumRepository.upsert(
|
await _localAlbumRepository.update(
|
||||||
deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection),
|
deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection),
|
||||||
);
|
);
|
||||||
_log.info("Album ${dbAlbum.name} is empty. Only metadata updated.");
|
_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...");
|
_log.info("Device album ${dbAlbum.name} has changed. Syncing...");
|
||||||
|
|
||||||
// Handle the case where assets are only added - fast path
|
// Faster path - only assets added
|
||||||
if (await handleOnlyAssetsAdded(dbAlbum, deviceAlbum)) {
|
if (await tryFastSync(dbAlbum, deviceAlbum)) {
|
||||||
_log.info("Fast synced device album ${dbAlbum.name}");
|
_log.info("Fast synced device album ${dbAlbum.name}");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slower path - full sync
|
// Slower path - full sync
|
||||||
return await handleAssetUpdate(dbAlbum, deviceAlbum, albumEntity);
|
return await fullSync(dbAlbum, deviceAlbum);
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
_log.warning("Error while diff device album", e, s);
|
_log.warning("Error while diff device album", e, s);
|
||||||
}
|
}
|
||||||
@ -203,10 +133,9 @@ class SyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
Future<bool> handleOnlyAssetsAdded(
|
// The [deviceAlbum] is expected to be refreshed before calling this method
|
||||||
LocalAlbum dbAlbum,
|
// with modified time and asset count
|
||||||
LocalAlbum deviceAlbum,
|
Future<bool> tryFastSync(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async {
|
||||||
) async {
|
|
||||||
try {
|
try {
|
||||||
_log.info("Fast syncing device album ${dbAlbum.name}");
|
_log.info("Fast syncing device album ${dbAlbum.name}");
|
||||||
if (!deviceAlbum.updatedAt.isAfter(dbAlbum.updatedAt)) {
|
if (!deviceAlbum.updatedAt.isAfter(dbAlbum.updatedAt)) {
|
||||||
@ -223,16 +152,13 @@ class SyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get all assets that are modified after the last known modifiedTime
|
// Get all assets that are modified after the last known modifiedTime
|
||||||
final filter = albumFilter.copyWith(
|
final newAssets = await _albumMediaRepository.getAssetsForAlbum(
|
||||||
updateTimeCond: DateTimeCond(
|
deviceAlbum.id,
|
||||||
|
updateTimeCond: DateTimeFilter(
|
||||||
min: dbAlbum.updatedAt.add(const Duration(seconds: 1)),
|
min: dbAlbum.updatedAt.add(const Duration(seconds: 1)),
|
||||||
max: deviceAlbum.updatedAt,
|
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
|
// Early return if no new assets were found
|
||||||
if (newAssets.isEmpty) {
|
if (newAssets.isEmpty) {
|
||||||
@ -262,19 +188,13 @@ class SyncService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _localAlbumRepository.transaction(() async {
|
await _handleUpdate(
|
||||||
await _localAssetRepository.upsertAll(newAssets);
|
deviceAlbum.copyWith(
|
||||||
await _localAlbumAssetRepository.linkAssetsToAlbum(
|
thumbnailId: NullableValue.valueOrEmpty(thumbnailId),
|
||||||
deviceAlbum.id,
|
backupSelection: dbAlbum.backupSelection,
|
||||||
newAssets.map(((a) => a.localId)),
|
),
|
||||||
);
|
assetsToUpsert: newAssets,
|
||||||
await _localAlbumRepository.upsert(
|
);
|
||||||
deviceAlbum.copyWith(
|
|
||||||
thumbnailId: thumbnailId,
|
|
||||||
backupSelection: dbAlbum.backupSelection,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
@ -284,100 +204,123 @@ class SyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
Future<bool> handleAssetUpdate(
|
// The [deviceAlbum] is expected to be refreshed before calling this method
|
||||||
LocalAlbum dbAlbum,
|
// with modified time and asset count
|
||||||
LocalAlbum deviceAlbum,
|
Future<bool> fullSync(LocalAlbum dbAlbum, LocalAlbum deviceAlbum) async {
|
||||||
AssetPathEntity deviceAlbumEntity,
|
|
||||||
) async {
|
|
||||||
try {
|
try {
|
||||||
final assetsInDevice = deviceAlbum.assetCount > 0
|
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>[];
|
: <LocalAsset>[];
|
||||||
|
|
||||||
final assetsInDb = dbAlbum.assetCount > 0
|
if (deviceAlbum.assetCount == 0) {
|
||||||
? await _localAlbumAssetRepository.getAssetsForAlbum(dbAlbum.id)
|
_log.fine(
|
||||||
: <LocalAsset>[];
|
"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
|
// The below assumes the list is already sorted by createdDate from the filter
|
||||||
String? thumbnailId =
|
String? thumbnailId = assetsInDevice.isNotEmpty
|
||||||
assetsInDevice.firstOrNull?.localId ?? dbAlbum.thumbnailId;
|
? assetsInDevice.firstOrNull?.localId
|
||||||
|
: dbAlbum.thumbnailId;
|
||||||
|
|
||||||
final assetsToAdd = <LocalAsset>{},
|
final updatedDeviceAlbum = deviceAlbum.copyWith(
|
||||||
assetsToUpsert = <LocalAsset>{},
|
thumbnailId: NullableValue.valueOrEmpty(thumbnailId),
|
||||||
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,
|
|
||||||
backupSelection: dbAlbum.backupSelection,
|
backupSelection: dbAlbum.backupSelection,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Remove all assets that are only in this particular album
|
if (dbAlbum.assetCount == 0) {
|
||||||
// We cannot remove all assets in the album because they might be in other albums in iOS
|
_log.fine(
|
||||||
final assetsOnlyInAlbum = assetsToDelete.isEmpty
|
"Device album ${deviceAlbum.name} is empty. Adding assets to DB.",
|
||||||
? <String>{}
|
);
|
||||||
: (await _localAlbumRepository.getAssetIdsOnlyInAlbum(deviceAlbum.id))
|
await _handleUpdate(updatedDeviceAlbum, assetsToUpsert: assetsInDevice);
|
||||||
.toSet();
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
await _localAlbumRepository.transaction(() async {
|
// Sort assets by localId for the diffSortedLists function
|
||||||
await _localAssetRepository
|
assetsInDb.sort((a, b) => a.localId.compareTo(b.localId));
|
||||||
.upsertAll(assetsToAdd.followedBy(assetsToUpsert));
|
assetsInDevice.sort((a, b) => a.localId.compareTo(b.localId));
|
||||||
await _localAlbumAssetRepository.linkAssetsToAlbum(
|
|
||||||
dbAlbum.id,
|
final assetsToAddOrUpdate = <LocalAsset>[];
|
||||||
assetsToAdd.map((a) => a.localId),
|
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);
|
_localAlbumRepository.update(updatedDeviceAlbum);
|
||||||
// Delete all assets that are only in this particular album
|
return true;
|
||||||
await _localAssetRepository.deleteIds(
|
}
|
||||||
assetsToDelete.intersection(assetsOnlyInAlbum),
|
|
||||||
);
|
await _handleUpdate(
|
||||||
// Unlink the others
|
updatedDeviceAlbum,
|
||||||
await _localAlbumAssetRepository.unlinkAssetsFromAlbum(
|
assetsToUpsert: assetsToAddOrUpdate,
|
||||||
dbAlbum.id,
|
assetIdsToDelete: assetIdsToDelete,
|
||||||
assetsToDelete.difference(assetsOnlyInAlbum),
|
);
|
||||||
);
|
|
||||||
});
|
return true;
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
_log.warning("Error on full syncing local album: ${dbAlbum.name}", 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/constants/constants.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/album_media.interface.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/asset/asset.model.dart' as asset;
|
||||||
|
import 'package:immich_mobile/domain/models/local_album.model.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
class AlbumMediaRepository implements IAlbumMediaRepository {
|
class AlbumMediaRepository implements IAlbumMediaRepository {
|
||||||
const AlbumMediaRepository();
|
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
|
@override
|
||||||
Future<List<AssetPathEntity>> getAll({PMFilter? filter}) async {
|
Future<List<LocalAlbum>> getAll({
|
||||||
return await PhotoManager.getAssetPathList(
|
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,
|
hasAll: true,
|
||||||
filterOption: filter,
|
filterOption: filter,
|
||||||
);
|
);
|
||||||
|
return entities.toDtoList(withAssetCount: withAssetCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<asset.LocalAsset>> getAssetsForAlbum(
|
Future<List<asset.LocalAsset>> getAssetsForAlbum(
|
||||||
AssetPathEntity assetPathEntity,
|
String albumId, {
|
||||||
) async {
|
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>[];
|
final assets = <AssetEntity>[];
|
||||||
int pageNumber = 0, lastPageCount = 0;
|
int pageNumber = 0, lastPageCount = 0;
|
||||||
do {
|
do {
|
||||||
@ -33,14 +89,23 @@ class AlbumMediaRepository implements IAlbumMediaRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<AssetPathEntity> refresh(String albumId, {PMFilter? filter}) =>
|
Future<LocalAlbum> refresh(
|
||||||
AssetPathEntity.obtainPathFromProperties(
|
String albumId, {
|
||||||
|
withModifiedTime = false,
|
||||||
|
withAssetCount = false,
|
||||||
|
withAssetTitle = false,
|
||||||
|
}) async =>
|
||||||
|
(await AssetPathEntity.obtainPathFromProperties(
|
||||||
id: albumId,
|
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 {
|
Future<asset.LocalAsset> toDto() async {
|
||||||
return asset.LocalAsset(
|
return asset.LocalAsset(
|
||||||
localId: id,
|
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<List<asset.LocalAsset>> toDtoList() =>
|
||||||
Future.wait(map((a) => a.toDto()));
|
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:drift/drift.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/local_album.interface.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/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.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.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:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
|
import 'package:platform/platform.dart';
|
||||||
|
|
||||||
class DriftLocalAlbumRepository extends DriftDatabaseRepository
|
class DriftLocalAlbumRepository extends DriftDatabaseRepository
|
||||||
implements ILocalAlbumRepository {
|
implements ILocalAlbumRepository {
|
||||||
final Drift _db;
|
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
|
@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(
|
final companion = LocalAlbumEntityCompanion.insert(
|
||||||
id: localAlbum.id,
|
id: localAlbum.id,
|
||||||
name: localAlbum.name,
|
name: localAlbum.name,
|
||||||
@ -26,22 +127,43 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository
|
|||||||
.insertOne(companion, onConflict: DoUpdate((_) => companion));
|
.insertOne(companion, onConflict: DoUpdate((_) => companion));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
Future<void> _linkAssetsToAlbum(
|
||||||
Future<List<LocalAlbum>> getAll({SortLocalAlbumsBy? sortBy}) {
|
String albumId,
|
||||||
final query = _db.localAlbumEntity.select();
|
Iterable<LocalAsset> assets,
|
||||||
if (sortBy == SortLocalAlbumsBy.id) {
|
) =>
|
||||||
query.orderBy([(a) => OrderingTerm.asc(a.id)]);
|
_db.batch(
|
||||||
}
|
(batch) => batch.insertAll(
|
||||||
return query.map((a) => a.toDto()).get();
|
_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
|
/// Get all asset ids that are only in this album and not in other albums.
|
||||||
Future<void> delete(String albumId) => _db.managers.localAlbumEntity
|
/// This is useful in cases where the album is a smart album or a user-created album, especially on iOS
|
||||||
.filter((a) => a.id.equals(albumId))
|
Future<List<String>> _getUniqueAssetsInAlbum(String albumId) {
|
||||||
.delete();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<String>> getAssetIdsOnlyInAlbum(String albumId) {
|
|
||||||
final assetId = _db.localAlbumAssetEntity.assetId;
|
final assetId = _db.localAlbumAssetEntity.assetId;
|
||||||
final query = _db.localAlbumAssetEntity.selectOnly()
|
final query = _db.localAlbumAssetEntity.selectOnly()
|
||||||
..addColumns([assetId])
|
..addColumns([assetId])
|
||||||
@ -53,4 +175,31 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository
|
|||||||
|
|
||||||
return query.map((row) => row.read(assetId)!).get();
|
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/interfaces/local_asset.interface.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/asset.model.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.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
|
|
||||||
class DriftLocalAssetRepository extends DriftDatabaseRepository
|
class DriftLocalAssetRepository extends DriftDatabaseRepository
|
||||||
@ -10,35 +8,6 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository
|
|||||||
final Drift _db;
|
final Drift _db;
|
||||||
const DriftLocalAssetRepository(this._db) : super(_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
|
@override
|
||||||
Future<LocalAsset> get(String assetId) => _db.managers.localAssetEntity
|
Future<LocalAsset> get(String assetId) => _db.managers.localAssetEntity
|
||||||
.filter((f) => f.localId(assetId))
|
.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
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: platform
|
name: platform
|
||||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||||
|
@ -51,6 +51,7 @@ dependencies:
|
|||||||
permission_handler: ^11.4.0
|
permission_handler: ^11.4.0
|
||||||
photo_manager: ^3.6.4
|
photo_manager: ^3.6.4
|
||||||
photo_manager_image_provider: ^2.2.0
|
photo_manager_image_provider: ^2.2.0
|
||||||
|
platform: ^3.1.6
|
||||||
punycode: ^1.0.0
|
punycode: ^1.0.0
|
||||||
riverpod_annotation: ^2.6.1
|
riverpod_annotation: ^2.6.1
|
||||||
scrollable_positioned_list: ^0.3.8
|
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/background.service.dart';
|
||||||
import 'package:immich_mobile/services/hash.service.dart';
|
import 'package:immich_mobile/services/hash.service.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
import '../../external.mock.dart';
|
|
||||||
import '../../fixtures/asset.stub.dart';
|
import '../../fixtures/asset.stub.dart';
|
||||||
import '../../infrastructure/repository.mock.dart';
|
import '../../infrastructure/repository.mock.dart';
|
||||||
import '../../service.mocks.dart';
|
import '../../service.mocks.dart';
|
||||||
|
|
||||||
class MockAsset extends Mock implements Asset {}
|
class MockAsset extends Mock implements Asset {}
|
||||||
|
|
||||||
|
class MockAssetEntity extends Mock implements AssetEntity {}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late HashService sut;
|
late HashService sut;
|
||||||
late BackgroundService mockBackgroundService;
|
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,
|
backupSelection: BackupSelection.none,
|
||||||
isAll: false,
|
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,
|
height: 1080,
|
||||||
durationInSeconds: 0,
|
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/album_media.interface.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/device_asset.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.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/local_asset.interface.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/log.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/log.interface.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/store.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 MockAlbumMediaRepository extends Mock implements IAlbumMediaRepository {}
|
||||||
|
|
||||||
class MockLocalAlbumAssetRepository extends Mock
|
|
||||||
implements ILocalAlbumAssetRepository {}
|
|
||||||
|
|
||||||
// API Repos
|
// API Repos
|
||||||
class MockUserApiRepository extends Mock implements IUserApiRepository {}
|
class MockUserApiRepository extends Mock implements IUserApiRepository {}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user