mirror of
https://github.com/immich-app/immich.git
synced 2025-03-01 15:11:21 +01:00
fix(server): external library motion photo video asset handling (#8721)
* added "isExternal" to the getLibraryAssetPaths query * handleQueueAssetRefresh skip "non external" video asset, closes #8562 * correctly implements live photo deletion for external library * use "external asset" for external library tests * minor: external library asset checksum is "path hash" not file hash * renamed to getExternalLibraryAssetPaths and added isExternal where clause * generated sql * reverted leftover change
This commit is contained in:
parent
a903898781
commit
85df3f1e99
8 changed files with 61 additions and 17 deletions
|
@ -155,7 +155,7 @@ export interface IAssetRepository {
|
||||||
getRandom(userId: string, count: number): Promise<AssetEntity[]>;
|
getRandom(userId: string, count: number): Promise<AssetEntity[]>;
|
||||||
getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
|
getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
|
||||||
getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
|
getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
|
||||||
getLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated<AssetPathEntity>;
|
getExternalLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated<AssetPathEntity>;
|
||||||
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise<AssetEntity | null>;
|
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise<AssetEntity | null>;
|
||||||
deleteAll(ownerId: string): Promise<void>;
|
deleteAll(ownerId: string): Promise<void>;
|
||||||
getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
|
getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
|
||||||
|
|
|
@ -253,7 +253,7 @@ DELETE FROM "assets"
|
||||||
WHERE
|
WHERE
|
||||||
"ownerId" = $1
|
"ownerId" = $1
|
||||||
|
|
||||||
-- AssetRepository.getLibraryAssetPaths
|
-- AssetRepository.getExternalLibraryAssetPaths
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
"distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id"
|
"distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id"
|
||||||
FROM
|
FROM
|
||||||
|
@ -272,6 +272,7 @@ FROM
|
||||||
(
|
(
|
||||||
(
|
(
|
||||||
((("AssetEntity__AssetEntity_library"."id" = $1)))
|
((("AssetEntity__AssetEntity_library"."id" = $1)))
|
||||||
|
AND ("AssetEntity"."isExternal" = $2)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
AND ("AssetEntity"."deletedAt" IS NULL)
|
AND ("AssetEntity"."deletedAt" IS NULL)
|
||||||
|
|
|
@ -160,10 +160,10 @@ export class AssetRepository implements IAssetRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [{ take: 1, skip: 0 }, DummyValue.UUID] })
|
@GenerateSql({ params: [{ take: 1, skip: 0 }, DummyValue.UUID] })
|
||||||
getLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated<AssetPathEntity> {
|
getExternalLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated<AssetPathEntity> {
|
||||||
return paginate(this.repository, pagination, {
|
return paginate(this.repository, pagination, {
|
||||||
select: { id: true, originalPath: true, isOffline: true },
|
select: { id: true, originalPath: true, isOffline: true },
|
||||||
where: { library: { id: libraryId } },
|
where: { library: { id: libraryId }, isExternal: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -396,7 +396,10 @@ 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, fromExternal },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath];
|
const files = [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath];
|
||||||
|
|
|
@ -160,7 +160,7 @@ describe(LibraryService.name, () => {
|
||||||
storageMock.walk.mockImplementation(async function* generator() {
|
storageMock.walk.mockImplementation(async function* generator() {
|
||||||
yield '/data/user1/photo.jpg';
|
yield '/data/user1/photo.jpg';
|
||||||
});
|
});
|
||||||
assetMock.getLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false });
|
assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false });
|
||||||
|
|
||||||
await sut.handleQueueAssetRefresh(mockLibraryJob);
|
await sut.handleQueueAssetRefresh(mockLibraryJob);
|
||||||
|
|
||||||
|
@ -189,7 +189,7 @@ describe(LibraryService.name, () => {
|
||||||
storageMock.walk.mockImplementation(async function* generator() {
|
storageMock.walk.mockImplementation(async function* generator() {
|
||||||
yield '/data/user1/photo.jpg';
|
yield '/data/user1/photo.jpg';
|
||||||
});
|
});
|
||||||
assetMock.getLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false });
|
assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false });
|
||||||
|
|
||||||
await sut.handleQueueAssetRefresh(mockLibraryJob);
|
await sut.handleQueueAssetRefresh(mockLibraryJob);
|
||||||
|
|
||||||
|
@ -238,7 +238,7 @@ describe(LibraryService.name, () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
|
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
|
||||||
assetMock.getLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false });
|
assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false });
|
||||||
|
|
||||||
await sut.handleQueueAssetRefresh(mockLibraryJob);
|
await sut.handleQueueAssetRefresh(mockLibraryJob);
|
||||||
|
|
||||||
|
@ -256,8 +256,8 @@ describe(LibraryService.name, () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
||||||
assetMock.getLibraryAssetPaths.mockResolvedValue({
|
assetMock.getExternalLibraryAssetPaths.mockResolvedValue({
|
||||||
items: [assetStub.image],
|
items: [assetStub.external],
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -278,16 +278,16 @@ describe(LibraryService.name, () => {
|
||||||
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
|
||||||
// eslint-disable-next-line @typescript-eslint/require-await
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
storageMock.walk.mockImplementation(async function* generator() {
|
storageMock.walk.mockImplementation(async function* generator() {
|
||||||
yield assetStub.offline.originalPath;
|
yield assetStub.externalOffline.originalPath;
|
||||||
});
|
});
|
||||||
assetMock.getLibraryAssetPaths.mockResolvedValue({
|
assetMock.getExternalLibraryAssetPaths.mockResolvedValue({
|
||||||
items: [assetStub.offline],
|
items: [assetStub.externalOffline],
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await sut.handleQueueAssetRefresh(mockLibraryJob);
|
await sut.handleQueueAssetRefresh(mockLibraryJob);
|
||||||
|
|
||||||
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.offline.id], { isOffline: false });
|
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.externalOffline.id], { isOffline: false });
|
||||||
expect(assetMock.updateAll).not.toHaveBeenCalledWith(expect.anything(), { isOffline: true });
|
expect(assetMock.updateAll).not.toHaveBeenCalledWith(expect.anything(), { isOffline: true });
|
||||||
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
|
@ -616,7 +616,7 @@ export class LibraryService extends EventEmitter {
|
||||||
const assetIdsToMarkOffline = [];
|
const assetIdsToMarkOffline = [];
|
||||||
const assetIdsToMarkOnline = [];
|
const assetIdsToMarkOnline = [];
|
||||||
const pagination = usePagination(LIBRARY_SCAN_BATCH_SIZE, (pagination) =>
|
const pagination = usePagination(LIBRARY_SCAN_BATCH_SIZE, (pagination) =>
|
||||||
this.assetRepository.getLibraryAssetPaths(pagination, library.id),
|
this.assetRepository.getExternalLibraryAssetPaths(pagination, library.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.verbose(`Crawled asset paths paginated`);
|
this.logger.verbose(`Crawled asset paths paginated`);
|
||||||
|
|
42
server/test/fixtures/asset.stub.ts
vendored
42
server/test/fixtures/asset.stub.ts
vendored
|
@ -225,7 +225,7 @@ export const assetStub = {
|
||||||
deviceId: 'device-id',
|
deviceId: 'device-id',
|
||||||
originalPath: '/data/user1/photo.jpg',
|
originalPath: '/data/user1/photo.jpg',
|
||||||
previewPath: '/uploads/user-id/thumbs/path.jpg',
|
previewPath: '/uploads/user-id/thumbs/path.jpg',
|
||||||
checksum: Buffer.from('file hash', 'utf8'),
|
checksum: Buffer.from('path hash', 'utf8'),
|
||||||
type: AssetType.IMAGE,
|
type: AssetType.IMAGE,
|
||||||
thumbnailPath: '/uploads/user-id/webp/path.ext',
|
thumbnailPath: '/uploads/user-id/webp/path.ext',
|
||||||
thumbhash: Buffer.from('blablabla', 'base64'),
|
thumbhash: Buffer.from('blablabla', 'base64'),
|
||||||
|
@ -295,6 +295,46 @@ export const assetStub = {
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
externalOffline: Object.freeze<AssetEntity>({
|
||||||
|
id: 'asset-id',
|
||||||
|
deviceAssetId: 'device-asset-id',
|
||||||
|
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||||
|
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||||
|
owner: userStub.user1,
|
||||||
|
ownerId: 'user-id',
|
||||||
|
deviceId: 'device-id',
|
||||||
|
originalPath: '/data/user1/photo.jpg',
|
||||||
|
previewPath: '/uploads/user-id/thumbs/path.jpg',
|
||||||
|
checksum: Buffer.from('path hash', 'utf8'),
|
||||||
|
type: AssetType.IMAGE,
|
||||||
|
thumbnailPath: '/uploads/user-id/webp/path.ext',
|
||||||
|
thumbhash: Buffer.from('blablabla', 'base64'),
|
||||||
|
encodedVideoPath: null,
|
||||||
|
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||||
|
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||||
|
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
|
||||||
|
isFavorite: true,
|
||||||
|
isArchived: false,
|
||||||
|
isReadOnly: false,
|
||||||
|
isExternal: true,
|
||||||
|
duration: null,
|
||||||
|
isVisible: true,
|
||||||
|
livePhotoVideo: null,
|
||||||
|
livePhotoVideoId: null,
|
||||||
|
isOffline: true,
|
||||||
|
libraryId: 'library-id',
|
||||||
|
library: libraryStub.externalLibrary1,
|
||||||
|
tags: [],
|
||||||
|
sharedLinks: [],
|
||||||
|
originalFileName: 'asset-id.jpg',
|
||||||
|
faces: [],
|
||||||
|
sidecarPath: null,
|
||||||
|
exifInfo: {
|
||||||
|
fileSizeInByte: 5000,
|
||||||
|
} as ExifEntity,
|
||||||
|
deletedAt: null,
|
||||||
|
}),
|
||||||
|
|
||||||
image1: Object.freeze<AssetEntity>({
|
image1: Object.freeze<AssetEntity>({
|
||||||
id: 'asset-id-1',
|
id: 'asset-id-1',
|
||||||
deviceAssetId: 'device-asset-id',
|
deviceAssetId: 'device-asset-id',
|
||||||
|
|
|
@ -20,7 +20,7 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
|
||||||
getAll: jest.fn().mockResolvedValue({ items: [], hasNextPage: false }),
|
getAll: jest.fn().mockResolvedValue({ items: [], hasNextPage: false }),
|
||||||
getAllByDeviceId: jest.fn(),
|
getAllByDeviceId: jest.fn(),
|
||||||
updateAll: jest.fn(),
|
updateAll: jest.fn(),
|
||||||
getLibraryAssetPaths: jest.fn(),
|
getExternalLibraryAssetPaths: jest.fn(),
|
||||||
getByLibraryIdAndOriginalPath: jest.fn(),
|
getByLibraryIdAndOriginalPath: jest.fn(),
|
||||||
deleteAll: jest.fn(),
|
deleteAll: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
|
|
Loading…
Add table
Reference in a new issue