feat(server): read-write external assets (#9235)

* refactor: remove isReadOnly and isExternal usages

* chore: open api

* fix: linting

* remove mobile isReadOnly dependency

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Jason Rasmussen
2024-05-03 15:34:57 -04:00
committed by GitHub
parent d26ac431b8
commit 5b87abb021
57 changed files with 181 additions and 603 deletions

View File

@@ -295,7 +295,6 @@ export class AssetServiceV1 {
livePhotoVideo: livePhotoAssetId === null ? null : ({ id: livePhotoAssetId } as AssetEntity),
originalFileName: file.originalName,
sidecarPath: sidecarPath || null,
isReadOnly: dto.isReadOnly ?? false,
isOffline: dto.isOffline ?? false,
});

View File

@@ -685,61 +685,6 @@ describe(AssetService.name, () => {
});
});
it('should only delete generated files for readonly assets', async () => {
assetMock.getById.mockResolvedValue(assetStub.readOnly);
await sut.handleAssetDeletion({ id: assetStub.readOnly.id });
expect(jobMock.queue.mock.calls).toEqual([
[
{
name: JobName.DELETE_FILES,
data: {
files: [
assetStub.readOnly.thumbnailPath,
assetStub.readOnly.previewPath,
assetStub.readOnly.encodedVideoPath,
],
},
},
],
]);
expect(assetMock.remove).toHaveBeenCalledWith(assetStub.readOnly);
});
it('should not process assets from external library without fromExternal flag', async () => {
assetMock.getById.mockResolvedValue(assetStub.external);
await sut.handleAssetDeletion({ id: assetStub.external.id });
expect(jobMock.queue).not.toHaveBeenCalled();
expect(jobMock.queueAll).not.toHaveBeenCalled();
expect(assetMock.remove).not.toHaveBeenCalled();
});
it('should process assets from external library with fromExternal flag', async () => {
assetMock.getById.mockResolvedValue(assetStub.external);
await sut.handleAssetDeletion({ id: assetStub.external.id, fromExternal: true });
expect(assetMock.remove).toHaveBeenCalledWith(assetStub.external);
expect(jobMock.queue.mock.calls).toEqual([
[
{
name: JobName.DELETE_FILES,
data: {
files: [
assetStub.external.thumbnailPath,
assetStub.external.previewPath,
assetStub.external.encodedVideoPath,
],
},
},
],
]);
});
it('should delete a live photo', async () => {
assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset);

View File

@@ -33,7 +33,7 @@ import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
import {
IAssetDeletionJob,
IEntityJob,
IJobRepository,
ISidecarWriteJob,
JOBS_ASSET_PAGINATION_SIZE,
@@ -371,8 +371,8 @@ export class AssetService {
return JobStatus.SUCCESS;
}
async handleAssetDeletion(job: IAssetDeletionJob): Promise<JobStatus> {
const { id, fromExternal } = job;
async handleAssetDeletion(job: IEntityJob): Promise<JobStatus> {
const { id } = job;
const asset = await this.assetRepository.getById(id, {
faces: {
@@ -387,11 +387,6 @@ export class AssetService {
return JobStatus.FAILED;
}
// Ignore requests that are not from external library job but is for an external asset
if (!fromExternal && (!asset.library || asset.library.type === LibraryType.EXTERNAL)) {
return JobStatus.SKIPPED;
}
// Replace the parent of the stack children with a new asset
if (asset.stack?.primaryAssetId === id) {
const stackAssetIds = asset.stack.assets.map((a) => a.id);
@@ -414,18 +409,15 @@ export class AssetService {
// TODO refactor this to use cascades
if (asset.livePhotoVideoId) {
await this.jobRepository.queue({
name: JobName.ASSET_DELETION,
data: { id: asset.livePhotoVideoId, fromExternal },
});
await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.livePhotoVideoId } });
}
const files = [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath];
if (!(asset.isExternal || asset.isReadOnly)) {
files.push(asset.sidecarPath, asset.originalPath);
}
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files } });
await this.jobRepository.queue({
name: JobName.DELETE_FILES,
data: {
files: [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath, asset.sidecarPath, asset.originalPath],
},
});
return JobStatus.SUCCESS;
}

View File

@@ -368,7 +368,6 @@ describe(LibraryService.name, () => {
type: AssetType.IMAGE,
originalFileName: 'photo.jpg',
sidecarPath: null,
isReadOnly: true,
isExternal: true,
},
],
@@ -416,7 +415,6 @@ describe(LibraryService.name, () => {
type: AssetType.IMAGE,
originalFileName: 'photo.jpg',
sidecarPath: '/data/user1/photo.jpg.xmp',
isReadOnly: true,
isExternal: true,
},
],
@@ -463,7 +461,6 @@ describe(LibraryService.name, () => {
type: AssetType.VIDEO,
originalFileName: 'video.mp4',
sidecarPath: null,
isReadOnly: true,
isExternal: true,
},
],
@@ -1458,10 +1455,7 @@ describe(LibraryService.name, () => {
await expect(sut.handleOfflineRemoval({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS);
expect(jobMock.queueAll).toHaveBeenCalledWith([
{
name: JobName.ASSET_DELETION,
data: { id: assetStub.image1.id, fromExternal: true },
},
{ name: JobName.ASSET_DELETION, data: { id: assetStub.image1.id } },
]);
});
});

View File

@@ -387,7 +387,7 @@ export class LibraryService {
const assetIds = await this.repository.getAssetIds(job.id, true);
this.logger.debug(`Will delete ${assetIds.length} asset(s) in library ${job.id}`);
await this.jobRepository.queueAll(
assetIds.map((assetId) => ({ name: JobName.ASSET_DELETION, data: { id: assetId, fromExternal: true } })),
assetIds.map((assetId) => ({ name: JobName.ASSET_DELETION, data: { id: assetId } })),
);
if (assetIds.length === 0) {
@@ -503,7 +503,6 @@ export class LibraryService {
type: assetType,
originalFileName,
sidecarPath,
isReadOnly: true,
isExternal: true,
});
assetId = addedAsset.id;
@@ -580,7 +579,7 @@ export class LibraryService {
for await (const assets of assetPagination) {
this.logger.debug(`Removing ${assets.length} offline assets`);
await this.jobRepository.queueAll(
assets.map((asset) => ({ name: JobName.ASSET_DELETION, data: { id: asset.id, fromExternal: true } })),
assets.map((asset) => ({ name: JobName.ASSET_DELETION, data: { id: asset.id } })),
);
}

View File

@@ -440,7 +440,6 @@ export class MetadataService {
originalPath: motionPath,
originalFileName: asset.originalFileName,
isVisible: false,
isReadOnly: false,
deviceAssetId: 'NONE',
deviceId: 'NONE',
});

View File

@@ -558,26 +558,5 @@ describe(StorageTemplateService.name, () => {
);
expect(assetMock.update).not.toHaveBeenCalled();
});
it('should not move read-only asset', async () => {
assetMock.getAll.mockResolvedValue({
items: [
{
...assetStub.image,
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg',
isReadOnly: true,
},
],
hasNextPage: false,
});
userMock.getList.mockResolvedValue([userStub.user1]);
await sut.handleMigration();
expect(assetMock.getAll).toHaveBeenCalled();
expect(storageMock.rename).not.toHaveBeenCalled();
expect(storageMock.copyFile).not.toHaveBeenCalled();
expect(assetMock.update).not.toHaveBeenCalled();
});
});
});

View File

@@ -170,7 +170,7 @@ export class StorageTemplateService {
}
async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) {
if (asset.isReadOnly || asset.isExternal || StorageCore.isAndroidMotionPath(asset.originalPath)) {
if (asset.isExternal || StorageCore.isAndroidMotionPath(asset.originalPath)) {
// External assets are not affected by storage template
// TODO: shouldn't this only apply to external assets?
return;