feat: API operation replaceAsset, POST /api/asset/:id/file (#9684)

* impl and unit tests for replaceAsset

* Remove it.only

* Typo in generated spec +regen

* Remove unused dtos

* Dto removal fallout/bugfix

* fix - missed a line

* sql:generate

* Review comments

* Unused imports

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Min Idzelis 2024-05-23 20:26:22 -04:00 committed by GitHub
parent 76fdcc9863
commit 4f21f6a2e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 952 additions and 150 deletions

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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",

View file

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

View file

@ -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<AssetMediaResponseDto> {
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;
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -111,7 +111,10 @@ export type AssetWithoutRelations = Omit<
| 'tags'
>;
export type AssetUpdateOptions = Pick<AssetWithoutRelations, 'id'> & Partial<AssetWithoutRelations>;
type AssetUpdateWithoutRelations = Pick<AssetWithoutRelations, 'id'> & Partial<AssetWithoutRelations>;
type AssetUpdateWithLivePhotoRelation = Pick<AssetWithoutRelations, 'id'> & Pick<AssetEntity, 'livePhotoVideo'>;
export type AssetUpdateOptions = AssetUpdateWithoutRelations | AssetUpdateWithLivePhotoRelation;
export type AssetUpdateAllOptions = Omit<Partial<AssetWithoutRelations>, 'id'>;

View file

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

View file

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

View file

@ -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<IAssetRepository>;
let jobMock: Mocked<IJobRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let storageMock: Mocked<IStorageRepository>;
let userMock: Mocked<IUserRepository>;
let eventMock: Mocked<IEventRepository>;
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();
});
});
});

View file

@ -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<AssetMediaResponseDto> {
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<AssetMediaResponseDto> {
// 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<void> {
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<AssetEntity> {
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!');
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -75,7 +75,7 @@
{#if sharedLink.allowUpload}
<CircleIconButton
title="Add Photos"
on:click={() => openFileUploadDialog(album.id)}
on:click={() => openFileUploadDialog({ albumId: album.id })}
icon={mdiFileImagePlusOutline}
/>
{/if}

View file

@ -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'}
/>
<MenuOption
icon={mdiUpload}
on:click={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
text="Replace with upload"
/>
<hr />
<MenuOption
icon={mdiDatabaseRefreshOutline}

View file

@ -52,6 +52,7 @@
import SlideshowBar from './slideshow-bar.svelte';
import VideoViewer from './video-wrapper-viewer.svelte';
import { navigate } from '$lib/utils/navigation';
import { websocketEvents } from '$lib/stores/websocket';
export let assetStore: AssetStore | null = null;
export let asset: AssetResponseDto;
@ -98,7 +99,7 @@
let isLiked: ActivityResponseDto | null = null;
let numberOfComments: number;
let fullscreenElement: Element;
let unsubscribe: () => 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}
<VideoViewer
assetId={previewStackedAsset.id}
checksum={previewStackedAsset.checksum}
projectionType={previewStackedAsset.exifInfo?.projectionType}
loopVideo={true}
on:close={closeViewer}
@ -655,6 +663,7 @@
{#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
<VideoViewer
assetId={asset.livePhotoVideoId}
checksum={asset.checksum}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
on:close={closeViewer}
@ -670,6 +679,7 @@
{:else}
<VideoViewer
assetId={asset.id}
checksum={asset.checksum}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
on:close={closeViewer}

View file

@ -3,7 +3,7 @@
import { boundingBoxesArray } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { downloadRequest, getAssetFileUrl, handlePromiseError } from '$lib/utils';
import { getAssetFileUrl, handlePromiseError } from '$lib/utils';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { getBoundingBox } from '$lib/utils/people-utils';
import { shortcuts } from '$lib/actions/shortcut';
@ -24,12 +24,14 @@
export let haveFadeTransition = true;
let imgElement: HTMLDivElement;
let assetData: string;
let abortController: AbortController;
let hasZoomed = false;
let assetFileUrl: string = '';
let copyImageToClipboard: (source: string) => Promise<Blob>;
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 },
]}
/>
<div
bind:this={element}
transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
class="relative h-full select-none"
>
{#if imageError}
<div class="h-full flex items-center justify-center">Error loading image</div>
{/if}
<div bind:this={element} class="relative h-full select-none">
<img
style="display:none"
src={assetFileUrl}
alt={getAltText(asset)}
on:load={() => (imageLoaded = true)}
on:error={() => (imageError = imageLoaded = true)}
/>
{#if !imageLoaded}
<div class="flex h-full items-center justify-center">
<div class:hidden={imageLoaded} class="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
{:else}
<div bind:this={imgElement} class="h-full w-full" transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}>
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
{:else if !imageError}
{#key assetFileUrl}
<div
bind:this={imgElement}
class:hidden={!imageLoaded}
class="h-full w-full"
transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
>
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
<img
src={assetFileUrl}
alt={getAltText(asset)}
class="absolute top-0 left-0 -z-10 object-cover h-full w-full blur-lg"
draggable="false"
/>
{/if}
<img
src={assetData}
bind:this={$photoViewer}
src={assetFileUrl}
alt={getAltText(asset)}
class="absolute top-0 left-0 -z-10 object-cover h-full w-full blur-lg"
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'
: slideshowLookCssMapping[$slideshowLook]}"
draggable="false"
/>
{/if}
<img
bind:this={$photoViewer}
src={assetData}
alt={getAltText(asset)}
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'
: slideshowLookCssMapping[$slideshowLook]}"
draggable="false"
/>
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox}
<div
class="absolute border-solid border-white border-[3px] rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
/>
{/each}
</div>
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox}
<div
class="absolute border-solid border-white border-[3px] rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
/>
{/each}
</div>
{/key}
{/if}
</div>

