mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00:00
feat(mobile): use cached asset info if unchanged instead of downloading all assets (#1017)
* feat(mobile): use cached asset info if unchanged instead of downloading all assets This adds an HTTP ETag to the getAllAssets endpoint and client-side support in the app. If locally cache content is identical to the content on the server, the potentially large list of all assets does not need to be downloaded. * use ts import instead of require
This commit is contained in:
committed by
GitHub
parent
efa7b3ba54
commit
47f5e4134e
@@ -4,6 +4,7 @@ const String accessTokenKey = "immichBoxAccessTokenKey"; // Key 1
|
||||
const String deviceIdKey = 'immichBoxDeviceIdKey'; // Key 2
|
||||
const String isLoggedInKey = 'immichIsLoggedInKey'; // Key 3
|
||||
const String serverEndpointKey = 'immichBoxServerEndpoint'; // Key 4
|
||||
const String assetEtagKey = 'immichAssetEtagKey'; // Key 5
|
||||
|
||||
// Login Info
|
||||
const String hiveLoginInfoBox = "immichLoginInfoBox"; // Box
|
||||
|
||||
@@ -10,8 +10,9 @@ import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/openapi_extensions.dart';
|
||||
import 'package:immich_mobile/utils/tuple.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
final assetServiceProvider = Provider(
|
||||
(ref) => AssetService(
|
||||
@@ -28,39 +29,22 @@ class AssetService {
|
||||
|
||||
AssetService(this._apiService, this._backupService, this._backgroundService);
|
||||
|
||||
/// Returns all local, remote assets in that order
|
||||
Future<List<Asset>> getAllAsset({bool urgent = false}) async {
|
||||
final List<Asset> assets = [];
|
||||
try {
|
||||
// not using `await` here to fetch local & remote assets concurrently
|
||||
final Future<List<AssetResponseDto>?> remoteTask =
|
||||
_apiService.assetApi.getAllAssets();
|
||||
final Iterable<AssetEntity> newLocalAssets;
|
||||
final List<AssetEntity> localAssets = await _getLocalAssets(urgent);
|
||||
final List<AssetResponseDto> remoteAssets = await remoteTask ?? [];
|
||||
if (remoteAssets.isNotEmpty && localAssets.isNotEmpty) {
|
||||
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
||||
final Set<String> existingIds = remoteAssets
|
||||
.where((e) => e.deviceId == deviceId)
|
||||
.map((e) => e.deviceAssetId)
|
||||
.toSet();
|
||||
newLocalAssets = localAssets.where((e) => !existingIds.contains(e.id));
|
||||
} else {
|
||||
newLocalAssets = localAssets;
|
||||
}
|
||||
|
||||
assets.addAll(newLocalAssets.map((e) => Asset.local(e)));
|
||||
// the order (first all local, then remote assets) is important!
|
||||
assets.addAll(remoteAssets.map((e) => Asset.remote(e)));
|
||||
} catch (e) {
|
||||
debugPrint("Error [getAllAsset] ${e.toString()}");
|
||||
/// Returns `null` if the server state did not change, else list of assets
|
||||
Future<List<Asset>?> getRemoteAssets() async {
|
||||
final Box box = Hive.box(userInfoBox);
|
||||
final Pair<List<AssetResponseDto>, String?>? remote = await _apiService
|
||||
.assetApi
|
||||
.getAllAssetsWithETag(eTag: box.get(assetEtagKey));
|
||||
if (remote == null) {
|
||||
return null;
|
||||
}
|
||||
return assets;
|
||||
box.put(assetEtagKey, remote.second);
|
||||
return remote.first.map(Asset.remote).toList(growable: false);
|
||||
}
|
||||
|
||||
/// if [urgent] is `true`, do not block by waiting on the background service
|
||||
/// to finish running. Returns an empty list instead after a timeout.
|
||||
Future<List<AssetEntity>> _getLocalAssets(bool urgent) async {
|
||||
/// to finish running. Returns `null` instead after a timeout.
|
||||
Future<List<Asset>?> getLocalAssets({bool urgent = false}) async {
|
||||
try {
|
||||
final Future<bool> hasAccess = urgent
|
||||
? _backgroundService.hasAccess
|
||||
@@ -71,15 +55,16 @@ class AssetService {
|
||||
}
|
||||
final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
|
||||
|
||||
return backupAlbumInfo != null
|
||||
? await _backupService
|
||||
.buildUploadCandidates(backupAlbumInfo.deepCopy())
|
||||
: [];
|
||||
if (backupAlbumInfo != null) {
|
||||
return (await _backupService
|
||||
.buildUploadCandidates(backupAlbumInfo.deepCopy()))
|
||||
.map(Asset.local)
|
||||
.toList(growable: false);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error [_getLocalAssets] ${e.toString()}");
|
||||
return [];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<Asset?> getAssetById(String assetId) async {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter/foundation.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/home/services/asset.service.dart';
|
||||
import 'package:immich_mobile/modules/home/services/asset_cache.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
@@ -33,10 +35,11 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
|
||||
final stopwatch = Stopwatch();
|
||||
try {
|
||||
_getAllAssetInProgress = true;
|
||||
|
||||
final bool isCacheValid = await _assetCacheService.isValid();
|
||||
stopwatch.start();
|
||||
final localTask = _assetService.getLocalAssets(urgent: !isCacheValid);
|
||||
final remoteTask = _assetService.getRemoteAssets();
|
||||
if (isCacheValid && state.isEmpty) {
|
||||
stopwatch.start();
|
||||
state = await _assetCacheService.get();
|
||||
debugPrint(
|
||||
"Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms",
|
||||
@@ -44,21 +47,49 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
|
||||
stopwatch.reset();
|
||||
}
|
||||
|
||||
stopwatch.start();
|
||||
var allAssets = await _assetService.getAllAsset(urgent: !isCacheValid);
|
||||
debugPrint("Query assets from API: ${stopwatch.elapsedMilliseconds}ms");
|
||||
int remoteBegin = state.indexWhere((a) => a.isRemote);
|
||||
remoteBegin = remoteBegin == -1 ? state.length : remoteBegin;
|
||||
final List<Asset> currentLocal = state.slice(0, remoteBegin);
|
||||
List<Asset>? newRemote = await remoteTask;
|
||||
List<Asset>? newLocal = await localTask;
|
||||
debugPrint("Load assets: ${stopwatch.elapsedMilliseconds}ms");
|
||||
stopwatch.reset();
|
||||
|
||||
state = allAssets;
|
||||
if (newRemote == null &&
|
||||
(newLocal == null || currentLocal.equals(newLocal))) {
|
||||
debugPrint("state is already up-to-date");
|
||||
return;
|
||||
}
|
||||
newRemote ??= state.slice(remoteBegin);
|
||||
newLocal ??= [];
|
||||
state = _combineLocalAndRemoteAssets(local: newLocal, remote: newRemote);
|
||||
debugPrint("Combining assets: ${stopwatch.elapsedMilliseconds}ms");
|
||||
} finally {
|
||||
_getAllAssetInProgress = false;
|
||||
}
|
||||
debugPrint("[getAllAsset] setting new asset state");
|
||||
|
||||
stopwatch.start();
|
||||
stopwatch.reset();
|
||||
_cacheState();
|
||||
debugPrint("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
|
||||
stopwatch.reset();
|
||||
}
|
||||
|
||||
List<Asset> _combineLocalAndRemoteAssets({
|
||||
required Iterable<Asset> local,
|
||||
required List<Asset> remote,
|
||||
}) {
|
||||
final List<Asset> assets = [];
|
||||
if (remote.isNotEmpty && local.isNotEmpty) {
|
||||
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
||||
final Set<String> existingIds = remote
|
||||
.where((e) => e.deviceId == deviceId)
|
||||
.map((e) => e.deviceAssetId)
|
||||
.toSet();
|
||||
local = local.where((e) => !existingIds.contains(e.id));
|
||||
}
|
||||
assets.addAll(local);
|
||||
// the order (first all local, then remote assets) is important!
|
||||
assets.addAll(remote);
|
||||
return assets;
|
||||
}
|
||||
|
||||
clearAllAsset() {
|
||||
|
||||
53
mobile/lib/utils/openapi_extensions.dart
Normal file
53
mobile/lib/utils/openapi_extensions.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
import 'tuple.dart';
|
||||
|
||||
/// Extension methods to retrieve ETag together with the API call
|
||||
extension WithETag on AssetApi {
|
||||
/// Get all AssetEntity belong to the user
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] eTag:
|
||||
/// ETag of data already cached on the client
|
||||
Future<Pair<List<AssetResponseDto>, String?>?> getAllAssetsWithETag({
|
||||
String? eTag,
|
||||
}) async {
|
||||
final response = await getAllAssetsWithHttpInfo(
|
||||
ifNoneMatch: eTag,
|
||||
);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty &&
|
||||
response.statusCode != HttpStatus.noContent) {
|
||||
final responseBody = await _decodeBodyBytes(response);
|
||||
final etag = response.headers[HttpHeaders.etagHeader];
|
||||
final data = (await apiClient.deserializeAsync(
|
||||
responseBody, 'List<AssetResponseDto>') as List)
|
||||
.cast<AssetResponseDto>()
|
||||
.toList();
|
||||
return Pair(data, etag);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the decoded body as UTF-8 if the given headers indicate an 'application/json'
|
||||
/// content type. Otherwise, returns the decoded body as decoded by dart:http package.
|
||||
Future<String> _decodeBodyBytes(Response response) async {
|
||||
final contentType = response.headers['content-type'];
|
||||
return contentType != null &&
|
||||
contentType.toLowerCase().startsWith('application/json')
|
||||
? response.bodyBytes.isEmpty
|
||||
? ''
|
||||
: utf8.decode(response.bodyBytes)
|
||||
: response.body;
|
||||
}
|
||||
8
mobile/lib/utils/tuple.dart
Normal file
8
mobile/lib/utils/tuple.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
/// An immutable pair or 2-tuple
|
||||
/// TODO replace with Record once Dart 2.19 is available
|
||||
class Pair<T1, T2> {
|
||||
final T1 first;
|
||||
final T2 second;
|
||||
|
||||
const Pair(this.first, this.second);
|
||||
}
|
||||
Reference in New Issue
Block a user