immich/server/src/controllers/asset-media.controller.ts
Eli Gao 5c80e8734b
feat: original-sized previews for non-web-friendly images (#14446)
* feat(server): extract full-size previews from RAW images

* feat(web): load fullsize preview for RAW images when zoomed in

* refactor: tweaks for code review

* refactor: rename "converted" preview/assets to "fullsize"

* feat(web/server): fullsize preview for non-web-friendly images

* feat: tweaks for code review

* feat(server): require ASSET_DOWNLOAD premission for fullsize previews

* test: fix types and interfaces

* chore: gen open-api

* feat(server): keep only essential exif in fullsize preview

* chore: regen openapi

* test: revert unnecessary timeout

* feat: move full-size preview config to standalone entry

* feat(i18n): update en texts

* fix: don't return fullsizePath when disabled

* test: full-size previews

* test(web): full-size previews

* chore: make open-api

* feat(server): redirect to preview/original URL when fullsize thumbnail not available

* fix(server): delete fullsize preview image on thumbnail regen after fullsize preview turned off

* refactor(server): AssetRepository.deleteFiles with Kysely

* fix(server): type of MediaRepository.writeExif

* minor simplification

* minor styling changes and condensed wording

* simplify

* chore: reuild open-api

* test(server): fix media.service tests

* test(web): fix photo-viewer test

* fix(server):  use fullsize image when requested

* fix file path extension

* formatting

* use fullsize when zooming back out or when "display original photos" is enabled

* simplify condition

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2025-03-31 13:24:28 -04:00

204 lines
6.5 KiB
TypeScript

import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Next,
Param,
ParseFilePipe,
Post,
Put,
Query,
Req,
Res,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger';
import { NextFunction, Request, Response } from 'express';
import { EndpointLifecycle } from 'src/decorators';
import {
AssetBulkUploadCheckResponseDto,
AssetMediaResponseDto,
AssetMediaStatus,
CheckExistingAssetsResponseDto,
} from 'src/dtos/asset-media-response.dto';
import {
AssetBulkUploadCheckDto,
AssetMediaCreateDto,
AssetMediaOptionsDto,
AssetMediaReplaceDto,
AssetMediaSize,
CheckExistingAssetsDto,
UploadFieldName,
} from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { ImmichHeader, RouteKey } from 'src/enum';
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { FileUploadInterceptor, getFiles } from 'src/middleware/file-upload.interceptor';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { AssetMediaService } from 'src/services/asset-media.service';
import { UploadFiles } from 'src/types';
import { ImmichFileResponse, sendFile } from 'src/utils/file';
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
@ApiTags('Assets')
@Controller(RouteKey.ASSET)
export class AssetMediaController {
constructor(
private logger: LoggingRepository,
private service: AssetMediaService,
) {}
@Post()
@UseInterceptors(AssetUploadInterceptor, FileUploadInterceptor)
@ApiConsumes('multipart/form-data')
@ApiHeader({
name: ImmichHeader.CHECKSUM,
description: 'sha1 checksum that can be used for duplicate detection before the file is uploaded',
required: false,
})
@ApiBody({ description: 'Asset Upload Information', type: AssetMediaCreateDto })
@Authenticated({ sharedLink: true })
async uploadAsset(
@Auth() auth: AuthDto,
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles,
@Body() dto: AssetMediaCreateDto,
@Res({ passthrough: true }) res: Response,
): Promise<AssetMediaResponseDto> {
const { file, sidecarFile } = getFiles(files);
const responseDto = await this.service.uploadAsset(auth, dto, file, sidecarFile);
if (responseDto.status === AssetMediaStatus.DUPLICATE) {
res.status(HttpStatus.OK);
}
return responseDto;
}
@Get(':id/original')
@FileResponse()
@Authenticated({ sharedLink: true })
async downloadAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Res() res: Response,
@Next() next: NextFunction,
) {
await sendFile(res, next, () => this.service.downloadOriginal(auth, id), this.logger);
}
/**
* Replace the asset with new file, without changing its id
*/
@Put(':id/original')
@UseInterceptors(FileUploadInterceptor)
@ApiConsumes('multipart/form-data')
@EndpointLifecycle({ addedAt: 'v1.106.0' })
@ApiOperation({
summary: 'replaceAsset',
description: 'Replace the asset with new file, without changing its id',
})
@Authenticated({ sharedLink: true })
async replaceAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator([UploadFieldName.ASSET_DATA])] }))
files: UploadFiles,
@Body() dto: AssetMediaReplaceDto,
@Res({ passthrough: true }) res: Response,
): Promise<AssetMediaResponseDto> {
const { file } = getFiles(files);
const responseDto = await this.service.replaceAsset(auth, id, dto, file);
if (responseDto.status === AssetMediaStatus.DUPLICATE) {
res.status(HttpStatus.OK);
}
return responseDto;
}
@Get(':id/thumbnail')
@FileResponse()
@Authenticated({ sharedLink: true })
async viewAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Query() dto: AssetMediaOptionsDto,
@Req() req: Request,
@Res() res: Response,
@Next() next: NextFunction,
) {
const viewThumbnailRes = await this.service.viewThumbnail(auth, id, dto);
if (viewThumbnailRes instanceof ImmichFileResponse) {
await sendFile(res, next, () => Promise.resolve(viewThumbnailRes), this.logger);
} else {
// viewThumbnailRes is a AssetMediaRedirectResponse
// which redirects to the original asset or a specific size to make better use of caching
const { targetSize } = viewThumbnailRes;
const [reqPath, reqSearch] = req.url.split('?');
let redirPath: string;
const redirSearchParams = new URLSearchParams(reqSearch);
if (targetSize === 'original') {
// relative path to this.downloadAsset
redirPath = 'original';
redirSearchParams.delete('size');
} else if (Object.values(AssetMediaSize).includes(targetSize)) {
redirPath = reqPath;
redirSearchParams.set('size', targetSize);
} else {
throw new Error('Invalid targetSize: ' + targetSize);
}
const finalRedirPath = redirPath + '?' + redirSearchParams.toString();
return res.redirect(finalRedirPath);
}
}
@Get(':id/video/playback')
@FileResponse()
@Authenticated({ sharedLink: true })
async playAssetVideo(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Res() res: Response,
@Next() next: NextFunction,
) {
await sendFile(res, next, () => this.service.playbackVideo(auth, id), this.logger);
}
/**
* Checks if multiple assets exist on the server and returns all existing - used by background backup
*/
@Post('exist')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'checkExistingAssets',
description: 'Checks if multiple assets exist on the server and returns all existing - used by background backup',
})
@Authenticated()
checkExistingAssets(
@Auth() auth: AuthDto,
@Body() dto: CheckExistingAssetsDto,
): Promise<CheckExistingAssetsResponseDto> {
return this.service.checkExistingAssets(auth, dto);
}
/**
* Checks if assets exist by checksums
*/
@Post('bulk-upload-check')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'checkBulkUpload',
description: 'Checks if assets exist by checksums',
})
@Authenticated()
checkBulkUpload(
@Auth() auth: AuthDto,
@Body() dto: AssetBulkUploadCheckDto,
): Promise<AssetBulkUploadCheckResponseDto> {
return this.service.bulkUploadCheck(auth, dto);
}
}