1
0
Fork 0
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:
Kevin Huang 2024-04-14 16:55:44 -07:00 committed by GitHub
parent a903898781
commit 85df3f1e99
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 61 additions and 17 deletions

View file

@ -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>;

View file

@ -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)

View file

@ -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 },
}); });
} }

View file

@ -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];

View file

@ -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();
}); });

View file

@ -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`);

View file

@ -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',

View file

@ -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(),