From 1a94530935588c7bfa076072803f3f9e3d4a39cf Mon Sep 17 00:00:00 2001 From: Zeyad Tamimi Date: Sun, 26 Mar 2023 17:38:23 -0700 Subject: [PATCH 1/3] [ Mobile ] Fixed mobile app not reporting webm MIME type (#2090) --- mobile/lib/utils/files_helper.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mobile/lib/utils/files_helper.dart b/mobile/lib/utils/files_helper.dart index 6231764d86..8b9d5c0409 100644 --- a/mobile/lib/utils/files_helper.dart +++ b/mobile/lib/utils/files_helper.dart @@ -44,6 +44,9 @@ class FileHelper { case '3gp': return {"type": "video", "subType": "3gpp"}; + case 'webm': + return {"type": "video", "subType": "webm"}; + default: return {"type": "unsupport", "subType": "unsupport"}; } From cae37657e9e09467af8e9bb830a5f3f3f02e909a Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Mon, 27 Mar 2023 04:35:52 +0200 Subject: [PATCH 2/3] feature(mobile): Hardening synchronization mechanism + Pull to refresh (#2085) * fix(mobile): allow syncing duplicate local IDs * enable to run isar unit tests on CI * serialize sync operations, add pull to refresh on timeline --------- Co-authored-by: Fynn Petersen-Frey --- mobile/lib/main.dart | 1 + .../album/ui/album_thumbnail_card.dart | 2 +- .../lib/modules/album/views/sharing_page.dart | 2 +- .../home/ui/asset_grid/immich_asset_grid.dart | 46 +++--- .../ui/asset_grid/immich_asset_grid_view.dart | 34 +++-- mobile/lib/modules/home/views/home_page.dart | 18 +++ .../providers/authentication.provider.dart | 2 - mobile/lib/shared/models/asset.dart | 111 +++++++++++--- mobile/lib/shared/models/asset.g.dart | Bin 64357 -> 64415 bytes mobile/lib/shared/models/store.dart | 2 +- .../lib/shared/providers/asset.provider.dart | 96 +++++------- mobile/lib/shared/services/asset.service.dart | 13 +- .../services/immich_logger.service.dart | 16 +- mobile/lib/shared/services/sync.service.dart | 121 ++++++++++++--- mobile/lib/utils/async_mutex.dart | 5 + mobile/lib/utils/db.dart | 14 ++ mobile/lib/utils/migration.dart | 15 +- .../test/asset_grid_data_structure_test.dart | 1 + mobile/test/async_mutex_test.dart | 41 +++++ mobile/test/favorite_provider_test.dart | 1 + mobile/test/sync_service_test.dart | 143 ++++++++++++++++++ 21 files changed, 531 insertions(+), 153 deletions(-) create mode 100644 mobile/lib/utils/db.dart create mode 100644 mobile/test/async_mutex_test.dart create mode 100644 mobile/test/sync_service_test.dart diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index b64a48b80c..316641e039 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -50,6 +50,7 @@ void main() async { await initApp(); await migrateHiveToStoreIfNecessary(); await migrateJsonCacheIfNecessary(); + await migrateDatabaseIfNeeded(db); runApp(getMainWidget(db)); } diff --git a/mobile/lib/modules/album/ui/album_thumbnail_card.dart b/mobile/lib/modules/album/ui/album_thumbnail_card.dart index 12e5f27dfe..f37751e3e1 100644 --- a/mobile/lib/modules/album/ui/album_thumbnail_card.dart +++ b/mobile/lib/modules/album/ui/album_thumbnail_card.dart @@ -53,7 +53,7 @@ class AlbumThumbnailCard extends StatelessWidget { // Add the owner name to the subtitle String? owner; if (showOwner) { - if (album.ownerId == Store.get(StoreKey.userRemoteId)) { + if (album.ownerId == Store.get(StoreKey.currentUser).id) { owner = 'album_thumbnail_owned'.tr(); } else if (album.ownerName != null) { owner = 'album_thumbnail_shared_by'.tr(args: [album.ownerName!]); diff --git a/mobile/lib/modules/album/views/sharing_page.dart b/mobile/lib/modules/album/views/sharing_page.dart index 32b0a3e145..54807ffe79 100644 --- a/mobile/lib/modules/album/views/sharing_page.dart +++ b/mobile/lib/modules/album/views/sharing_page.dart @@ -17,7 +17,7 @@ class SharingPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final List sharedAlbums = ref.watch(sharedAlbumProvider); - final userId = store.Store.get(store.StoreKey.userRemoteId); + final userId = store.Store.get(store.StoreKey.currentUser).id; var isDarkMode = Theme.of(context).brightness == Brightness.dark; useEffect( diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart index c4f9558018..fc245e4a1b 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart @@ -17,10 +17,12 @@ class ImmichAssetGrid extends HookConsumerWidget { final bool selectionActive; final List assets; final RenderList? renderList; + final Future Function()? onRefresh; const ImmichAssetGrid({ super.key, required this.assets, + this.onRefresh, this.renderList, this.assetsPerRow, this.showStorageIndicator, @@ -62,11 +64,12 @@ class ImmichAssetGrid extends HookConsumerWidget { enabled: enableHeroAnimations.value, child: ImmichAssetGridView( allAssets: assets, - assetsPerRow: assetsPerRow - ?? settings.getSetting(AppSettingsEnum.tilesPerRow), + onRefresh: onRefresh, + assetsPerRow: assetsPerRow ?? + settings.getSetting(AppSettingsEnum.tilesPerRow), listener: listener, - showStorageIndicator: showStorageIndicator - ?? settings.getSetting(AppSettingsEnum.storageIndicator), + showStorageIndicator: showStorageIndicator ?? + settings.getSetting(AppSettingsEnum.storageIndicator), renderList: renderList!, margin: margin, selectionActive: selectionActive, @@ -76,26 +79,25 @@ class ImmichAssetGrid extends HookConsumerWidget { } return renderListFuture.when( - data: (renderList) => - WillPopScope( - onWillPop: onWillPop, - child: HeroMode( - enabled: enableHeroAnimations.value, - child: ImmichAssetGridView( - allAssets: assets, - assetsPerRow: assetsPerRow - ?? settings.getSetting(AppSettingsEnum.tilesPerRow), - listener: listener, - showStorageIndicator: showStorageIndicator - ?? settings.getSetting(AppSettingsEnum.storageIndicator), - renderList: renderList, - margin: margin, - selectionActive: selectionActive, - ), + data: (renderList) => WillPopScope( + onWillPop: onWillPop, + child: HeroMode( + enabled: enableHeroAnimations.value, + child: ImmichAssetGridView( + allAssets: assets, + onRefresh: onRefresh, + assetsPerRow: assetsPerRow ?? + settings.getSetting(AppSettingsEnum.tilesPerRow), + listener: listener, + showStorageIndicator: showStorageIndicator ?? + settings.getSetting(AppSettingsEnum.storageIndicator), + renderList: renderList, + margin: margin, + selectionActive: selectionActive, ), ), - error: (err, stack) => - Center(child: Text("$err")), + ), + error: (err, stack) => Center(child: Text("$err")), loading: () => const Center( child: ImmichLoadingIndicator(), ), diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart index 8c3026f462..0b3b447c63 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart @@ -199,21 +199,23 @@ class ImmichAssetGridViewState extends State { addRepaintBoundaries: true, ); - if (!useDragScrolling) { - return listWidget; - } + final child = useDragScrolling + ? DraggableScrollbar.semicircle( + scrollStateListener: dragScrolling, + itemPositionsListener: _itemPositionsListener, + controller: _itemScrollController, + backgroundColor: Theme.of(context).hintColor, + labelTextBuilder: _labelBuilder, + labelConstraints: const BoxConstraints(maxHeight: 28), + scrollbarAnimationDuration: const Duration(seconds: 1), + scrollbarTimeToFade: const Duration(seconds: 4), + child: listWidget, + ) + : listWidget; - return DraggableScrollbar.semicircle( - scrollStateListener: dragScrolling, - itemPositionsListener: _itemPositionsListener, - controller: _itemScrollController, - backgroundColor: Theme.of(context).hintColor, - labelTextBuilder: _labelBuilder, - labelConstraints: const BoxConstraints(maxHeight: 28), - scrollbarAnimationDuration: const Duration(seconds: 1), - scrollbarTimeToFade: const Duration(seconds: 4), - child: listWidget, - ); + return widget.onRefresh == null + ? child + : RefreshIndicator(onRefresh: widget.onRefresh!, child: child); } @override @@ -248,7 +250,7 @@ class ImmichAssetGridViewState extends State { } void _scrollToTop() { - // for some reason, this is necessary as well in order + // for some reason, this is necessary as well in order // to correctly reposition the drag thumb scroll bar _itemScrollController.jumpTo( index: 0, @@ -281,6 +283,7 @@ class ImmichAssetGridView extends StatefulWidget { final ImmichAssetGridSelectionListener? listener; final bool selectionActive; final List allAssets; + final Future Function()? onRefresh; const ImmichAssetGridView({ super.key, @@ -291,6 +294,7 @@ class ImmichAssetGridView extends StatefulWidget { this.listener, this.margin = 5.0, this.selectionActive = false, + this.onRefresh, }); @override diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index b428e5023d..b01d1b73d9 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -43,6 +43,7 @@ class HomePage extends HookConsumerWidget { final albumService = ref.watch(albumServiceProvider); final tipOneOpacity = useState(0.0); + final refreshCount = useState(0); useEffect( () { @@ -182,6 +183,22 @@ class HomePage extends HookConsumerWidget { } } + Future refreshAssets() async { + debugPrint("refreshCount.value ${refreshCount.value}"); + final fullRefresh = refreshCount.value > 0; + await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh); + if (fullRefresh) { + // refresh was forced: user requested another refresh within 2 seconds + refreshCount.value = 0; + } else { + refreshCount.value++; + // set counter back to 0 if user does not request refresh again + Timer(const Duration(seconds: 2), () { + refreshCount.value = 0; + }); + } + } + buildLoadingIndicator() { Timer(const Duration(seconds: 2), () { tipOneOpacity.value = 1; @@ -241,6 +258,7 @@ class HomePage extends HookConsumerWidget { .getSetting(AppSettingsEnum.storageIndicator), listener: selectionListener, selectionActive: selectionEnabledHook.value, + onRefresh: refreshAssets, ), if (selectionEnabledHook.value) SafeArea( diff --git a/mobile/lib/modules/login/providers/authentication.provider.dart b/mobile/lib/modules/login/providers/authentication.provider.dart index 76e6b74820..b2979df859 100644 --- a/mobile/lib/modules/login/providers/authentication.provider.dart +++ b/mobile/lib/modules/login/providers/authentication.provider.dart @@ -78,7 +78,6 @@ class AuthenticationNotifier extends StateNotifier { await Future.wait([ _apiService.authenticationApi.logout(), Store.delete(StoreKey.assetETag), - Store.delete(StoreKey.userRemoteId), Store.delete(StoreKey.currentUser), Store.delete(StoreKey.accessToken), ]); @@ -133,7 +132,6 @@ class AuthenticationNotifier extends StateNotifier { var deviceInfo = await _deviceInfoService.getDeviceInfo(); Store.put(StoreKey.deviceId, deviceInfo["deviceId"]); Store.put(StoreKey.deviceIdHash, fastHash(deviceInfo["deviceId"])); - Store.put(StoreKey.userRemoteId, userResponseDto.id); Store.put(StoreKey.currentUser, User.fromDto(userResponseDto)); Store.put(StoreKey.serverUrl, serverUrl); Store.put(StoreKey.accessToken, accessToken); diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart index d4fbdf1835..709accb50d 100644 --- a/mobile/lib/shared/models/asset.dart +++ b/mobile/lib/shared/models/asset.dart @@ -15,11 +15,11 @@ class Asset { Asset.remote(AssetResponseDto remote) : remoteId = remote.id, isLocal = false, - fileCreatedAt = DateTime.parse(remote.fileCreatedAt).toUtc(), - fileModifiedAt = DateTime.parse(remote.fileModifiedAt).toUtc(), - updatedAt = DateTime.parse(remote.updatedAt).toUtc(), - // use -1 as fallback duration (to not mix it up with non-video assets correctly having duration=0) - durationInSeconds = remote.duration.toDuration()?.inSeconds ?? -1, + fileCreatedAt = DateTime.parse(remote.fileCreatedAt), + fileModifiedAt = DateTime.parse(remote.fileModifiedAt), + updatedAt = DateTime.parse(remote.updatedAt), + durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0, + type = remote.type.toAssetType(), fileName = p.basename(remote.originalPath), height = remote.exifInfo?.exifImageHeight?.toInt(), width = remote.exifInfo?.exifImageWidth?.toInt(), @@ -35,15 +35,16 @@ class Asset { : localId = local.id, isLocal = true, durationInSeconds = local.duration, + type = AssetType.values[local.typeInt], height = local.height, width = local.width, fileName = local.title!, deviceId = Store.get(StoreKey.deviceIdHash), ownerId = Store.get(StoreKey.currentUser).isarId, - fileModifiedAt = local.modifiedDateTime.toUtc(), - updatedAt = local.modifiedDateTime.toUtc(), + fileModifiedAt = local.modifiedDateTime, + updatedAt = local.modifiedDateTime, isFavorite = local.isFavorite, - fileCreatedAt = local.createDateTime.toUtc() { + fileCreatedAt = local.createDateTime { if (fileCreatedAt.year == 1970) { fileCreatedAt = fileModifiedAt; } @@ -61,6 +62,7 @@ class Asset { required this.fileModifiedAt, required this.updatedAt, required this.durationInSeconds, + required this.type, this.width, this.height, required this.fileName, @@ -77,10 +79,10 @@ class Asset { AssetEntity? get local { if (isLocal && _local == null) { _local = AssetEntity( - id: localId.toString(), + id: localId, typeInt: isImage ? 1 : 2, - width: width!, - height: height!, + width: width ?? 0, + height: height ?? 0, duration: durationInSeconds, createDateSecond: fileCreatedAt.millisecondsSinceEpoch ~/ 1000, modifiedDateSecond: fileModifiedAt.millisecondsSinceEpoch ~/ 1000, @@ -96,7 +98,7 @@ class Asset { String? remoteId; @Index( - unique: true, + unique: false, replace: false, type: IndexType.hash, composite: [CompositeIndex('deviceId')], @@ -115,6 +117,9 @@ class Asset { int durationInSeconds; + @Enumerated(EnumType.ordinal) + AssetType type; + short? width; short? height; @@ -140,7 +145,7 @@ class Asset { bool get isRemote => remoteId != null; @ignore - bool get isImage => durationInSeconds == 0; + bool get isImage => type == AssetType.image; @ignore Duration get duration => Duration(seconds: durationInSeconds); @@ -148,12 +153,43 @@ class Asset { @override bool operator ==(other) { if (other is! Asset) return false; - return id == other.id; + return id == other.id && + remoteId == other.remoteId && + localId == other.localId && + deviceId == other.deviceId && + ownerId == other.ownerId && + fileCreatedAt == other.fileCreatedAt && + fileModifiedAt == other.fileModifiedAt && + updatedAt == other.updatedAt && + durationInSeconds == other.durationInSeconds && + type == other.type && + width == other.width && + height == other.height && + fileName == other.fileName && + livePhotoVideoId == other.livePhotoVideoId && + isFavorite == other.isFavorite && + isLocal == other.isLocal; } @override @ignore - int get hashCode => id.hashCode; + int get hashCode => + id.hashCode ^ + remoteId.hashCode ^ + localId.hashCode ^ + deviceId.hashCode ^ + ownerId.hashCode ^ + fileCreatedAt.hashCode ^ + fileModifiedAt.hashCode ^ + updatedAt.hashCode ^ + durationInSeconds.hashCode ^ + type.hashCode ^ + width.hashCode ^ + height.hashCode ^ + fileName.hashCode ^ + livePhotoVideoId.hashCode ^ + isFavorite.hashCode ^ + isLocal.hashCode; bool updateFromAssetEntity(AssetEntity ae) { // TODO check more fields; @@ -192,9 +228,24 @@ class Asset { } } - static int compareByDeviceIdLocalId(Asset a, Asset b) { - final int order = a.deviceId.compareTo(b.deviceId); - return order == 0 ? a.localId.compareTo(b.localId) : order; + /// compares assets by [ownerId], [deviceId], [localId] + static int compareByOwnerDeviceLocalId(Asset a, Asset b) { + final int ownerIdOrder = a.ownerId.compareTo(b.ownerId); + if (ownerIdOrder != 0) { + return ownerIdOrder; + } + final int deviceIdOrder = a.deviceId.compareTo(b.deviceId); + if (deviceIdOrder != 0) { + return deviceIdOrder; + } + final int localIdOrder = a.localId.compareTo(b.localId); + return localIdOrder; + } + + /// compares assets by [ownerId], [deviceId], [localId], [fileModifiedAt] + static int compareByOwnerDeviceLocalIdModified(Asset a, Asset b) { + final int order = compareByOwnerDeviceLocalId(a, b); + return order != 0 ? order : a.fileModifiedAt.compareTo(b.fileModifiedAt); } static int compareById(Asset a, Asset b) => a.id.compareTo(b.id); @@ -203,6 +254,30 @@ class Asset { a.localId.compareTo(b.localId); } +enum AssetType { + // do not change this order! + other, + image, + video, + audio, +} + +extension AssetTypeEnumHelper on AssetTypeEnum { + AssetType toAssetType() { + switch (this) { + case AssetTypeEnum.IMAGE: + return AssetType.image; + case AssetTypeEnum.VIDEO: + return AssetType.video; + case AssetTypeEnum.AUDIO: + return AssetType.audio; + case AssetTypeEnum.OTHER: + return AssetType.other; + } + throw Exception(); + } +} + extension AssetsHelper on IsarCollection { Future deleteAllByRemoteId(Iterable ids) => ids.isEmpty ? Future.value(0) : _remote(ids).deleteAll(); diff --git a/mobile/lib/shared/models/asset.g.dart b/mobile/lib/shared/models/asset.g.dart index 704769031d1eb6b1f9a24984cc6de06a82aecf6a..a0f919f88c9dd50d8a66b098828e5da6dc650612 100644 GIT binary patch delta 970 zcmaF*jd}ie<_%Xkm`f@PCI_;~P5#9p!~$fePX5Rysa08$s>7uK2B~?axxR@7RtoWs z#l@*5ASJFqp|Hf9(o~@M8c+Tq zstdGCO>8A|5W-tTTZ3u?rX8;&^(NPdD>50HOimP+o-84iQ=golS6qT42o-DM2(R}!43dfKDkNi?dHE~ogLEPgi%x#j%~uE`&CSRqm$zluPl9DgOSg2F<5@-R>-T)KeyRd$L|C<8-C5<#@MyUmPG;ve~YwX>xO5Ds|G^9@yz3Uyewr zX_Cv0(}i=n?KF9CR3=M9d2;Dkp1dBqNWKk!&MY)ariN}o&3u^76*e;f=N;@=Xm;MV z5uQ_E4dI2xVy&tgCG9M#>s3_J$jejmLB7GAC9+i6%A!oNevf#X^ z+c*d^kMg`>#JEc|a_qy6m$9XrYYK=kUseqV*|NW^@LA%avpNJwA0W3sQ++@kfgB~S?E0CkSZI&r?_^lkc^8ce$OenXr zO2ve7n-Zo2O2vd4{THR03eD#jsU>PlR0C@VdsbC3txVPJWL3qq%77ad_kL(qsf>uq z<_$iqD!(cIwXmp0qQ53dw09-tSCreS=9eH7Q_1B$cz%ba3iLR*wu8|lCI@8^C3uRzRMgOv4Kf~Ujw7O$ zbP&Hm!ePOjO}Kx5%gJE~MuNiwSBL`86btfd=Y;nu@80v?KD~e3d-i7jus8Q%y4UOf P`Zz { - userRemoteId(0, type: String), + version(0, type: int), assetETag(1, type: String), currentUser(2, type: User, fromDb: _getUser, toDb: _toUser), deviceIdHash(3, type: int), diff --git a/mobile/lib/shared/providers/asset.provider.dart b/mobile/lib/shared/providers/asset.provider.dart index 11635b45f6..b20578ca4c 100644 --- a/mobile/lib/shared/providers/asset.provider.dart +++ b/mobile/lib/shared/providers/asset.provider.dart @@ -1,7 +1,5 @@ -import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; -import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/exif_info.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/models/user.dart'; @@ -12,6 +10,9 @@ import 'package:immich_mobile/modules/settings/providers/app_settings.provider.d import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:collection/collection.dart'; +import 'package:immich_mobile/shared/services/sync.service.dart'; +import 'package:immich_mobile/utils/async_mutex.dart'; +import 'package:immich_mobile/utils/db.dart'; import 'package:intl/intl.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; @@ -53,15 +54,18 @@ class AssetNotifier extends StateNotifier { final AssetService _assetService; final AppSettingsService _settingsService; final AlbumService _albumService; + final SyncService _syncService; final Isar _db; final log = Logger('AssetNotifier'); bool _getAllAssetInProgress = false; bool _deleteInProgress = false; + final AsyncMutex _stateUpdateLock = AsyncMutex(); AssetNotifier( this._assetService, this._settingsService, this._albumService, + this._syncService, this._db, ) : super(AssetsState.fromAssetList([])); @@ -81,24 +85,30 @@ class AssetNotifier extends StateNotifier { await _updateAssetsState(state.allAssets); } - getAllAsset() async { + Future getAllAsset({bool clear = false}) async { if (_getAllAssetInProgress || _deleteInProgress) { // guard against multiple calls to this method while it's still working return; } - final stopwatch = Stopwatch(); + final stopwatch = Stopwatch()..start(); try { _getAllAssetInProgress = true; final User me = Store.get(StoreKey.currentUser); - final int cachedCount = - await _db.assets.filter().ownerIdEqualTo(me.isarId).count(); - stopwatch.start(); - if (cachedCount > 0 && cachedCount != state.allAssets.length) { - await _updateAssetsState(await _getUserAssets(me.isarId)); - log.info( - "Reading assets ${state.allAssets.length} from DB: ${stopwatch.elapsedMilliseconds}ms", - ); - stopwatch.reset(); + if (clear) { + await clearAssetsAndAlbums(_db); + log.info("Manual refresh requested, cleared assets and albums from db"); + } else if (_stateUpdateLock.enqueued <= 1) { + final int cachedCount = + await _db.assets.filter().ownerIdEqualTo(me.isarId).count(); + if (cachedCount > 0 && cachedCount != state.allAssets.length) { + await _stateUpdateLock.run( + () async => _updateAssetsState(await _getUserAssets(me.isarId)), + ); + log.info( + "Reading assets ${state.allAssets.length} from DB: ${stopwatch.elapsedMilliseconds}ms", + ); + stopwatch.reset(); + } } final bool newRemote = await _assetService.refreshRemoteAssets(); final bool newLocal = await _albumService.refreshDeviceAlbums(); @@ -112,10 +122,14 @@ class AssetNotifier extends StateNotifier { return; } stopwatch.reset(); - final assets = await _getUserAssets(me.isarId); - if (!const ListEquality().equals(assets, state.allAssets)) { - log.info("setting new asset state"); - await _updateAssetsState(assets); + if (_stateUpdateLock.enqueued <= 1) { + _stateUpdateLock.run(() async { + final assets = await _getUserAssets(me.isarId); + if (!const ListEquality().equals(assets, state.allAssets)) { + log.info("setting new asset state"); + await _updateAssetsState(assets); + } + }); } } finally { _getAllAssetInProgress = false; @@ -130,47 +144,18 @@ class AssetNotifier extends StateNotifier { Future clearAllAsset() { state = AssetsState.empty(); - return _db.writeTxn(() async { - await _db.assets.clear(); - await _db.exifInfos.clear(); - await _db.albums.clear(); - }); + return clearAssetsAndAlbums(_db); } Future onNewAssetUploaded(Asset newAsset) async { - final int i = state.allAssets.indexWhere( - (a) => - a.isRemote || - (a.localId == newAsset.localId && a.deviceId == newAsset.deviceId), - ); - - if (i == -1 || - state.allAssets[i].localId != newAsset.localId || - state.allAssets[i].deviceId != newAsset.deviceId) { - await _updateAssetsState([...state.allAssets, newAsset]); - } else { - // unify local/remote assets by replacing the - // local-only asset in the DB with a local&remote asset - final Asset? inDb = await _db.assets - .where() - .localIdDeviceIdEqualTo(newAsset.localId, newAsset.deviceId) - .findFirst(); - if (inDb != null) { - newAsset.id = inDb.id; - newAsset.isLocal = inDb.isLocal; - } - - // order is important to keep all local-only assets at the beginning! - await _updateAssetsState([ - ...state.allAssets.slice(0, i), - ...state.allAssets.slice(i + 1), - newAsset, - ]); - } - try { - await _db.writeTxn(() => newAsset.put(_db)); - } on IsarError catch (e) { - debugPrint(e.toString()); + final bool ok = await _syncService.syncNewAssetToDb(newAsset); + if (ok && _stateUpdateLock.enqueued <= 1) { + // run this sequentially if there is at most 1 other task waiting + await _stateUpdateLock.run(() async { + final userId = Store.get(StoreKey.currentUser).isarId; + final assets = await _getUserAssets(userId); + await _updateAssetsState(assets); + }); } } @@ -253,6 +238,7 @@ final assetProvider = StateNotifierProvider((ref) { ref.watch(assetServiceProvider), ref.watch(appSettingsServiceProvider), ref.watch(albumServiceProvider), + ref.watch(syncServiceProvider), ref.watch(dbProvider), ); }); diff --git a/mobile/lib/shared/services/asset.service.dart b/mobile/lib/shared/services/asset.service.dart index 3867a0ed42..ffebaf0536 100644 --- a/mobile/lib/shared/services/asset.service.dart +++ b/mobile/lib/shared/services/asset.service.dart @@ -45,14 +45,11 @@ class AssetService { .filter() .ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId) .count(); - final List? dtos = - await _getRemoteAssets(hasCache: numOwnedRemoteAssets > 0); - if (dtos == null) { - debugPrint("refreshRemoteAssets fast took ${sw.elapsedMilliseconds}ms"); - return false; - } - final bool changes = await _syncService - .syncRemoteAssetsToDb(dtos.map(Asset.remote).toList()); + final bool changes = await _syncService.syncRemoteAssetsToDb( + () async => (await _getRemoteAssets(hasCache: numOwnedRemoteAssets > 0)) + ?.map(Asset.remote) + .toList(), + ); debugPrint("refreshRemoteAssets full took ${sw.elapsedMilliseconds}ms"); return changes; } diff --git a/mobile/lib/shared/services/immich_logger.service.dart b/mobile/lib/shared/services/immich_logger.service.dart index 2d0adfce36..4987e66572 100644 --- a/mobile/lib/shared/services/immich_logger.service.dart +++ b/mobile/lib/shared/services/immich_logger.service.dart @@ -20,7 +20,7 @@ class ImmichLogger { static final ImmichLogger _instance = ImmichLogger._internal(); final maxLogEntries = 200; final Isar _db = Isar.getInstance()!; - final List _msgBuffer = []; + List _msgBuffer = []; Timer? _timer; factory ImmichLogger() => _instance; @@ -41,7 +41,12 @@ class ImmichLogger { final msgCount = _db.loggerMessages.countSync(); if (msgCount > maxLogEntries) { final numberOfEntryToBeDeleted = msgCount - maxLogEntries; - _db.loggerMessages.where().limit(numberOfEntryToBeDeleted).deleteAll(); + _db.writeTxn( + () => _db.loggerMessages + .where() + .limit(numberOfEntryToBeDeleted) + .deleteAll(), + ); } } @@ -63,8 +68,9 @@ class ImmichLogger { void _flushBufferToDatabase() { _timer = null; - _db.writeTxnSync(() => _db.loggerMessages.putAllSync(_msgBuffer)); - _msgBuffer.clear(); + final buffer = _msgBuffer; + _msgBuffer = []; + _db.writeTxn(() => _db.loggerMessages.putAll(buffer)); } void clearLogs() { @@ -111,7 +117,7 @@ class ImmichLogger { void flush() { if (_timer != null) { _timer!.cancel(); - _flushBufferToDatabase(); + _db.writeTxnSync(() => _db.loggerMessages.putAllSync(_msgBuffer)); } } } diff --git a/mobile/lib/shared/services/sync.service.dart b/mobile/lib/shared/services/sync.service.dart index 248055e8be..97a2d54a67 100644 --- a/mobile/lib/shared/services/sync.service.dart +++ b/mobile/lib/shared/services/sync.service.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/asset.dart'; @@ -61,8 +60,10 @@ class SyncService { /// Syncs remote assets owned by the logged-in user to the DB /// Returns `true` if there were any changes - Future syncRemoteAssetsToDb(List remote) => - _lock.run(() => _syncRemoteAssetsToDb(remote)); + Future syncRemoteAssetsToDb( + FutureOr?> Function() loadAssets, + ) => + _lock.run(() => _syncRemoteAssetsToDb(loadAssets)); /// Syncs remote albums to the database /// returns `true` if there were any changes @@ -97,19 +98,72 @@ class SyncService { .toList(); } + /// Syncs a new asset to the db. Returns `true` if successful + Future syncNewAssetToDb(Asset newAsset) => + _lock.run(() => _syncNewAssetToDb(newAsset)); + // private methods: + /// Syncs a new asset to the db. Returns `true` if successful + Future _syncNewAssetToDb(Asset newAsset) async { + final List inDb = await _db.assets + .where() + .localIdDeviceIdEqualTo(newAsset.localId, newAsset.deviceId) + .findAll(); + Asset? match; + if (inDb.length == 1) { + // exactly one match: trivial case + match = inDb.first; + } else if (inDb.length > 1) { + // TODO instead of this heuristics: match by checksum once available + for (Asset a in inDb) { + if (a.ownerId == newAsset.ownerId && + a.fileModifiedAt == newAsset.fileModifiedAt) { + assert(match == null); + match = a; + } + } + if (match == null) { + for (Asset a in inDb) { + if (a.ownerId == newAsset.ownerId) { + assert(match == null); + match = a; + } + } + } + } + if (match != null) { + // unify local/remote assets by replacing the + // local-only asset in the DB with a local&remote asset + newAsset.updateFromDb(match); + } + try { + await _db.writeTxn(() => newAsset.put(_db)); + } on IsarError catch (e) { + _log.severe("Failed to put new asset into db: $e"); + return false; + } + return true; + } + /// Syncs remote assets to the databas /// returns `true` if there were any changes - Future _syncRemoteAssetsToDb(List remote) async { + Future _syncRemoteAssetsToDb( + FutureOr?> Function() loadAssets, + ) async { + final List? remote = await loadAssets(); + if (remote == null) { + return false; + } final User user = Store.get(StoreKey.currentUser); final List inDb = await _db.assets .filter() .ownerIdEqualTo(user.isarId) .sortByDeviceId() .thenByLocalId() + .thenByFileModifiedAt() .findAll(); - remote.sort(Asset.compareByDeviceIdLocalId); + remote.sort(Asset.compareByOwnerDeviceLocalIdModified); final diff = _diffAssets(remote, inDb, remote: true); if (diff.first.isEmpty && diff.second.isEmpty && diff.third.isEmpty) { return false; @@ -119,7 +173,7 @@ class SyncService { await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete)); await _upsertAssetsWithExif(diff.first + diff.second); } on IsarError catch (e) { - debugPrint(e.toString()); + _log.severe("Failed to sync remote assets to db: $e"); } return true; } @@ -188,10 +242,15 @@ class SyncService { if (dto.assetCount != dto.assets.length) { return false; } - final assetsInDb = - await album.assets.filter().sortByDeviceId().thenByLocalId().findAll(); + final assetsInDb = await album.assets + .filter() + .sortByOwnerId() + .thenByDeviceId() + .thenByLocalId() + .thenByFileModifiedAt() + .findAll(); final List assetsOnRemote = dto.getAssets(); - assetsOnRemote.sort(Asset.compareByDeviceIdLocalId); + assetsOnRemote.sort(Asset.compareByOwnerDeviceLocalIdModified); final d = _diffAssets(assetsOnRemote, assetsInDb); final List toAdd = d.first, toUpdate = d.second, toUnlink = d.third; @@ -237,7 +296,7 @@ class SyncService { await _db.albums.put(album); }); } on IsarError catch (e) { - debugPrint(e.toString()); + _log.severe("Failed to sync remote album to database $e"); } if (album.shared || dto.shared) { @@ -300,7 +359,7 @@ class SyncService { assert(ok); _log.info("Removed local album $album from DB"); } catch (e) { - _log.warning("Failed to remove local album $album from DB"); + _log.severe("Failed to remove local album $album from DB"); } } @@ -331,7 +390,7 @@ class SyncService { _addAlbumFromDevice(ape, existing, excludedAssets), onlySecond: (Album a) => _removeAlbumFromDb(a, deleteCandidates), ); - final pair = _handleAssetRemoval(deleteCandidates, existing); + final pair = _handleAssetRemoval(deleteCandidates, existing, remote: false); if (pair.first.isNotEmpty || pair.second.isNotEmpty) { await _db.writeTxn(() async { await _db.assets.deleteAll(pair.first); @@ -366,7 +425,12 @@ class SyncService { } // general case, e.g. some assets have been deleted or there are excluded albums on iOS - final inDb = await album.assets.filter().sortByLocalId().findAll(); + final inDb = await album.assets + .filter() + .ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId) + .deviceIdEqualTo(Store.get(StoreKey.deviceIdHash)) + .sortByLocalId() + .findAll(); final List onDevice = await ape.getAssets(excludedAssets: excludedAssets); onDevice.sort(Asset.compareByLocalId); @@ -401,7 +465,7 @@ class SyncService { }); _log.info("Synced changes of local album $ape to DB"); } on IsarError catch (e) { - _log.warning("Failed to update synced album $ape in DB: $e"); + _log.severe("Failed to update synced album $ape in DB: $e"); } return true; @@ -438,7 +502,7 @@ class SyncService { }); _log.info("Fast synced local album $ape to DB"); } on IsarError catch (e) { - _log.warning("Failed to fast sync local album $ape to DB: $e"); + _log.severe("Failed to fast sync local album $ape to DB: $e"); return false; } @@ -470,7 +534,7 @@ class SyncService { await _db.writeTxn(() => _db.albums.store(a)); _log.info("Added a new local album to DB: $ape"); } on IsarError catch (e) { - _log.warning("Failed to add new local album $ape to DB: $e"); + _log.severe("Failed to add new local album $ape to DB: $e"); } } @@ -487,15 +551,19 @@ class SyncService { assets, (q, Asset e) => q.localIdDeviceIdEqualTo(e.localId, e.deviceId), ) - .sortByDeviceId() + .sortByOwnerId() + .thenByDeviceId() .thenByLocalId() + .thenByFileModifiedAt() .findAll(); - assets.sort(Asset.compareByDeviceIdLocalId); + assets.sort(Asset.compareByOwnerDeviceLocalIdModified); final List existing = [], toUpsert = []; diffSortedListsSync( inDb, assets, - compare: Asset.compareByDeviceIdLocalId, + // do not compare by modified date because for some assets dates differ on + // client and server, thus never reaching "both" case below + compare: Asset.compareByOwnerDeviceLocalId, both: (Asset a, Asset b) { if ((a.isLocal || !b.isLocal) && (a.isRemote || !b.isRemote) && @@ -541,7 +609,7 @@ Triple, List, List> _diffAssets( List assets, List inDb, { bool? remote, - int Function(Asset, Asset) compare = Asset.compareByDeviceIdLocalId, + int Function(Asset, Asset) compare = Asset.compareByOwnerDeviceLocalId, }) { final List toAdd = []; final List toUpdate = []; @@ -582,15 +650,20 @@ Triple, List, List> _diffAssets( /// returns a tuple (toDelete toUpdate) when assets are to be deleted Pair, List> _handleAssetRemoval( List deleteCandidates, - List existing, -) { + List existing, { + bool? remote, +}) { if (deleteCandidates.isEmpty) { return const Pair([], []); } deleteCandidates.sort(Asset.compareById); existing.sort(Asset.compareById); - final triple = - _diffAssets(existing, deleteCandidates, compare: Asset.compareById); + final triple = _diffAssets( + existing, + deleteCandidates, + compare: Asset.compareById, + remote: remote, + ); return Pair(triple.third.map((e) => e.id).toList(), triple.second); } diff --git a/mobile/lib/utils/async_mutex.dart b/mobile/lib/utils/async_mutex.dart index fe4b00b5e8..c61e6b13f3 100644 --- a/mobile/lib/utils/async_mutex.dart +++ b/mobile/lib/utils/async_mutex.dart @@ -3,12 +3,17 @@ import 'dart:async'; /// Async mutex to guarantee actions are performed sequentially and do not interleave class AsyncMutex { Future _running = Future.value(null); + int _enqueued = 0; + + get enqueued => _enqueued; /// Execute [operation] exclusively, after any currently running operations. /// Returns a [Future] with the result of the [operation]. Future run(Future Function() operation) { final completer = Completer(); + _enqueued++; _running.whenComplete(() { + _enqueued--; completer.complete(Future.sync(operation)); }); return _running = completer.future; diff --git a/mobile/lib/utils/db.dart b/mobile/lib/utils/db.dart new file mode 100644 index 0000000000..3892e20cb5 --- /dev/null +++ b/mobile/lib/utils/db.dart @@ -0,0 +1,14 @@ +import 'package:immich_mobile/shared/models/album.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:isar/isar.dart'; + +Future clearAssetsAndAlbums(Isar db) async { + await Store.delete(StoreKey.assetETag); + await db.writeTxn(() async { + await db.assets.clear(); + await db.exifInfos.clear(); + await db.albums.clear(); + }); +} diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index b2fa92d3e4..77ce350ec5 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -15,6 +15,7 @@ import 'package:immich_mobile/modules/settings/services/app_settings.service.dar import 'package:immich_mobile/shared/models/immich_logger_message.model.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/services/asset_cache.service.dart'; +import 'package:immich_mobile/utils/db.dart'; import 'package:isar/isar.dart'; Future migrateHiveToStoreIfNecessary() async { @@ -53,7 +54,6 @@ Future _migrateLoginInfoBox(Box box) async { } Future _migrateHiveUserInfoBox(Box box) async { - await _migrateKey(box, userIdKey, StoreKey.userRemoteId); await _migrateKey(box, assetEtagKey, StoreKey.assetETag); if (Store.tryGet(StoreKey.deviceId) == null) { await _migrateKey(box, deviceIdKey, StoreKey.deviceId); @@ -143,3 +143,16 @@ Future migrateJsonCacheIfNecessary() async { await SharedAlbumCacheService().invalidate(); await AssetCacheService().invalidate(); } + +Future migrateDatabaseIfNeeded(Isar db) async { + final int version = Store.get(StoreKey.version, 1); + switch (version) { + case 1: + await _migrateV1ToV2(db); + } +} + +Future _migrateV1ToV2(Isar db) async { + await clearAssetsAndAlbums(db); + await Store.put(StoreKey.version, 2); +} diff --git a/mobile/test/asset_grid_data_structure_test.dart b/mobile/test/asset_grid_data_structure_test.dart index 78c4200e21..196a6cd1e6 100644 --- a/mobile/test/asset_grid_data_structure_test.dart +++ b/mobile/test/asset_grid_data_structure_test.dart @@ -20,6 +20,7 @@ void main() { fileModifiedAt: date, updatedAt: date, durationInSeconds: 0, + type: AssetType.image, fileName: '', isFavorite: false, isLocal: false, diff --git a/mobile/test/async_mutex_test.dart b/mobile/test/async_mutex_test.dart new file mode 100644 index 0000000000..c1b39035b4 --- /dev/null +++ b/mobile/test/async_mutex_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/utils/async_mutex.dart'; + +void main() { + group('Test AsyncMutex grouped', () { + test('test ordered execution', () async { + AsyncMutex lock = AsyncMutex(); + List events = []; + expect(0, lock.enqueued); + lock.run( + () => Future.delayed( + const Duration(milliseconds: 10), + () => events.add(1), + ), + ); + expect(1, lock.enqueued); + lock.run( + () => Future.delayed( + const Duration(milliseconds: 3), + () => events.add(2), + ), + ); + expect(2, lock.enqueued); + lock.run( + () => Future.delayed( + const Duration(milliseconds: 1), + () => events.add(3), + ), + ); + expect(3, lock.enqueued); + await lock.run( + () => Future.delayed( + const Duration(milliseconds: 10), + () => events.add(4), + ), + ); + expect(0, lock.enqueued); + expect(events, [1, 2, 3, 4]); + }); + }); +} diff --git a/mobile/test/favorite_provider_test.dart b/mobile/test/favorite_provider_test.dart index 04d6f712f2..99db1f25fc 100644 --- a/mobile/test/favorite_provider_test.dart +++ b/mobile/test/favorite_provider_test.dart @@ -23,6 +23,7 @@ Asset _getTestAsset(int id, bool favorite) { updatedAt: DateTime.now(), isLocal: false, durationInSeconds: 0, + type: AssetType.image, fileName: '', isFavorite: favorite, ); diff --git a/mobile/test/sync_service_test.dart b/mobile/test/sync_service_test.dart new file mode 100644 index 0000000000..8fec43f9c4 --- /dev/null +++ b/mobile/test/sync_service_test.dart @@ -0,0 +1,143 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/shared/models/album.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/models/exif_info.dart'; +import 'package:immich_mobile/shared/models/logger_message.model.dart'; +import 'package:immich_mobile/shared/models/store.dart'; +import 'package:immich_mobile/shared/models/user.dart'; +import 'package:immich_mobile/shared/services/immich_logger.service.dart'; +import 'package:immich_mobile/shared/services/sync.service.dart'; +import 'package:isar/isar.dart'; + +void main() { + Asset makeAsset({ + required String localId, + String? remoteId, + int deviceId = 1, + int ownerId = 590700560494856554, // hash of "1" + bool isLocal = false, + }) { + final DateTime date = DateTime(2000); + return Asset( + localId: localId, + remoteId: remoteId, + deviceId: deviceId, + ownerId: ownerId, + fileCreatedAt: date, + fileModifiedAt: date, + updatedAt: date, + durationInSeconds: 0, + type: AssetType.image, + fileName: localId, + isFavorite: false, + isLocal: isLocal, + ); + } + + Isar loadDb() { + return Isar.openSync( + [ + ExifInfoSchema, + AssetSchema, + AlbumSchema, + UserSchema, + StoreValueSchema, + LoggerMessageSchema + ], + maxSizeMiB: 256, + ); + } + + group('Test SyncService grouped', () { + late final Isar db; + setUpAll(() async { + WidgetsFlutterBinding.ensureInitialized(); + await Isar.initializeIsarCore(download: true); + db = loadDb(); + ImmichLogger(); + db.writeTxnSync(() => db.clearSync()); + Store.init(db); + await Store.put( + StoreKey.currentUser, + User( + id: "1", + updatedAt: DateTime.now(), + email: "a@b.c", + firstName: "first", + lastName: "last", + isAdmin: false, + ), + ); + }); + final List initialAssets = [ + makeAsset(localId: "1", remoteId: "0-1", deviceId: 0), + makeAsset(localId: "1", remoteId: "2-1", deviceId: 2), + makeAsset(localId: "1", remoteId: "1-1", isLocal: true), + makeAsset(localId: "2", isLocal: true), + makeAsset(localId: "3", isLocal: true), + ]; + setUp(() { + db.writeTxnSync(() { + db.assets.clearSync(); + db.assets.putAllSync(initialAssets); + }); + }); + test('test inserting existing assets', () async { + SyncService s = SyncService(db); + final List remoteAssets = [ + makeAsset(localId: "1", remoteId: "0-1", deviceId: 0), + makeAsset(localId: "1", remoteId: "2-1", deviceId: 2), + makeAsset(localId: "1", remoteId: "1-1"), + ]; + expect(db.assets.countSync(), 5); + final bool c1 = await s.syncRemoteAssetsToDb(() => remoteAssets); + expect(c1, false); + expect(db.assets.countSync(), 5); + }); + + test('test inserting new assets', () async { + SyncService s = SyncService(db); + final List remoteAssets = [ + makeAsset(localId: "1", remoteId: "0-1", deviceId: 0), + makeAsset(localId: "1", remoteId: "2-1", deviceId: 2), + makeAsset(localId: "1", remoteId: "1-1"), + makeAsset(localId: "2", remoteId: "1-2"), + makeAsset(localId: "4", remoteId: "1-4"), + makeAsset(localId: "1", remoteId: "3-1", deviceId: 3), + ]; + expect(db.assets.countSync(), 5); + final bool c1 = await s.syncRemoteAssetsToDb(() => remoteAssets); + expect(c1, true); + expect(db.assets.countSync(), 7); + }); + + test('test syncing duplicate assets', () async { + SyncService s = SyncService(db); + final List remoteAssets = [ + makeAsset(localId: "1", remoteId: "0-1", deviceId: 0), + makeAsset(localId: "1", remoteId: "1-1"), + makeAsset(localId: "1", remoteId: "2-1", deviceId: 2), + makeAsset(localId: "1", remoteId: "2-1b", deviceId: 2), + makeAsset(localId: "1", remoteId: "2-1c", deviceId: 2), + makeAsset(localId: "1", remoteId: "2-1d", deviceId: 2), + ]; + expect(db.assets.countSync(), 5); + final bool c1 = await s.syncRemoteAssetsToDb(() => remoteAssets); + expect(c1, true); + expect(db.assets.countSync(), 8); + final bool c2 = await s.syncRemoteAssetsToDb(() => remoteAssets); + expect(c2, false); + expect(db.assets.countSync(), 8); + remoteAssets.removeAt(4); + final bool c3 = await s.syncRemoteAssetsToDb(() => remoteAssets); + expect(c3, true); + expect(db.assets.countSync(), 7); + remoteAssets.add(makeAsset(localId: "1", remoteId: "2-1e", deviceId: 2)); + remoteAssets.add(makeAsset(localId: "2", remoteId: "2-2", deviceId: 2)); + final bool c4 = await s.syncRemoteAssetsToDb(() => remoteAssets); + expect(c4, true); + expect(db.assets.countSync(), 9); + }); + }); +} From 4e526dfaae577336ab5a5e4aea451e722eeabdd7 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Mon, 27 Mar 2023 05:53:35 +0200 Subject: [PATCH 3/3] feat(web): improve and refactor thumbnails (#2087) * feat(web): improve and refactor thumbnails * only play live photos on icon hover --- web/src/api/api.ts | 28 ++ .../album-page/thumbnail-selection.svelte | 4 +- .../assets/thumbnail/image-thumbnail.svelte | 19 ++ .../assets/thumbnail/thumbnail.svelte | 140 ++++++++ .../assets/thumbnail/video-thumbnail.svelte | 88 +++++ .../photos-page/asset-date-group.svelte | 4 +- .../gallery-viewer/gallery-viewer.svelte | 4 +- .../shared-components/immich-thumbnail.svelte | 311 ------------------ web/src/lib/utils/time-to-seconds.spec.ts | 24 ++ web/src/lib/utils/time-to-seconds.ts | 13 + web/src/routes/(user)/explore/+page.svelte | 16 +- 11 files changed, 321 insertions(+), 330 deletions(-) create mode 100644 web/src/lib/components/assets/thumbnail/image-thumbnail.svelte create mode 100644 web/src/lib/components/assets/thumbnail/thumbnail.svelte create mode 100644 web/src/lib/components/assets/thumbnail/video-thumbnail.svelte delete mode 100644 web/src/lib/components/shared-components/immich-thumbnail.svelte create mode 100644 web/src/lib/utils/time-to-seconds.spec.ts create mode 100644 web/src/lib/utils/time-to-seconds.ts diff --git a/web/src/api/api.ts b/web/src/api/api.ts index 329e146283..197d4598ac 100644 --- a/web/src/api/api.ts +++ b/web/src/api/api.ts @@ -12,8 +12,11 @@ import { ServerInfoApi, ShareApi, SystemConfigApi, + ThumbnailFormat, UserApi } from './open-api'; +import { BASE_PATH } from './open-api/base'; +import { DUMMY_BASE_URL, toPathString } from './open-api/common'; export class ImmichApi { public userApi: UserApi; @@ -48,6 +51,21 @@ export class ImmichApi { this.shareApi = new ShareApi(this.config); } + private createUrl(path: string, params?: Record) { + const searchParams = new URLSearchParams(); + for (const key in params) { + const value = params[key]; + if (value !== undefined && value !== null) { + searchParams.set(key, value.toString()); + } + } + + const url = new URL(path, DUMMY_BASE_URL); + url.search = searchParams.toString(); + + return (this.config.basePath || BASE_PATH) + toPathString(url); + } + public setAccessToken(accessToken: string) { this.config.accessToken = accessToken; } @@ -59,6 +77,16 @@ export class ImmichApi { public setBaseUrl(baseUrl: string) { this.config.basePath = baseUrl; } + + public getAssetFileUrl(assetId: string, isThumb?: boolean, isWeb?: boolean, key?: string) { + const path = `/asset/file/${assetId}`; + return this.createUrl(path, { isThumb, isWeb, key }); + } + + public getAssetThumbnailUrl(assetId: string, format?: ThumbnailFormat, key?: string) { + const path = `/asset/thumbnail/${assetId}`; + return this.createUrl(path, { format, key }); + } } export const api = new ImmichApi({ basePath: '/api' }); diff --git a/web/src/lib/components/album-page/thumbnail-selection.svelte b/web/src/lib/components/album-page/thumbnail-selection.svelte index 59778eca7d..1da40fb21a 100644 --- a/web/src/lib/components/album-page/thumbnail-selection.svelte +++ b/web/src/lib/components/album-page/thumbnail-selection.svelte @@ -3,8 +3,8 @@ import { createEventDispatcher } from 'svelte'; import { quintOut } from 'svelte/easing'; import { fly } from 'svelte/transition'; + import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; import ControlAppBar from '../shared-components/control-app-bar.svelte'; - import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte'; export let album: AlbumResponseDto; @@ -43,7 +43,7 @@
{#each album.assets as asset} - (selectedThumbnail = asset)} selected={isSelected(asset.id)} diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte new file mode 100644 index 0000000000..8472a7a764 --- /dev/null +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -0,0 +1,19 @@ + + +{altText} (loading = false)} +/> diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte new file mode 100644 index 0000000000..daf18873b3 --- /dev/null +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -0,0 +1,140 @@ + + + +
(mouseOver = true)} + on:mouseleave={() => (mouseOver = false)} + on:click={thumbnailClickedHandler} + on:keydown={thumbnailClickedHandler} + > + {#if intersecting} +
+ + {#if !readonly} + + {/if} +
+ +
+ +
+ + + {#if asset.isFavorite && !publicSharedKey} +
+ +
+ {/if} + + + + {#if asset.type === AssetTypeEnum.Video} +
+ +
+ {/if} + + {#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId} +
+ +
+ {/if} +
+ {/if} +
+ diff --git a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte new file mode 100644 index 0000000000..b8690794df --- /dev/null +++ b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte @@ -0,0 +1,88 @@ + + +
+ {#if showTime} + + {Duration.fromObject({ seconds: remainingSeconds }).toFormat('m:ss')} + + {/if} + + { + if (playbackOnIconHover) { + enablePlayback = true; + } + }} + on:mouseleave={() => { + if (playbackOnIconHover) { + enablePlayback = false; + } + }} + > + {#if enablePlayback} + {#if loading} + + {:else if error} + + {:else} + + {/if} + {:else} + + {/if} + +
+ +{#if enablePlayback} +