mirror of
https://github.com/immich-app/immich.git
synced 2024-12-29 15:11:58 +00:00
fix(server): asset delete logic (#10077)
* fix(server): asset delete logic * test: e2e
This commit is contained in:
parent
4698c39855
commit
7651f70c88
10 changed files with 130 additions and 29 deletions
|
@ -1,4 +1,11 @@
|
||||||
import { LibraryResponseDto, LoginResponseDto, ScanLibraryDto, getAllLibraries, scanLibrary } from '@immich/sdk';
|
import {
|
||||||
|
LibraryResponseDto,
|
||||||
|
LoginResponseDto,
|
||||||
|
ScanLibraryDto,
|
||||||
|
getAllLibraries,
|
||||||
|
removeOfflineFiles,
|
||||||
|
scanLibrary,
|
||||||
|
} from '@immich/sdk';
|
||||||
import { cpSync, existsSync } from 'node:fs';
|
import { cpSync, existsSync } from 'node:fs';
|
||||||
import { Socket } from 'socket.io-client';
|
import { Socket } from 'socket.io-client';
|
||||||
import { userDto, uuidDto } from 'src/fixtures';
|
import { userDto, uuidDto } from 'src/fixtures';
|
||||||
|
@ -384,6 +391,51 @@ describe('/libraries', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not try to delete offline files', async () => {
|
||||||
|
utils.createImageFile(`${testAssetDir}/temp/offline1/assetA.png`);
|
||||||
|
|
||||||
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
|
ownerId: admin.userId,
|
||||||
|
importPaths: [`${testAssetDirInternal}/temp/offline1`],
|
||||||
|
});
|
||||||
|
|
||||||
|
await scan(admin.accessToken, library.id);
|
||||||
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
|
||||||
|
const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
|
||||||
|
expect(initialAssets).toEqual({
|
||||||
|
count: 1,
|
||||||
|
total: 1,
|
||||||
|
facets: [],
|
||||||
|
items: [expect.objectContaining({ originalFileName: 'assetA.png' })],
|
||||||
|
nextPage: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
utils.removeImageFile(`${testAssetDir}/temp/offline1/assetA.png`);
|
||||||
|
|
||||||
|
await scan(admin.accessToken, library.id);
|
||||||
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
|
||||||
|
const { assets: offlineAssets } = await utils.metadataSearch(admin.accessToken, {
|
||||||
|
libraryId: library.id,
|
||||||
|
isOffline: true,
|
||||||
|
});
|
||||||
|
expect(offlineAssets).toEqual({
|
||||||
|
count: 1,
|
||||||
|
total: 1,
|
||||||
|
facets: [],
|
||||||
|
items: [expect.objectContaining({ originalFileName: 'assetA.png' })],
|
||||||
|
nextPage: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
utils.createImageFile(`${testAssetDir}/temp/offline1/assetA.png`);
|
||||||
|
await removeOfflineFiles({ id: library.id }, { headers: asBearerAuth(admin.accessToken) });
|
||||||
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
await utils.waitForWebsocketEvent({ event: 'assetDelete', total: 1 });
|
||||||
|
|
||||||
|
expect(existsSync(`${testAssetDir}/temp/offline1/assetA.png`)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('should scan new files', async () => {
|
it('should scan new files', async () => {
|
||||||
const library = await utils.createLibrary(admin.accessToken, {
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
ownerId: admin.userId,
|
ownerId: admin.userId,
|
||||||
|
@ -507,10 +559,10 @@ describe('/libraries', () => {
|
||||||
it('should remove offline files', async () => {
|
it('should remove offline files', async () => {
|
||||||
const library = await utils.createLibrary(admin.accessToken, {
|
const library = await utils.createLibrary(admin.accessToken, {
|
||||||
ownerId: admin.userId,
|
ownerId: admin.userId,
|
||||||
importPaths: [`${testAssetDirInternal}/temp`],
|
importPaths: [`${testAssetDirInternal}/temp/offline2`],
|
||||||
});
|
});
|
||||||
|
|
||||||
utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.png`);
|
utils.createImageFile(`${testAssetDir}/temp/offline2/assetA.png`);
|
||||||
|
|
||||||
await scan(admin.accessToken, library.id);
|
await scan(admin.accessToken, library.id);
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
@ -518,9 +570,9 @@ describe('/libraries', () => {
|
||||||
const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, {
|
const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, {
|
||||||
libraryId: library.id,
|
libraryId: library.id,
|
||||||
});
|
});
|
||||||
expect(initialAssets.count).toBe(3);
|
expect(initialAssets.count).toBe(1);
|
||||||
|
|
||||||
utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.png`);
|
utils.removeImageFile(`${testAssetDir}/temp/offline2/assetA.png`);
|
||||||
|
|
||||||
await scan(admin.accessToken, library.id);
|
await scan(admin.accessToken, library.id);
|
||||||
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
await utils.waitForQueueFinish(admin.accessToken, 'library');
|
||||||
|
@ -541,7 +593,7 @@ describe('/libraries', () => {
|
||||||
|
|
||||||
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
|
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
|
||||||
|
|
||||||
expect(assets.count).toBe(2);
|
expect(assets.count).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not remove online files', async () => {
|
it('should not remove online files', async () => {
|
||||||
|
|
|
@ -120,6 +120,10 @@ export interface IEntityJob extends IBaseJob {
|
||||||
source?: 'upload' | 'sidecar-write' | 'copy';
|
source?: 'upload' | 'sidecar-write' | 'copy';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IAssetDeleteJob extends IEntityJob {
|
||||||
|
deleteOnDisk: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ILibraryFileJob extends IEntityJob {
|
export interface ILibraryFileJob extends IEntityJob {
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
assetPath: string;
|
assetPath: string;
|
||||||
|
@ -246,7 +250,7 @@ export type JobItem =
|
||||||
|
|
||||||
// Asset Deletion
|
// Asset Deletion
|
||||||
| { name: JobName.PERSON_CLEANUP; data?: IBaseJob }
|
| { name: JobName.PERSON_CLEANUP; data?: IBaseJob }
|
||||||
| { name: JobName.ASSET_DELETION; data: IEntityJob }
|
| { name: JobName.ASSET_DELETION; data: IAssetDeleteJob }
|
||||||
| { name: JobName.ASSET_DELETION_CHECK; data?: IBaseJob }
|
| { name: JobName.ASSET_DELETION_CHECK; data?: IBaseJob }
|
||||||
|
|
||||||
// Library Management
|
// Library Management
|
||||||
|
|
|
@ -389,8 +389,8 @@ describe(AssetService.name, () => {
|
||||||
await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: true });
|
await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: true });
|
||||||
|
|
||||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||||
{ name: JobName.ASSET_DELETION, data: { id: 'asset1' } },
|
{ name: JobName.ASSET_DELETION, data: { id: 'asset1', deleteOnDisk: true } },
|
||||||
{ name: JobName.ASSET_DELETION, data: { id: 'asset2' } },
|
{ name: JobName.ASSET_DELETION, data: { id: 'asset2', deleteOnDisk: true } },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -410,7 +410,7 @@ describe(AssetService.name, () => {
|
||||||
|
|
||||||
assetMock.getById.mockResolvedValue(assetWithFace);
|
assetMock.getById.mockResolvedValue(assetWithFace);
|
||||||
|
|
||||||
await sut.handleAssetDeletion({ id: assetWithFace.id });
|
await sut.handleAssetDeletion({ id: assetWithFace.id, deleteOnDisk: true });
|
||||||
|
|
||||||
expect(jobMock.queue.mock.calls).toEqual([
|
expect(jobMock.queue.mock.calls).toEqual([
|
||||||
[
|
[
|
||||||
|
@ -435,7 +435,7 @@ describe(AssetService.name, () => {
|
||||||
it('should update stack primary asset if deleted asset was primary asset in a stack', async () => {
|
it('should update stack primary asset if deleted asset was primary asset in a stack', async () => {
|
||||||
assetMock.getById.mockResolvedValue(assetStub.primaryImage as AssetEntity);
|
assetMock.getById.mockResolvedValue(assetStub.primaryImage as AssetEntity);
|
||||||
|
|
||||||
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id });
|
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true });
|
||||||
|
|
||||||
expect(assetStackMock.update).toHaveBeenCalledWith({
|
expect(assetStackMock.update).toHaveBeenCalledWith({
|
||||||
id: 'stack-1',
|
id: 'stack-1',
|
||||||
|
@ -446,10 +446,21 @@ 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);
|
||||||
|
|
||||||
await sut.handleAssetDeletion({ id: assetStub.livePhotoStillAsset.id });
|
await sut.handleAssetDeletion({
|
||||||
|
id: assetStub.livePhotoStillAsset.id,
|
||||||
|
deleteOnDisk: true,
|
||||||
|
});
|
||||||
|
|
||||||
expect(jobMock.queue.mock.calls).toEqual([
|
expect(jobMock.queue.mock.calls).toEqual([
|
||||||
[{ name: JobName.ASSET_DELETION, data: { id: assetStub.livePhotoMotionAsset.id } }],
|
[
|
||||||
|
{
|
||||||
|
name: JobName.ASSET_DELETION,
|
||||||
|
data: {
|
||||||
|
id: assetStub.livePhotoMotionAsset.id,
|
||||||
|
deleteOnDisk: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
name: JobName.DELETE_FILES,
|
name: JobName.DELETE_FILES,
|
||||||
|
@ -463,7 +474,7 @@ describe(AssetService.name, () => {
|
||||||
|
|
||||||
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 });
|
await sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true });
|
||||||
expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.image.ownerId, -5000);
|
expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.image.ownerId, -5000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -27,7 +27,7 @@ import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
|
||||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||||
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
||||||
import {
|
import {
|
||||||
IEntityJob,
|
IAssetDeleteJob,
|
||||||
IJobRepository,
|
IJobRepository,
|
||||||
ISidecarWriteJob,
|
ISidecarWriteJob,
|
||||||
JOBS_ASSET_PAGINATION_SIZE,
|
JOBS_ASSET_PAGINATION_SIZE,
|
||||||
|
@ -256,15 +256,21 @@ export class AssetService {
|
||||||
|
|
||||||
for await (const assets of assetPagination) {
|
for await (const assets of assetPagination) {
|
||||||
await this.jobRepository.queueAll(
|
await this.jobRepository.queueAll(
|
||||||
assets.map((asset) => ({ name: JobName.ASSET_DELETION, data: { id: asset.id } })),
|
assets.map((asset) => ({
|
||||||
|
name: JobName.ASSET_DELETION,
|
||||||
|
data: {
|
||||||
|
id: asset.id,
|
||||||
|
deleteOnDisk: true,
|
||||||
|
},
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleAssetDeletion(job: IEntityJob): Promise<JobStatus> {
|
async handleAssetDeletion(job: IAssetDeleteJob): Promise<JobStatus> {
|
||||||
const { id } = job;
|
const { id, deleteOnDisk } = job;
|
||||||
|
|
||||||
const asset = await this.assetRepository.getById(id, {
|
const asset = await this.assetRepository.getById(id, {
|
||||||
faces: {
|
faces: {
|
||||||
|
@ -301,12 +307,14 @@ export class AssetService {
|
||||||
|
|
||||||
// TODO refactor this to use cascades
|
// TODO refactor this to use cascades
|
||||||
if (asset.livePhotoVideoId) {
|
if (asset.livePhotoVideoId) {
|
||||||
await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.livePhotoVideoId } });
|
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];
|
||||||
// skip originals if the user deleted the whole library
|
if (deleteOnDisk) {
|
||||||
if (!asset.library?.deletedAt) {
|
|
||||||
files.push(asset.sidecarPath, asset.originalPath);
|
files.push(asset.sidecarPath, asset.originalPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -321,7 +329,12 @@ export class AssetService {
|
||||||
await this.access.requirePermission(auth, Permission.ASSET_DELETE, ids);
|
await this.access.requirePermission(auth, Permission.ASSET_DELETE, ids);
|
||||||
|
|
||||||
if (force) {
|
if (force) {
|
||||||
await this.jobRepository.queueAll(ids.map((id) => ({ name: JobName.ASSET_DELETION, data: { id } })));
|
await this.jobRepository.queueAll(
|
||||||
|
ids.map((id) => ({
|
||||||
|
name: JobName.ASSET_DELETION,
|
||||||
|
data: { id, deleteOnDisk: true },
|
||||||
|
})),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
await this.assetRepository.softDeleteAll(ids);
|
await this.assetRepository.softDeleteAll(ids);
|
||||||
this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, auth.user.id, ids);
|
this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, auth.user.id, ids);
|
||||||
|
|
|
@ -1276,7 +1276,7 @@ describe(LibraryService.name, () => {
|
||||||
await expect(sut.handleOfflineRemoval({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS);
|
await expect(sut.handleOfflineRemoval({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS);
|
||||||
|
|
||||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||||
{ name: JobName.ASSET_DELETION, data: { id: assetStub.image1.id } },
|
{ name: JobName.ASSET_DELETION, data: { id: assetStub.image1.id, deleteOnDisk: false } },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -355,7 +355,13 @@ export class LibraryService {
|
||||||
const assetIds = await this.repository.getAssetIds(job.id, true);
|
const assetIds = await this.repository.getAssetIds(job.id, true);
|
||||||
this.logger.debug(`Will delete ${assetIds.length} asset(s) in library ${job.id}`);
|
this.logger.debug(`Will delete ${assetIds.length} asset(s) in library ${job.id}`);
|
||||||
await this.jobRepository.queueAll(
|
await this.jobRepository.queueAll(
|
||||||
assetIds.map((assetId) => ({ name: JobName.ASSET_DELETION, data: { id: assetId } })),
|
assetIds.map((assetId) => ({
|
||||||
|
name: JobName.ASSET_DELETION,
|
||||||
|
data: {
|
||||||
|
id: assetId,
|
||||||
|
deleteOnDisk: false,
|
||||||
|
},
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (assetIds.length === 0) {
|
if (assetIds.length === 0) {
|
||||||
|
@ -544,7 +550,13 @@ export class LibraryService {
|
||||||
for await (const assets of assetPagination) {
|
for await (const assets of assetPagination) {
|
||||||
this.logger.debug(`Removing ${assets.length} offline assets`);
|
this.logger.debug(`Removing ${assets.length} offline assets`);
|
||||||
await this.jobRepository.queueAll(
|
await this.jobRepository.queueAll(
|
||||||
assets.map((asset) => ({ name: JobName.ASSET_DELETION, data: { id: asset.id } })),
|
assets.map((asset) => ({
|
||||||
|
name: JobName.ASSET_DELETION,
|
||||||
|
data: {
|
||||||
|
id: asset.id,
|
||||||
|
deleteOnDisk: false,
|
||||||
|
},
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -510,7 +510,7 @@ describe(MetadataService.name, () => {
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id });
|
await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id });
|
||||||
expect(jobMock.queue).toHaveBeenNthCalledWith(1, {
|
expect(jobMock.queue).toHaveBeenNthCalledWith(1, {
|
||||||
name: JobName.ASSET_DELETION,
|
name: JobName.ASSET_DELETION,
|
||||||
data: { id: assetStub.livePhotoWithOriginalFileName.livePhotoVideoId },
|
data: { id: assetStub.livePhotoWithOriginalFileName.livePhotoVideoId, deleteOnDisk: true },
|
||||||
});
|
});
|
||||||
expect(jobMock.queue).toHaveBeenNthCalledWith(2, {
|
expect(jobMock.queue).toHaveBeenNthCalledWith(2, {
|
||||||
name: JobName.METADATA_EXTRACTION,
|
name: JobName.METADATA_EXTRACTION,
|
||||||
|
|
|
@ -460,7 +460,10 @@ export class MetadataService {
|
||||||
// (if it did, getByChecksum() would've returned a motionAsset with the same ID as livePhotoVideoId)
|
// (if it did, getByChecksum() would've returned a motionAsset with the same ID as livePhotoVideoId)
|
||||||
// note asset.livePhotoVideoId is not motionAsset.id yet
|
// note asset.livePhotoVideoId is not motionAsset.id yet
|
||||||
if (asset.livePhotoVideoId) {
|
if (asset.livePhotoVideoId) {
|
||||||
await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.livePhotoVideoId } });
|
await this.jobRepository.queue({
|
||||||
|
name: JobName.ASSET_DELETION,
|
||||||
|
data: { id: asset.livePhotoVideoId, deleteOnDisk: true },
|
||||||
|
});
|
||||||
this.logger.log(`Removed old motion photo video asset (${asset.livePhotoVideoId})`);
|
this.logger.log(`Removed old motion photo video asset (${asset.livePhotoVideoId})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -79,7 +79,7 @@ describe(TrashService.name, () => {
|
||||||
assetMock.getByUserId.mockResolvedValue({ items: [assetStub.image], hasNextPage: false });
|
assetMock.getByUserId.mockResolvedValue({ items: [assetStub.image], hasNextPage: false });
|
||||||
await expect(sut.empty(authStub.user1)).resolves.toBeUndefined();
|
await expect(sut.empty(authStub.user1)).resolves.toBeUndefined();
|
||||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||||
{ name: JobName.ASSET_DELETION, data: { id: assetStub.image.id } },
|
{ name: JobName.ASSET_DELETION, data: { id: assetStub.image.id, deleteOnDisk: true } },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -49,7 +49,13 @@ export class TrashService {
|
||||||
|
|
||||||
for await (const assets of assetPagination) {
|
for await (const assets of assetPagination) {
|
||||||
await this.jobRepository.queueAll(
|
await this.jobRepository.queueAll(
|
||||||
assets.map((asset) => ({ name: JobName.ASSET_DELETION, data: { id: asset.id } })),
|
assets.map((asset) => ({
|
||||||
|
name: JobName.ASSET_DELETION,
|
||||||
|
data: {
|
||||||
|
id: asset.id,
|
||||||
|
deleteOnDisk: true,
|
||||||
|
},
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue