diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 737d72da35..277d676daa 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -5895,7 +5895,7 @@ export const AssetApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getAssetThumbnail(id: string, format?: ThumbnailFormat, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async getAssetThumbnail(id: string, format?: ThumbnailFormat, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetThumbnail(id, format, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -5992,7 +5992,7 @@ export const AssetApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async serveFile(id: string, isThumb?: boolean, isWeb?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async serveFile(id: string, isThumb?: boolean, isWeb?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.serveFile(id, isThumb, isWeb, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -6161,7 +6161,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getAssetThumbnail(requestParameters: AssetApiGetAssetThumbnailRequest, options?: AxiosRequestConfig): AxiosPromise { + getAssetThumbnail(requestParameters: AssetApiGetAssetThumbnailRequest, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.getAssetThumbnail(requestParameters.id, requestParameters.format, requestParameters.key, options).then((request) => request(axios, basePath)); }, /** @@ -6240,7 +6240,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath * @param {*} [options] Override http request option. * @throws {RequiredError} */ - serveFile(requestParameters: AssetApiServeFileRequest, options?: AxiosRequestConfig): AxiosPromise { + serveFile(requestParameters: AssetApiServeFileRequest, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.serveFile(requestParameters.id, requestParameters.isThumb, requestParameters.isWeb, requestParameters.key, options).then((request) => request(axios, basePath)); }, /** @@ -8868,7 +8868,7 @@ export const PersonApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getPersonThumbnail(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async getPersonThumbnail(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonThumbnail(id, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -8925,7 +8925,7 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getPersonThumbnail(requestParameters: PersonApiGetPersonThumbnailRequest, options?: AxiosRequestConfig): AxiosPromise { + getPersonThumbnail(requestParameters: PersonApiGetPersonThumbnailRequest, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.getPersonThumbnail(requestParameters.id, options).then((request) => request(axios, basePath)); }, /** diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index 41f4e2e020..319deb2acf 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -768,7 +768,7 @@ This endpoint does not need any parameter. [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **getAssetThumbnail** -> getAssetThumbnail(id, format, key) +> MultipartFile getAssetThumbnail(id, format, key) @@ -796,7 +796,8 @@ final format = ; // ThumbnailFormat | final key = key_example; // String | try { - api_instance.getAssetThumbnail(id, format, key); + final result = api_instance.getAssetThumbnail(id, format, key); + print(result); } catch (e) { print('Exception when calling AssetApi->getAssetThumbnail: $e\n'); } @@ -812,7 +813,7 @@ Name | Type | Description | Notes ### Return type -void (empty response body) +[**MultipartFile**](MultipartFile.md) ### Authorization @@ -821,7 +822,7 @@ void (empty response body) ### HTTP request headers - **Content-Type**: Not defined - - **Accept**: Not defined + - **Accept**: application/octet-stream [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) @@ -1272,7 +1273,7 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **serveFile** -> serveFile(id, isThumb, isWeb, key) +> MultipartFile serveFile(id, isThumb, isWeb, key) @@ -1301,7 +1302,8 @@ final isWeb = true; // bool | final key = key_example; // String | try { - api_instance.serveFile(id, isThumb, isWeb, key); + final result = api_instance.serveFile(id, isThumb, isWeb, key); + print(result); } catch (e) { print('Exception when calling AssetApi->serveFile: $e\n'); } @@ -1318,7 +1320,7 @@ Name | Type | Description | Notes ### Return type -void (empty response body) +[**MultipartFile**](MultipartFile.md) ### Authorization @@ -1327,7 +1329,7 @@ void (empty response body) ### HTTP request headers - **Content-Type**: Not defined - - **Accept**: Not defined + - **Accept**: application/octet-stream [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/PersonApi.md b/mobile/openapi/doc/PersonApi.md index ca35880ddc..dd1c0eb8e4 100644 --- a/mobile/openapi/doc/PersonApi.md +++ b/mobile/openapi/doc/PersonApi.md @@ -178,7 +178,7 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **getPersonThumbnail** -> getPersonThumbnail(id) +> MultipartFile getPersonThumbnail(id) @@ -204,7 +204,8 @@ final api_instance = PersonApi(); final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | try { - api_instance.getPersonThumbnail(id); + final result = api_instance.getPersonThumbnail(id); + print(result); } catch (e) { print('Exception when calling PersonApi->getPersonThumbnail: $e\n'); } @@ -218,7 +219,7 @@ Name | Type | Description | Notes ### Return type -void (empty response body) +[**MultipartFile**](MultipartFile.md) ### Authorization @@ -227,7 +228,7 @@ void (empty response body) ### HTTP request headers - **Content-Type**: Not defined - - **Accept**: Not defined + - **Accept**: application/octet-stream [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 94394f020a..a73ec3b1ec 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -772,11 +772,19 @@ class AssetApi { /// * [ThumbnailFormat] format: /// /// * [String] key: - Future getAssetThumbnail(String id, { ThumbnailFormat? format, String? key, }) async { + Future getAssetThumbnail(String id, { ThumbnailFormat? format, String? key, }) async { final response = await getAssetThumbnailWithHttpInfo(id, format: format, key: key, ); 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) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile; + + } + return null; } /// Performs an HTTP 'GET /asset/curated-locations' operation and returns the [Response]. @@ -1276,11 +1284,19 @@ class AssetApi { /// * [bool] isWeb: /// /// * [String] key: - Future serveFile(String id, { bool? isThumb, bool? isWeb, String? key, }) async { + Future serveFile(String id, { bool? isThumb, bool? isWeb, String? key, }) async { final response = await serveFileWithHttpInfo(id, isThumb: isThumb, isWeb: isWeb, key: key, ); 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) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile; + + } + return null; } /// Update an asset diff --git a/mobile/openapi/lib/api/person_api.dart b/mobile/openapi/lib/api/person_api.dart index a27c5845f3..37f8bf8a30 100644 --- a/mobile/openapi/lib/api/person_api.dart +++ b/mobile/openapi/lib/api/person_api.dart @@ -192,11 +192,19 @@ class PersonApi { /// Parameters: /// /// * [String] id (required): - Future getPersonThumbnail(String id,) async { + Future getPersonThumbnail(String id,) async { final response = await getPersonThumbnailWithHttpInfo(id,); 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) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile; + + } + return null; } /// Performs an HTTP 'PUT /person/{id}' operation and returns the [Response]. diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index 003956969c..1c5f08536b 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -92,7 +92,7 @@ void main() { // TODO }); - //Future getAssetThumbnail(String id, { ThumbnailFormat format, String key }) async + //Future getAssetThumbnail(String id, { ThumbnailFormat format, String key }) async test('test getAssetThumbnail', () async { // TODO }); @@ -139,7 +139,7 @@ void main() { // TODO }); - //Future serveFile(String id, { bool isThumb, bool isWeb, String key }) async + //Future serveFile(String id, { bool isThumb, bool isWeb, String key }) async test('test serveFile', () async { // TODO }); diff --git a/mobile/openapi/test/person_api_test.dart b/mobile/openapi/test/person_api_test.dart index 956ec138d3..33e78b8a7c 100644 --- a/mobile/openapi/test/person_api_test.dart +++ b/mobile/openapi/test/person_api_test.dart @@ -32,7 +32,7 @@ void main() { // TODO }); - //Future getPersonThumbnail(String id) async + //Future getPersonThumbnail(String id) async test('test getPersonThumbnail', () async { // TODO }); diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 3e74a64c62..caae449e25 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -1351,6 +1351,14 @@ ], "responses": { "200": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, "description": "" } }, @@ -1664,6 +1672,14 @@ ], "responses": { "200": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, "description": "" } }, @@ -2687,6 +2703,14 @@ ], "responses": { "200": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, "description": "" } }, diff --git a/server/src/immich/api-v1/asset/asset.controller.ts b/server/src/immich/api-v1/asset/asset.controller.ts index 7cd93bd7ed..c2d3e6b39d 100644 --- a/server/src/immich/api-v1/asset/asset.controller.ts +++ b/server/src/immich/api-v1/asset/asset.controller.ts @@ -19,7 +19,7 @@ import { ValidationPipe, } from '@nestjs/common'; import { FileFieldsInterceptor } from '@nestjs/platform-express'; -import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger'; +import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { Response as Res } from 'express'; import { Authenticated, AuthUser, SharedLinkRoute } from '../../app.guard'; import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config'; @@ -122,6 +122,7 @@ export class AssetController { @SharedLinkRoute() @Get('/file/:id') @Header('Cache-Control', 'private, max-age=86400, no-transform') + @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) serveFile( @AuthUser() authUser: AuthUserDto, @Headers() headers: Record, @@ -135,6 +136,7 @@ export class AssetController { @SharedLinkRoute() @Get('/thumbnail/:id') @Header('Cache-Control', 'private, max-age=86400, no-transform') + @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) getAssetThumbnail( @AuthUser() authUser: AuthUserDto, @Headers() headers: Record, diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index 26c0ca7bbe..0a1ee8e0e2 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -256,8 +256,8 @@ export class AssetService { } try { - const [thumbnailPath, contentType] = this.getThumbnailPath(asset, query.format); - return this.streamFile(thumbnailPath, res, headers, contentType); + const thumbnailPath = this.getThumbnailPath(asset, query.format); + return this.streamFile(thumbnailPath, res, headers); } catch (e) { res.header('Cache-Control', 'none'); this.logger.error(`Cannot create read stream for asset ${asset.id}`, 'getAssetThumbnail'); @@ -522,17 +522,16 @@ export class AssetService { private getThumbnailPath(asset: AssetEntity, format: GetAssetThumbnailFormatEnum) { switch (format) { case GetAssetThumbnailFormatEnum.WEBP: - if (asset.webpPath) { - return [asset.webpPath, 'image/webp']; + if (asset.webpPath && asset.webpPath.length > 0) { + return asset.webpPath; } - this.logger.warn(`WebP thumbnail requested but not found for asset ${asset.id}, falling back to JPEG`); case GetAssetThumbnailFormatEnum.JPEG: default: if (!asset.resizePath) { - throw new NotFoundException(`No thumbnail found for asset ${asset.id}`); + throw new NotFoundException('resizePath not set'); } - return [asset.resizePath, 'image/jpeg']; + return asset.resizePath; } } diff --git a/server/src/immich/api-v1/asset/dto/get-asset-thumbnail.dto.ts b/server/src/immich/api-v1/asset/dto/get-asset-thumbnail.dto.ts index ad0e755d6a..5a8dc06872 100644 --- a/server/src/immich/api-v1/asset/dto/get-asset-thumbnail.dto.ts +++ b/server/src/immich/api-v1/asset/dto/get-asset-thumbnail.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEnum, IsOptional } from 'class-validator'; +import { IsOptional } from 'class-validator'; export enum GetAssetThumbnailFormatEnum { JPEG = 'JPEG', @@ -8,7 +8,6 @@ export enum GetAssetThumbnailFormatEnum { export class GetAssetThumbnailDto { @IsOptional() - @IsEnum(GetAssetThumbnailFormatEnum) @ApiProperty({ type: String, enum: GetAssetThumbnailFormatEnum, diff --git a/server/src/immich/controllers/person.controller.ts b/server/src/immich/controllers/person.controller.ts index 2e8135161f..5752304598 100644 --- a/server/src/immich/controllers/person.controller.ts +++ b/server/src/immich/controllers/person.controller.ts @@ -7,7 +7,7 @@ import { PersonUpdateDto, } from '@app/domain'; import { Body, Controller, Get, Param, Put, StreamableFile } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { Authenticated, AuthUser } from '../app.guard'; import { UseValidation } from '../app.utils'; import { UUIDParamDto } from './dto/uuid-param.dto'; @@ -43,6 +43,7 @@ export class PersonController { } @Get(':id/thumbnail') + @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) getPersonThumbnail(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) { return this.service.getThumbnail(authUser, id).then(asStreamableFile); } diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 72b41daa7f..c1a8f7f222 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -5904,7 +5904,7 @@ export const AssetApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getAssetThumbnail(id: string, format?: ThumbnailFormat, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async getAssetThumbnail(id: string, format?: ThumbnailFormat, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetThumbnail(id, format, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -6001,7 +6001,7 @@ export const AssetApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async serveFile(id: string, isThumb?: boolean, isWeb?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async serveFile(id: string, isThumb?: boolean, isWeb?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.serveFile(id, isThumb, isWeb, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -6181,7 +6181,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getAssetThumbnail(id: string, format?: ThumbnailFormat, key?: string, options?: any): AxiosPromise { + getAssetThumbnail(id: string, format?: ThumbnailFormat, key?: string, options?: any): AxiosPromise { return localVarFp.getAssetThumbnail(id, format, key, options).then((request) => request(axios, basePath)); }, /** @@ -6269,7 +6269,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath * @param {*} [options] Override http request option. * @throws {RequiredError} */ - serveFile(id: string, isThumb?: boolean, isWeb?: boolean, key?: string, options?: any): AxiosPromise { + serveFile(id: string, isThumb?: boolean, isWeb?: boolean, key?: string, options?: any): AxiosPromise { return localVarFp.serveFile(id, isThumb, isWeb, key, options).then((request) => request(axios, basePath)); }, /** @@ -8913,7 +8913,7 @@ export const PersonApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getPersonThumbnail(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async getPersonThumbnail(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonThumbnail(id, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -8970,7 +8970,7 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getPersonThumbnail(id: string, options?: any): AxiosPromise { + getPersonThumbnail(id: string, options?: any): AxiosPromise { return localVarFp.getPersonThumbnail(id, options).then((request) => request(axios, basePath)); }, /**