mirror of
https://github.com/immich-app/immich.git
synced 2025-07-09 03:04:16 -04:00
fix(server): live photo relation (#10637)
* fix(server): live photo relation * handle deletion and unit test * lint * chore: clean up and e2e tests * fix test * sql --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
parent
8ff9c37d79
commit
7e99394c70
@ -588,6 +588,58 @@ describe('/asset', () => {
|
|||||||
const after = await utils.getAssetInfo(admin.accessToken, assetId);
|
const after = await utils.getAssetInfo(admin.accessToken, assetId);
|
||||||
expect(after.isTrashed).toBe(true);
|
expect(after.isTrashed).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should clean up live photos', async () => {
|
||||||
|
const { id: motionId } = await utils.createAsset(admin.accessToken, {
|
||||||
|
assetData: { filename: 'test.mp4', bytes: makeRandomImage() },
|
||||||
|
});
|
||||||
|
const { id: photoId } = await utils.createAsset(admin.accessToken, { livePhotoVideoId: motionId });
|
||||||
|
|
||||||
|
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: photoId });
|
||||||
|
await utils.waitForWebsocketEvent({ event: 'assetHidden', id: motionId });
|
||||||
|
|
||||||
|
const asset = await utils.getAssetInfo(admin.accessToken, photoId);
|
||||||
|
expect(asset.livePhotoVideoId).toBe(motionId);
|
||||||
|
|
||||||
|
const { status } = await request(app)
|
||||||
|
.delete('/assets')
|
||||||
|
.send({ ids: [photoId], force: true })
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
expect(status).toBe(204);
|
||||||
|
|
||||||
|
await utils.waitForWebsocketEvent({ event: 'assetDelete', id: photoId });
|
||||||
|
await utils.waitForWebsocketEvent({ event: 'assetDelete', id: motionId });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not delete a shared motion asset', async () => {
|
||||||
|
const { id: motionId } = await utils.createAsset(admin.accessToken, {
|
||||||
|
assetData: { filename: 'test.mp4', bytes: makeRandomImage() },
|
||||||
|
});
|
||||||
|
const { id: asset1 } = await utils.createAsset(admin.accessToken, { livePhotoVideoId: motionId });
|
||||||
|
const { id: asset2 } = await utils.createAsset(admin.accessToken, { livePhotoVideoId: motionId });
|
||||||
|
|
||||||
|
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset1 });
|
||||||
|
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset2 });
|
||||||
|
await utils.waitForWebsocketEvent({ event: 'assetHidden', id: motionId });
|
||||||
|
|
||||||
|
const asset = await utils.getAssetInfo(admin.accessToken, asset1);
|
||||||
|
expect(asset.livePhotoVideoId).toBe(motionId);
|
||||||
|
|
||||||
|
const { status } = await request(app)
|
||||||
|
.delete('/assets')
|
||||||
|
.send({ ids: [asset1], force: true })
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
expect(status).toBe(204);
|
||||||
|
|
||||||
|
await utils.waitForWebsocketEvent({ event: 'assetDelete', id: asset1 });
|
||||||
|
await utils.waitForQueueFinish(admin.accessToken, 'backgroundTask');
|
||||||
|
|
||||||
|
await expect(utils.getAssetInfo(admin.accessToken, motionId)).resolves.toMatchObject({ id: motionId });
|
||||||
|
await expect(utils.getAssetInfo(admin.accessToken, asset2)).resolves.toMatchObject({
|
||||||
|
id: asset2,
|
||||||
|
livePhotoVideoId: motionId,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /assets/:id/thumbnail', () => {
|
describe('GET /assets/:id/thumbnail', () => {
|
||||||
|
@ -47,7 +47,7 @@ import { makeRandomImage } from 'src/generators';
|
|||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
|
|
||||||
type CommandResponse = { stdout: string; stderr: string; exitCode: number | null };
|
type CommandResponse = { stdout: string; stderr: string; exitCode: number | null };
|
||||||
type EventType = 'assetUpload' | 'assetUpdate' | 'assetDelete' | 'userDelete';
|
type EventType = 'assetUpload' | 'assetUpdate' | 'assetDelete' | 'userDelete' | 'assetHidden';
|
||||||
type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: number };
|
type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: number };
|
||||||
type AdminSetupOptions = { onboarding?: boolean };
|
type AdminSetupOptions = { onboarding?: boolean };
|
||||||
type AssetData = { bytes?: Buffer; filename: string };
|
type AssetData = { bytes?: Buffer; filename: string };
|
||||||
@ -92,6 +92,7 @@ const executeCommand = (command: string, args: string[]) => {
|
|||||||
let client: pg.Client | null = null;
|
let client: pg.Client | null = null;
|
||||||
|
|
||||||
const events: Record<EventType, Set<string>> = {
|
const events: Record<EventType, Set<string>> = {
|
||||||
|
assetHidden: new Set<string>(),
|
||||||
assetUpload: new Set<string>(),
|
assetUpload: new Set<string>(),
|
||||||
assetUpdate: new Set<string>(),
|
assetUpdate: new Set<string>(),
|
||||||
assetDelete: new Set<string>(),
|
assetDelete: new Set<string>(),
|
||||||
@ -203,6 +204,7 @@ export const utils = {
|
|||||||
.on('connect', () => resolve(websocket))
|
.on('connect', () => resolve(websocket))
|
||||||
.on('on_upload_success', (data: AssetResponseDto) => onEvent({ event: 'assetUpload', id: data.id }))
|
.on('on_upload_success', (data: AssetResponseDto) => onEvent({ event: 'assetUpload', id: data.id }))
|
||||||
.on('on_asset_update', (data: AssetResponseDto) => onEvent({ event: 'assetUpdate', id: data.id }))
|
.on('on_asset_update', (data: AssetResponseDto) => onEvent({ event: 'assetUpdate', id: data.id }))
|
||||||
|
.on('on_asset_hidden', (assetId: string) => onEvent({ event: 'assetHidden', id: assetId }))
|
||||||
.on('on_asset_delete', (assetId: string) => onEvent({ event: 'assetDelete', id: assetId }))
|
.on('on_asset_delete', (assetId: string) => onEvent({ event: 'assetDelete', id: assetId }))
|
||||||
.on('on_user_delete', (userId: string) => onEvent({ event: 'userDelete', id: userId }))
|
.on('on_user_delete', (userId: string) => onEvent({ event: 'userDelete', id: userId }))
|
||||||
.connect();
|
.connect();
|
||||||
|
@ -124,7 +124,7 @@ export class AssetEntity {
|
|||||||
@Column({ type: 'boolean', default: true })
|
@Column({ type: 'boolean', default: true })
|
||||||
isVisible!: boolean;
|
isVisible!: boolean;
|
||||||
|
|
||||||
@OneToOne(() => AssetEntity, { nullable: true, onUpdate: 'CASCADE', onDelete: 'SET NULL' })
|
@ManyToOne(() => AssetEntity, { nullable: true, onUpdate: 'CASCADE', onDelete: 'SET NULL' })
|
||||||
@JoinColumn()
|
@JoinColumn()
|
||||||
livePhotoVideo!: AssetEntity | null;
|
livePhotoVideo!: AssetEntity | null;
|
||||||
|
|
||||||
|
@ -173,6 +173,7 @@ export interface IAssetRepository {
|
|||||||
deleteAll(ownerId: string): Promise<void>;
|
deleteAll(ownerId: string): Promise<void>;
|
||||||
getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
|
getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
|
||||||
getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>;
|
getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>;
|
||||||
|
getLivePhotoCount(motionId: string): Promise<number>;
|
||||||
updateAll(ids: string[], options: Partial<AssetUpdateAllOptions>): Promise<void>;
|
updateAll(ids: string[], options: Partial<AssetUpdateAllOptions>): Promise<void>;
|
||||||
updateDuplicates(options: AssetUpdateDuplicateOptions): Promise<void>;
|
updateDuplicates(options: AssetUpdateDuplicateOptions): Promise<void>;
|
||||||
update(asset: AssetUpdateOptions): Promise<void>;
|
update(asset: AssetUpdateOptions): Promise<void>;
|
||||||
|
@ -0,0 +1,18 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class FixLivePhotoVideoRelation1719359859887 implements MigrationInterface {
|
||||||
|
name = 'FixLivePhotoVideoRelation1719359859887'
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "FK_16294b83fa8c0149719a1f631ef"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "UQ_16294b83fa8c0149719a1f631ef"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "FK_16294b83fa8c0149719a1f631ef" FOREIGN KEY ("livePhotoVideoId") REFERENCES "assets"("id") ON DELETE SET NULL ON UPDATE CASCADE`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "FK_16294b83fa8c0149719a1f631ef"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "UQ_16294b83fa8c0149719a1f631ef" UNIQUE ("livePhotoVideoId")`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "FK_16294b83fa8c0149719a1f631ef" FOREIGN KEY ("livePhotoVideoId") REFERENCES "assets"("id") ON DELETE SET NULL ON UPDATE CASCADE`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -377,6 +377,14 @@ WHERE
|
|||||||
AND ("AssetEntity"."isVisible" = $3)
|
AND ("AssetEntity"."isVisible" = $3)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
-- AssetRepository.getLivePhotoCount
|
||||||
|
SELECT
|
||||||
|
COUNT(1) AS "cnt"
|
||||||
|
FROM
|
||||||
|
"assets" "AssetEntity"
|
||||||
|
WHERE
|
||||||
|
(("AssetEntity"."livePhotoVideoId" = $1))
|
||||||
|
|
||||||
-- AssetRepository.getById
|
-- AssetRepository.getById
|
||||||
SELECT
|
SELECT
|
||||||
"AssetEntity"."id" AS "AssetEntity_id",
|
"AssetEntity"."id" AS "AssetEntity_id",
|
||||||
|
@ -249,6 +249,16 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
return items.map((asset) => asset.deviceAssetId);
|
return items.map((asset) => asset.deviceAssetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
|
getLivePhotoCount(motionId: string): Promise<number> {
|
||||||
|
return this.repository.count({
|
||||||
|
where: {
|
||||||
|
livePhotoVideoId: motionId,
|
||||||
|
},
|
||||||
|
withDeleted: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
getById(
|
getById(
|
||||||
id: string,
|
id: string,
|
||||||
|
@ -171,6 +171,7 @@ export class AssetMediaService {
|
|||||||
}
|
}
|
||||||
if (motionAsset.isVisible) {
|
if (motionAsset.isVisible) {
|
||||||
await this.assetRepository.update({ id: motionAsset.id, isVisible: false });
|
await this.assetRepository.update({ id: motionAsset.id, isVisible: false });
|
||||||
|
this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, auth.user.id, motionAsset.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -445,6 +445,7 @@ describe(AssetService.name, () => {
|
|||||||
|
|
||||||
it('should delete a live photo', async () => {
|
it('should delete a live photo', async () => {
|
||||||
assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset);
|
assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset);
|
||||||
|
assetMock.getLivePhotoCount.mockResolvedValue(0);
|
||||||
|
|
||||||
await sut.handleAssetDeletion({
|
await sut.handleAssetDeletion({
|
||||||
id: assetStub.livePhotoStillAsset.id,
|
id: assetStub.livePhotoStillAsset.id,
|
||||||
@ -472,6 +473,27 @@ describe(AssetService.name, () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not delete a live motion part if it is being used by another asset', async () => {
|
||||||
|
assetMock.getLivePhotoCount.mockResolvedValue(2);
|
||||||
|
assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset);
|
||||||
|
|
||||||
|
await sut.handleAssetDeletion({
|
||||||
|
id: assetStub.livePhotoStillAsset.id,
|
||||||
|
deleteOnDisk: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(jobMock.queue.mock.calls).toEqual([
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: JobName.DELETE_FILES,
|
||||||
|
data: {
|
||||||
|
files: [undefined, undefined, undefined, undefined, 'fake_path/asset_1.jpeg'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('should update usage', async () => {
|
it('should update usage', async () => {
|
||||||
assetMock.getById.mockResolvedValue(assetStub.image);
|
assetMock.getById.mockResolvedValue(assetStub.image);
|
||||||
await sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true });
|
await sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true });
|
||||||
|
@ -304,12 +304,15 @@ export class AssetService {
|
|||||||
}
|
}
|
||||||
this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, asset.ownerId, id);
|
this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, asset.ownerId, id);
|
||||||
|
|
||||||
// TODO refactor this to use cascades
|
// delete the motion if it is not used by another asset
|
||||||
if (asset.livePhotoVideoId) {
|
if (asset.livePhotoVideoId) {
|
||||||
await this.jobRepository.queue({
|
const count = await this.assetRepository.getLivePhotoCount(asset.livePhotoVideoId);
|
||||||
name: JobName.ASSET_DELETION,
|
if (count === 0) {
|
||||||
data: { id: asset.livePhotoVideoId, deleteOnDisk },
|
await this.jobRepository.queue({
|
||||||
});
|
name: JobName.ASSET_DELETION,
|
||||||
|
data: { id: asset.livePhotoVideoId, deleteOnDisk },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath];
|
const files = [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath];
|
||||||
|
15
server/test/fixtures/asset.stub.ts
vendored
15
server/test/fixtures/asset.stub.ts
vendored
@ -568,6 +568,21 @@ export const assetStub = {
|
|||||||
},
|
},
|
||||||
} as AssetEntity),
|
} as AssetEntity),
|
||||||
|
|
||||||
|
livePhotoStillAssetWithTheSameLivePhotoMotionAsset: Object.freeze({
|
||||||
|
id: 'live-photo-still-asset-1',
|
||||||
|
originalPath: fileStub.livePhotoStill.originalPath,
|
||||||
|
ownerId: authStub.user1.user.id,
|
||||||
|
type: AssetType.IMAGE,
|
||||||
|
livePhotoVideoId: 'live-photo-motion-asset',
|
||||||
|
isVisible: true,
|
||||||
|
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||||
|
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||||
|
exifInfo: {
|
||||||
|
fileSizeInByte: 25_000,
|
||||||
|
timeZone: `America/New_York`,
|
||||||
|
},
|
||||||
|
} as AssetEntity),
|
||||||
|
|
||||||
livePhotoWithOriginalFileName: Object.freeze({
|
livePhotoWithOriginalFileName: Object.freeze({
|
||||||
id: 'live-photo-still-asset',
|
id: 'live-photo-still-asset',
|
||||||
originalPath: fileStub.livePhotoStill.originalPath,
|
originalPath: fileStub.livePhotoStill.originalPath,
|
||||||
|
@ -23,6 +23,7 @@ export const newAssetRepositoryMock = (): Mocked<IAssetRepository> => {
|
|||||||
getLastUpdatedAssetForAlbumId: vitest.fn(),
|
getLastUpdatedAssetForAlbumId: vitest.fn(),
|
||||||
getAll: vitest.fn().mockResolvedValue({ items: [], hasNextPage: false }),
|
getAll: vitest.fn().mockResolvedValue({ items: [], hasNextPage: false }),
|
||||||
getAllByDeviceId: vitest.fn(),
|
getAllByDeviceId: vitest.fn(),
|
||||||
|
getLivePhotoCount: vitest.fn(),
|
||||||
updateAll: vitest.fn(),
|
updateAll: vitest.fn(),
|
||||||
updateDuplicates: vitest.fn(),
|
updateDuplicates: vitest.fn(),
|
||||||
getExternalLibraryAssetPaths: vitest.fn(),
|
getExternalLibraryAssetPaths: vitest.fn(),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user