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

@ -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')

View file

@ -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 {

View file

@ -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,
};
}

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}"`;
}