diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index c3a4921601..4b5ef2f0dd 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -104,6 +104,7 @@ Class | Method | HTTP request | Description *AssetApi* | [**getMapMarkers**](doc//AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | *AssetApi* | [**getMemoryLane**](doc//AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | *AssetApi* | [**getRandom**](doc//AssetApi.md#getrandom) | **GET** /asset/random | +*AssetApi* | [**replaceAsset**](doc//AssetApi.md#replaceasset) | **PUT** /asset/{id}/file | *AssetApi* | [**runAssetJobs**](doc//AssetApi.md#runassetjobs) | **POST** /asset/jobs | *AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{id} | *AssetApi* | [**updateAsset**](doc//AssetApi.md#updateasset) | **PUT** /asset/{id} | @@ -261,6 +262,8 @@ Class | Method | HTTP request | Description - [AssetIdsResponseDto](doc//AssetIdsResponseDto.md) - [AssetJobName](doc//AssetJobName.md) - [AssetJobsDto](doc//AssetJobsDto.md) + - [AssetMediaResponseDto](doc//AssetMediaResponseDto.md) + - [AssetMediaStatus](doc//AssetMediaStatus.md) - [AssetOrder](doc//AssetOrder.md) - [AssetResponseDto](doc//AssetResponseDto.md) - [AssetStatsResponseDto](doc//AssetStatsResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index be7c4a936e..e74fe8e03e 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -92,6 +92,8 @@ part 'model/asset_ids_dto.dart'; part 'model/asset_ids_response_dto.dart'; part 'model/asset_job_name.dart'; part 'model/asset_jobs_dto.dart'; +part 'model/asset_media_response_dto.dart'; +part 'model/asset_media_status.dart'; part 'model/asset_order.dart'; part 'model/asset_response_dto.dart'; part 'model/asset_stats_response_dto.dart'; diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index f3c8389ab4..326237a93f 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -710,6 +710,121 @@ class AssetApi { return null; } + /// Replace the asset with new file, without changing its id + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [MultipartFile] assetData (required): + /// + /// * [String] deviceAssetId (required): + /// + /// * [String] deviceId (required): + /// + /// * [DateTime] fileCreatedAt (required): + /// + /// * [DateTime] fileModifiedAt (required): + /// + /// * [String] key: + /// + /// * [String] duration: + Future replaceAssetWithHttpInfo(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? duration, }) async { + // ignore: prefer_const_declarations + final path = r'/asset/{id}/file' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (key != null) { + queryParams.addAll(_queryParams('', 'key', key)); + } + + const contentTypes = ['multipart/form-data']; + + bool hasFields = false; + final mp = MultipartRequest('PUT', Uri.parse(path)); + if (assetData != null) { + hasFields = true; + mp.fields[r'assetData'] = assetData.field; + mp.files.add(assetData); + } + if (deviceAssetId != null) { + hasFields = true; + mp.fields[r'deviceAssetId'] = parameterToString(deviceAssetId); + } + if (deviceId != null) { + hasFields = true; + mp.fields[r'deviceId'] = parameterToString(deviceId); + } + if (duration != null) { + hasFields = true; + mp.fields[r'duration'] = parameterToString(duration); + } + if (fileCreatedAt != null) { + hasFields = true; + mp.fields[r'fileCreatedAt'] = parameterToString(fileCreatedAt); + } + if (fileModifiedAt != null) { + hasFields = true; + mp.fields[r'fileModifiedAt'] = parameterToString(fileModifiedAt); + } + if (hasFields) { + postBody = mp; + } + + return apiClient.invokeAPI( + path, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Replace the asset with new file, without changing its id + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [MultipartFile] assetData (required): + /// + /// * [String] deviceAssetId (required): + /// + /// * [String] deviceId (required): + /// + /// * [DateTime] fileCreatedAt (required): + /// + /// * [DateTime] fileModifiedAt (required): + /// + /// * [String] key: + /// + /// * [String] duration: + Future replaceAsset(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? duration, }) async { + final response = await replaceAssetWithHttpInfo(id, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, duration: duration, ); + 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), 'AssetMediaResponseDto',) as AssetMediaResponseDto; + + } + return null; + } + /// Performs an HTTP 'POST /asset/jobs' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 3e2f2c15c6..1f959757da 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -250,6 +250,10 @@ class ApiClient { return AssetJobNameTypeTransformer().decode(value); case 'AssetJobsDto': return AssetJobsDto.fromJson(value); + case 'AssetMediaResponseDto': + return AssetMediaResponseDto.fromJson(value); + case 'AssetMediaStatus': + return AssetMediaStatusTypeTransformer().decode(value); case 'AssetOrder': return AssetOrderTypeTransformer().decode(value); case 'AssetResponseDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index f945dbb115..4a8c774623 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -61,6 +61,9 @@ String parameterToString(dynamic value) { if (value is AssetJobName) { return AssetJobNameTypeTransformer().encode(value).toString(); } + if (value is AssetMediaStatus) { + return AssetMediaStatusTypeTransformer().encode(value).toString(); + } if (value is AssetOrder) { return AssetOrderTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/asset_media_response_dto.dart b/mobile/openapi/lib/model/asset_media_response_dto.dart new file mode 100644 index 0000000000..c2801c93cc --- /dev/null +++ b/mobile/openapi/lib/model/asset_media_response_dto.dart @@ -0,0 +1,106 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetMediaResponseDto { + /// Returns a new [AssetMediaResponseDto] instance. + AssetMediaResponseDto({ + required this.id, + required this.status, + }); + + String id; + + AssetMediaStatus status; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMediaResponseDto && + other.id == id && + other.status == status; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (id.hashCode) + + (status.hashCode); + + @override + String toString() => 'AssetMediaResponseDto[id=$id, status=$status]'; + + Map toJson() { + final json = {}; + json[r'id'] = this.id; + json[r'status'] = this.status; + return json; + } + + /// Returns a new [AssetMediaResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMediaResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return AssetMediaResponseDto( + id: mapValueOfType(json, r'id')!, + status: AssetMediaStatus.fromJson(json[r'status'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetMediaResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetMediaResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMediaResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetMediaResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'id', + 'status', + }; +} + diff --git a/mobile/openapi/lib/model/asset_media_status.dart b/mobile/openapi/lib/model/asset_media_status.dart new file mode 100644 index 0000000000..ff6f62e33f --- /dev/null +++ b/mobile/openapi/lib/model/asset_media_status.dart @@ -0,0 +1,85 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class AssetMediaStatus { + /// Instantiate a new enum with the provided [value]. + const AssetMediaStatus._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const replaced = AssetMediaStatus._(r'replaced'); + static const duplicate = AssetMediaStatus._(r'duplicate'); + + /// List of all possible values in this [enum][AssetMediaStatus]. + static const values = [ + replaced, + duplicate, + ]; + + static AssetMediaStatus? fromJson(dynamic value) => AssetMediaStatusTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetMediaStatus.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [AssetMediaStatus] to String, +/// and [decode] dynamic data back to [AssetMediaStatus]. +class AssetMediaStatusTypeTransformer { + factory AssetMediaStatusTypeTransformer() => _instance ??= const AssetMediaStatusTypeTransformer._(); + + const AssetMediaStatusTypeTransformer._(); + + String encode(AssetMediaStatus data) => data.value; + + /// Decodes a [dynamic value][data] to a AssetMediaStatus. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + AssetMediaStatus? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'replaced': return AssetMediaStatus.replaced; + case r'duplicate': return AssetMediaStatus.duplicate; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [AssetMediaStatusTypeTransformer] instance. + static AssetMediaStatusTypeTransformer? _instance; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 4d3ccb1ea1..4bec15e4ac 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1840,6 +1840,70 @@ ] } }, + "/asset/{id}/file": { + "put": { + "description": "Replace the asset with new file, without changing its id", + "operationId": "replaceAsset", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/AssetMediaReplaceDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetMediaResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Asset" + ], + "x-immich-lifecycle": { + "addedAt": "v1.106.0" + } + } + }, "/audit/deletes": { "get": { "operationId": "getAuditDeletes", @@ -7330,6 +7394,61 @@ ], "type": "object" }, + "AssetMediaReplaceDto": { + "properties": { + "assetData": { + "format": "binary", + "type": "string" + }, + "deviceAssetId": { + "type": "string" + }, + "deviceId": { + "type": "string" + }, + "duration": { + "type": "string" + }, + "fileCreatedAt": { + "format": "date-time", + "type": "string" + }, + "fileModifiedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "assetData", + "deviceAssetId", + "deviceId", + "fileCreatedAt", + "fileModifiedAt" + ], + "type": "object" + }, + "AssetMediaResponseDto": { + "properties": { + "id": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/AssetMediaStatus" + } + }, + "required": [ + "id", + "status" + ], + "type": "object" + }, + "AssetMediaStatus": { + "enum": [ + "replaced", + "duplicate" + ], + "type": "string" + }, "AssetOrder": { "enum": [ "asc", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index ff776bb3bd..2f895a76e8 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -324,6 +324,18 @@ export type UpdateAssetDto = { latitude?: number; longitude?: number; }; +export type AssetMediaReplaceDto = { + assetData: Blob; + deviceAssetId: string; + deviceId: string; + duration?: string; + fileCreatedAt: string; + fileModifiedAt: string; +}; +export type AssetMediaResponseDto = { + id: string; + status: AssetMediaStatus; +}; export type AuditDeletesResponseDto = { ids: string[]; needsFullSync: boolean; @@ -1585,6 +1597,25 @@ export function updateAsset({ id, updateAssetDto }: { body: updateAssetDto }))); } +/** + * Replace the asset with new file, without changing its id + */ +export function replaceAsset({ id, key, assetMediaReplaceDto }: { + id: string; + key?: string; + assetMediaReplaceDto: AssetMediaReplaceDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetMediaResponseDto; + }>(`/asset/${encodeURIComponent(id)}/file${QS.query(QS.explode({ + key + }))}`, oazapfts.multipart({ + ...opts, + method: "PUT", + body: assetMediaReplaceDto + }))); +} export function getAuditDeletes({ after, entityType, userId }: { after: string; entityType: EntityType; @@ -2892,6 +2923,10 @@ export enum ThumbnailFormat { Jpeg = "JPEG", Webp = "WEBP" } +export enum AssetMediaStatus { + Replaced = "replaced", + Duplicate = "duplicate" +} export enum EntityType { Asset = "ASSET", Album = "ALBUM" diff --git a/server/src/controllers/asset-media.controller.ts b/server/src/controllers/asset-media.controller.ts new file mode 100644 index 0000000000..cd0847142b --- /dev/null +++ b/server/src/controllers/asset-media.controller.ts @@ -0,0 +1,56 @@ +import { + Body, + Controller, + HttpStatus, + Inject, + Param, + ParseFilePipe, + Put, + Res, + UploadedFiles, + UseInterceptors, +} from '@nestjs/common'; +import { ApiConsumes, ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; +import { EndpointLifecycle } from 'src/decorators'; +import { AssetMediaResponseDto, AssetMediaStatusEnum } from 'src/dtos/asset-media-response.dto'; +import { AssetMediaReplaceDto, UploadFieldName } from 'src/dtos/asset-media.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { FileUploadInterceptor, Route, UploadFiles, getFiles } from 'src/middleware/file-upload.interceptor'; +import { AssetMediaService } from 'src/services/asset-media.service'; +import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation'; + +@ApiTags('Asset') +@Controller(Route.ASSET) +export class AssetMediaController { + constructor( + @Inject(ILoggerRepository) private logger: ILoggerRepository, + private service: AssetMediaService, + ) {} + + /** + * Replace the asset with new file, without changing its id + */ + @Put(':id/file') + @UseInterceptors(FileUploadInterceptor) + @ApiConsumes('multipart/form-data') + @Authenticated({ sharedLink: true }) + @EndpointLifecycle({ addedAt: 'v1.106.0' }) + 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 { + const { file } = getFiles(files); + const responseDto = await this.service.replaceAsset(auth, id, dto, file); + if (responseDto.status === AssetMediaStatusEnum.DUPLICATE) { + res.status(HttpStatus.OK); + } + return responseDto; + } +} diff --git a/server/src/controllers/asset-v1.controller.ts b/server/src/controllers/asset-v1.controller.ts index 37a94f24a1..ba29e462cb 100644 --- a/server/src/controllers/asset-v1.controller.ts +++ b/server/src/controllers/asset-v1.controller.ts @@ -34,17 +34,11 @@ import { AuthDto, ImmichHeader } from 'src/dtos/auth.dto'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; -import { FileUploadInterceptor, ImmichFile, Route, mapToUploadFile } from 'src/middleware/file-upload.interceptor'; +import { FileUploadInterceptor, Route, UploadFiles, mapToUploadFile } from 'src/middleware/file-upload.interceptor'; import { AssetServiceV1 } from 'src/services/asset-v1.service'; import { sendFile } from 'src/utils/file'; import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation'; -interface UploadFiles { - assetData: ImmichFile[]; - livePhotoData?: ImmichFile[]; - sidecarData: ImmichFile[]; -} - @ApiTags('Asset') @Controller(Route.ASSET) export class AssetControllerV1 { diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index feec616f21..187ba4b4db 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -2,6 +2,7 @@ import { ActivityController } from 'src/controllers/activity.controller'; import { AlbumController } from 'src/controllers/album.controller'; import { APIKeyController } from 'src/controllers/api-key.controller'; import { AppController } from 'src/controllers/app.controller'; +import { AssetMediaController } from 'src/controllers/asset-media.controller'; import { AssetControllerV1 } from 'src/controllers/asset-v1.controller'; import { AssetController } from 'src/controllers/asset.controller'; import { AuditController } from 'src/controllers/audit.controller'; @@ -35,6 +36,7 @@ export const controllers = [ AppController, AssetController, AssetControllerV1, + AssetMediaController, AuditController, AuthController, DownloadController, diff --git a/server/src/dtos/asset-media-response.dto.ts b/server/src/dtos/asset-media-response.dto.ts new file mode 100644 index 0000000000..7b65772f76 --- /dev/null +++ b/server/src/dtos/asset-media-response.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export enum AssetMediaStatusEnum { + REPLACED = 'replaced', + DUPLICATE = 'duplicate', +} +export class AssetMediaResponseDto { + @ApiProperty({ enum: AssetMediaStatusEnum, enumName: 'AssetMediaStatus' }) + status!: AssetMediaStatusEnum; + id!: string; +} diff --git a/server/src/dtos/asset-media.dto.ts b/server/src/dtos/asset-media.dto.ts new file mode 100644 index 0000000000..a30ff8a107 --- /dev/null +++ b/server/src/dtos/asset-media.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; +import { Optional, ValidateDate } from 'src/validation'; + +export enum UploadFieldName { + ASSET_DATA = 'assetData', + LIVE_PHOTO_DATA = 'livePhotoData', + SIDECAR_DATA = 'sidecarData', + PROFILE_DATA = 'file', +} + +export class AssetMediaReplaceDto { + @IsNotEmpty() + @IsString() + deviceAssetId!: string; + + @IsNotEmpty() + @IsString() + deviceId!: string; + + @ValidateDate() + fileCreatedAt!: Date; + + @ValidateDate() + fileModifiedAt!: Date; + + @Optional() + @IsString() + duration?: string; + + // The properties below are added to correctly generate the API docs + // and client SDKs. Validation should be handled in the controller. + @ApiProperty({ type: 'string', format: 'binary' }) + [UploadFieldName.ASSET_DATA]!: any; +} diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 0a8437818e..88f494c15b 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -111,7 +111,10 @@ export type AssetWithoutRelations = Omit< | 'tags' >; -export type AssetUpdateOptions = Pick & Partial; +type AssetUpdateWithoutRelations = Pick & Partial; +type AssetUpdateWithLivePhotoRelation = Pick & Pick; + +export type AssetUpdateOptions = AssetUpdateWithoutRelations | AssetUpdateWithLivePhotoRelation; export type AssetUpdateAllOptions = Omit, 'id'>; diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index 37401df896..6393e3167a 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -113,7 +113,7 @@ export interface IBaseJob { export interface IEntityJob extends IBaseJob { id: string; - source?: 'upload' | 'sidecar-write'; + source?: 'upload' | 'sidecar-write' | 'copy'; } export interface ILibraryFileJob extends IEntityJob { diff --git a/server/src/middleware/file-upload.interceptor.ts b/server/src/middleware/file-upload.interceptor.ts index 6af502786e..f4fc755100 100644 --- a/server/src/middleware/file-upload.interceptor.ts +++ b/server/src/middleware/file-upload.interceptor.ts @@ -6,10 +6,30 @@ import { NextFunction, RequestHandler } from 'express'; import multer, { StorageEngine, diskStorage } from 'multer'; import { createHash, randomUUID } from 'node:crypto'; import { Observable } from 'rxjs'; -import { UploadFieldName } from 'src/dtos/asset.dto'; +import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthRequest } from 'src/middleware/auth.guard'; -import { AssetService, UploadFile } from 'src/services/asset.service'; +import { UploadFile } from 'src/services/asset-media.service'; +import { AssetService } from 'src/services/asset.service'; + +export interface UploadFiles { + assetData: ImmichFile[]; + livePhotoData?: ImmichFile[]; + sidecarData: ImmichFile[]; +} + +export function getFile(files: UploadFiles, property: 'assetData' | 'livePhotoData' | 'sidecarData') { + const file = files[property]?.[0]; + return file ? mapToUploadFile(file) : file; +} + +export function getFiles(files: UploadFiles) { + return { + file: getFile(files, 'assetData') as UploadFile, + livePhotoFile: getFile(files, 'livePhotoData'), + sidecarFile: getFile(files, 'sidecarData'), + }; +} export enum Route { ASSET = 'asset', diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts new file mode 100644 index 0000000000..1779635630 --- /dev/null +++ b/server/src/services/asset-media.service.spec.ts @@ -0,0 +1,280 @@ +import { Stats } from 'node:fs'; +import { AssetMediaStatusEnum } from 'src/dtos/asset-media-response.dto'; +import { AssetMediaReplaceDto } from 'src/dtos/asset-media.dto'; +import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity'; +import { ExifEntity } from 'src/entities/exif.entity'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; +import { IJobRepository, JobName } from 'src/interfaces/job.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { IStorageRepository } from 'src/interfaces/storage.interface'; +import { IUserRepository } from 'src/interfaces/user.interface'; +import { AssetMediaService, UploadFile } from 'src/services/asset-media.service'; +import { mimeTypes } from 'src/utils/mime-types'; +import { authStub } from 'test/fixtures/auth.stub'; +import { fileStub } from 'test/fixtures/file.stub'; +import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; +import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; +import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; +import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; +import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { QueryFailedError } from 'typeorm'; +import { Mocked } from 'vitest'; + +const _getUpdateAssetDto = (): AssetMediaReplaceDto => { + return Object.assign(new AssetMediaReplaceDto(), { + deviceAssetId: 'deviceAssetId', + deviceId: 'deviceId', + fileModifiedAt: new Date('2024-04-15T23:41:36.910Z'), + fileCreatedAt: new Date('2024-04-15T23:41:36.910Z'), + updatedAt: new Date('2024-04-15T23:41:36.910Z'), + }); +}; + +const _getAsset_1 = () => { + const asset_1 = new AssetEntity(); + + asset_1.id = 'id_1'; + asset_1.ownerId = 'user_id_1'; + asset_1.deviceAssetId = 'device_asset_id_1'; + asset_1.deviceId = 'device_id_1'; + asset_1.type = AssetType.VIDEO; + asset_1.originalPath = 'fake_path/asset_1.jpeg'; + asset_1.previewPath = ''; + asset_1.fileModifiedAt = new Date('2022-06-19T23:41:36.910Z'); + asset_1.fileCreatedAt = new Date('2022-06-19T23:41:36.910Z'); + asset_1.updatedAt = new Date('2022-06-19T23:41:36.910Z'); + asset_1.isFavorite = false; + asset_1.isArchived = false; + asset_1.thumbnailPath = ''; + asset_1.encodedVideoPath = ''; + asset_1.duration = '0:00:00.000000'; + asset_1.exifInfo = new ExifEntity(); + asset_1.exifInfo.latitude = 49.533_547; + asset_1.exifInfo.longitude = 10.703_075; + asset_1.livePhotoVideoId = null; + asset_1.sidecarPath = null; + return asset_1; +}; +const _getExistingAsset = () => { + return { + ..._getAsset_1(), + duration: null, + type: AssetType.IMAGE, + checksum: Buffer.from('_getExistingAsset', 'utf8'), + libraryId: 'libraryId', + } as AssetEntity; +}; +const _getExistingAssetWithSideCar = () => { + return { + ..._getExistingAsset(), + sidecarPath: 'sidecar-path', + checksum: Buffer.from('_getExistingAssetWithSideCar', 'utf8'), + } as AssetEntity; +}; +const _getCopiedAsset = () => { + return { + id: 'copied-asset', + originalPath: 'copied-path', + } as AssetEntity; +}; + +describe('AssetMediaService', () => { + let sut: AssetMediaService; + let accessMock: IAccessRepositoryMock; + let assetMock: Mocked; + let jobMock: Mocked; + let loggerMock: Mocked; + let storageMock: Mocked; + let userMock: Mocked; + let eventMock: Mocked; + + beforeEach(() => { + accessMock = newAccessRepositoryMock(); + assetMock = newAssetRepositoryMock(); + jobMock = newJobRepositoryMock(); + loggerMock = newLoggerRepositoryMock(); + storageMock = newStorageRepositoryMock(); + userMock = newUserRepositoryMock(); + eventMock = newEventRepositoryMock(); + + sut = new AssetMediaService(accessMock, assetMock, jobMock, storageMock, userMock, eventMock, loggerMock); + }); + + describe('replaceAsset', () => { + const expectAssetUpdate = ( + existingAsset: AssetEntity, + uploadFile: UploadFile, + dto: AssetMediaReplaceDto, + livePhotoVideo?: AssetEntity, + sidecarPath?: UploadFile, + // eslint-disable-next-line unicorn/consistent-function-scoping + ) => { + expect(assetMock.update).toHaveBeenCalledWith({ + id: existingAsset.id, + checksum: uploadFile.checksum, + originalFileName: uploadFile.originalName, + originalPath: uploadFile.originalPath, + deviceAssetId: dto.deviceAssetId, + deviceId: dto.deviceId, + fileCreatedAt: dto.fileCreatedAt, + fileModifiedAt: dto.fileModifiedAt, + localDateTime: dto.fileCreatedAt, + type: mimeTypes.assetType(uploadFile.originalPath), + duration: dto.duration || null, + livePhotoVideo: livePhotoVideo ? { id: livePhotoVideo?.id } : null, + sidecarPath: sidecarPath?.originalPath || null, + }); + }; + + // eslint-disable-next-line unicorn/consistent-function-scoping + const expectAssetCreateCopy = (existingAsset: AssetEntity) => { + expect(assetMock.create).toHaveBeenCalledWith({ + ownerId: existingAsset.ownerId, + originalPath: existingAsset.originalPath, + originalFileName: existingAsset.originalFileName, + libraryId: existingAsset.libraryId, + deviceAssetId: existingAsset.deviceAssetId, + deviceId: existingAsset.deviceId, + type: existingAsset.type, + checksum: existingAsset.checksum, + fileCreatedAt: existingAsset.fileCreatedAt, + localDateTime: existingAsset.localDateTime, + fileModifiedAt: existingAsset.fileModifiedAt, + livePhotoVideoId: existingAsset.livePhotoVideoId || null, + sidecarPath: existingAsset.sidecarPath || null, + }); + }; + + it('should error when update photo does not exist', async () => { + const dto = _getUpdateAssetDto(); + assetMock.getById.mockResolvedValueOnce(null); + + await expect(sut.replaceAsset(authStub.user1, 'id', dto, fileStub.photo)).rejects.toThrow( + 'Not found or no asset.update access', + ); + + expect(assetMock.create).not.toHaveBeenCalled(); + }); + it('should update a photo with no sidecar to photo with no sidecar', async () => { + const existingAsset = _getExistingAsset(); + const updatedFile = fileStub.photo; + const updatedAsset = { ...existingAsset, ...updatedFile }; + const dto = _getUpdateAssetDto(); + assetMock.getById.mockResolvedValueOnce(existingAsset); + assetMock.getById.mockResolvedValueOnce(updatedAsset); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); + // this is the original file size + storageMock.stat.mockResolvedValue({ size: 0 } as Stats); + // this is for the clone call + assetMock.create.mockResolvedValue(_getCopiedAsset()); + + await expect(sut.replaceAsset(authStub.user1, existingAsset.id, dto, updatedFile)).resolves.toEqual({ + status: AssetMediaStatusEnum.REPLACED, + id: _getCopiedAsset().id, + }); + + expectAssetUpdate(existingAsset, updatedFile, dto); + expectAssetCreateCopy(existingAsset); + + expect(assetMock.softDeleteAll).toHaveBeenCalledWith([_getCopiedAsset().id]); + expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); + expect(storageMock.utimes).toHaveBeenCalledWith( + updatedFile.originalPath, + expect.any(Date), + new Date(dto.fileModifiedAt), + ); + }); + it('should update a photo with sidecar to photo with sidecar', async () => { + const existingAsset = _getExistingAssetWithSideCar(); + + const updatedFile = fileStub.photo; + const sidecarFile = fileStub.photoSidecar; + const dto = _getUpdateAssetDto(); + const updatedAsset = { ...existingAsset, ...updatedFile }; + assetMock.getById.mockResolvedValueOnce(existingAsset); + assetMock.getById.mockResolvedValueOnce(updatedAsset); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); + // this is the original file size + storageMock.stat.mockResolvedValue({ size: 0 } as Stats); + // this is for the clone call + assetMock.create.mockResolvedValue(_getCopiedAsset()); + + await expect(sut.replaceAsset(authStub.user1, existingAsset.id, dto, updatedFile, sidecarFile)).resolves.toEqual({ + status: AssetMediaStatusEnum.REPLACED, + id: _getCopiedAsset().id, + }); + + expectAssetUpdate(existingAsset, updatedFile, dto, undefined, sidecarFile); + expectAssetCreateCopy(existingAsset); + expect(assetMock.softDeleteAll).toHaveBeenCalledWith([_getCopiedAsset().id]); + expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); + expect(storageMock.utimes).toHaveBeenCalledWith( + updatedFile.originalPath, + expect.any(Date), + new Date(dto.fileModifiedAt), + ); + }); + it('should update a photo with a sidecar to photo with no sidecar', async () => { + const existingAsset = _getExistingAssetWithSideCar(); + const updatedFile = fileStub.photo; + + const dto = _getUpdateAssetDto(); + const updatedAsset = { ...existingAsset, ...updatedFile }; + assetMock.getById.mockResolvedValueOnce(existingAsset); + assetMock.getById.mockResolvedValueOnce(updatedAsset); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); + // this is the original file size + storageMock.stat.mockResolvedValue({ size: 0 } as Stats); + // this is for the copy call + assetMock.create.mockResolvedValue(_getCopiedAsset()); + + await expect(sut.replaceAsset(authStub.user1, existingAsset.id, dto, updatedFile)).resolves.toEqual({ + status: AssetMediaStatusEnum.REPLACED, + id: _getCopiedAsset().id, + }); + + expectAssetUpdate(existingAsset, updatedFile, dto); + expectAssetCreateCopy(existingAsset); + expect(assetMock.softDeleteAll).toHaveBeenCalledWith([_getCopiedAsset().id]); + expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); + expect(storageMock.utimes).toHaveBeenCalledWith( + updatedFile.originalPath, + expect.any(Date), + new Date(dto.fileModifiedAt), + ); + }); + it('should handle a photo with sidecar to duplicate photo ', async () => { + const existingAsset = _getExistingAssetWithSideCar(); + const updatedFile = fileStub.photo; + const dto = _getUpdateAssetDto(); + const error = new QueryFailedError('', [], new Error('unique key violation')); + (error as any).constraint = ASSET_CHECKSUM_CONSTRAINT; + + assetMock.update.mockRejectedValue(error); + assetMock.getById.mockResolvedValueOnce(existingAsset); + assetMock.getUploadAssetIdByChecksum.mockResolvedValue(existingAsset.id); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); + // this is the original file size + storageMock.stat.mockResolvedValue({ size: 0 } as Stats); + // this is for the clone call + assetMock.create.mockResolvedValue(_getCopiedAsset()); + + await expect(sut.replaceAsset(authStub.user1, existingAsset.id, dto, updatedFile)).resolves.toEqual({ + status: AssetMediaStatusEnum.DUPLICATE, + id: existingAsset.id, + }); + + expectAssetUpdate(existingAsset, updatedFile, dto); + expect(assetMock.create).not.toHaveBeenCalled(); + expect(assetMock.softDeleteAll).not.toHaveBeenCalled(); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.DELETE_FILES, + data: { files: [updatedFile.originalPath, undefined] }, + }); + expect(userMock.updateUsage).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts new file mode 100644 index 0000000000..ef8c46c5b7 --- /dev/null +++ b/server/src/services/asset-media.service.ts @@ -0,0 +1,177 @@ +import { BadRequestException, Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; +import { AccessCore, Permission } from 'src/cores/access.core'; +import { AssetMediaResponseDto, AssetMediaStatusEnum } from 'src/dtos/asset-media-response.dto'; +import { AssetMediaReplaceDto, UploadFieldName } from 'src/dtos/asset-media.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; +import { IAccessRepository } from 'src/interfaces/access.interface'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { IJobRepository, JobName } from 'src/interfaces/job.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { IStorageRepository } from 'src/interfaces/storage.interface'; +import { IUserRepository } from 'src/interfaces/user.interface'; +import { mimeTypes } from 'src/utils/mime-types'; +import { QueryFailedError } from 'typeorm'; + +export interface UploadRequest { + auth: AuthDto | null; + fieldName: UploadFieldName; + file: UploadFile; +} + +export interface UploadFile { + uuid: string; + checksum: Buffer; + originalPath: string; + originalName: string; + size: number; +} + +@Injectable() +export class AssetMediaService { + private access: AccessCore; + + constructor( + @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(IJobRepository) private jobRepository: IJobRepository, + @Inject(IStorageRepository) private storageRepository: IStorageRepository, + @Inject(IUserRepository) private userRepository: IUserRepository, + @Inject(IEventRepository) private eventRepository: IEventRepository, + @Inject(ILoggerRepository) private logger: ILoggerRepository, + ) { + this.logger.setContext(AssetMediaService.name); + this.access = AccessCore.create(accessRepository); + } + + public async replaceAsset( + auth: AuthDto, + id: string, + dto: AssetMediaReplaceDto, + file: UploadFile, + sidecarFile?: UploadFile, + ): Promise { + try { + await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id); + const existingAssetEntity = (await this.assetRepository.getById(id)) as AssetEntity; + + this.requireQuota(auth, file.size); + + await this.replaceFileData(existingAssetEntity.id, dto, file, sidecarFile?.originalPath); + + // Next, create a backup copy of the existing record. The db record has already been updated above, + // but the local variable holds the original file data paths. + const copiedPhoto = await this.createCopy(existingAssetEntity); + // and immediate trash it + await this.assetRepository.softDeleteAll([copiedPhoto.id]); + this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, auth.user.id, [copiedPhoto.id]); + + await this.userRepository.updateUsage(auth.user.id, file.size); + + return { status: AssetMediaStatusEnum.REPLACED, id: copiedPhoto.id }; + } catch (error: any) { + return await this.handleUploadError(error, auth, file, sidecarFile); + } + } + + private async handleUploadError( + error: any, + auth: AuthDto, + file: UploadFile, + sidecarFile?: UploadFile, + ): Promise { + // clean up files + await this.jobRepository.queue({ + name: JobName.DELETE_FILES, + data: { files: [file.originalPath, sidecarFile?.originalPath] }, + }); + + // handle duplicates with a success response + if (error instanceof QueryFailedError && (error as any).constraint === ASSET_CHECKSUM_CONSTRAINT) { + const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum); + if (!duplicateId) { + this.logger.error(`Error locating duplicate for checksum constraint`); + throw new InternalServerErrorException(); + } + return { status: AssetMediaStatusEnum.DUPLICATE, id: duplicateId }; + } + + this.logger.error(`Error uploading file ${error}`, error?.stack); + throw error; + } + + /** + * Updates the specified assetId to the specified photo data file properties: checksum, path, + * timestamps, deviceIds, and sidecar. Derived properties like: faces, smart search info, etc + * are UNTOUCHED. The photo data files modification times on the filesysytem are updated to + * the specified timestamps. The exif db record is upserted, and then A METADATA_EXTRACTION + * job is queued to update these derived properties. + */ + private async replaceFileData( + assetId: string, + dto: AssetMediaReplaceDto, + file: UploadFile, + sidecarPath?: string, + ): Promise { + await this.assetRepository.update({ + id: assetId, + + checksum: file.checksum, + originalPath: file.originalPath, + type: mimeTypes.assetType(file.originalPath), + originalFileName: file.originalName, + + deviceAssetId: dto.deviceAssetId, + deviceId: dto.deviceId, + fileCreatedAt: dto.fileCreatedAt, + fileModifiedAt: dto.fileModifiedAt, + localDateTime: dto.fileCreatedAt, + duration: dto.duration || null, + + livePhotoVideo: null, + sidecarPath: sidecarPath || null, + }); + + await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); + await this.assetRepository.upsertExif({ assetId, fileSizeInByte: file.size }); + await this.jobRepository.queue({ + name: JobName.METADATA_EXTRACTION, + data: { id: assetId, source: 'upload' }, + }); + } + + /** + * Create a 'shallow' copy of the specified asset record creating a new asset record in the database. + * Uses only vital properties excluding things like: stacks, faces, smart search info, etc, + * and then queues a METADATA_EXTRACTION job. + */ + private async createCopy(asset: AssetEntity): Promise { + const created = await this.assetRepository.create({ + ownerId: asset.ownerId, + originalPath: asset.originalPath, + originalFileName: asset.originalFileName, + libraryId: asset.libraryId, + deviceAssetId: asset.deviceAssetId, + deviceId: asset.deviceId, + type: asset.type, + checksum: asset.checksum, + fileCreatedAt: asset.fileCreatedAt, + localDateTime: asset.localDateTime, + fileModifiedAt: asset.fileModifiedAt, + livePhotoVideoId: asset.livePhotoVideoId, + sidecarPath: asset.sidecarPath, + }); + + const { size } = await this.storageRepository.stat(created.originalPath); + await this.assetRepository.upsertExif({ assetId: created.id, fileSizeInByte: size }); + await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: created.id, source: 'copy' } }); + return created; + } + + private requireQuota(auth: AuthDto, size: number) { + if (auth.user.quotaSizeInBytes && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) { + throw new BadRequestException('Quota has been exceeded!'); + } + } +} diff --git a/server/src/services/asset-v1.service.ts b/server/src/services/asset-v1.service.ts index 6868ca2dad..9346204506 100644 --- a/server/src/services/asset-v1.service.ts +++ b/server/src/services/asset-v1.service.ts @@ -33,7 +33,7 @@ import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; -import { UploadFile } from 'src/services/asset.service'; +import { UploadFile } from 'src/services/asset-media.service'; import { CacheControl, ImmichFileResponse, getLivePhotoMotionFilename } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { fromChecksum } from 'src/utils/request'; diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index c37d8d8b65..053f4ba987 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -46,24 +46,11 @@ import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { UploadRequest } from 'src/services/asset-media.service'; import { mimeTypes } from 'src/utils/mime-types'; import { usePagination } from 'src/utils/pagination'; import { fromChecksum } from 'src/utils/request'; -export interface UploadRequest { - auth: AuthDto | null; - fieldName: UploadFieldName; - file: UploadFile; -} - -export interface UploadFile { - uuid: string; - checksum: Buffer; - originalPath: string; - originalName: string; - size: number; -} - export class AssetService { private access: AccessCore; private configCore: SystemConfigCore; diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 76fe7244c0..5ea16d9e4b 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -2,6 +2,7 @@ import { ActivityService } from 'src/services/activity.service'; import { AlbumService } from 'src/services/album.service'; import { APIKeyService } from 'src/services/api-key.service'; import { ApiService } from 'src/services/api.service'; +import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetServiceV1 } from 'src/services/asset-v1.service'; import { AssetService } from 'src/services/asset.service'; import { AuditService } from 'src/services/audit.service'; @@ -41,6 +42,7 @@ export const services = [ APIKeyService, ActivityService, AlbumService, + AssetMediaService, AssetService, AssetServiceV1, AuditService, diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 8504631d4d..dabbf4259b 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -250,7 +250,7 @@ export class JobService { } case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: { - if (item.data.source === 'upload') { + if (item.data.source === 'upload' || item.data.source === 'copy') { await this.jobRepository.queue({ name: JobName.GENERATE_PREVIEW, data: item.data }); } break; diff --git a/server/test/fixtures/file.stub.ts b/server/test/fixtures/file.stub.ts index c2c40633d9..f63d2d10fb 100644 --- a/server/test/fixtures/file.stub.ts +++ b/server/test/fixtures/file.stub.ts @@ -13,4 +13,19 @@ export const fileStub = { originalName: 'asset_1.mp4', size: 69, }), + photo: Object.freeze({ + uuid: 'photo', + originalPath: 'fake_path/photo1.jpeg', + mimeType: 'image/jpeg', + checksum: Buffer.from('photo file hash', 'utf8'), + originalName: 'photo1.jpeg', + size: 24, + }), + photoSidecar: Object.freeze({ + uuid: 'photo-sidecar', + originalPath: 'fake_path/photo1.jpeg.xmp', + originalName: 'photo1.jpeg.xmp', + checksum: Buffer.from('photo-sidecar file hash', 'utf8'), + size: 96, + }), }; diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 07fcc58834..43fbe8e7e3 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -75,7 +75,7 @@ {#if sharedLink.allowUpload} openFileUploadDialog(album.id)} + on:click={() => openFileUploadDialog({ albumId: album.id })} icon={mdiFileImagePlusOutline} /> {/if} diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 731148dd40..135886d978 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -5,7 +5,8 @@ import { getAssetJobName } from '$lib/utils'; import { clickOutside } from '$lib/actions/click-outside'; import { getContextMenuPosition } from '$lib/utils/context-menu'; - import { AssetJobName, AssetTypeEnum, type AssetResponseDto, type AlbumResponseDto } from '@immich/sdk'; + import { openFileUploadDialog } from '$lib/utils/file-uploader'; + import { AssetJobName, AssetTypeEnum, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk'; import { mdiAccountCircleOutline, mdiAlertOutline, @@ -32,6 +33,7 @@ mdiPlaySpeed, mdiPresentationPlay, mdiShareVariantOutline, + mdiUpload, } from '@mdi/js'; import { createEventDispatcher } from 'svelte'; import ContextMenu from '../shared-components/context-menu/context-menu.svelte'; @@ -243,6 +245,11 @@ icon={asset.isArchived ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline} text={asset.isArchived ? 'Unarchive' : 'Archive'} /> + openFileUploadDialog({ multiple: false, assetId: asset.id })} + text="Replace with upload" + />
void; $: isFullScreen = fullscreenElement !== null; $: { @@ -192,6 +193,11 @@ } onMount(async () => { + unsubscribe = websocketEvents.on('on_upload_success', (assetUpdate) => { + if (assetUpdate.id === asset.id) { + asset = assetUpdate; + } + }); await navigate({ targetRoute: 'current', assetId: asset.id }); slideshowStateUnsubscribe = slideshowState.subscribe((value) => { if (value === SlideshowState.PlaySlideshow) { @@ -237,6 +243,7 @@ if (shuffleSlideshowUnsubscribe) { shuffleSlideshowUnsubscribe(); } + unsubscribe?.(); }); $: asset.id && !sharedLink && handlePromiseError(handleGetAllAlbums()); // Update the album information when the asset ID changes @@ -633,6 +640,7 @@ {:else} Promise; let canCopyImagesToClipboard: () => boolean; let imageLoaded: boolean = false; + let imageError: boolean = false; + // set to true when am image has been zoomed, to force loading of the original image regardless + // of app settings + let forceLoadOriginal: boolean = false; const loadOriginalByDefault = $alwaysLoadOriginalFile && isWebCompatibleImage(asset); @@ -40,60 +42,53 @@ }); } + $: { + preload({ preloadAssets, loadOriginal: loadOriginalByDefault }); + } + + $: assetFileUrl = load(asset.id, !loadOriginalByDefault || forceLoadOriginal, false, asset.checksum); + onMount(async () => { // Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295 // TODO: Move to regular import once the package correctly supports ESM. const module = await import('copy-image-clipboard'); copyImageToClipboard = module.copyImageToClipboard; canCopyImagesToClipboard = module.canCopyImagesToClipboard; - - imageLoaded = false; - await loadAssetData({ loadOriginal: loadOriginalByDefault }); }); onDestroy(() => { $boundingBoxesArray = []; - abortController?.abort(); }); - const loadAssetData = async ({ loadOriginal }: { loadOriginal: boolean }) => { - try { - abortController?.abort(); - abortController = new AbortController(); - - // TODO: Use sdk once it supports signals - const { data } = await downloadRequest({ - url: getAssetFileUrl(asset.id, !loadOriginal, false), - signal: abortController.signal, - }); - - assetData = URL.createObjectURL(data); - imageLoaded = true; - - if (!preloadAssets) { - return; + const preload = ({ + preloadAssets, + loadOriginal, + }: { + preloadAssets: AssetResponseDto[] | null; + loadOriginal: boolean; + }) => { + for (const preloadAsset of preloadAssets || []) { + if (preloadAsset.type === AssetTypeEnum.Image) { + let img = new Image(); + img.src = getAssetFileUrl(preloadAsset.id, !loadOriginal, false, preloadAsset.checksum); } - - for (const preloadAsset of preloadAssets) { - if (preloadAsset.type === AssetTypeEnum.Image) { - await downloadRequest({ - url: getAssetFileUrl(preloadAsset.id, !loadOriginal, false), - signal: abortController.signal, - }); - } - } - } catch { - imageLoaded = false; } }; + const load = (assetId: string, isWeb: boolean, isThumb: boolean, checksum: string) => { + const assetUrl = getAssetFileUrl(assetId, isWeb, isThumb, checksum); + // side effect, only flag imageLoaded when url is different + imageLoaded = assetFileUrl === assetUrl; + return assetUrl; + }; + const doCopy = async () => { if (!canCopyImagesToClipboard()) { return; } try { - await copyImageToClipboard(assetData); + await copyImageToClipboard(assetFileUrl); notificationController.show({ type: NotificationType.Info, message: 'Copied image to clipboard.', @@ -122,12 +117,7 @@ zoomImageWheelState.subscribe((state) => { photoZoomState.set(state); - - if (state.currentZoom > 1 && isWebCompatibleImage(asset) && !hasZoomed && !$alwaysLoadOriginalFile) { - hasZoomed = true; - - handlePromiseError(loadAssetData({ loadOriginal: true })); - } + forceLoadOriginal = state.currentZoom > 1 && isWebCompatibleImage(asset) ? true : false; }); const onCopyShortcut = () => { @@ -146,41 +136,53 @@ { shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut }, ]} /> - -
+{#if imageError} +
Error loading image
+{/if} +
+ {getAltText(asset)} (imageLoaded = true)} + on:error={() => (imageError = imageLoaded = true)} + /> {#if !imageLoaded} -
+
- {:else} -
- {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} + {:else if !imageError} + {#key assetFileUrl} +
+ {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} + {getAltText(asset)} + {/if} {getAltText(asset)} - {/if} - {getAltText(asset)} - {#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox} -
- {/each} -
+ {#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox} +
+ {/each} +
+ {/key} {/if}
diff --git a/web/src/lib/components/asset-viewer/video-native-viewer.svelte b/web/src/lib/components/asset-viewer/video-native-viewer.svelte index 7863513bfb..0f68964dbc 100644 --- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte @@ -9,9 +9,19 @@ export let assetId: string; export let loopVideo: boolean; + export let checksum: string; let element: HTMLVideoElement | undefined = undefined; let isVideoLoading = true; + let assetFileUrl: string; + + $: { + const next = getAssetFileUrl(assetId, false, true, checksum); + if (assetFileUrl !== next) { + assetFileUrl = next; + element && element.load(); + } + } const dispatch = createEventDispatcher<{ onVideoEnded: void; onVideoStarted: void }>(); @@ -44,9 +54,9 @@ on:ended={() => dispatch('onVideoEnded')} bind:muted={$videoViewerMuted} bind:volume={$videoViewerVolume} - poster={getAssetThumbnailUrl(assetId, ThumbnailFormat.Jpeg)} + poster={getAssetThumbnailUrl(assetId, ThumbnailFormat.Jpeg, checksum)} > - + diff --git a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte index be4a9d5150..129b6c8be7 100644 --- a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte @@ -6,11 +6,12 @@ export let assetId: string; export let projectionType: string | null | undefined; + export let checksum: string; export let loopVideo: boolean; {#if projectionType === ProjectionType.EQUIRECTANGULAR} {:else} - + {/if} diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 2cfb28674f..1ee4463756 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -180,7 +180,7 @@ {#if asset.resized} { interface UploadRequestOptions { url: string; + method?: 'POST' | 'PUT'; data: FormData; onUploadProgress?: (event: ProgressEvent) => void; } @@ -64,7 +65,7 @@ export const uploadRequest = async (options: UploadRequestOptions): Promise<{ xhr.upload.addEventListener('progress', (event) => onProgress(event)); } - xhr.open('POST', url); + xhr.open(options.method || 'POST', url); xhr.responseType = 'json'; xhr.send(data); }); @@ -158,18 +159,28 @@ const createUrl = (path: string, parameters?: Record) => { return getBaseUrl() + url.pathname + url.search + url.hash; }; -export const getAssetFileUrl = (...[assetId, isWeb, isThumb]: [string, boolean, boolean]) => { +export const getAssetFileUrl = ( + ...[assetId, isWeb, isThumb, checksum]: + | [assetId: string, isWeb: boolean, isThumb: boolean] + | [assetId: string, isWeb: boolean, isThumb: boolean, checksum: string] +) => { const path = `/asset/file/${assetId}`; - return createUrl(path, { isThumb, isWeb, key: getKey() }); + return createUrl(path, { isThumb, isWeb, key: getKey(), c: checksum }); }; -export const getAssetThumbnailUrl = (...[assetId, format]: [string, ThumbnailFormat | undefined]) => { +export const getAssetThumbnailUrl = ( + ...[assetId, format, checksum]: + | [assetId: string, format: ThumbnailFormat | undefined] + | [assetId: string, format: ThumbnailFormat | undefined, checksum: string] +) => { + // checksum (optional) is used as a cache-buster param, since thumbs are + // served with static resource cache headers const path = `/asset/thumbnail/${assetId}`; - return createUrl(path, { format, key: getKey() }); + return createUrl(path, { format, key: getKey(), c: checksum }); }; export const getProfileImageUrl = (...[userId]: [string]) => { - const path = `/users/${userId}/profile-image`; + const path = `/users/profile-image/${userId}`; return createUrl(path); }; diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts index 9472364d09..d1a0fce8d4 100644 --- a/web/src/lib/utils/file-uploader.ts +++ b/web/src/lib/utils/file-uploader.ts @@ -5,10 +5,12 @@ import { addAssetsToAlbum } from '$lib/utils/asset-utils'; import { ExecutorQueue } from '$lib/utils/executor-queue'; import { Action, + AssetMediaStatus, checkBulkUpload, getBaseUrl, getSupportedMediaTypes, type AssetFileUploadResponseDto, + type AssetMediaResponseDto, } from '@immich/sdk'; import { tick } from 'svelte'; import { getServerErrorMessage, handleError } from './handle-error'; @@ -25,7 +27,12 @@ const getExtensions = async () => { return _extensions; }; -export const openFileUploadDialog = async (albumId?: string | undefined) => { +type FileUploadParam = { multiple?: boolean } & ( + | { albumId?: string; assetId?: never } + | { albumId?: never; assetId?: string } +); +export const openFileUploadDialog = async (options?: FileUploadParam) => { + const { albumId, multiple, assetId } = options || { multiple: true }; const extensions = await getExtensions(); return new Promise<(string | undefined)[]>((resolve, reject) => { @@ -33,7 +40,7 @@ export const openFileUploadDialog = async (albumId?: string | undefined) => { const fileSelector = document.createElement('input'); fileSelector.type = 'file'; - fileSelector.multiple = true; + fileSelector.multiple = !!multiple; fileSelector.accept = extensions.join(','); fileSelector.addEventListener('change', (e: Event) => { const target = e.target as HTMLInputElement; @@ -42,7 +49,7 @@ export const openFileUploadDialog = async (albumId?: string | undefined) => { } const files = Array.from(target.files); - resolve(fileUploadHandler(files, albumId)); + resolve(fileUploadHandler(files, albumId, assetId)); }); fileSelector.click(); @@ -53,14 +60,14 @@ export const openFileUploadDialog = async (albumId?: string | undefined) => { }); }; -export const fileUploadHandler = async (files: File[], albumId: string | undefined = undefined): Promise => { +export const fileUploadHandler = async (files: File[], albumId?: string, assetId?: string): Promise => { const extensions = await getExtensions(); const promises = []; for (const file of files) { const name = file.name.toLowerCase(); if (extensions.some((extension) => name.endsWith(extension))) { - uploadAssetsStore.addNewUploadAsset({ id: getDeviceAssetId(file), file, albumId }); - promises.push(uploadExecutionQueue.addTask(() => fileUploader(file, albumId))); + uploadAssetsStore.addNewUploadAsset({ id: getDeviceAssetId(file), file, albumId, assetId }); + promises.push(uploadExecutionQueue.addTask(() => fileUploader(file, albumId, assetId))); } } @@ -73,9 +80,9 @@ function getDeviceAssetId(asset: File) { } // TODO: should probably use the @api SDK -async function fileUploader(asset: File, albumId: string | undefined = undefined): Promise { - const fileCreatedAt = new Date(asset.lastModified).toISOString(); - const deviceAssetId = getDeviceAssetId(asset); +async function fileUploader(assetFile: File, albumId?: string, replaceAssetId?: string): Promise { + const fileCreatedAt = new Date(assetFile.lastModified).toISOString(); + const deviceAssetId = getDeviceAssetId(assetFile); uploadAssetsStore.markStarted(deviceAssetId); @@ -85,21 +92,21 @@ async function fileUploader(asset: File, albumId: string | undefined = undefined deviceAssetId, deviceId: 'WEB', fileCreatedAt, - fileModifiedAt: new Date(asset.lastModified).toISOString(), + fileModifiedAt: new Date(assetFile.lastModified).toISOString(), isFavorite: 'false', duration: '0:00:00.000000', - assetData: new File([asset], asset.name), + assetData: new File([assetFile], assetFile.name), })) { formData.append(key, value); } - let responseData: AssetFileUploadResponseDto | undefined; + let responseData: AssetMediaResponseDto | undefined; const key = getKey(); if (crypto?.subtle?.digest && !key) { uploadAssetsStore.updateAsset(deviceAssetId, { message: 'Hashing...' }); await tick(); try { - const bytes = await asset.arrayBuffer(); + const bytes = await assetFile.arrayBuffer(); const hash = await crypto.subtle.digest('SHA-1', bytes); const checksum = Array.from(new Uint8Array(hash)) .map((b) => b.toString(16).padStart(2, '0')) @@ -107,48 +114,64 @@ async function fileUploader(asset: File, albumId: string | undefined = undefined const { results: [checkUploadResult], - } = await checkBulkUpload({ assetBulkUploadCheckDto: { assets: [{ id: asset.name, checksum }] } }); + } = await checkBulkUpload({ assetBulkUploadCheckDto: { assets: [{ id: assetFile.name, checksum }] } }); if (checkUploadResult.action === Action.Reject && checkUploadResult.assetId) { - responseData = { duplicate: true, id: checkUploadResult.assetId }; + responseData = { status: AssetMediaStatus.Duplicate, id: checkUploadResult.assetId }; } } catch (error) { - console.error(`Error calculating sha1 file=${asset.name})`, error); + console.error(`Error calculating sha1 file=${assetFile.name})`, error); } } + let status; + let id; if (!responseData) { uploadAssetsStore.updateAsset(deviceAssetId, { message: 'Uploading...' }); - const response = await uploadRequest({ - url: getBaseUrl() + '/asset/upload' + (key ? `?key=${key}` : ''), - data: formData, - onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total), - }); - if (![200, 201].includes(response.status)) { - throw new Error('Failed to upload file'); + if (replaceAssetId) { + const response = await uploadRequest({ + url: getBaseUrl() + '/asset/' + replaceAssetId + '/file' + (key ? `?key=${key}` : ''), + method: 'PUT', + data: formData, + onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total), + }); + ({ status, id } = response.data); + } else { + const response = await uploadRequest({ + url: getBaseUrl() + '/asset/upload' + (key ? `?key=${key}` : ''), + data: formData, + onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total), + }); + if (![200, 201].includes(response.status)) { + throw new Error('Failed to upload file'); + } + if (response.data.duplicate) { + status = AssetMediaStatus.Duplicate; + } else { + id = response.data.id; + } } - responseData = response.data; } - const { duplicate, id: assetId } = responseData; - if (duplicate) { + if (status === AssetMediaStatus.Duplicate) { uploadAssetsStore.duplicateCounter.update((count) => count + 1); } else { uploadAssetsStore.successCounter.update((c) => c + 1); + if (albumId && id) { + uploadAssetsStore.updateAsset(deviceAssetId, { message: 'Adding to album...' }); + await addAssetsToAlbum(albumId, [id]); + uploadAssetsStore.updateAsset(deviceAssetId, { message: 'Added to album' }); + } } - if (albumId && assetId) { - uploadAssetsStore.updateAsset(deviceAssetId, { message: 'Adding to album...' }); - await addAssetsToAlbum(albumId, [assetId]); - uploadAssetsStore.updateAsset(deviceAssetId, { message: 'Added to album' }); - } - - uploadAssetsStore.updateAsset(deviceAssetId, { state: duplicate ? UploadState.DUPLICATED : UploadState.DONE }); + uploadAssetsStore.updateAsset(deviceAssetId, { + state: status === AssetMediaStatus.Duplicate ? UploadState.DUPLICATED : UploadState.DONE, + }); setTimeout(() => { uploadAssetsStore.removeUploadAsset(deviceAssetId); }, 1000); - return assetId; + return id; } catch (error) { handleError(error, 'Unable to upload file'); const reason = getServerErrorMessage(error) || error; diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index f157f75c91..d63776342c 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -314,7 +314,7 @@ }; const handleSelectFromComputer = async () => { - await openFileUploadDialog(album.id); + await openFileUploadDialog({ albumId: album.id }); timelineInteractionStore.clearMultiselect(); viewMode = ViewMode.VIEW; }; diff --git a/web/src/routes/auth/login/+page.ts b/web/src/routes/auth/login/+page.ts index c24eea46bc..e308e117f7 100644 --- a/web/src/routes/auth/login/+page.ts +++ b/web/src/routes/auth/login/+page.ts @@ -1,9 +1,10 @@ import { AppRoute } from '$lib/constants'; -import { getServerConfig } from '@immich/sdk'; +import { defaults, getServerConfig } from '@immich/sdk'; import { redirect } from '@sveltejs/kit'; import type { PageLoad } from './$types'; -export const load = (async () => { +export const load = (async ({ fetch }) => { + defaults.fetch = fetch; const { isInitialized } = await getServerConfig(); if (!isInitialized) { // Admin not registered