mirror of https://github.com/immich-app/immich.git
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 requirepull/1023/head
parent
efa7b3ba54
commit
47f5e4134e
@ -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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
declare module 'crypto' {
|
||||||
|
namespace webcrypto {
|
||||||
|
const subtle: SubtleCrypto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
Loading…
Reference in New Issue