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:
Fynn Petersen-Frey 2022-11-26 17:16:02 +01:00 committed by GitHub
parent efa7b3ba54
commit 47f5e4134e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 322 additions and 201 deletions

View File

@ -4,6 +4,7 @@ const String accessTokenKey = "immichBoxAccessTokenKey"; // Key 1
const String deviceIdKey = 'immichBoxDeviceIdKey'; // Key 2 const String deviceIdKey = 'immichBoxDeviceIdKey'; // Key 2
const String isLoggedInKey = 'immichIsLoggedInKey'; // Key 3 const String isLoggedInKey = 'immichIsLoggedInKey'; // Key 3
const String serverEndpointKey = 'immichBoxServerEndpoint'; // Key 4 const String serverEndpointKey = 'immichBoxServerEndpoint'; // Key 4
const String assetEtagKey = 'immichAssetEtagKey'; // Key 5
// Login Info // Login Info
const String hiveLoginInfoBox = "immichLoginInfoBox"; // Box const String hiveLoginInfoBox = "immichLoginInfoBox"; // Box

View File

@ -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/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.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:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
final assetServiceProvider = Provider( final assetServiceProvider = Provider(
(ref) => AssetService( (ref) => AssetService(
@ -28,39 +29,22 @@ class AssetService {
AssetService(this._apiService, this._backupService, this._backgroundService); AssetService(this._apiService, this._backupService, this._backgroundService);
/// Returns all local, remote assets in that order /// Returns `null` if the server state did not change, else list of assets
Future<List<Asset>> getAllAsset({bool urgent = false}) async { Future<List<Asset>?> getRemoteAssets() async {
final List<Asset> assets = []; final Box box = Hive.box(userInfoBox);
try { final Pair<List<AssetResponseDto>, String?>? remote = await _apiService
// not using `await` here to fetch local & remote assets concurrently .assetApi
final Future<List<AssetResponseDto>?> remoteTask = .getAllAssetsWithETag(eTag: box.get(assetEtagKey));
_apiService.assetApi.getAllAssets(); if (remote == null) {
final Iterable<AssetEntity> newLocalAssets; return null;
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()}");
} }
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 /// if [urgent] is `true`, do not block by waiting on the background service
/// to finish running. Returns an empty list instead after a timeout. /// to finish running. Returns `null` instead after a timeout.
Future<List<AssetEntity>> _getLocalAssets(bool urgent) async { Future<List<Asset>?> getLocalAssets({bool urgent = false}) async {
try { try {
final Future<bool> hasAccess = urgent final Future<bool> hasAccess = urgent
? _backgroundService.hasAccess ? _backgroundService.hasAccess
@ -71,15 +55,16 @@ class AssetService {
} }
final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox); final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey); final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
if (backupAlbumInfo != null) {
return backupAlbumInfo != null return (await _backupService
? await _backupService .buildUploadCandidates(backupAlbumInfo.deepCopy()))
.buildUploadCandidates(backupAlbumInfo.deepCopy()) .map(Asset.local)
: []; .toList(growable: false);
}
} catch (e) { } catch (e) {
debugPrint("Error [_getLocalAssets] ${e.toString()}"); debugPrint("Error [_getLocalAssets] ${e.toString()}");
return [];
} }
return null;
} }
Future<Asset?> getAssetById(String assetId) async { Future<Asset?> getAssetById(String assetId) async {

View File

@ -1,7 +1,9 @@
import 'dart:collection'; import 'dart:collection';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.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.service.dart';
import 'package:immich_mobile/modules/home/services/asset_cache.service.dart'; import 'package:immich_mobile/modules/home/services/asset_cache.service.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
@ -33,10 +35,11 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
final stopwatch = Stopwatch(); final stopwatch = Stopwatch();
try { try {
_getAllAssetInProgress = true; _getAllAssetInProgress = true;
final bool isCacheValid = await _assetCacheService.isValid(); final bool isCacheValid = await _assetCacheService.isValid();
stopwatch.start();
final localTask = _assetService.getLocalAssets(urgent: !isCacheValid);
final remoteTask = _assetService.getRemoteAssets();
if (isCacheValid && state.isEmpty) { if (isCacheValid && state.isEmpty) {
stopwatch.start();
state = await _assetCacheService.get(); state = await _assetCacheService.get();
debugPrint( debugPrint(
"Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms", "Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms",
@ -44,21 +47,49 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
stopwatch.reset(); stopwatch.reset();
} }
stopwatch.start(); int remoteBegin = state.indexWhere((a) => a.isRemote);
var allAssets = await _assetService.getAllAsset(urgent: !isCacheValid); remoteBegin = remoteBegin == -1 ? state.length : remoteBegin;
debugPrint("Query assets from API: ${stopwatch.elapsedMilliseconds}ms"); 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(); stopwatch.reset();
if (newRemote == null &&
state = allAssets; (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 { } finally {
_getAllAssetInProgress = false; _getAllAssetInProgress = false;
} }
debugPrint("[getAllAsset] setting new asset state"); debugPrint("[getAllAsset] setting new asset state");
stopwatch.start(); stopwatch.reset();
_cacheState(); _cacheState();
debugPrint("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms"); 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() { clearAllAsset() {

View 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;
}

View 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);
}

View File

@ -274,7 +274,7 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getAllAssets** # **getAllAssets**
> List<AssetResponseDto> getAllAssets() > List<AssetResponseDto> getAllAssets(ifNoneMatch)
@ -291,9 +291,10 @@ import 'package:openapi/api.dart';
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction); //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = AssetApi(); final api_instance = AssetApi();
final ifNoneMatch = ifNoneMatch_example; // String | ETag of data already cached on the client
try { try {
final result = api_instance.getAllAssets(); final result = api_instance.getAllAssets(ifNoneMatch);
print(result); print(result);
} catch (e) { } catch (e) {
print('Exception when calling AssetApi->getAllAssets: $e\n'); print('Exception when calling AssetApi->getAllAssets: $e\n');
@ -301,7 +302,10 @@ try {
``` ```
### Parameters ### Parameters
This endpoint does not need any parameter.
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**ifNoneMatch** | **String**| ETag of data already cached on the client | [optional]
### Return type ### Return type

View File

@ -21,10 +21,10 @@ Name | Type | Description | Notes
**mimeType** | **String** | | **mimeType** | **String** | |
**duration** | **String** | | **duration** | **String** | |
**webpPath** | **String** | | **webpPath** | **String** | |
**encodedVideoPath** | **String** | | **encodedVideoPath** | **String** | | [optional]
**exifInfo** | [**ExifResponseDto**](ExifResponseDto.md) | | [optional] **exifInfo** | [**ExifResponseDto**](ExifResponseDto.md) | | [optional]
**smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) | | [optional] **smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) | | [optional]
**livePhotoVideoId** | **String** | | **livePhotoVideoId** | **String** | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -16,7 +16,7 @@ Name | Type | Description | Notes
**profileImagePath** | **String** | | **profileImagePath** | **String** | |
**shouldChangePassword** | **bool** | | **shouldChangePassword** | **bool** | |
**isAdmin** | **bool** | | **isAdmin** | **bool** | |
**deletedAt** | [**DateTime**](DateTime.md) | | **deletedAt** | [**DateTime**](DateTime.md) | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -297,7 +297,12 @@ class AssetApi {
/// Get all AssetEntity belong to the user /// Get all AssetEntity belong to the user
/// ///
/// Note: This method returns the HTTP [Response]. /// Note: This method returns the HTTP [Response].
Future<Response> getAllAssetsWithHttpInfo() async { ///
/// Parameters:
///
/// * [String] ifNoneMatch:
/// ETag of data already cached on the client
Future<Response> getAllAssetsWithHttpInfo({ String? ifNoneMatch, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final path = r'/asset'; final path = r'/asset';
@ -308,6 +313,10 @@ class AssetApi {
final headerParams = <String, String>{}; final headerParams = <String, String>{};
final formParams = <String, String>{}; final formParams = <String, String>{};
if (ifNoneMatch != null) {
headerParams[r'if-none-match'] = parameterToString(ifNoneMatch);
}
const contentTypes = <String>[]; const contentTypes = <String>[];
@ -325,8 +334,13 @@ class AssetApi {
/// ///
/// ///
/// Get all AssetEntity belong to the user /// Get all AssetEntity belong to the user
Future<List<AssetResponseDto>?> getAllAssets() async { ///
final response = await getAllAssetsWithHttpInfo(); /// Parameters:
///
/// * [String] ifNoneMatch:
/// ETag of data already cached on the client
Future<List<AssetResponseDto>?> getAllAssets({ String? ifNoneMatch, }) async {
final response = await getAllAssetsWithHttpInfo( ifNoneMatch: ifNoneMatch, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }

View File

@ -26,10 +26,10 @@ class AssetResponseDto {
required this.mimeType, required this.mimeType,
required this.duration, required this.duration,
required this.webpPath, required this.webpPath,
required this.encodedVideoPath, this.encodedVideoPath,
this.exifInfo, this.exifInfo,
this.smartInfo, this.smartInfo,
required this.livePhotoVideoId, this.livePhotoVideoId,
}); });
AssetTypeEnum type; AssetTypeEnum type;
@ -79,74 +79,71 @@ class AssetResponseDto {
String? livePhotoVideoId; String? livePhotoVideoId;
@override @override
bool operator ==(Object other) => bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
identical(this, other) || other.type == type &&
other is AssetResponseDto && other.id == id &&
other.type == type && other.deviceAssetId == deviceAssetId &&
other.id == id && other.ownerId == ownerId &&
other.deviceAssetId == deviceAssetId && other.deviceId == deviceId &&
other.ownerId == ownerId && other.originalPath == originalPath &&
other.deviceId == deviceId && other.resizePath == resizePath &&
other.originalPath == originalPath && other.createdAt == createdAt &&
other.resizePath == resizePath && other.modifiedAt == modifiedAt &&
other.createdAt == createdAt && other.isFavorite == isFavorite &&
other.modifiedAt == modifiedAt && other.mimeType == mimeType &&
other.isFavorite == isFavorite && other.duration == duration &&
other.mimeType == mimeType && other.webpPath == webpPath &&
other.duration == duration && other.encodedVideoPath == encodedVideoPath &&
other.webpPath == webpPath && other.exifInfo == exifInfo &&
other.encodedVideoPath == encodedVideoPath && other.smartInfo == smartInfo &&
other.exifInfo == exifInfo && other.livePhotoVideoId == livePhotoVideoId;
other.smartInfo == smartInfo &&
other.livePhotoVideoId == livePhotoVideoId;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(type.hashCode) + (type.hashCode) +
(id.hashCode) + (id.hashCode) +
(deviceAssetId.hashCode) + (deviceAssetId.hashCode) +
(ownerId.hashCode) + (ownerId.hashCode) +
(deviceId.hashCode) + (deviceId.hashCode) +
(originalPath.hashCode) + (originalPath.hashCode) +
(resizePath == null ? 0 : resizePath!.hashCode) + (resizePath == null ? 0 : resizePath!.hashCode) +
(createdAt.hashCode) + (createdAt.hashCode) +
(modifiedAt.hashCode) + (modifiedAt.hashCode) +
(isFavorite.hashCode) + (isFavorite.hashCode) +
(mimeType == null ? 0 : mimeType!.hashCode) + (mimeType == null ? 0 : mimeType!.hashCode) +
(duration.hashCode) + (duration.hashCode) +
(webpPath == null ? 0 : webpPath!.hashCode) + (webpPath == null ? 0 : webpPath!.hashCode) +
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) + (encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
(exifInfo == null ? 0 : exifInfo!.hashCode) + (exifInfo == null ? 0 : exifInfo!.hashCode) +
(smartInfo == null ? 0 : smartInfo!.hashCode) + (smartInfo == null ? 0 : smartInfo!.hashCode) +
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode); (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode);
@override @override
String toString() => String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId]';
'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final _json = <String, dynamic>{}; final _json = <String, dynamic>{};
_json[r'type'] = type; _json[r'type'] = type;
_json[r'id'] = id; _json[r'id'] = id;
_json[r'deviceAssetId'] = deviceAssetId; _json[r'deviceAssetId'] = deviceAssetId;
_json[r'ownerId'] = ownerId; _json[r'ownerId'] = ownerId;
_json[r'deviceId'] = deviceId; _json[r'deviceId'] = deviceId;
_json[r'originalPath'] = originalPath; _json[r'originalPath'] = originalPath;
if (resizePath != null) { if (resizePath != null) {
_json[r'resizePath'] = resizePath; _json[r'resizePath'] = resizePath;
} else { } else {
_json[r'resizePath'] = null; _json[r'resizePath'] = null;
} }
_json[r'createdAt'] = createdAt; _json[r'createdAt'] = createdAt;
_json[r'modifiedAt'] = modifiedAt; _json[r'modifiedAt'] = modifiedAt;
_json[r'isFavorite'] = isFavorite; _json[r'isFavorite'] = isFavorite;
if (mimeType != null) { if (mimeType != null) {
_json[r'mimeType'] = mimeType; _json[r'mimeType'] = mimeType;
} else { } else {
_json[r'mimeType'] = null; _json[r'mimeType'] = null;
} }
_json[r'duration'] = duration; _json[r'duration'] = duration;
if (webpPath != null) { if (webpPath != null) {
_json[r'webpPath'] = webpPath; _json[r'webpPath'] = webpPath;
} else { } else {
@ -185,13 +182,13 @@ class AssetResponseDto {
// Ensure that the map contains the required keys. // Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null. // Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode! // Note 2: this code is stripped in release mode!
// assert(() { assert(() {
// requiredKeys.forEach((key) { requiredKeys.forEach((key) {
// assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.'); assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
// assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.'); assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
// }); });
// return true; return true;
// }()); }());
return AssetResponseDto( return AssetResponseDto(
type: AssetTypeEnum.fromJson(json[r'type'])!, type: AssetTypeEnum.fromJson(json[r'type'])!,
@ -216,10 +213,7 @@ class AssetResponseDto {
return null; return null;
} }
static List<AssetResponseDto>? listFromJson( static List<AssetResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
dynamic json, {
bool growable = false,
}) {
final result = <AssetResponseDto>[]; final result = <AssetResponseDto>[];
if (json is List && json.isNotEmpty) { if (json is List && json.isNotEmpty) {
for (final row in json) { for (final row in json) {
@ -247,18 +241,12 @@ class AssetResponseDto {
} }
// maps a json object with a list of AssetResponseDto-objects as value to a dart map // maps a json object with a list of AssetResponseDto-objects as value to a dart map
static Map<String, List<AssetResponseDto>> mapListFromJson( static Map<String, List<AssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
dynamic json, {
bool growable = false,
}) {
final map = <String, List<AssetResponseDto>>{}; final map = <String, List<AssetResponseDto>>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) { for (final entry in json.entries) {
final value = AssetResponseDto.listFromJson( final value = AssetResponseDto.listFromJson(entry.value, growable: growable,);
entry.value,
growable: growable,
);
if (value != null) { if (value != null) {
map[entry.key] = value; map[entry.key] = value;
} }
@ -282,7 +270,6 @@ class AssetResponseDto {
'mimeType', 'mimeType',
'duration', 'duration',
'webpPath', 'webpPath',
'encodedVideoPath',
'livePhotoVideoId',
}; };
} }

View File

@ -21,7 +21,7 @@ class UserResponseDto {
required this.profileImagePath, required this.profileImagePath,
required this.shouldChangePassword, required this.shouldChangePassword,
required this.isAdmin, required this.isAdmin,
required this.deletedAt, this.deletedAt,
}); });
String id; String id;
@ -40,49 +40,52 @@ class UserResponseDto {
bool isAdmin; bool isAdmin;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
DateTime? deletedAt; DateTime? deletedAt;
@override @override
bool operator ==(Object other) => bool operator ==(Object other) => identical(this, other) || other is UserResponseDto &&
identical(this, other) || other.id == id &&
other is UserResponseDto && other.email == email &&
other.id == id && other.firstName == firstName &&
other.email == email && other.lastName == lastName &&
other.firstName == firstName && other.createdAt == createdAt &&
other.lastName == lastName && other.profileImagePath == profileImagePath &&
other.createdAt == createdAt && other.shouldChangePassword == shouldChangePassword &&
other.profileImagePath == profileImagePath && other.isAdmin == isAdmin &&
other.shouldChangePassword == shouldChangePassword && other.deletedAt == deletedAt;
other.isAdmin == isAdmin &&
other.deletedAt == deletedAt;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(id.hashCode) + (id.hashCode) +
(email.hashCode) + (email.hashCode) +
(firstName.hashCode) + (firstName.hashCode) +
(lastName.hashCode) + (lastName.hashCode) +
(createdAt.hashCode) + (createdAt.hashCode) +
(profileImagePath.hashCode) + (profileImagePath.hashCode) +
(shouldChangePassword.hashCode) + (shouldChangePassword.hashCode) +
(isAdmin.hashCode) + (isAdmin.hashCode) +
(deletedAt == null ? 0 : deletedAt!.hashCode); (deletedAt == null ? 0 : deletedAt!.hashCode);
@override @override
String toString() => String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt]';
'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final _json = <String, dynamic>{}; final _json = <String, dynamic>{};
_json[r'id'] = id; _json[r'id'] = id;
_json[r'email'] = email; _json[r'email'] = email;
_json[r'firstName'] = firstName; _json[r'firstName'] = firstName;
_json[r'lastName'] = lastName; _json[r'lastName'] = lastName;
_json[r'createdAt'] = createdAt; _json[r'createdAt'] = createdAt;
_json[r'profileImagePath'] = profileImagePath; _json[r'profileImagePath'] = profileImagePath;
_json[r'shouldChangePassword'] = shouldChangePassword; _json[r'shouldChangePassword'] = shouldChangePassword;
_json[r'isAdmin'] = isAdmin; _json[r'isAdmin'] = isAdmin;
if (deletedAt != null) { if (deletedAt != null) {
_json[r'deletedAt'] = deletedAt!.toUtc().toIso8601String(); _json[r'deletedAt'] = deletedAt!.toUtc().toIso8601String();
} else { } else {
@ -101,13 +104,13 @@ class UserResponseDto {
// Ensure that the map contains the required keys. // Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null. // Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode! // Note 2: this code is stripped in release mode!
// assert(() { assert(() {
// requiredKeys.forEach((key) { requiredKeys.forEach((key) {
// assert(json.containsKey(key), 'Required key "UserResponseDto[$key]" is missing from JSON.'); assert(json.containsKey(key), 'Required key "UserResponseDto[$key]" is missing from JSON.');
// assert(json[key] != null, 'Required key "UserResponseDto[$key]" has a null value in JSON.'); assert(json[key] != null, 'Required key "UserResponseDto[$key]" has a null value in JSON.');
// }); });
// return true; return true;
// }()); }());
return UserResponseDto( return UserResponseDto(
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
@ -116,8 +119,7 @@ class UserResponseDto {
lastName: mapValueOfType<String>(json, r'lastName')!, lastName: mapValueOfType<String>(json, r'lastName')!,
createdAt: mapValueOfType<String>(json, r'createdAt')!, createdAt: mapValueOfType<String>(json, r'createdAt')!,
profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!, profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
shouldChangePassword: shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
mapValueOfType<bool>(json, r'shouldChangePassword')!,
isAdmin: mapValueOfType<bool>(json, r'isAdmin')!, isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
deletedAt: mapDateTime(json, r'deletedAt', ''), deletedAt: mapDateTime(json, r'deletedAt', ''),
); );
@ -125,10 +127,7 @@ class UserResponseDto {
return null; return null;
} }
static List<UserResponseDto>? listFromJson( static List<UserResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
dynamic json, {
bool growable = false,
}) {
final result = <UserResponseDto>[]; final result = <UserResponseDto>[];
if (json is List && json.isNotEmpty) { if (json is List && json.isNotEmpty) {
for (final row in json) { for (final row in json) {
@ -156,18 +155,12 @@ class UserResponseDto {
} }
// maps a json object with a list of UserResponseDto-objects as value to a dart map // maps a json object with a list of UserResponseDto-objects as value to a dart map
static Map<String, List<UserResponseDto>> mapListFromJson( static Map<String, List<UserResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
dynamic json, {
bool growable = false,
}) {
final map = <String, List<UserResponseDto>>{}; final map = <String, List<UserResponseDto>>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) { for (final entry in json.entries) {
final value = UserResponseDto.listFromJson( final value = UserResponseDto.listFromJson(entry.value, growable: growable,);
entry.value,
growable: growable,
);
if (value != null) { if (value != null) {
map[entry.key] = value; map[entry.key] = value;
} }
@ -186,6 +179,6 @@ class UserResponseDto {
'profileImagePath', 'profileImagePath',
'shouldChangePassword', 'shouldChangePassword',
'isAdmin', 'isAdmin',
'deletedAt',
}; };
} }

View File

@ -14,6 +14,7 @@ import {
Header, Header,
Put, Put,
UploadedFiles, UploadedFiles,
Request,
} from '@nestjs/common'; } from '@nestjs/common';
import { Authenticated } from '../../decorators/authenticated.decorator'; import { Authenticated } from '../../decorators/authenticated.decorator';
import { AssetService } from './asset.service'; import { AssetService } from './asset.service';
@ -21,12 +22,12 @@ import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { assetUploadOption } from '../../config/asset-upload.config'; import { assetUploadOption } from '../../config/asset-upload.config';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { ServeFileDto } from './dto/serve-file.dto'; import { ServeFileDto } from './dto/serve-file.dto';
import { Response as Res } from 'express'; import { Response as Res, Request as Req } from 'express';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service'; import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
import { DeleteAssetDto } from './dto/delete-asset.dto'; import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-asset.dto'; import { SearchAssetDto } from './dto/search-asset.dto';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto'; import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiHeader, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto'; import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto'; import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { AssetResponseDto } from './response-dto/asset-response.dto'; import { AssetResponseDto } from './response-dto/asset-response.dto';
@ -49,6 +50,7 @@ import {
IMMICH_ARCHIVE_FILE_COUNT, IMMICH_ARCHIVE_FILE_COUNT,
IMMICH_CONTENT_LENGTH_HINT, IMMICH_CONTENT_LENGTH_HINT,
} from '../../constants/download.constant'; } from '../../constants/download.constant';
import { etag } from '../../utils/etag';
@Authenticated() @Authenticated()
@ApiBearerAuth() @ApiBearerAuth()
@ -168,8 +170,28 @@ export class AssetController {
* Get all AssetEntity belong to the user * Get all AssetEntity belong to the user
*/ */
@Get('/') @Get('/')
async getAllAssets(@GetAuthUser() authUser: AuthUserDto): Promise<AssetResponseDto[]> { @ApiHeader({
return await this.assetService.getAllAssets(authUser); name: 'if-none-match',
description: 'ETag of data already cached on the client',
required: false,
schema: { type: 'string' },
})
@ApiResponse({
status: 200,
headers: { ETag: { required: true, schema: { type: 'string' } } },
type: [AssetResponseDto],
})
async getAllAssets(@GetAuthUser() authUser: AuthUserDto, @Response() response: Res, @Request() request: Req) {
const assets = await this.assetService.getAllAssets(authUser);
const clientEtag = request.headers['if-none-match'];
const json = JSON.stringify(assets);
const serverEtag = await etag(json);
response.setHeader('ETag', serverEtag);
if (clientEtag === serverEtag) {
response.status(304).end();
} else {
response.contentType('application/json').status(200).send(json);
}
} }
@Post('/time-bucket') @Post('/time-bucket')

View File

@ -19,10 +19,10 @@ export class AssetResponseDto {
mimeType!: string | null; mimeType!: string | null;
duration!: string; duration!: string;
webpPath!: string | null; webpPath!: string | null;
encodedVideoPath!: string | null; encodedVideoPath?: string | null;
exifInfo?: ExifResponseDto; exifInfo?: ExifResponseDto;
smartInfo?: SmartInfoResponseDto; smartInfo?: SmartInfoResponseDto;
livePhotoVideoId!: string | null; livePhotoVideoId?: string | null;
} }
export function mapAsset(entity: AssetEntity): AssetResponseDto { export function mapAsset(entity: AssetEntity): AssetResponseDto {

View File

@ -9,7 +9,7 @@ export class UserResponseDto {
profileImagePath!: string; profileImagePath!: string;
shouldChangePassword!: boolean; shouldChangePassword!: boolean;
isAdmin!: boolean; isAdmin!: boolean;
deletedAt!: Date | null; deletedAt?: Date;
} }
export function mapUser(entity: UserEntity): UserResponseDto { export function mapUser(entity: UserEntity): UserResponseDto {
@ -22,6 +22,6 @@ export function mapUser(entity: UserEntity): UserResponseDto {
profileImagePath: entity.profileImagePath, profileImagePath: entity.profileImagePath,
shouldChangePassword: entity.shouldChangePassword, shouldChangePassword: entity.shouldChangePassword,
isAdmin: entity.isAdmin, isAdmin: entity.isAdmin,
deletedAt: entity.deletedAt || null, deletedAt: entity.deletedAt,
}; };
} }

View File

@ -0,0 +1,5 @@
declare module 'crypto' {
namespace webcrypto {
const subtle: SubtleCrypto;
}
}

View File

@ -0,0 +1,10 @@
import { webcrypto } from 'node:crypto';
const { subtle } = webcrypto;
export async function etag(text: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(text);
const buffer = await subtle.digest('SHA-1', data);
const hash = Buffer.from(buffer).toString('base64').slice(0, 27);
return `"${data.length}-${hash}"`;
}

File diff suppressed because one or more lines are too long

View File

@ -427,7 +427,7 @@ export interface AssetResponseDto {
* @type {string} * @type {string}
* @memberof AssetResponseDto * @memberof AssetResponseDto
*/ */
'encodedVideoPath': string | null; 'encodedVideoPath'?: string | null;
/** /**
* *
* @type {ExifResponseDto} * @type {ExifResponseDto}
@ -445,7 +445,7 @@ export interface AssetResponseDto {
* @type {string} * @type {string}
* @memberof AssetResponseDto * @memberof AssetResponseDto
*/ */
'livePhotoVideoId': string | null; 'livePhotoVideoId'?: string | null;
} }
/** /**
* *
@ -1729,7 +1729,7 @@ export interface UserResponseDto {
* @type {string} * @type {string}
* @memberof UserResponseDto * @memberof UserResponseDto
*/ */
'deletedAt': string | null; 'deletedAt'?: string;
} }
/** /**
* *
@ -2788,10 +2788,11 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
/** /**
* Get all AssetEntity belong to the user * Get all AssetEntity belong to the user
* @summary * @summary
* @param {string} [ifNoneMatch] ETag of data already cached on the client
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
getAllAssets: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => { getAllAssets: async (ifNoneMatch?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/asset`; const localVarPath = `/asset`;
// use dummy base URL string because the URL constructor only accepts absolute URLs. // use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -2808,6 +2809,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
// http bearer authentication required // http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration) await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (ifNoneMatch !== undefined && ifNoneMatch !== null) {
localVarHeaderParameter['if-none-match'] = String(ifNoneMatch);
}
setSearchParams(localVarUrlObj, localVarQueryParameter); setSearchParams(localVarUrlObj, localVarQueryParameter);
@ -3388,11 +3393,12 @@ export const AssetApiFp = function(configuration?: Configuration) {
/** /**
* Get all AssetEntity belong to the user * Get all AssetEntity belong to the user
* @summary * @summary
* @param {string} [ifNoneMatch] ETag of data already cached on the client
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
async getAllAssets(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> { async getAllAssets(ifNoneMatch?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(options); const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(ifNoneMatch, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/** /**
@ -3590,11 +3596,12 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
/** /**
* Get all AssetEntity belong to the user * Get all AssetEntity belong to the user
* @summary * @summary
* @param {string} [ifNoneMatch] ETag of data already cached on the client
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
getAllAssets(options?: any): AxiosPromise<Array<AssetResponseDto>> { getAllAssets(ifNoneMatch?: string, options?: any): AxiosPromise<Array<AssetResponseDto>> {
return localVarFp.getAllAssets(options).then((request) => request(axios, basePath)); return localVarFp.getAllAssets(ifNoneMatch, options).then((request) => request(axios, basePath));
}, },
/** /**
* Get a single asset\'s information * Get a single asset\'s information
@ -3788,12 +3795,13 @@ export class AssetApi extends BaseAPI {
/** /**
* Get all AssetEntity belong to the user * Get all AssetEntity belong to the user
* @summary * @summary
* @param {string} [ifNoneMatch] ETag of data already cached on the client
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
* @memberof AssetApi * @memberof AssetApi
*/ */
public getAllAssets(options?: AxiosRequestConfig) { public getAllAssets(ifNoneMatch?: string, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getAllAssets(options).then((request) => request(this.axios, this.basePath)); return AssetApiFp(this.configuration).getAllAssets(ifNoneMatch, options).then((request) => request(this.axios, this.basePath));
} }
/** /**