remove trashed asset model

remove trash_sync.service
refactor DriftTrashedLocalAssetRepository, LocalSyncService
This commit is contained in:
Peter Ombodi 2025-10-07 18:24:57 +03:00
parent ebfab4b01b
commit df0ed1e8da
5 changed files with 81 additions and 262 deletions

View file

@ -1,117 +0,0 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
class TrashedAsset extends LocalAsset {
final String albumId;
const TrashedAsset({
required this.albumId,
required super.id,
super.remoteId,
required super.name,
super.checksum,
required super.type,
required super.createdAt,
required super.updatedAt,
super.width,
super.height,
super.durationInSeconds,
super.isFavorite = false,
super.livePhotoVideoId,
super.orientation = 0,
});
@override
TrashedAsset copyWith({
String? id,
String? remoteId,
String? name,
String? checksum,
AssetType? type,
DateTime? createdAt,
DateTime? updatedAt,
int? width,
int? height,
int? durationInSeconds,
bool? isFavorite,
String? livePhotoVideoId,
int? orientation,
String? albumId,
}) {
return TrashedAsset(
id: id ?? this.id,
remoteId: remoteId ?? this.remoteId,
name: name ?? this.name,
checksum: checksum ?? this.checksum,
type: type ?? this.type,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
width: width ?? this.width,
height: height ?? this.height,
durationInSeconds: durationInSeconds ?? this.durationInSeconds,
isFavorite: isFavorite ?? this.isFavorite,
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
orientation: orientation ?? this.orientation,
albumId: albumId ?? this.albumId,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TrashedAsset &&
runtimeType == other.runtimeType &&
// LocalAsset identity
id == other.id &&
name == other.name &&
remoteId == other.remoteId &&
checksum == other.checksum &&
type == other.type &&
createdAt == other.createdAt &&
updatedAt == other.updatedAt &&
width == other.width &&
height == other.height &&
durationInSeconds == other.durationInSeconds &&
isFavorite == other.isFavorite &&
livePhotoVideoId == other.livePhotoVideoId &&
orientation == other.orientation &&
// TrashedAsset extras
albumId == other.albumId;
@override
int get hashCode => Object.hash(
id,
name,
remoteId,
checksum,
type,
createdAt,
updatedAt,
width,
height,
durationInSeconds,
isFavorite,
livePhotoVideoId,
orientation,
albumId,
);
@override
String toString() {
return 'TrashedAsset('
'id: $id, '
'remoteId: $remoteId, '
'name: $name, '
'checksum: $checksum, '
'type: $type, '
'createdAt: $createdAt, '
'updatedAt: $updatedAt, '
'width: $width, '
'height: $height, '
'durationInSeconds: $durationInSeconds, '
'isFavorite: $isFavorite, '
'livePhotoVideoId: $livePhotoVideoId, '
'orientation: $orientation, '
'albumId: $albumId, '
')';
}
}

View file

@ -4,7 +4,6 @@ import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset/trashed_asset.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
@ -299,15 +298,9 @@ class LocalSyncService {
if (trashUpdates.isEmpty) { if (trashUpdates.isEmpty) {
return Future.value(); return Future.value();
} }
final trashedAssets = <TrashedAsset>[]; final trashedAssets = delta.toTrashedAssets();
for (final update in trashUpdates) { _log.info("updateLocalTrashChanges trashedAssets: ${trashedAssets.map((e) => e.asset.id)}");
final albums = delta.assetAlbums.cast<String, List<Object?>>(); await _trashedLocalAssetRepository.applyDelta(trashedAssets);
for (final String id in albums[update.id]!.cast<String?>().nonNulls) {
trashedAssets.add(update.toTrashedAsset(id));
}
}
_log.info("updateLocalTrashChanges trashedAssets: ${trashedAssets.map((e) => e.id)}");
await _trashedLocalAssetRepository.saveTrashedAssets(trashedAssets);
await _applyRemoteRestoreToLocal(); await _applyRemoteRestoreToLocal();
} }
@ -321,7 +314,7 @@ class LocalSyncService {
_log.info("syncDeviceTrashSnapshot prepare, album: ${album.id}/${album.name}"); _log.info("syncDeviceTrashSnapshot prepare, album: ${album.id}/${album.name}");
final trashedPlatformAssets = await _nativeSyncApi.getTrashedAssetsForAlbum(album.id); final trashedPlatformAssets = await _nativeSyncApi.getTrashedAssetsForAlbum(album.id);
final trashedAssets = trashedPlatformAssets.toTrashedAssets(album.id); final trashedAssets = trashedPlatformAssets.toTrashedAssets(album.id);
await _trashedLocalAssetRepository.applyTrashSnapshot(trashedAssets, album.id); await _trashedLocalAssetRepository.applyTrashSnapshot(trashedAssets);
} }
await _applyRemoteRestoreToLocal(); await _applyRemoteRestoreToLocal();
} }
@ -352,33 +345,22 @@ extension on Iterable<PlatformAlbum> {
extension on Iterable<PlatformAsset> { extension on Iterable<PlatformAsset> {
List<LocalAsset> toLocalAssets() { List<LocalAsset> toLocalAssets() {
return map( return map((e) => e.toLocalAsset()).toList();
(e) => LocalAsset( }
id: e.id,
name: e.name, Iterable<TrashedAsset> toTrashedAssets(String albumId) {
checksum: null, return map((e) => (albumId: albumId, asset: e.toLocalAsset()));
type: AssetType.values.elementAtOrNull(e.type) ?? AssetType.other,
createdAt: tryFromSecondsSinceEpoch(e.createdAt, isUtc: true) ?? DateTime.timestamp(),
updatedAt: tryFromSecondsSinceEpoch(e.updatedAt, isUtc: true) ?? DateTime.timestamp(),
width: e.width,
height: e.height,
durationInSeconds: e.durationInSeconds,
orientation: e.orientation,
isFavorite: e.isFavorite,
),
).toList();
} }
} }
extension on PlatformAsset { extension on PlatformAsset {
TrashedAsset toTrashedAsset(String albumId) => TrashedAsset( LocalAsset toLocalAsset() => LocalAsset(
id: id, id: id,
name: name, name: name,
checksum: null, checksum: null,
type: AssetType.values.elementAtOrNull(type) ?? AssetType.other, type: AssetType.values.elementAtOrNull(type) ?? AssetType.other,
createdAt: tryFromSecondsSinceEpoch(createdAt) ?? DateTime.now(), createdAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(),
updatedAt: tryFromSecondsSinceEpoch(updatedAt) ?? DateTime.now(), updatedAt: tryFromSecondsSinceEpoch(createdAt, isUtc: true) ?? DateTime.timestamp(),
albumId: albumId,
width: width, width: width,
height: height, height: height,
durationInSeconds: durationInSeconds, durationInSeconds: durationInSeconds,
@ -387,8 +369,12 @@ extension on PlatformAsset {
); );
} }
extension PlatformAssetsExtension on Iterable<PlatformAsset> { extension SyncDeltaExtension on SyncDelta {
Iterable<TrashedAsset> toTrashedAssets(String albumId) { Iterable<TrashedAsset> toTrashedAssets() {
return map((e) => e.toTrashedAsset(albumId)); return updates.map((e) {
final albums = assetAlbums.cast<String, List<Object?>>();
final albumId = albums[e.id]!.cast<String>().first;
return (albumId: albumId, asset: e.toLocalAsset());
});
} }
} }

View file

@ -1,53 +0,0 @@
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:logging/logging.dart';
class TrashSyncService {
final AppSettingsService _appSettingsService;
final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final LocalFilesManagerRepository _localFilesManager;
final StorageRepository _storageRepository;
final Logger _logger = Logger('TrashService');
TrashSyncService({
required AppSettingsService appSettingsService,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required LocalFilesManagerRepository localFilesManager,
required StorageRepository storageRepository,
}) : _appSettingsService = appSettingsService,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_localFilesManager = localFilesManager,
_storageRepository = storageRepository;
bool get isTrashSyncMode =>
CurrentPlatform.isAndroid && _appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid);
Future<void> handleRemoteTrashed(Iterable<String> checksums) async {
if (checksums.isEmpty) {
return Future.value();
} else {
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(checksums);
if (localAssetsToTrash.isNotEmpty) {
final mediaUrls = await Future.wait(
localAssetsToTrash.values
.expand((e) => e)
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
);
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
}
} else {
_logger.info("No assets found in backup-enabled albums for assets: $checksums");
}
}
}
}

