From 91e416b104fe8bc56f77aa747f4aedc9114ca5b5 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Fri, 30 Oct 2026 15:26:04 +0000 Subject: [PATCH] fix(server): live photo match library (#32080) (cherry picked from commit 05658ecba8679f54c4b95deeec3940b54ab0ecf7) --- server/src/repositories/asset.repository.ts | 3 +- .../repositories/asset.repository.spec.ts | 57 ++++++++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 032f2ca39..c43497cec 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -698,13 +698,14 @@ export class AssetRepository { } findLivePhotoMatch(options: LivePhotoSearchOptions) { - const { ownerId, otherAssetId, livePhotoCID, type } = options; + const { ownerId, libraryId, otherAssetId, livePhotoCID, type } = options; return this.db .selectFrom('asset') .select(['asset.id', 'asset.ownerId']) .innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId') .where('id', '!=', asUuid(otherAssetId)) .where('ownerId', '=', asUuid(ownerId)) + .$call((qb) => (libraryId ? qb.where('libraryId', '=', asUuid(libraryId)) : qb.where('libraryId', 'is', null))) .where('type', '=', type) .where('asset_exif.livePhotoCID', '=', livePhotoCID) .limit(1) diff --git a/server/test/medium/specs/repositories/asset.repository.spec.ts b/server/test/medium/specs/repositories/asset.repository.spec.ts index 5be638597..c26b937de 100644 --- a/server/test/medium/specs/repositories/asset.repository.spec.ts +++ b/server/test/medium/specs/repositories/asset.repository.spec.ts @@ -1,6 +1,7 @@ import { Kysely } from 'kysely'; -import { AssetFileType, AssetOrder, AssetOrderBy, AssetVisibility } from 'src/enum.js'; +import { AssetFileType, AssetOrder, AssetOrderBy, AssetType, AssetVisibility } from 'src/enum.js'; import { AssetRepository } from 'src/repositories/asset.repository.js'; +import { LibraryRepository } from 'src/repositories/library.repository.js'; import { LoggingRepository } from 'src/repositories/logging.repository.js'; import { DB } from 'src/schema/index.js'; import { BaseService } from 'src/services/base.service.js'; @@ -564,6 +565,60 @@ describe(AssetRepository.name, () => { }); }); + describe('findLivePhotoMatch', () => { + type LibraryKind = 'upload' | 'external' | 'other external'; + + const setupLivePhoto = async ({ still, motion }: { still: LibraryKind; motion: LibraryKind }) => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const newLibrary = () => + ctx + .get(LibraryRepository) + .create({ ownerId: user.id, name: 'library', importPaths: [], exclusionPatterns: [] }); + const [external, otherExternal] = await Promise.all([newLibrary(), newLibrary()]); + const libraryIds = { upload: null, external: external.id, 'other external': otherExternal.id }; + const livePhotoCID = 'live-photo-cid'; + + const [{ asset: stillAsset }, { asset: motionAsset }] = await Promise.all([ + ctx.newAsset({ ownerId: user.id, type: AssetType.Image, libraryId: libraryIds[still] }), + ctx.newAsset({ ownerId: user.id, type: AssetType.Video, libraryId: libraryIds[motion] }), + ]); + await Promise.all([ + ctx.newExif({ assetId: stillAsset.id, livePhotoCID }), + ctx.newExif({ assetId: motionAsset.id, livePhotoCID }), + ]); + + const match = await sut.findLivePhotoMatch({ + ownerId: user.id, + libraryId: libraryIds[still], + livePhotoCID, + otherAssetId: stillAsset.id, + type: AssetType.Video, + }); + + return { match, motionAsset }; + }; + + it.each(['upload', 'external'])( + 'should match a motion video in the same %s library', + async (library) => { + const { match, motionAsset } = await setupLivePhoto({ still: library, motion: library }); + + expect(match).toEqual({ id: motionAsset.id, ownerId: motionAsset.ownerId }); + }, + ); + + it.each<{ still: LibraryKind; motion: LibraryKind }>([ + { still: 'upload', motion: 'external' }, + { still: 'external', motion: 'upload' }, + { still: 'external', motion: 'other external' }, + ])('should not match a still in the $still library to a motion video in the $motion library', async (libraries) => { + const { match } = await setupLivePhoto(libraries); + + expect(match).toBeUndefined(); + }); + }); + describe('createAll', () => { it('should return an empty array when given an empty input', async () => { const { sut } = setup();