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:
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;
|
||||
/// 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;
|
||||
}
|
||||
|
||||
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
|
||||
/// 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();
|
||||
if (isCacheValid && state.isEmpty) {
|
||||
stopwatch.start();
|
||||
final localTask = _assetService.getLocalAssets(urgent: !isCacheValid);
|
||||
final remoteTask = _assetService.getRemoteAssets();
|
||||
if (isCacheValid && state.isEmpty) {
|
||||
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);
|
||||
}
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -14,6 +14,7 @@ import {
|
||||
Header,
|
||||
Put,
|
||||
UploadedFiles,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { Authenticated } from '../../decorators/authenticated.decorator';
|
||||
import { AssetService } from './asset.service';
|
||||
@ -21,12 +22,12 @@ import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
||||
import { assetUploadOption } from '../../config/asset-upload.config';
|
||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||
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 { DeleteAssetDto } from './dto/delete-asset.dto';
|
||||
import { SearchAssetDto } from './dto/search-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 { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
|
||||
import { AssetResponseDto } from './response-dto/asset-response.dto';
|
||||
@ -49,6 +50,7 @@ import {
|
||||
IMMICH_ARCHIVE_FILE_COUNT,
|
||||
IMMICH_CONTENT_LENGTH_HINT,
|
||||
} from '../../constants/download.constant';
|
||||
import { etag } from '../../utils/etag';
|
||||
|
||||
@Authenticated()
|
||||
@ApiBearerAuth()
|
||||
@ -168,8 +170,28 @@ export class AssetController {
|
||||
* Get all AssetEntity belong to the user
|
||||
*/
|
||||
@Get('/')
|
||||
async getAllAssets(@GetAuthUser() authUser: AuthUserDto): Promise<AssetResponseDto[]> {
|
||||
return await this.assetService.getAllAssets(authUser);
|
||||
@ApiHeader({
|
||||
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')
|
||||
|
@ -19,10 +19,10 @@ export class AssetResponseDto {
|
||||
mimeType!: string | null;
|
||||
duration!: string;
|
||||
webpPath!: string | null;
|
||||
encodedVideoPath!: string | null;
|
||||
encodedVideoPath?: string | null;
|
||||
exifInfo?: ExifResponseDto;
|
||||
smartInfo?: SmartInfoResponseDto;
|
||||
livePhotoVideoId!: string | null;
|
||||
livePhotoVideoId?: string | null;
|
||||
}
|
||||
|
||||
export function mapAsset(entity: AssetEntity): AssetResponseDto {
|
||||
|
@ -9,7 +9,7 @@ export class UserResponseDto {
|
||||
profileImagePath!: string;
|
||||
shouldChangePassword!: boolean;
|
||||
isAdmin!: boolean;
|
||||
deletedAt!: Date | null;
|
||||
deletedAt?: Date;
|
||||
}
|
||||
|
||||
export function mapUser(entity: UserEntity): UserResponseDto {
|
||||
@ -22,6 +22,6 @@ export function mapUser(entity: UserEntity): UserResponseDto {
|
||||
profileImagePath: entity.profileImagePath,
|
||||
shouldChangePassword: entity.shouldChangePassword,
|
||||
isAdmin: entity.isAdmin,
|
||||
deletedAt: entity.deletedAt || null,
|
||||
deletedAt: entity.deletedAt,
|
||||
};
|
||||
}
|
||||
|
5
server/apps/immich/src/types/index.d.ts
vendored
Normal file
5
server/apps/immich/src/types/index.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
declare module 'crypto' {
|
||||
namespace webcrypto {
|
||||
const subtle: SubtleCrypto;
|
||||
}
|
||||
}
|
10
server/apps/immich/src/utils/etag.ts
Normal file
10
server/apps/immich/src/utils/etag.ts
Normal 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
@ -427,7 +427,7 @@ export interface AssetResponseDto {
|
||||
* @type {string}
|
||||
* @memberof AssetResponseDto
|
||||
*/
|
||||
'encodedVideoPath': string | null;
|
||||
'encodedVideoPath'?: string | null;
|
||||
/**
|
||||
*
|
||||
* @type {ExifResponseDto}
|
||||
@ -445,7 +445,7 @@ export interface AssetResponseDto {
|
||||
* @type {string}
|
||||
* @memberof AssetResponseDto
|
||||
*/
|
||||
'livePhotoVideoId': string | null;
|
||||
'livePhotoVideoId'?: string | null;
|
||||
}
|
||||
/**
|
||||
*
|
||||
@ -1729,7 +1729,7 @@ export interface UserResponseDto {
|
||||
* @type {string}
|
||||
* @memberof UserResponseDto
|
||||
*/
|
||||
'deletedAt': string | null;
|
||||
'deletedAt'?: string;
|
||||
}
|
||||
/**
|
||||
*
|
||||
@ -2788,10 +2788,11 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
|
||||
/**
|
||||
* Get all AssetEntity belong to the user
|
||||
* @summary
|
||||
* @param {string} [ifNoneMatch] ETag of data already cached on the client
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getAllAssets: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
getAllAssets: async (ifNoneMatch?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
const localVarPath = `/asset`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
@ -2808,6 +2809,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
if (ifNoneMatch !== undefined && ifNoneMatch !== null) {
|
||||
localVarHeaderParameter['if-none-match'] = String(ifNoneMatch);
|
||||
}
|
||||
|
||||
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
@ -3388,11 +3393,12 @@ export const AssetApiFp = function(configuration?: Configuration) {
|
||||
/**
|
||||
* Get all AssetEntity belong to the user
|
||||
* @summary
|
||||
* @param {string} [ifNoneMatch] ETag of data already cached on the client
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async getAllAssets(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(options);
|
||||
async getAllAssets(ifNoneMatch?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(ifNoneMatch, options);
|
||||
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
|
||||
* @summary
|
||||
* @param {string} [ifNoneMatch] ETag of data already cached on the client
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getAllAssets(options?: any): AxiosPromise<Array<AssetResponseDto>> {
|
||||
return localVarFp.getAllAssets(options).then((request) => request(axios, basePath));
|
||||
getAllAssets(ifNoneMatch?: string, options?: any): AxiosPromise<Array<AssetResponseDto>> {
|
||||
return localVarFp.getAllAssets(ifNoneMatch, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Get a single asset\'s information
|
||||
@ -3788,12 +3795,13 @@ export class AssetApi extends BaseAPI {
|
||||
/**
|
||||
* Get all AssetEntity belong to the user
|
||||
* @summary
|
||||
* @param {string} [ifNoneMatch] ETag of data already cached on the client
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof AssetApi
|
||||
*/
|
||||
public getAllAssets(options?: AxiosRequestConfig) {
|
||||
return AssetApiFp(this.configuration).getAllAssets(options).then((request) => request(this.axios, this.basePath));
|
||||
public getAllAssets(ifNoneMatch?: string, options?: AxiosRequestConfig) {
|
||||
return AssetApiFp(this.configuration).getAllAssets(ifNoneMatch, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
|
Loading…
Reference in New Issue
Block a user