mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-30 10:24:58 -04:00 
			
		
		
		
	* feat(mobile): find & delete corrupt asset backups * show backup fix only for advanced troubleshooting
		
			
				
	
	
		
			233 lines
		
	
	
		
			7.6 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			233 lines
		
	
	
		
			7.6 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| import 'dart:async';
 | |
| import 'dart:typed_data';
 | |
| 
 | |
| import 'package:collection/collection.dart';
 | |
| import 'package:flutter/foundation.dart';
 | |
| import 'package:flutter/services.dart';
 | |
| import 'package:hooks_riverpod/hooks_riverpod.dart';
 | |
| import 'package:immich_mobile/shared/models/asset.dart';
 | |
| import 'package:immich_mobile/shared/models/exif_info.dart';
 | |
| import 'package:immich_mobile/shared/models/store.dart';
 | |
| import 'package:immich_mobile/shared/providers/db.provider.dart';
 | |
| import 'package:immich_mobile/shared/services/api.service.dart';
 | |
| import 'package:immich_mobile/utils/diff.dart';
 | |
| import 'package:isar/isar.dart';
 | |
| import 'package:photo_manager/photo_manager.dart' show PhotoManager;
 | |
| 
 | |
| /// Finds duplicates originating from missing EXIF information
 | |
| class BackupVerificationService {
 | |
|   final Isar _db;
 | |
| 
 | |
|   BackupVerificationService(this._db);
 | |
| 
 | |
|   /// Returns at most [limit] assets that were backed up without exif
 | |
|   Future<List<Asset>> findWronglyBackedUpAssets({int limit = 100}) async {
 | |
|     final owner = Store.get(StoreKey.currentUser).isarId;
 | |
|     final List<Asset> onlyLocal = await _db.assets
 | |
|         .where()
 | |
|         .remoteIdIsNull()
 | |
|         .filter()
 | |
|         .ownerIdEqualTo(owner)
 | |
|         .localIdIsNotNull()
 | |
|         .findAll();
 | |
|     final List<Asset> remoteMatches = await _getMatches(
 | |
|       _db.assets.where().localIdIsNull().filter().remoteIdIsNotNull(),
 | |
|       owner,
 | |
|       onlyLocal,
 | |
|       limit,
 | |
|     );
 | |
|     final List<Asset> localMatches = await _getMatches(
 | |
|       _db.assets.where().remoteIdIsNull().filter().localIdIsNotNull(),
 | |
|       owner,
 | |
|       remoteMatches,
 | |
|       limit,
 | |
|     );
 | |
| 
 | |
|     final List<Asset> deleteCandidates = [], originals = [];
 | |
| 
 | |
|     await diffSortedLists(
 | |
|       remoteMatches,
 | |
|       localMatches,
 | |
|       compare: (a, b) => a.fileName.compareTo(b.fileName),
 | |
|       both: (a, b) async {
 | |
|         a.exifInfo = await _db.exifInfos.get(a.id);
 | |
|         deleteCandidates.add(a);
 | |
|         originals.add(b);
 | |
|         return false;
 | |
|       },
 | |
|       onlyFirst: (a) {},
 | |
|       onlySecond: (b) {},
 | |
|     );
 | |
|     final isolateToken = ServicesBinding.rootIsolateToken!;
 | |
|     final List<Asset> toDelete;
 | |
|     if (deleteCandidates.length > 10) {
 | |
|       // performs 2 checks in parallel for a nice speedup
 | |
|       final half = deleteCandidates.length ~/ 2;
 | |
|       final lower = compute(
 | |
|         _computeSaveToDelete,
 | |
|         (
 | |
|           deleteCandidates: deleteCandidates.slice(0, half),
 | |
|           originals: originals.slice(0, half),
 | |
|           auth: Store.get(StoreKey.accessToken),
 | |
|           endpoint: Store.get(StoreKey.serverEndpoint),
 | |
|           rootIsolateToken: isolateToken,
 | |
|         ),
 | |
|       );
 | |
|       final upper = compute(
 | |
|         _computeSaveToDelete,
 | |
|         (
 | |
|           deleteCandidates: deleteCandidates.slice(half),
 | |
|           originals: originals.slice(half),
 | |
|           auth: Store.get(StoreKey.accessToken),
 | |
|           endpoint: Store.get(StoreKey.serverEndpoint),
 | |
|           rootIsolateToken: isolateToken,
 | |
|         ),
 | |
|       );
 | |
|       toDelete = await lower + await upper;
 | |
|     } else {
 | |
|       toDelete = await compute(
 | |
|         _computeSaveToDelete,
 | |
|         (
 | |
|           deleteCandidates: deleteCandidates,
 | |
|           originals: originals,
 | |
|           auth: Store.get(StoreKey.accessToken),
 | |
|           endpoint: Store.get(StoreKey.serverEndpoint),
 | |
|           rootIsolateToken: isolateToken,
 | |
|         ),
 | |
|       );
 | |
|     }
 | |
|     return toDelete;
 | |
|   }
 | |
| 
 | |
|   static Future<List<Asset>> _computeSaveToDelete(
 | |
|     ({
 | |
|       List<Asset> deleteCandidates,
 | |
|       List<Asset> originals,
 | |
|       String auth,
 | |
|       String endpoint,
 | |
|       RootIsolateToken rootIsolateToken,
 | |
|     }) tuple,
 | |
|   ) async {
 | |
|     assert(tuple.deleteCandidates.length == tuple.originals.length);
 | |
|     final List<Asset> result = [];
 | |
|     BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken);
 | |
|     await PhotoManager.setIgnorePermissionCheck(true);
 | |
|     final ApiService apiService = ApiService();
 | |
|     apiService.setEndpoint(tuple.endpoint);
 | |
|     apiService.setAccessToken(tuple.auth);
 | |
|     for (int i = 0; i < tuple.deleteCandidates.length; i++) {
 | |
|       if (await _compareAssets(
 | |
|         tuple.deleteCandidates[i],
 | |
|         tuple.originals[i],
 | |
|         apiService,
 | |
|       )) {
 | |
|         result.add(tuple.deleteCandidates[i]);
 | |
|       }
 | |
|     }
 | |
|     return result;
 | |
|   }
 | |
