Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion server/src/repositories/asset.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,12 +327,42 @@ export class AssetRepository {
});
}

/**
* Insert an asset, gracefully handling duplicate checksums via ON CONFLICT.
* Returns `undefined` when the asset already exists for this owner (same checksum,
* no library), rather than throwing a constraint violation. Callers must handle
* the `undefined` case to look up and return the existing duplicate.
*/
create(asset: Insertable<AssetTable>) {
return this.db
.insertInto('asset')
.values(asset)
.onConflict((oc) => oc.columns(['ownerId', 'checksum']).where('libraryId', 'is', null).doNothing())
.returningAll()
.executeTakeFirst();
}

/**
* Insert an asset, throwing on any constraint violation (including duplicate checksum).
* Use this for operations where a conflict is a real error, such as creating backup
* copies during asset replacement.
*/
createStrict(asset: Insertable<AssetTable>) {
return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow();
}

/**
* Batch-insert assets, skipping any that violate the owner+checksum uniqueness
* constraint. The returned array may be shorter than the input when duplicates
* are silently skipped.
*/
createAll(assets: Insertable<AssetTable>[]) {
return this.db.insertInto('asset').values(assets).returningAll().execute();
return this.db
.insertInto('asset')
.values(assets)
.onConflict((oc) => oc.columns(['ownerId', 'checksum']).where('libraryId', 'is', null).doNothing())
.returningAll()
.execute();
}

@GenerateSql({ params: [DummyValue.UUID, { year: 2000, day: 1, month: 1 }] })
Expand Down
26 changes: 13 additions & 13 deletions server/src/services/asset-media.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ describe(AssetMediaService.name, () => {
);
});

