1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-19 00:32:49 +02:00
immich/mobile/lib/modules/backup/services/backup.service.dart
Fynn Petersen-Frey 33b1410d82
upload new photos in background with a service (#382)
* properly done background backup service

* new concurrency/locking management with heartbeat

fix communication erros with Kotlin plugin on start/stop service methods

better error handling for BackgroundService public methods

Add default notification message when service is running

* configurable WiFi & charging requirement for service

* use translations in background service
2022-08-18 09:41:59 -05:00

358 lines
11 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/utils/files_helper.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:http_parser/http_parser.dart';
import 'package:path/path.dart' as p;
import 'package:cancellation_token_http/http.dart' as http;
final backupServiceProvider = Provider(
(ref) => BackupService(
ref.watch(apiServiceProvider),
),
);
class BackupService {
final ApiService _apiService;
BackupService(this._apiService);
Future<List<String>?> getDeviceBackupAsset() async {
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
try {
return await _apiService.assetApi.getUserAssetsByDeviceId(deviceId);
} catch (e) {
debugPrint('Error [getDeviceBackupAsset] ${e.toString()}');
return null;
}
}
/// Returns all assets to backup from the backup info taking into account the
/// time of the last successfull backup per album
Future<List<AssetEntity>> getAssetsToBackup(
HiveBackupAlbums backupAlbumInfo,
) async {
final List<AssetEntity> candidates =
await _buildUploadCandidates(backupAlbumInfo);
final List<AssetEntity> toUpload = candidates.isEmpty
? []
: await _removeAlreadyUploadedAssets(candidates);
return toUpload;
}
Future<List<AssetEntity>> _buildUploadCandidates(
HiveBackupAlbums backupAlbums,
) async {
final filter = FilterOptionGroup(
containsPathModified: true,
orders: [const OrderOption(type: OrderOptionType.updateDate)],
);
final now = DateTime.now();
final List<AssetPathEntity?> selectedAlbums =
await _loadAlbumsWithTimeFilter(
backupAlbums.selectedAlbumIds,
backupAlbums.lastSelectedBackupTime,
filter,
now,
);
if (selectedAlbums.every((e) => e == null)) {
return [];
}
final int allIdx = selectedAlbums.indexWhere((e) => e != null && e.isAll);
if (allIdx != -1) {
final List<AssetPathEntity?> excludedAlbums =
await _loadAlbumsWithTimeFilter(
backupAlbums.excludedAlbumsIds,
backupAlbums.lastExcludedBackupTime,
filter,
now,
);
final List<AssetEntity> toAdd = await _fetchAssetsAndUpdateLastBackup(
selectedAlbums.slice(allIdx, allIdx + 1),
backupAlbums.lastSelectedBackupTime.slice(allIdx, allIdx + 1),
now,
);
final List<AssetEntity> toRemove = await _fetchAssetsAndUpdateLastBackup(
excludedAlbums,
backupAlbums.lastExcludedBackupTime,
now,
);
return toAdd.toSet().difference(toRemove.toSet()).toList();
} else {
return await _fetchAssetsAndUpdateLastBackup(
selectedAlbums,
backupAlbums.lastSelectedBackupTime,
now,
);
}
}
Future<List<AssetPathEntity?>> _loadAlbumsWithTimeFilter(
List<String> albumIds,
List<DateTime> lastBackups,
FilterOptionGroup filter,
DateTime now,
) async {
List<AssetPathEntity?> result = List.filled(albumIds.length, null);
for (int i = 0; i < albumIds.length; i++) {
try {
final AssetPathEntity album =
await AssetPathEntity.obtainPathFromProperties(
id: albumIds[i],
optionGroup: filter.copyWith(
updateTimeCond: DateTimeCond(
// subtract 2 seconds to prevent missing assets due to rounding issues
min: lastBackups[i].subtract(const Duration(seconds: 2)),
max: now,
),
),
maxDateTimeToNow: false,
);
result[i] = album;
} on StateError {
// either there are no assets matching the filter criteria OR the album no longer exists
}
}
return result;
}
Future<List<AssetEntity>> _fetchAssetsAndUpdateLastBackup(
List<AssetPathEntity?> albums,
List<DateTime> lastBackup,
DateTime now,
) async {
List<AssetEntity> result = [];
for (int i = 0; i < albums.length; i++) {
final AssetPathEntity? a = albums[i];
if (a != null && a.lastModified?.isBefore(lastBackup[i]) != true) {
result.addAll(await a.getAssetListRange(start: 0, end: a.assetCount));
lastBackup[i] = now;
}
}
return result;
}
Future<List<AssetEntity>> _removeAlreadyUploadedAssets(
List<AssetEntity> candidates,
) async {
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
if (candidates.length < 10) {
final List<CheckDuplicateAssetResponseDto?> duplicateResponse =
await Future.wait(
candidates.map(
(e) => _apiService.assetApi.checkDuplicateAsset(
CheckDuplicateAssetDto(deviceAssetId: e.id, deviceId: deviceId),
),
),
);
return candidates
.whereIndexed((i, e) => duplicateResponse[i]?.isExist == false)
.toList();
} else {
final List<String>? allAssetsInDatabase = await getDeviceBackupAsset();
if (allAssetsInDatabase == null) {
return candidates;
}
final Set<String> inDb = allAssetsInDatabase.toSet();
return candidates.whereNot((e) => inDb.contains(e.id)).toList();
}
}
Future<bool> backupAsset(
Iterable<AssetEntity> assetList,
http.CancellationToken cancelToken,
Function(String, String) singleAssetDoneCb,
Function(int, int) uploadProgressCb,
Function(CurrentUploadAsset) setCurrentUploadAssetCb,
Function(ErrorUploadAsset) errorCb,
) async {
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
File? file;
bool anyErrors = false;
for (var entity in assetList) {
try {
if (entity.type == AssetType.video) {
file = await entity.originFile;
} else {
file = await entity.originFile.timeout(const Duration(seconds: 5));
}
if (file != null) {
String originalFileName =
entity.title != null ? entity.title! : await entity.titleAsync;
String fileNameWithoutPath =
originalFileName.toString().split(".")[0];
var fileExtension = p.extension(file.path);
var mimeType = FileHelper.getMimeType(file.path);
var fileStream = file.openRead();
var assetRawUploadData = http.MultipartFile(
"assetData",
fileStream,
file.lengthSync(),
filename: fileNameWithoutPath,
contentType: MediaType(
mimeType["type"],
mimeType["subType"],
),
);
var box = Hive.box(userInfoBox);
var req = MultipartRequest(
'POST',
Uri.parse('$savedEndpoint/asset/upload'),
onProgress: ((bytes, totalBytes) =>
uploadProgressCb(bytes, totalBytes)),
);
req.headers["Authorization"] = "Bearer ${box.get(accessTokenKey)}";
req.fields['deviceAssetId'] = entity.id;
req.fields['deviceId'] = deviceId;
req.fields['assetType'] = _getAssetType(entity.type);
req.fields['createdAt'] = entity.createDateTime.toIso8601String();
req.fields['modifiedAt'] = entity.modifiedDateTime.toIso8601String();
req.fields['isFavorite'] = entity.isFavorite.toString();
req.fields['fileExtension'] = fileExtension;
req.fields['duration'] = entity.videoDuration.toString();
req.files.add(assetRawUploadData);
setCurrentUploadAssetCb(
CurrentUploadAsset(
id: entity.id,
createdAt: entity.createDateTime,
fileName: originalFileName,
fileType: _getAssetType(entity.type),
),
);
var response = await req.send(cancellationToken: cancelToken);
if (response.statusCode == 201) {
singleAssetDoneCb(entity.id, deviceId);
} else {
var data = await response.stream.bytesToString();
var error = jsonDecode(data);
debugPrint(
"Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}",
);
errorCb(
ErrorUploadAsset(
asset: entity,
id: entity.id,
createdAt: entity.createDateTime,
fileName: originalFileName,
fileType: _getAssetType(entity.type),
errorMessage: error['error'],
),
);
continue;
}
}
} on http.CancelledException {
debugPrint("Backup was cancelled by the user");
return false;
} catch (e) {
debugPrint("ERROR backupAsset: ${e.toString()}");
anyErrors = true;
continue;
} finally {
if (Platform.isIOS) {
file?.deleteSync();
}
}
}
return !anyErrors;
}
String _getAssetType(AssetType assetType) {
switch (assetType) {
case AssetType.audio:
return "AUDIO";
case AssetType.image:
return "IMAGE";
case AssetType.video:
return "VIDEO";
case AssetType.other:
return "OTHER";
}
}
Future<DeviceInfoResponseDto> setAutoBackup(
bool status,
String deviceId,
DeviceTypeEnum deviceType,
) async {
try {
var updatedDeviceInfo = await _apiService.deviceInfoApi.updateDeviceInfo(
UpdateDeviceInfoDto(
deviceId: deviceId,
deviceType: deviceType,
isAutoBackup: status,
),
);
if (updatedDeviceInfo == null) {
throw Exception("Error updating device info");
}
return updatedDeviceInfo;
} catch (e) {
debugPrint("Error setAutoBackup: ${e.toString()}");
throw Error();
}
}
}
class MultipartRequest extends http.MultipartRequest {
/// Creates a new [MultipartRequest].
MultipartRequest(
String method,
Uri url, {
required this.onProgress,
}) : super(method, url);
final void Function(int bytes, int totalBytes) onProgress;
/// Freezes all mutable fields and returns a
/// single-subscription [http.ByteStream]
/// that will emit the request body.
@override
http.ByteStream finalize() {
final byteStream = super.finalize();
final total = contentLength;
var bytes = 0;
final t = StreamTransformer.fromHandlers(
handleData: (List<int> data, EventSink<List<int>> sink) {
bytes += data.length;
onProgress.call(bytes, total);
sink.add(data);
},
);
final stream = byteStream.transform(t);
return http.ByteStream(stream);
}
}