mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-12-07 19:59:07 +00:00
feature(mobile): sync assets, albums & users to local database on device (#1759)
* feature(mobile): sync assets, albums & users to local database on device * try to fix tests * move DB sync operations to new SyncService * clear db on user logout * fix reason for endless loading timeline * fix error when deleting album * fix thumbnail of device albums * add a few comments * fix Hive box not open in album service when loading local assets * adjust tests to int IDs * fix bug: show all albums when Recent is selected * update generated api * reworked Recents album isAll handling * guard against wrongly interleaved sync operations * fix: timeline asset ordering (sort asset state by created at) * fix: sort assets in albums by created at
This commit is contained in:
committed by
GitHub
parent
8f11529a75
commit
8708867c1c
@@ -1,34 +1,129 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:immich_mobile/shared/services/sync.service.dart';
|
||||
import 'package:immich_mobile/shared/services/user.service.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
final albumServiceProvider = Provider(
|
||||
(ref) => AlbumService(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(userServiceProvider),
|
||||
ref.watch(backgroundServiceProvider),
|
||||
ref.watch(syncServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class AlbumService {
|
||||
final ApiService _apiService;
|
||||
final UserService _userService;
|
||||
final BackgroundService _backgroundService;
|
||||
final SyncService _syncService;
|
||||
final Isar _db;
|
||||
Completer<bool> _localCompleter = Completer()..complete(false);
|
||||
Completer<bool> _remoteCompleter = Completer()..complete(false);
|
||||
|
||||
AlbumService(this._apiService);
|
||||
AlbumService(
|
||||
this._apiService,
|
||||
this._userService,
|
||||
this._backgroundService,
|
||||
this._syncService,
|
||||
this._db,
|
||||
);
|
||||
|
||||
Future<List<Album>?> getAlbums({required bool isShared}) async {
|
||||
try {
|
||||
final dto = await _apiService.albumApi
|
||||
.getAllAlbums(shared: isShared ? isShared : null);
|
||||
return dto?.map(Album.remote).toList();
|
||||
} catch (e) {
|
||||
debugPrint("Error getAllSharedAlbum ${e.toString()}");
|
||||
return null;
|
||||
/// Checks all selected device albums for changes of albums and their assets
|
||||
/// Updates the local database and returns `true` if there were any changes
|
||||
Future<bool> refreshDeviceAlbums() async {
|
||||
if (!_localCompleter.isCompleted) {
|
||||
// guard against concurrent calls
|
||||
return _localCompleter.future;
|
||||
}
|
||||
_localCompleter = Completer();
|
||||
final Stopwatch sw = Stopwatch()..start();
|
||||
bool changes = false;
|
||||
try {
|
||||
if (!await _backgroundService.hasAccess) {
|
||||
return false;
|
||||
}
|
||||
final HiveBackupAlbums? infos =
|
||||
(await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox))
|
||||
.get(backupInfoKey);
|
||||
if (infos == null) {
|
||||
return false;
|
||||
}
|
||||
final List<AssetPathEntity> onDevice =
|
||||
await PhotoManager.getAssetPathList(
|
||||
hasAll: true,
|
||||
filterOption: FilterOptionGroup(containsPathModified: true),
|
||||
);
|
||||
if (infos.excludedAlbumsIds.isNotEmpty) {
|
||||
// remove all excluded albums
|
||||
onDevice.removeWhere((e) => infos.excludedAlbumsIds.contains(e.id));
|
||||
}
|
||||
final hasAll = infos.selectedAlbumIds
|
||||
.map((id) => onDevice.firstWhereOrNull((a) => a.id == id))
|
||||
.whereNotNull()
|
||||
.any((a) => a.isAll);
|
||||
if (hasAll) {
|
||||
// remove the virtual "Recents" album and keep and individual albums
|
||||
onDevice.removeWhere((e) => e.isAll);
|
||||
} else {
|
||||
// keep only the explicitly selected albums
|
||||
onDevice.removeWhere((e) => !infos.selectedAlbumIds.contains(e.id));
|
||||
}
|
||||
changes = await _syncService.syncLocalAlbumAssetsToDb(onDevice);
|
||||
} finally {
|
||||
_localCompleter.complete(changes);
|
||||
}
|
||||
debugPrint("refreshDeviceAlbums took ${sw.elapsedMilliseconds}ms");
|
||||
return changes;
|
||||
}
|
||||
|
||||
/// Checks remote albums (owned if `isShared` is false) for changes,
|
||||
/// updates the local database and returns `true` if there were any changes
|
||||
Future<bool> refreshRemoteAlbums({required bool isShared}) async {
|
||||
if (!_remoteCompleter.isCompleted) {
|
||||
// guard against concurrent calls
|
||||
return _remoteCompleter.future;
|
||||
}
|
||||
_remoteCompleter = Completer();
|
||||
final Stopwatch sw = Stopwatch()..start();
|
||||
bool changes = false;
|
||||
try {
|
||||
await _userService.refreshUsers();
|
||||
final List<AlbumResponseDto>? serverAlbums = await _apiService.albumApi
|
||||
.getAllAlbums(shared: isShared ? true : null);
|
||||
if (serverAlbums == null) {
|
||||
return false;
|
||||
}
|
||||
changes = await _syncService.syncRemoteAlbumsToDb(
|
||||
serverAlbums,
|
||||
isShared: isShared,
|
||||
loadDetails: (dto) async => dto.assetCount == dto.assets.length
|
||||
? dto
|
||||
: (await _apiService.albumApi.getAlbumInfo(dto.id)) ?? dto,
|
||||
);
|
||||
} finally {
|
||||
_remoteCompleter.complete(changes);
|
||||
}
|
||||
debugPrint("refreshRemoteAlbums took ${sw.elapsedMilliseconds}ms");
|
||||
return changes;
|
||||
}
|
||||
|
||||
Future<Album?> createAlbum(
|
||||
@@ -37,56 +132,51 @@ class AlbumService {
|
||||
Iterable<User> sharedUsers = const [],
|
||||
]) async {
|
||||
try {
|
||||
final dto = await _apiService.albumApi.createAlbum(
|
||||
AlbumResponseDto? remote = await _apiService.albumApi.createAlbum(
|
||||
CreateAlbumDto(
|
||||
albumName: albumName,
|
||||
assetIds: assets.map((asset) => asset.remoteId!).toList(),
|
||||
sharedWithUserIds: sharedUsers.map((e) => e.id).toList(),
|
||||
),
|
||||
);
|
||||
return dto != null ? Album.remote(dto) : null;
|
||||
if (remote != null) {
|
||||
Album album = await Album.remote(remote);
|
||||
await _db.writeTxn(() => _db.albums.store(album));
|
||||
return album;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error createSharedAlbum ${e.toString()}");
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Creates names like Untitled, Untitled (1), Untitled (2), ...
|
||||
*/
|
||||
String _getNextAlbumName(List<Album>? albums) {
|
||||
Future<String> _getNextAlbumName() async {
|
||||
const baseName = "Untitled";
|
||||
for (int round = 0;; round++) {
|
||||
final proposedName = "$baseName${round == 0 ? "" : " ($round)"}";
|
||||
|
||||
if (albums != null) {
|
||||
for (int round = 0; round < albums.length; round++) {
|
||||
final proposedName = "$baseName${round == 0 ? "" : " ($round)"}";
|
||||
|
||||
if (albums.where((a) => a.name == proposedName).isEmpty) {
|
||||
return proposedName;
|
||||
}
|
||||
if (null ==
|
||||
await _db.albums.filter().nameEqualTo(proposedName).findFirst()) {
|
||||
return proposedName;
|
||||
}
|
||||
}
|
||||
return baseName;
|
||||
}
|
||||
|
||||
Future<Album?> createAlbumWithGeneratedName(
|
||||
Iterable<Asset> assets,
|
||||
) async {
|
||||
return createAlbum(
|
||||
_getNextAlbumName(await getAlbums(isShared: false)),
|
||||
await _getNextAlbumName(),
|
||||
assets,
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
Future<Album?> getAlbumDetail(String albumId) async {
|
||||
try {
|
||||
final dto = await _apiService.albumApi.getAlbumInfo(albumId);
|
||||
return dto != null ? Album.remote(dto) : null;
|
||||
} catch (e) {
|
||||
debugPrint('Error [getAlbumDetail] ${e.toString()}');
|
||||
return null;
|
||||
}
|
||||
Future<Album?> getAlbumDetail(int albumId) {
|
||||
return _db.albums.get(albumId);
|
||||
}
|
||||
|
||||
Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum(
|
||||
@@ -98,6 +188,10 @@ class AlbumService {
|
||||
album.remoteId!,
|
||||
AddAssetsDto(assetIds: assets.map((asset) => asset.remoteId!).toList()),
|
||||
);
|
||||
if (result != null && result.successfullyAdded > 0) {
|
||||
album.assets.addAll(assets);
|
||||
await _db.writeTxn(() => album.assets.save());
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}");
|
||||
@@ -110,26 +204,53 @@ class AlbumService {
|
||||
Album album,
|
||||
) async {
|
||||
try {
|
||||
var result = await _apiService.albumApi.addUsersToAlbum(
|
||||
final result = await _apiService.albumApi.addUsersToAlbum(
|
||||
album.remoteId!,
|
||||
AddUsersDto(sharedUserIds: sharedUserIds),
|
||||
);
|
||||
|
||||
return result != null;
|
||||
if (result != null) {
|
||||
album.sharedUsers
|
||||
.addAll((await _db.users.getAllById(sharedUserIds)).cast());
|
||||
await _db.writeTxn(() => album.sharedUsers.save());
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error addAdditionalUserToAlbum ${e.toString()}");
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> deleteAlbum(Album album) async {
|
||||
try {
|
||||
await _apiService.albumApi.deleteAlbum(album.remoteId!);
|
||||
final userId = Store.get<User>(StoreKey.currentUser)!.isarId;
|
||||
if (album.owner.value?.isarId == userId) {
|
||||
await _apiService.albumApi.deleteAlbum(album.remoteId!);
|
||||
}
|
||||
if (album.shared) {
|
||||
final foreignAssets =
|
||||
await album.assets.filter().not().ownerIdEqualTo(userId).findAll();
|
||||
await _db.writeTxn(() => _db.albums.delete(album.id));
|
||||
final List<Album> albums =
|
||||
await _db.albums.filter().sharedEqualTo(true).findAll();
|
||||
final List<Asset> existing = [];
|
||||
for (Album a in albums) {
|
||||
existing.addAll(
|
||||
await a.assets.filter().not().ownerIdEqualTo(userId).findAll(),
|
||||
);
|
||||
}
|
||||
final List<int> idsToRemove =
|
||||
_syncService.sharedAssetsToRemove(foreignAssets, existing);
|
||||
if (idsToRemove.isNotEmpty) {
|
||||
await _db.writeTxn(() => _db.assets.deleteAll(idsToRemove));
|
||||
}
|
||||
} else {
|
||||
await _db.writeTxn(() => _db.albums.delete(album.id));
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint("Error deleteAlbum ${e.toString()}");
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> leaveAlbum(Album album) async {
|
||||
@@ -153,6 +274,8 @@ class AlbumService {
|
||||
assetIds: assets.map((e) => e.remoteId!).toList(growable: false),
|
||||
),
|
||||
);
|
||||
album.assets.removeAll(assets);
|
||||
await _db.writeTxn(() => album.assets.update(unlink: assets));
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -173,6 +296,7 @@ class AlbumService {
|
||||
),
|
||||
);
|
||||
album.name = newAlbumTitle;
|
||||
await _db.writeTxn(() => _db.albums.put(album));
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user