| 
 | |
|   static Future<bool> _compareAssets(
 | |
|     Asset remote,
 | |
|     Asset local,
 | |
|     ApiService apiService,
 | |
|   ) async {
 | |
|     if (remote.checksum == local.checksum) return false;
 | |
|     ExifInfo? exif = remote.exifInfo;
 | |
|     if (exif != null && exif.lat != null) return false;
 | |
|     if (exif == null || exif.fileSize == null) {
 | |
|       final dto = await apiService.assetApi.getAssetById(remote.remoteId!);
 | |
|       if (dto != null && dto.exifInfo != null) {
 | |
|         exif = ExifInfo.fromDto(dto.exifInfo!);
 | |
|       }
 | |
|     }
 | |
|     final file = await local.local!.originFile;
 | |
|     if (exif != null && file != null && exif.fileSize != null) {
 | |
|       final origSize = await file.length();
 | |
|       if (exif.fileSize! == origSize || exif.fileSize! != origSize) {
 | |
|         final latLng = await local.local!.latlngAsync();
 | |
| 
 | |
|         if (exif.lat == null &&
 | |
|             latLng.latitude != null &&
 | |
|             (remote.fileCreatedAt.isAtSameMomentAs(local.fileCreatedAt) ||
 | |
|                 remote.fileModifiedAt.isAtSameMomentAs(local.fileModifiedAt) ||
 | |
|                 _sameExceptTimeZone(
 | |
|                   remote.fileCreatedAt,
 | |
|                   local.fileCreatedAt,
 | |
|                 ))) {
 | |
|           if (remote.type == AssetType.video) {
 | |
|             // it's very unlikely that a video of same length, filesize, name
 | |
|             // and date is wrong match. Cannot easily compare videos anyway
 | |
|             return true;
 | |
|           }
 | |
| 
 | |
|           // for images: make sure they are pixel-wise identical
 | |
|           // (skip first few KBs containing metadata)
 | |
|           final Uint64List localImage =
 | |
|               _fakeDecodeImg(local, await file.readAsBytes());
 | |
|           final res = await apiService.assetApi
 | |
|               .downloadFileWithHttpInfo(remote.remoteId!);
 | |
|           final Uint64List remoteImage = _fakeDecodeImg(remote, res.bodyBytes);
 | |
| 
 | |
|           final eq = const ListEquality().equals(remoteImage, localImage);
 | |
|           return eq;
 | |
|         }
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   static Uint64List _fakeDecodeImg(Asset asset, Uint8List bytes) {
 | |
|     const headerLength = 131072; // assume header is at most 128 KB
 | |
|     final start = bytes.length < headerLength * 2
 | |
|         ? (bytes.length ~/ (4 * 8)) * 8
 | |
|         : headerLength;
 | |
|     return bytes.buffer.asUint64List(start);
 | |
|   }
 | |
| 
 | |
|   static Future<List<Asset>> _getMatches(
 | |
|     QueryBuilder<Asset, Asset, QAfterFilterCondition> query,
 | |
|     int ownerId,
 | |
|     List<Asset> assets,
 | |
|     int limit,
 | |
|   ) =>
 | |
|       query
 | |
|           .ownerIdEqualTo(ownerId)
 | |
|           .anyOf(
 | |
|             assets,
 | |
|             (q, Asset a) => q
 | |
|                 .fileNameEqualTo(a.fileName)
 | |
|                 .and()
 | |
|                 .durationInSecondsEqualTo(a.durationInSeconds)
 | |
|                 .and()
 | |
|                 .fileCreatedAtBetween(
 | |
|                   a.fileCreatedAt.subtract(const Duration(hours: 12)),
 | |
|                   a.fileCreatedAt.add(const Duration(hours: 12)),
 | |
|                 )
 | |
|                 .and()
 | |
|                 .not()
 | |
|                 .checksumEqualTo(a.checksum),
 | |
|           )
 | |
|           .sortByFileName()
 | |
|           .thenByFileCreatedAt()
 | |
|           .thenByFileModifiedAt()
 | |
|           .limit(limit)
 | |
|           .findAll();
 | |
| 
 | |
|   static bool _sameExceptTimeZone(DateTime a, DateTime b) {
 | |
|     final ms = a.isAfter(b)
 | |
|         ? a.millisecondsSinceEpoch - b.millisecondsSinceEpoch
 | |
|         : b.millisecondsSinceEpoch - a.microsecondsSinceEpoch;
 | |
|     final x = ms / (1000 * 60 * 30);
 | |
|     final y = ms ~/ (1000 * 60 * 30);
 | |
|     return y.toDouble() == x && y < 24;
 | |
|   }
 | |
| }
 | |
| 
 | |
| final backupVerificationServiceProvider = Provider(
 | |
|   (ref) => BackupVerificationService(
 | |
|     ref.watch(dbProvider),
 | |
|   ),
 | |
| );
 |