1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02: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:
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 187 additions and 64 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);
}

Binary file not shown.

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));
} }
/** /**