View file

@ -1,5 +1,5 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset/trashed_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@ -23,10 +23,9 @@ class TrashedLocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntity
} }
extension TrashedLocalAssetEntityDataDomainExtension on TrashedLocalAssetEntityData { extension TrashedLocalAssetEntityDataDomainExtension on TrashedLocalAssetEntityData {
TrashedAsset toDto() => TrashedAsset( LocalAsset toLocalAsset() => LocalAsset(
id: id, id: id,
name: name, name: name,
albumId: albumId,
checksum: checksum, checksum: checksum,
type: type, type: type,
createdAt: createdAt, createdAt: createdAt,

View file

@ -2,13 +2,14 @@ import 'package:collection/collection.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset/trashed_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/trashed_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:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
typedef TrashedAsset = ({String albumId, LocalAsset asset});
class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository { class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
final Drift _db; final Drift _db;
@ -30,12 +31,12 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
}); });
} }
Future<Iterable<TrashedAsset>> getAssetsToHash(Iterable<String> albumIds) { Future<Iterable<LocalAsset>> getAssetsToHash(Iterable<String> albumIds) {
final query = _db.trashedLocalAssetEntity.select()..where((r) => r.albumId.isIn(albumIds) & r.checksum.isNull()); final query = _db.trashedLocalAssetEntity.select()..where((r) => r.albumId.isIn(albumIds) & r.checksum.isNull());
return query.map((row) => row.toDto()).get(); return query.map((row) => row.toLocalAsset()).get();
} }
Future<Iterable<TrashedAsset>> getToRestore() async { Future<Iterable<LocalAsset>> getToRestore() async {
final selectedAlbumIds = (_db.selectOnly(_db.localAlbumEntity) final selectedAlbumIds = (_db.selectOnly(_db.localAlbumEntity)
..addColumns([_db.localAlbumEntity.id]) ..addColumns([_db.localAlbumEntity.id])
..where(_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected))); ..where(_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected)));
@ -52,55 +53,54 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
)) ))
.get(); .get();
return rows.map((result) => result.readTable(_db.trashedLocalAssetEntity).toDto()); return rows.map((result) => result.readTable(_db.trashedLocalAssetEntity).toLocalAsset());
} }
/// Applies resulted snapshot of trashed assets: /// Applies resulted snapshot of trashed assets:
/// - upserts incoming rows /// - upserts incoming rows
/// - deletes rows that are not present in the snapshot /// - deletes rows that are not present in the snapshot
Future<void> applyTrashSnapshot(Iterable<TrashedAsset> assets, String albumId) async { Future<void> applyTrashSnapshot(Iterable<TrashedAsset> trashedAssets) async {
if (assets.isEmpty) { if (trashedAssets.isEmpty) {
await _db.delete(_db.trashedLocalAssetEntity).go(); await _db.delete(_db.trashedLocalAssetEntity).go();
return; return;
} }
Map<String, String> localChecksumById = await _getCachedChecksums(assets); final assetIds = trashedAssets.map((e) => e.asset.id).toSet();
Map<String, String> localChecksumById = await _getCachedChecksums(assetIds);
return _db.transaction(() async { return _db.transaction(() async {
await _db.batch((batch) { await _db.batch((batch) {
for (final asset in assets) { for (final item in trashedAssets) {
final effectiveChecksum = localChecksumById[asset.id] ?? asset.checksum; final effectiveChecksum = localChecksumById[item.asset.id] ?? item.asset.checksum;
final companion = TrashedLocalAssetEntityCompanion.insert( final companion = TrashedLocalAssetEntityCompanion.insert(
id: asset.id, id: item.asset.id,
albumId: albumId, albumId: item.albumId,
checksum: effectiveChecksum == null ? const Value.absent() : Value(effectiveChecksum), checksum: effectiveChecksum == null ? const Value.absent() : Value(effectiveChecksum),
name: asset.name, name: item.asset.name,
type: asset.type, type: item.asset.type,
createdAt: Value(asset.createdAt), createdAt: Value(item.asset.createdAt),
updatedAt: Value(asset.updatedAt), updatedAt: Value(item.asset.updatedAt),
width: Value(asset.width), width: Value(item.asset.width),
height: Value(asset.height), height: Value(item.asset.height),
durationInSeconds: Value(asset.durationInSeconds), durationInSeconds: Value(item.asset.durationInSeconds),
isFavorite: Value(asset.isFavorite), isFavorite: Value(item.asset.isFavorite),
orientation: Value(asset.orientation), orientation: Value(item.asset.orientation),
); );
batch.insert<$TrashedLocalAssetEntityTable, TrashedLocalAssetEntityData>( batch.insert<$TrashedLocalAssetEntityTable, TrashedLocalAssetEntityData>(
_db.trashedLocalAssetEntity, _db.trashedLocalAssetEntity,
companion, companion,
onConflict: DoUpdate((_) => companion, where: (old) => old.updatedAt.isNotValue(asset.updatedAt)), onConflict: DoUpdate((_) => companion, where: (old) => old.updatedAt.isNotValue(item.asset.updatedAt)),
); );
} }
}); });
final keepIds = assets.map((asset) => asset.id); if (assetIds.length <= 32000) {
await (_db.delete(_db.trashedLocalAssetEntity)..where((row) => row.id.isNotIn(assetIds))).go();
if (keepIds.length <= 32000) {
await (_db.delete(_db.trashedLocalAssetEntity)..where((row) => row.id.isNotIn(keepIds))).go();
} else { } else {
final keepIdsSet = keepIds.toSet();
final existingIds = await (_db.selectOnly( final existingIds = await (_db.selectOnly(
_db.trashedLocalAssetEntity, _db.trashedLocalAssetEntity,
)..addColumns([_db.trashedLocalAssetEntity.id])).map((r) => r.read(_db.trashedLocalAssetEntity.id)!).get(); )..addColumns([_db.trashedLocalAssetEntity.id])).map((r) => r.read(_db.trashedLocalAssetEntity.id)!).get();
final idToDelete = existingIds.where((id) => !keepIdsSet.contains(id)); final idToDelete = existingIds.where((id) => !assetIds.contains(id));
await _db.batch((batch) { await _db.batch((batch) {
for (final id in idToDelete) { for (final id in idToDelete) {
batch.deleteWhere(_db.trashedLocalAssetEntity, (row) => row.id.equals(id)); batch.deleteWhere(_db.trashedLocalAssetEntity, (row) => row.id.equals(id));
@ -110,33 +110,33 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
}); });
} }
Future<void> saveTrashedAssets(Iterable<TrashedAsset> assets) async { Future<void> applyDelta(Iterable<TrashedAsset> trashedAssets) async {
if (assets.isEmpty) { if (trashedAssets.isEmpty) {
return; return;
} }
final assetIds = trashedAssets.map((e) => e.asset.id).toSet();
Map<String, String> localChecksumById = await _getCachedChecksums(assets); Map<String, String> localChecksumById = await _getCachedChecksums(assetIds);
await _db.batch((batch) { await _db.batch((batch) {
for (final asset in assets) { for (final item in trashedAssets) {
final effectiveChecksum = localChecksumById[asset.id] ?? asset.checksum; final effectiveChecksum = localChecksumById[item.asset.id] ?? item.asset.checksum;
final companion = TrashedLocalAssetEntityCompanion.insert( final companion = TrashedLocalAssetEntityCompanion.insert(
id: asset.id, id: item.asset.id,
albumId: asset.albumId, albumId: item.albumId,
name: asset.name, name: item.asset.name,
type: asset.type, type: item.asset.type,
checksum: effectiveChecksum == null ? const Value.absent() : Value(effectiveChecksum), checksum: effectiveChecksum == null ? const Value.absent() : Value(effectiveChecksum),
createdAt: Value(asset.createdAt), createdAt: Value(item.asset.createdAt),
width: Value(asset.width), width: Value(item.asset.width),
height: Value(asset.height), height: Value(item.asset.height),
durationInSeconds: Value(asset.durationInSeconds), durationInSeconds: Value(item.asset.durationInSeconds),
isFavorite: Value(asset.isFavorite), isFavorite: Value(item.asset.isFavorite),
orientation: Value(asset.orientation), orientation: Value(item.asset.orientation),
); );
batch.insert<$TrashedLocalAssetEntityTable, TrashedLocalAssetEntityData>( batch.insert<$TrashedLocalAssetEntityTable, TrashedLocalAssetEntityData>(
_db.trashedLocalAssetEntity, _db.trashedLocalAssetEntity,
companion, companion,
onConflict: DoUpdate((_) => companion, where: (old) => old.updatedAt.isNotValue(asset.updatedAt)), onConflict: DoUpdate((_) => companion, where: (old) => old.updatedAt.isNotValue(item.asset.updatedAt)),
); );
} }
}); });
@ -171,7 +171,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
TrashedLocalAssetEntityCompanion( TrashedLocalAssetEntityCompanion(
id: Value(asset.id), id: Value(asset.id),
name: Value(asset.name), name: Value(asset.name),
albumId: Value(entry.key), // albumId: Value(entry.key),
checksum: asset.checksum == null ? const Value.absent() : Value(asset.checksum), checksum: asset.checksum == null ? const Value.absent() : Value(asset.checksum),
type: Value(asset.type), type: Value(asset.type),
width: Value(asset.width), width: Value(asset.width),
@ -237,17 +237,21 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
} }
//attempt to reuse existing checksums //attempt to reuse existing checksums
Future<Map<String, String>> _getCachedChecksums(Iterable<TrashedAsset> trashUpdates) async { Future<Map<String, String>> _getCachedChecksums(Set<String> assetIds) async {
final ids = trashUpdates.map((e) => e.id).toSet(); final localChecksumById = <String, String>{};
for (final slice in assetIds.slices(32000)) {
final rows = final rows =
await (_db.selectOnly(_db.localAssetEntity) await (_db.selectOnly(_db.localAssetEntity)
..where(_db.localAssetEntity.id.isIn(ids) & _db.localAssetEntity.checksum.isNotNull()) ..where(_db.localAssetEntity.id.isIn(slice) & _db.localAssetEntity.checksum.isNotNull())
..addColumns([_db.localAssetEntity.id, _db.localAssetEntity.checksum])) ..addColumns([_db.localAssetEntity.id, _db.localAssetEntity.checksum]))
.get(); .get();
final localChecksumById = { for (final r in rows) {
for (final r in rows) r.read(_db.localAssetEntity.id)!: r.read(_db.localAssetEntity.checksum)!, localChecksumById[r.read(_db.localAssetEntity.id)!] = r.read(_db.localAssetEntity.checksum)!;
}; }
}
return localChecksumById; return localChecksumById;
} }
} }