it('should handle a duplicate', async () => {
it('should handle a duplicate via ON CONFLICT', async () => {
const file = {
uuid: 'random-uuid',
originalPath: 'fake_path/asset_1.jpeg',
Expand All @@ -382,10 +382,9 @@ describe(AssetMediaService.name, () => {
originalName: 'asset_1.jpeg',
size: 0,
};
const error = new Error('unique key violation');
(error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;

mocks.asset.create.mockRejectedValue(error);
// eslint-disable-next-line unicorn/no-useless-undefined
mocks.asset.create.mockResolvedValue(undefined);
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue(assetEntity.id);

await expect(sut.uploadAsset(authStub.user1, createDto, file)).resolves.toEqual({
Expand All @@ -409,10 +408,11 @@ describe(AssetMediaService.name, () => {
originalName: 'asset_1.jpeg',
size: 0,
};
const error = new Error('unique key violation');
(error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;

mocks.asset.create.mockRejectedValue(error);
// eslint-disable-next-line unicorn/no-useless-undefined
mocks.asset.create.mockResolvedValue(undefined);
// eslint-disable-next-line unicorn/no-useless-undefined
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue(undefined);

await expect(sut.uploadAsset(authStub.user1, createDto, file)).rejects.toBeInstanceOf(
InternalServerErrorException,
Expand Down Expand Up @@ -844,7 +844,7 @@ describe(AssetMediaService.name, () => {
// this is the original file size
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats);
// this is for the clone call
mocks.asset.create.mockResolvedValue(copiedAsset);
mocks.asset.createStrict.mockResolvedValue(copiedAsset);

await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({
status: AssetMediaStatus.REPLACED,
Expand All @@ -858,7 +858,7 @@ describe(AssetMediaService.name, () => {
originalPath: 'fake_path/photo1.jpeg',
}),
);
expect(mocks.asset.create).toHaveBeenCalledWith(
expect(mocks.asset.createStrict).toHaveBeenCalledWith(
expect.objectContaining({
originalFileName: 'existing-filename.jpeg',
originalPath: 'fake_path/asset_1.jpeg',
Expand Down Expand Up @@ -893,7 +893,7 @@ describe(AssetMediaService.name, () => {
// this is the original file size
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats);
// this is for the clone call
mocks.asset.create.mockResolvedValue(copiedAsset);
mocks.asset.createStrict.mockResolvedValue(copiedAsset);

await expect(
sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile, sidecarFile),
Expand Down Expand Up @@ -931,7 +931,7 @@ describe(AssetMediaService.name, () => {
// this is the original file size
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats);
// this is for the copy call
mocks.asset.create.mockResolvedValue(copiedAsset);
mocks.asset.createStrict.mockResolvedValue(copiedAsset);

await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({
status: AssetMediaStatus.REPLACED,
Expand Down Expand Up @@ -968,14 +968,14 @@ describe(AssetMediaService.name, () => {
// this is the original file size
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats);
// this is for the clone call
mocks.asset.create.mockResolvedValue(copiedAsset);
mocks.asset.createStrict.mockResolvedValue(copiedAsset);

await expect(sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile)).resolves.toEqual({
status: AssetMediaStatus.DUPLICATE,
id: sidecarAsset.id,
});

expect(mocks.asset.create).not.toHaveBeenCalled();
expect(mocks.asset.createStrict).not.toHaveBeenCalled();
expect(mocks.asset.updateAll).not.toHaveBeenCalled();
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
expect(mocks.asset.deleteFile).not.toHaveBeenCalled();
Expand Down
33 changes: 28 additions & 5 deletions server/src/services/asset-media.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,20 @@ export class AssetMediaService extends BaseService {
{ userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId },
);
}
const asset = await this.create(auth.user.id, dto, file, sidecarFile);

const result = await this.create(auth.user.id, dto, file, sidecarFile);

if (result.duplicate) {
await this.jobRepository.queue({
name: JobName.FileDelete,
data: { files: [file.originalPath, sidecarFile?.originalPath] },
});
return { id: result.id, status: AssetMediaStatus.DUPLICATE };
}

await this.userRepository.updateUsage(auth.user.id, file.size);

return { id: asset.id, status: AssetMediaStatus.CREATED };
return { id: result.id, status: AssetMediaStatus.CREATED };
} catch (error: any) {
return this.handleUploadError(error, auth, file, sidecarFile);
}
Expand Down Expand Up @@ -400,7 +409,7 @@ export class AssetMediaService extends BaseService {
* and then queues a METADATA_EXTRACTION job.
*/
private async createCopy(asset: Omit<Asset, 'id'>) {
const created = await this.assetRepository.create({
const created = await this.assetRepository.createStrict({
ownerId: asset.ownerId,
originalPath: asset.originalPath,
originalFileName: asset.originalFileName,
Expand All @@ -424,7 +433,12 @@ export class AssetMediaService extends BaseService {
return created;
}

private async create(ownerId: string, dto: AssetMediaCreateDto, file: UploadFile, sidecarFile?: UploadFile) {
private async create(
ownerId: string,
dto: AssetMediaCreateDto,
file: UploadFile,
sidecarFile?: UploadFile,
): Promise<{ id: string; duplicate: boolean }> {
const asset = await this.assetRepository.create({
ownerId,
libraryId: null,
Expand All @@ -447,6 +461,15 @@ export class AssetMediaService extends BaseService {
originalFileName: dto.filename || file.originalName,
});

if (!asset) {
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(ownerId, file.checksum);
if (!duplicateId) {
this.logger.error(`Error locating duplicate for checksum constraint`);
throw new InternalServerErrorException();
}
return { id: duplicateId, duplicate: true };
}

if (dto.metadata?.length) {
await this.assetRepository.upsertMetadata(asset.id, dto.metadata);
}
Expand All @@ -469,7 +492,7 @@ export class AssetMediaService extends BaseService {

await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } });

return asset;
return { id: asset.id, duplicate: false };
}

private requireQuota(auth: AuthDto, size: number) {
Expand Down
43 changes: 20 additions & 23 deletions server/src/services/metadata.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { PersonTable } from 'src/schema/tables/person.table';
import { BaseService } from 'src/services/base.service';
import { JobItem, JobOf } from 'src/types';
import { getAssetFiles } from 'src/utils/asset.util';
import { isAssetChecksumConstraint } from 'src/utils/database';

import { mimeTypes } from 'src/utils/mime-types';
import { isFaceImportEnabled } from 'src/utils/misc';
import { upsertTags } from 'src/utils/tag';
Expand Down Expand Up @@ -641,34 +641,31 @@ export class MetadataService extends BaseService {
let isNewMotionAsset = false;

if (!motionAsset) {
try {
const motionAssetId = this.cryptoRepository.randomUUID();
motionAsset = await this.assetRepository.create({
id: motionAssetId,
libraryId: asset.libraryId,
type: AssetType.Video,
fileCreatedAt: dates.dateTimeOriginal,
fileModifiedAt: stats.mtime,
localDateTime: dates.localDateTime,
checksum,
ownerId: asset.ownerId,
originalPath: StorageCore.getAndroidMotionPath(asset, motionAssetId),
originalFileName: `${parse(asset.originalFileName).name}.mp4`,
visibility: AssetVisibility.Hidden,
deviceAssetId: 'NONE',
deviceId: 'NONE',
});
const motionAssetId = this.cryptoRepository.randomUUID();
const created = await this.assetRepository.create({
id: motionAssetId,
libraryId: asset.libraryId,
type: AssetType.Video,
fileCreatedAt: dates.dateTimeOriginal,
fileModifiedAt: stats.mtime,
localDateTime: dates.localDateTime,
checksum,
ownerId: asset.ownerId,
originalPath: StorageCore.getAndroidMotionPath(asset, motionAssetId),
originalFileName: `${parse(asset.originalFileName).name}.mp4`,
visibility: AssetVisibility.Hidden,
deviceAssetId: 'NONE',
deviceId: 'NONE',
});

if (created) {
motionAsset = created;
isNewMotionAsset = true;

if (!asset.isExternal) {
await this.userRepository.updateUsage(asset.ownerId, video.byteLength);
}
} catch (error) {
if (!isAssetChecksumConstraint(error)) {
throw error;
}

} else {
motionAsset = await this.assetRepository.getByChecksum(checksumQuery);
if (!motionAsset) {
this.logger.warn(`Unable to find existing motion video asset for ${asset.id}: ${asset.originalPath}`);
Expand Down
Loading
Loading