View file

@ -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)}
>
<source src={getAssetFileUrl(assetId, false, true)} type="video/mp4" />
<source src={assetFileUrl} type="video/mp4" />
<track kind="captions" />
</video>

View file

@ -6,11 +6,12 @@
export let assetId: string;
export let projectionType: string | null | undefined;
export let checksum: string;
export let loopVideo: boolean;
</script>
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
<PanoramaViewer asset={{ id: assetId, type: AssetTypeEnum.Video }} />
{:else}
<VideoNativeViewer {loopVideo} {assetId} on:onVideoEnded on:onVideoStarted />
<VideoNativeViewer {loopVideo} {checksum} {assetId} on:onVideoEnded on:onVideoStarted />
{/if}

View file

@ -180,7 +180,7 @@
{#if asset.resized}
<ImageThumbnail
url={getAssetThumbnailUrl(asset.id, format)}
url={getAssetThumbnailUrl(asset.id, format, asset.checksum)}
altText={getAltText(asset)}
widthStyle="{width}px"
heightStyle="{height}px"
@ -196,7 +196,7 @@
{#if asset.type === AssetTypeEnum.Video}
<div class="absolute top-0 h-full w-full">
<VideoThumbnail
url={getAssetFileUrl(asset.id, false, true)}
url={getAssetFileUrl(asset.id, false, true, asset.checksum)}
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
curve={selected}
durationInSeconds={timeToSeconds(asset.duration)}
@ -208,7 +208,7 @@
{#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
<div class="absolute top-0 h-full w-full">
<VideoThumbnail
url={getAssetFileUrl(asset.livePhotoVideoId, false, true)}
url={getAssetFileUrl(asset.livePhotoVideoId, false, true, asset.checksum)}
pauseIcon={mdiMotionPauseOutline}
playIcon={mdiMotionPlayOutline}
showTime={false}

View file

@ -10,6 +10,7 @@ export type UploadAsset = {
id: string;
file: File;
albumId?: string;
assetId?: string;
progress?: number;
state?: UploadState;
startDate?: number;

View file

@ -25,6 +25,7 @@ interface DownloadRequestOptions<T = unknown> {
interface UploadRequestOptions {
url: string;
method?: 'POST' | 'PUT';
data: FormData;
onUploadProgress?: (event: ProgressEvent<XMLHttpRequestEventTarget>) => void;
}
@ -64,7 +65,7 @@ export const uploadRequest = async <T>(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<string, unknown>) => {
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);
};

View file

@ -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<string[]> => {
export const fileUploadHandler = async (files: File[], albumId?: string, assetId?: string): Promise<string[]> => {
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<string | undefined> {
const fileCreatedAt = new Date(asset.lastModified).toISOString();
const deviceAssetId = getDeviceAssetId(asset);
async function fileUploader(assetFile: File, albumId?: string, replaceAssetId?: string): Promise<string | undefined> {
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<AssetFileUploadResponseDto>({
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<AssetMediaResponseDto>({
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<AssetFileUploadResponseDto>({
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;

View file

@ -314,7 +314,7 @@
};
const handleSelectFromComputer = async () => {
await openFileUploadDialog(album.id);
await openFileUploadDialog({ albumId: album.id });
timelineInteractionStore.clearMultiselect();
viewMode = ViewMode.VIEW;
};

View file

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