diff --git a/server/e2e/api/specs/asset.e2e-spec.ts b/server/e2e/api/specs/asset.e2e-spec.ts index 4e7dc03b08..389d66f3ed 100644 --- a/server/e2e/api/specs/asset.e2e-spec.ts +++ b/server/e2e/api/specs/asset.e2e-spec.ts @@ -10,7 +10,7 @@ import { usePagination, } from '@app/domain'; import { AssetController } from '@app/immich'; -import { AssetEntity, AssetType, SharedLinkType } from '@app/infra/entities'; +import { AssetEntity, AssetStackEntity, AssetType, SharedLinkType } from '@app/infra/entities'; import { AssetRepository } from '@app/infra/repositories'; import { INestApplication } from '@nestjs/common'; import { errorStub, userDto, uuidStub } from '@test/fixtures'; @@ -94,32 +94,7 @@ describe(`${AssetController.name} (e2e)`, () => { }); beforeEach(async () => { - await testApp.reset({ entities: [AssetEntity] }); - - [asset1, asset2, asset3, asset4, asset5] = await Promise.all([ - createAsset(user1, new Date('1970-01-01')), - createAsset(user1, new Date('1970-02-10')), - createAsset(user1, new Date('1970-02-11'), { - isFavorite: true, - isArchived: true, - isExternal: true, - isReadOnly: true, - type: AssetType.VIDEO, - fileCreatedAt: yesterday.toJSDate(), - fileModifiedAt: yesterday.toJSDate(), - createdAt: yesterday.toJSDate(), - updatedAt: yesterday.toJSDate(), - localDateTime: yesterday.toJSDate(), - }), - createAsset(user2, new Date('1970-01-01')), - createAsset(user1, new Date('1970-01-01'), { - deletedAt: yesterday.toJSDate(), - }), - ]); - }); - - beforeEach(async () => { - await testApp.reset({ entities: [AssetEntity] }); + await testApp.reset({ entities: [AssetEntity, AssetStackEntity] }); [asset1, asset2, asset3, asset4, asset5] = await Promise.all([ createAsset(user1, new Date('1970-01-01')), @@ -571,11 +546,7 @@ describe(`${AssetController.name} (e2e)`, () => { .get(`/asset/assetById/${asset1.id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); - expect(body).toMatchObject({ - id: asset1.id, - stack: [], - stackCount: 0, - }); + expect(body).toMatchObject({ id: asset1.id }); }); it('should work with a shared link', async () => { @@ -586,11 +557,7 @@ describe(`${AssetController.name} (e2e)`, () => { const { status, body } = await request(server).get(`/asset/assetById/${asset1.id}?key=${sharedLink.key}`); expect(status).toBe(200); - expect(body).toMatchObject({ - id: asset1.id, - stack: [], - stackCount: 0, - }); + expect(body).toMatchObject({ id: asset1.id }); }); }); @@ -622,11 +589,7 @@ describe(`${AssetController.name} (e2e)`, () => { .get(`/asset/${asset1.id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); - expect(body).toMatchObject({ - id: asset1.id, - stack: [], - stackCount: 0, - }); + expect(body).toMatchObject({ id: asset1.id }); }); it('should work with a shared link', async () => { @@ -637,11 +600,7 @@ describe(`${AssetController.name} (e2e)`, () => { const { status, body } = await request(server).get(`/asset/${asset1.id}?key=${sharedLink.key}`); expect(status).toBe(200); - expect(body).toMatchObject({ - id: asset1.id, - stack: [], - stackCount: 0, - }); + expect(body).toMatchObject({ id: asset1.id }); }); }); @@ -1371,7 +1330,7 @@ describe(`${AssetController.name} (e2e)`, () => { expect(status).toBe(204); const asset = await api.assetApi.get(server, user1.accessToken, asset1.id); - expect(asset.stack).toHaveLength(0); + expect(asset.stack).toBeUndefined(); }); it('should merge stack children', async () => { diff --git a/server/e2e/api/utils.ts b/server/e2e/api/utils.ts index d8027ab251..5dffea98f6 100644 --- a/server/e2e/api/utils.ts +++ b/server/e2e/api/utils.ts @@ -30,6 +30,9 @@ export const db = { .map((entity) => entity.tableName) .filter((tableName) => !tableName.startsWith('geodata')); + if (tableNames.includes('asset_stack')) { + await em.query(`DELETE FROM "asset_stack" CASCADE;`); + } let deleteUsers = false; for (const tableName of tableNames) { if (tableName === 'users') { diff --git a/server/package.json b/server/package.json index 6a14c2eb65..43486a8a98 100644 --- a/server/package.json +++ b/server/package.json @@ -156,5 +156,8 @@ "^@app/domain(|/.*)$": "/src/domain/$1" }, "globalSetup": "/test/global-setup.js" + }, + "volta": { + "node": "20.11.0" } } diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index 0fed93c46e..3cbe2068b6 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -2,11 +2,13 @@ import { AssetEntity, AssetType } from '@app/infra/entities'; import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { IAccessRepositoryMock, + assetStackStub, assetStub, authStub, faceStub, newAccessRepositoryMock, newAssetRepositoryMock, + newAssetStackRepositoryMock, newCommunicationRepositoryMock, newJobRepositoryMock, newPartnerRepositoryMock, @@ -20,6 +22,7 @@ import { AssetStats, ClientEvent, IAssetRepository, + IAssetStackRepository, ICommunicationRepository, IJobRepository, IPartnerRepository, @@ -160,6 +163,7 @@ describe(AssetService.name, () => { let communicationMock: jest.Mocked; let configMock: jest.Mocked; let partnerMock: jest.Mocked; + let assetStackMock: jest.Mocked; it('should work', () => { expect(sut).toBeDefined(); @@ -174,6 +178,7 @@ describe(AssetService.name, () => { userMock = newUserRepositoryMock(); configMock = newSystemConfigRepositoryMock(); partnerMock = newPartnerRepositoryMock(); + assetStackMock = newAssetStackRepositoryMock(); sut = new AssetService( accessMock, @@ -184,6 +189,7 @@ describe(AssetService.name, () => { userMock, communicationMock, partnerMock, + assetStackMock, ); when(assetMock.getById) @@ -578,65 +584,121 @@ describe(AssetService.name, () => { ).rejects.toBeInstanceOf(BadRequestException); }); - it('should update parent asset when children are added', async () => { + it('should update parent asset updatedAt when children are added', async () => { accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['parent'])); + when(assetMock.getById) + .calledWith('parent', { stack: { assets: true } }) + .mockResolvedValue(assetStub.image); await sut.updateAll(authStub.user1, { ids: [], stackParentId: 'parent', }), - expect(assetMock.updateAll).toHaveBeenCalledWith(['parent'], { stackParentId: null }); + expect(assetMock.updateAll).toHaveBeenCalledWith(['parent'], { updatedAt: expect.any(Date) }); }); it('should update parent asset when children are removed', async () => { accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['child-1'])); - assetMock.getByIds.mockResolvedValue([{ id: 'child-1', stackParentId: 'parent' } as AssetEntity]); + assetMock.getByIds.mockResolvedValue([ + { + id: 'child-1', + stackId: 'stack-1', + stack: assetStackStub('stack-1', [{ id: 'parent' } as AssetEntity, { id: 'child-1' } as AssetEntity]), + } as AssetEntity, + ]); + when(assetStackMock.getById) + .calledWith('stack-1') + .mockResolvedValue(assetStackStub('stack-1', [{ id: 'parent' } as AssetEntity])); await sut.updateAll(authStub.user1, { ids: ['child-1'], removeParent: true, - }), - expect(assetMock.updateAll).toHaveBeenCalledWith(expect.arrayContaining(['parent']), { stackParentId: null }); + }); + expect(assetMock.updateAll).toHaveBeenCalledWith(expect.arrayContaining(['child-1']), { stack: null }); + expect(assetMock.updateAll).toHaveBeenCalledWith(expect.arrayContaining(['parent']), { + updatedAt: expect.any(Date), + }); + expect(assetStackMock.delete).toHaveBeenCalledWith('stack-1'); }); it('update parentId for new children', async () => { accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['child-1', 'child-2'])); accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['parent'])); + const stack = assetStackStub('stack-1', [ + { id: 'parent' } as AssetEntity, + { id: 'child-1' } as AssetEntity, + { id: 'child-2' } as AssetEntity, + ]); + when(assetMock.getById) + .calledWith('parent', { stack: { assets: true } }) + .mockResolvedValue({ + id: 'child-1', + stack, + } as AssetEntity); + await sut.updateAll(authStub.user1, { stackParentId: 'parent', ids: ['child-1', 'child-2'], }); - expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: 'parent' }); + expect(assetStackMock.update).toHaveBeenCalledWith({ + ...assetStackStub('stack-1', [ + { id: 'child-1' } as AssetEntity, + { id: 'child-2' } as AssetEntity, + { id: 'parent' } as AssetEntity, + ]), + primaryAsset: undefined, + }); + expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2', 'parent'], { updatedAt: expect.any(Date) }); }); - it('nullify parentId for remove children', async () => { + it('remove stack for removed children', async () => { accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['child-1', 'child-2'])); await sut.updateAll(authStub.user1, { removeParent: true, ids: ['child-1', 'child-2'], }); - expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: null }); + expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stack: null }); }); it('merge stacks if new child has children', async () => { accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['child-1'])); accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['parent'])); + when(assetMock.getById) + .calledWith('parent', { stack: { assets: true } }) + .mockResolvedValue({ ...assetStub.image, id: 'parent' }); assetMock.getByIds.mockResolvedValue([ - { id: 'child-1', stack: [{ id: 'child-2' } as AssetEntity] } as AssetEntity, + { + id: 'child-1', + stackId: 'stack-1', + stack: assetStackStub('stack-1', [{ id: 'child-1' } as AssetEntity, { id: 'child-2' } as AssetEntity]), + } as AssetEntity, ]); + when(assetStackMock.getById) + .calledWith('stack-1') + .mockResolvedValue(assetStackStub('stack-1', [{ id: 'parent' } as AssetEntity])); await sut.updateAll(authStub.user1, { ids: ['child-1'], stackParentId: 'parent', }); - expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: 'parent' }); + expect(assetStackMock.delete).toHaveBeenCalledWith('stack-1'); + expect(assetStackMock.create).toHaveBeenCalledWith({ + assets: [{ id: 'child-1' }, { id: 'parent' }, { id: 'child-1' }, { id: 'child-2' }], + primaryAssetId: 'parent', + }); + expect(assetMock.updateAll).toBeCalledWith(['child-1', 'parent', 'child-1', 'child-2'], { + updatedAt: expect.any(Date), + }); }); it('should send ws asset update event', async () => { accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['asset-1'])); accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['parent'])); + when(assetMock.getById) + .calledWith('parent', { stack: { assets: true } }) + .mockResolvedValue(assetStub.image); await sut.updateAll(authStub.user1, { ids: ['asset-1'], @@ -645,6 +707,7 @@ describe(AssetService.name, () => { expect(communicationMock.send).toHaveBeenCalledWith(ClientEvent.ASSET_UPDATE, authStub.user1.user.id, [ 'asset-1', + 'parent', ]); }); }); @@ -702,7 +765,7 @@ describe(AssetService.name, () => { person: true, }, library: true, - stack: true, + stack: { assets: true }, exifInfo: true, }) .mockResolvedValue(assetWithFace); @@ -729,25 +792,23 @@ describe(AssetService.name, () => { expect(assetMock.remove).toHaveBeenCalledWith(assetWithFace); }); - it('should update stack parent if asset has stack children', async () => { + it('should update stack primary asset if deleted asset was primary asset in a stack', async () => { when(assetMock.getById) .calledWith(assetStub.primaryImage.id, { faces: { person: true, }, library: true, - stack: true, + stack: { assets: true }, exifInfo: true, }) - .mockResolvedValue(assetStub.primaryImage); + .mockResolvedValue(assetStub.primaryImage as AssetEntity); await sut.handleAssetDeletion({ id: assetStub.primaryImage.id }); - expect(assetMock.updateAll).toHaveBeenCalledWith(['stack-child-asset-2'], { - stackParentId: 'stack-child-asset-1', - }); - expect(assetMock.updateAll).toHaveBeenCalledWith(['stack-child-asset-1'], { - stackParentId: null, + expect(assetStackMock.update).toHaveBeenCalledWith({ + id: 'stack-1', + primaryAssetId: 'stack-child-asset-1', }); }); @@ -758,7 +819,7 @@ describe(AssetService.name, () => { person: true, }, library: true, - stack: true, + stack: { assets: true }, exifInfo: true, }) .mockResolvedValue(assetStub.readOnly); @@ -787,7 +848,7 @@ describe(AssetService.name, () => { person: true, }, library: true, - stack: true, + stack: { assets: true }, exifInfo: true, }) .mockResolvedValue(assetStub.external); @@ -819,7 +880,7 @@ describe(AssetService.name, () => { person: true, }, library: true, - stack: true, + stack: { assets: true }, exifInfo: true, }) .mockResolvedValue(assetStub.livePhotoStillAsset); @@ -829,7 +890,7 @@ describe(AssetService.name, () => { person: true, }, library: true, - stack: true, + stack: { assets: true }, exifInfo: true, }) .mockResolvedValue(assetStub.livePhotoMotionAsset); @@ -864,7 +925,7 @@ describe(AssetService.name, () => { person: true, }, library: true, - stack: true, + stack: { assets: true }, exifInfo: true, }) .mockResolvedValue(assetStub.image); @@ -927,54 +988,21 @@ describe(AssetService.name, () => { person: true, }, library: true, - stack: true, + stack: { + assets: true, + }, }) - .mockResolvedValue(assetStub.image as AssetEntity); + .mockResolvedValue({ ...assetStub.image, stackId: 'stack-1' }); await sut.updateStackParent(authStub.user1, { oldParentId: assetStub.image.id, newParentId: 'new', }); - expect(assetMock.updateAll).toBeCalledWith([assetStub.image.id], { stackParentId: 'new' }); - }); - - it('remove stackParentId of new parent', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set([assetStub.primaryImage.id])); - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['new'])); - - await sut.updateStackParent(authStub.user1, { - oldParentId: assetStub.primaryImage.id, - newParentId: 'new', + expect(assetStackMock.update).toBeCalledWith({ id: 'stack-1', primaryAssetId: 'new' }); + expect(assetMock.updateAll).toBeCalledWith([assetStub.image.id, 'new', assetStub.image.id], { + updatedAt: expect.any(Date), }); - - expect(assetMock.updateAll).toBeCalledWith(['new'], { stackParentId: null }); - }); - - it('update stackParentId of old parents children to new parent', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set([assetStub.primaryImage.id])); - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['new'])); - when(assetMock.getById) - .calledWith(assetStub.primaryImage.id, { - faces: { - person: true, - }, - library: true, - stack: true, - }) - .mockResolvedValue(assetStub.primaryImage as AssetEntity); - - await sut.updateStackParent(authStub.user1, { - oldParentId: assetStub.primaryImage.id, - newParentId: 'new', - }); - - expect(assetMock.updateAll).toBeCalledWith( - [assetStub.primaryImage.id, 'stack-child-asset-1', 'stack-child-asset-2'], - { - stackParentId: 'new', - }, - ); }); }); diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 684270e232..4d1abe1872 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -14,6 +14,7 @@ import { ClientEvent, IAccessRepository, IAssetRepository, + IAssetStackRepository, ICommunicationRepository, IJobRepository, IPartnerRepository, @@ -85,6 +86,7 @@ export class AssetService { @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository, @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, + @Inject(IAssetStackRepository) private assetStackRepository: IAssetStackRepository, ) { this.access = AccessCore.create(accessRepository); this.configCore = SystemConfigCore.create(configRepository); @@ -299,7 +301,9 @@ export class AssetService { person: true, }, stack: { - exifInfo: true, + assets: { + exifInfo: true, + }, }, }); @@ -338,25 +342,51 @@ export class AssetService { const { ids, removeParent, dateTimeOriginal, latitude, longitude, ...options } = dto; await this.access.requirePermission(auth, Permission.ASSET_UPDATE, ids); + // TODO: refactor this logic into separate API calls POST /stack, PUT /stack, etc. + const stackIdsToCheckForDelete: string[] = []; if (removeParent) { - (options as Partial).stackParentId = null; + (options as Partial).stack = null; const assets = await this.assetRepository.getByIds(ids); + stackIdsToCheckForDelete.push(...new Set(assets.filter((a) => !!a.stackId).map((a) => a.stackId!))); // This updates the updatedAt column of the parents to indicate that one of its children is removed // All the unique parent's -> parent is set to null - ids.push(...new Set(assets.filter((a) => !!a.stackParentId).map((a) => a.stackParentId!))); + await this.assetRepository.updateAll( + assets.filter((a) => !!a.stack?.primaryAssetId).map((a) => a.stack!.primaryAssetId!), + { updatedAt: new Date() }, + ); } else if (options.stackParentId) { + //Creating new stack if parent doesn't have one already. If it does, then we add to the existing stack await this.access.requirePermission(auth, Permission.ASSET_UPDATE, options.stackParentId); + const primaryAsset = await this.assetRepository.getById(options.stackParentId, { stack: { assets: true } }); + if (!primaryAsset) { + throw new BadRequestException('Asset not found for given stackParentId'); + } + let stack = primaryAsset.stack; + + ids.push(options.stackParentId); + const assets = await this.assetRepository.getByIds(ids, { stack: { assets: true } }); + stackIdsToCheckForDelete.push( + ...new Set(assets.filter((a) => !!a.stackId && stack?.id !== a.stackId).map((a) => a.stackId!)), + ); + const assetsWithChildren = assets.filter((a) => a.stack && a.stack.assets.length > 0); + ids.push(...assetsWithChildren.flatMap((child) => child.stack!.assets.map((gChild) => gChild.id))); + + if (!stack) { + stack = await this.assetStackRepository.create({ + primaryAssetId: primaryAsset.id, + assets: ids.map((id) => ({ id }) as AssetEntity), + }); + } else { + await this.assetStackRepository.update({ + id: stack.id, + primaryAssetId: primaryAsset.id, + assets: ids.map((id) => ({ id }) as AssetEntity), + }); + } + // Merge stacks - const assets = await this.assetRepository.getByIds(ids); - const assetsWithChildren = assets.filter((a) => a.stack && a.stack.length > 0); - ids.push(...assetsWithChildren.flatMap((child) => child.stack!.map((gChild) => gChild.id))); - - // This updates the updatedAt column of the parent to indicate that a new child has been added - await this.assetRepository.updateAll([options.stackParentId], { stackParentId: null }); - } - - for (const id of ids) { - await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude }); + options.stackParentId = undefined; + (options as Partial).updatedAt = new Date(); } for (const id of ids) { @@ -364,6 +394,12 @@ export class AssetService { } await this.assetRepository.updateAll(ids, options); + const stacksToDelete = ( + await Promise.all(stackIdsToCheckForDelete.map((id) => this.assetStackRepository.getById(id))) + ) + .flatMap((stack) => (stack ? [stack] : [])) + .filter((stack) => stack.assets.length < 2); + await Promise.all(stacksToDelete.map((as) => this.assetStackRepository.delete(as.id))); this.communicationRepository.send(ClientEvent.ASSET_UPDATE, auth.user.id, ids); } @@ -394,7 +430,7 @@ export class AssetService { person: true, }, library: true, - stack: true, + stack: { assets: true }, exifInfo: true, }); @@ -408,11 +444,17 @@ export class AssetService { } // Replace the parent of the stack children with a new asset - if (asset.stack && asset.stack.length != 0) { - const stackIds = asset.stack.map((a) => a.id); - const newParentId = stackIds[0]; - await this.assetRepository.updateAll(stackIds.slice(1), { stackParentId: newParentId }); - await this.assetRepository.updateAll([newParentId], { stackParentId: null }); + if (asset.stack?.primaryAssetId === id) { + const stackAssetIds = asset.stack.assets.map((a) => a.id); + if (stackAssetIds.length > 2) { + const newPrimaryAssetId = stackAssetIds.find((a) => a !== id)!; + await this.assetStackRepository.update({ + id: asset.stack.id, + primaryAssetId: newPrimaryAssetId, + }); + } else { + await this.assetStackRepository.delete(asset.stack.id); + } } await this.assetRepository.remove(asset); @@ -460,18 +502,25 @@ export class AssetService { person: true, }, library: true, - stack: true, + stack: { + assets: true, + }, }); + if (!oldParent?.stackId) { + throw new Error('Asset not found or not in a stack'); + } if (oldParent != null) { childIds.push(oldParent.id); // Get all children of old parent - childIds.push(...(oldParent.stack?.map((a) => a.id) ?? [])); + childIds.push(...(oldParent.stack?.assets.map((a) => a.id) ?? [])); } + await this.assetStackRepository.update({ + id: oldParent.stackId, + primaryAssetId: newParentId, + }); - this.communicationRepository.send(ClientEvent.ASSET_UPDATE, auth.user.id, [...childIds, newParentId]); - await this.assetRepository.updateAll(childIds, { stackParentId: newParentId }); - // Remove ParentId of new parent if this was previously a child of some other asset - return this.assetRepository.updateAll([newParentId], { stackParentId: null }); + this.communicationRepository.send(ClientEvent.ASSET_UPDATE, auth.user.id, [...childIds, newParentId, oldParentId]); + await this.assetRepository.updateAll([oldParentId, newParentId, ...childIds], { updatedAt: new Date() }); } async run(auth: AuthDto, dto: AssetJobsDto) { diff --git a/server/src/domain/asset/response-dto/asset-response.dto.ts b/server/src/domain/asset/response-dto/asset-response.dto.ts index 666168a195..d70e5963c4 100644 --- a/server/src/domain/asset/response-dto/asset-response.dto.ts +++ b/server/src/domain/asset/response-dto/asset-response.dto.ts @@ -116,9 +116,13 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As tags: entity.tags?.map(mapTag), people: peopleWithFaces(entity.faces), checksum: entity.checksum.toString('base64'), - stackParentId: entity.stackParentId, - stack: withStack ? entity.stack?.map((a) => mapAsset(a, { stripMetadata })) ?? undefined : undefined, - stackCount: entity.stack?.length ?? null, + stackParentId: withStack ? entity.stack?.primaryAssetId : undefined, + stack: withStack + ? entity.stack?.assets + .filter((a) => a.id !== entity.stack?.primaryAssetId) + .map((a) => mapAsset(a, { stripMetadata })) + : undefined, + stackCount: entity.stack?.assets?.length ?? null, isExternal: entity.isExternal, isOffline: entity.isOffline, isReadOnly: entity.isReadOnly, diff --git a/server/src/domain/metadata/metadata.service.spec.ts b/server/src/domain/metadata/metadata.service.spec.ts index d1765813bd..9a1e11893f 100644 --- a/server/src/domain/metadata/metadata.service.spec.ts +++ b/server/src/domain/metadata/metadata.service.spec.ts @@ -499,6 +499,7 @@ describe(MetadataService.name, () => { expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: assetStub.image.id, bitsPerSample: expect.any(Number), + autoStackId: null, colorspace: tags.ColorSpace, dateTimeOriginal: new Date('1970-01-01'), description: tags.ImageDescription, diff --git a/server/src/domain/metadata/metadata.service.ts b/server/src/domain/metadata/metadata.service.ts index c14055b6ae..00d8412c8d 100644 --- a/server/src/domain/metadata/metadata.service.ts +++ b/server/src/domain/metadata/metadata.service.ts @@ -499,6 +499,7 @@ export class MetadataService { latitude: validate(tags.GPSLatitude), lensModel: tags.LensModel ?? null, livePhotoCID: (tags.ContentIdentifier || tags.MediaGroupUUID) ?? null, + autoStackId: this.getAutoStackId(tags), longitude: validate(tags.GPSLongitude), make: tags.Make ?? null, model: tags.Model ?? null, @@ -518,6 +519,13 @@ export class MetadataService { return { exifData, tags }; } + private getAutoStackId(tags: ImmichTags | null): string | null { + if (!tags) { + return null; + } + return tags.BurstID ?? tags.BurstUUID ?? tags.CameraBurstID ?? tags.MediaUniqueID ?? null; + } + private getDateTimeOriginal(tags: ImmichTags | Tags | null) { if (!tags) { return null; diff --git a/server/src/domain/repositories/asset-stack.repository.ts b/server/src/domain/repositories/asset-stack.repository.ts new file mode 100644 index 0000000000..66201ea3af --- /dev/null +++ b/server/src/domain/repositories/asset-stack.repository.ts @@ -0,0 +1,10 @@ +import { AssetStackEntity } from '@app/infra/entities/asset-stack.entity'; + +export const IAssetStackRepository = 'IAssetStackRepository'; + +export interface IAssetStackRepository { + create(assetStack: Partial): Promise; + update(asset: Pick & Partial): Promise; + delete(id: string): Promise; + getById(id: string): Promise; +} diff --git a/server/src/domain/repositories/index.ts b/server/src/domain/repositories/index.ts index 997cd4e8b5..48b1d7e8e2 100644 --- a/server/src/domain/repositories/index.ts +++ b/server/src/domain/repositories/index.ts @@ -2,6 +2,7 @@ export * from './access.repository'; export * from './activity.repository'; export * from './album.repository'; export * from './api-key.repository'; +export * from './asset-stack.repository'; export * from './asset.repository'; export * from './audit.repository'; export * from './communication.repository'; diff --git a/server/src/immich/api-v1/asset/asset-repository.ts b/server/src/immich/api-v1/asset/asset-repository.ts index 39701ef2f5..0b8f9d583f 100644 --- a/server/src/immich/api-v1/asset/asset-repository.ts +++ b/server/src/immich/api-v1/asset/asset-repository.ts @@ -108,7 +108,7 @@ export class AssetRepositoryV1 implements IAssetRepositoryV1 { relations: { exifInfo: true, tags: true, - stack: true, + stack: { assets: true }, }, skip: dto.skip || 0, take: dto.take, diff --git a/server/src/infra/entities/asset-stack.entity.ts b/server/src/infra/entities/asset-stack.entity.ts new file mode 100644 index 0000000000..d005fc0a59 --- /dev/null +++ b/server/src/infra/entities/asset-stack.entity.ts @@ -0,0 +1,19 @@ +import { Column, Entity, JoinColumn, OneToMany, OneToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { AssetEntity } from './asset.entity'; + +@Entity('asset_stack') +export class AssetStackEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @OneToMany(() => AssetEntity, (asset) => asset.stack) + assets!: AssetEntity[]; + + @OneToOne(() => AssetEntity) + @JoinColumn() + //TODO: Add constraint to ensure primary asset exists in the assets array + primaryAsset!: AssetEntity; + + @Column({ nullable: false }) + primaryAssetId!: string; +} diff --git a/server/src/infra/entities/asset.entity.ts b/server/src/infra/entities/asset.entity.ts index ea1ed123f1..10973be74e 100644 --- a/server/src/infra/entities/asset.entity.ts +++ b/server/src/infra/entities/asset.entity.ts @@ -16,6 +16,7 @@ import { import { AlbumEntity } from './album.entity'; import { AssetFaceEntity } from './asset-face.entity'; import { AssetJobStatusEntity } from './asset-job-status.entity'; +import { AssetStackEntity } from './asset-stack.entity'; import { ExifEntity } from './exif.entity'; import { LibraryEntity } from './library.entity'; import { SharedLinkEntity } from './shared-link.entity'; @@ -34,7 +35,6 @@ export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_library_checksum'; @Index('IDX_day_of_month', { synchronize: false }) @Index('IDX_month', { synchronize: false }) @Index('IDX_originalPath_libraryId', ['originalPath', 'libraryId']) -@Index(['stackParentId']) // For all assets, each originalpath must be unique per user and library export class AssetEntity { @PrimaryGeneratedColumn('uuid') @@ -157,14 +157,11 @@ export class AssetEntity { faces!: AssetFaceEntity[]; @Column({ nullable: true }) - stackParentId?: string | null; + stackId?: string | null; - @ManyToOne(() => AssetEntity, (asset) => asset.stack, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' }) - @JoinColumn({ name: 'stackParentId' }) - stackParent?: AssetEntity | null; - - @OneToMany(() => AssetEntity, (asset) => asset.stackParent) - stack?: AssetEntity[]; + @ManyToOne(() => AssetStackEntity, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' }) + @JoinColumn() + stack?: AssetStackEntity | null; @OneToOne(() => AssetJobStatusEntity, (jobStatus) => jobStatus.asset, { nullable: true }) jobStatus?: AssetJobStatusEntity; diff --git a/server/src/infra/entities/exif.entity.ts b/server/src/infra/entities/exif.entity.ts index 7b465c3c50..8d094ad414 100644 --- a/server/src/infra/entities/exif.entity.ts +++ b/server/src/infra/entities/exif.entity.ts @@ -54,6 +54,10 @@ export class ExifEntity { @Column({ type: 'varchar', nullable: true }) livePhotoCID!: string | null; + @Index('IDX_auto_stack_id') + @Column({ type: 'varchar', nullable: true }) + autoStackId!: string | null; + @Column({ type: 'varchar', nullable: true }) state!: string | null; diff --git a/server/src/infra/entities/index.ts b/server/src/infra/entities/index.ts index c4252b655b..957e15a887 100644 --- a/server/src/infra/entities/index.ts +++ b/server/src/infra/entities/index.ts @@ -1,13 +1,14 @@ -import { GeodataAdmin2Entity } from '@app/infra/entities/geodata-admin2.entity'; import { ActivityEntity } from './activity.entity'; import { AlbumEntity } from './album.entity'; import { APIKeyEntity } from './api-key.entity'; import { AssetFaceEntity } from './asset-face.entity'; import { AssetJobStatusEntity } from './asset-job-status.entity'; +import { AssetStackEntity } from './asset-stack.entity'; import { AssetEntity } from './asset.entity'; import { AuditEntity } from './audit.entity'; import { ExifEntity } from './exif.entity'; import { GeodataAdmin1Entity } from './geodata-admin1.entity'; +import { GeodataAdmin2Entity } from './geodata-admin2.entity'; import { GeodataPlacesEntity } from './geodata-places.entity'; import { LibraryEntity } from './library.entity'; import { MoveEntity } from './move.entity'; @@ -27,6 +28,7 @@ export * from './album.entity'; export * from './api-key.entity'; export * from './asset-face.entity'; export * from './asset-job-status.entity'; +export * from './asset-stack.entity'; export * from './asset.entity'; export * from './audit.entity'; export * from './exif.entity'; @@ -51,6 +53,7 @@ export const databaseEntities = [ AlbumEntity, APIKeyEntity, AssetEntity, + AssetStackEntity, AssetFaceEntity, AssetJobStatusEntity, AuditEntity, diff --git a/server/src/infra/infra.module.ts b/server/src/infra/infra.module.ts index caa4257287..93cb8fb681 100644 --- a/server/src/infra/infra.module.ts +++ b/server/src/infra/infra.module.ts @@ -3,6 +3,7 @@ import { IActivityRepository, IAlbumRepository, IAssetRepository, + IAssetStackRepository, IAuditRepository, ICommunicationRepository, ICryptoRepository, @@ -41,6 +42,7 @@ import { AlbumRepository, ApiKeyRepository, AssetRepository, + AssetStackRepository, AuditRepository, CommunicationRepository, CryptoRepository, @@ -69,6 +71,7 @@ const providers: Provider[] = [ { provide: IAccessRepository, useClass: AccessRepository }, { provide: IAlbumRepository, useClass: AlbumRepository }, { provide: IAssetRepository, useClass: AssetRepository }, + { provide: IAssetStackRepository, useClass: AssetStackRepository }, { provide: IAuditRepository, useClass: AuditRepository }, { provide: ICommunicationRepository, useClass: CommunicationRepository }, { provide: ICryptoRepository, useClass: CryptoRepository }, diff --git a/server/src/infra/migrations/1703035138085-AddAutoStackId.ts b/server/src/infra/migrations/1703035138085-AddAutoStackId.ts new file mode 100644 index 0000000000..6669142611 --- /dev/null +++ b/server/src/infra/migrations/1703035138085-AddAutoStackId.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddAutoStackId1703035138085 implements MigrationInterface { + name = 'AddAutoStackId1703035138085' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "exif" ADD "autoStackId" character varying`); + await queryRunner.query(`CREATE INDEX "IDX_auto_stack_id" ON "exif" ("autoStackId") `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_auto_stack_id"`); + await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "autoStackId"`); + } + +} diff --git a/server/src/infra/migrations/1705363967169-CreateAssetStackTable.ts b/server/src/infra/migrations/1705363967169-CreateAssetStackTable.ts new file mode 100644 index 0000000000..74c75d555c --- /dev/null +++ b/server/src/infra/migrations/1705363967169-CreateAssetStackTable.ts @@ -0,0 +1,83 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateAssetStackTable1705197515600 implements MigrationInterface { + name = 'CreateAssetStackTable1705197515600'; + + public async up(queryRunner: QueryRunner): Promise { + // create table + await queryRunner.query( + `CREATE TABLE "asset_stack" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "primaryAssetId" uuid NOT NULL, + CONSTRAINT "REL_91704e101438fd0653f582426d" UNIQUE ("primaryAssetId"), + CONSTRAINT "PK_74a27e7fcbd5852463d0af3034b" PRIMARY KEY ("id"))`, + ); + + // create stacks + await queryRunner.query( + `INSERT INTO "asset_stack" ("primaryAssetId") + SELECT DISTINCT("stackParentId" ) + FROM "assets" + WHERE "stackParentId" IS NOT NULL;`, + ); + + // add "stackId" + await queryRunner.query(`ALTER TABLE "assets" ADD COLUMN "stackId" uuid`); + + // set "stackId" for parents + await queryRunner.query( + `UPDATE "assets" + SET "stackId" = "asset_stack"."id" + FROM "asset_stack" + WHERE "assets"."id" = "asset_stack"."primaryAssetId"`, + ); + + // set "stackId" for children + await queryRunner.query( + `UPDATE "assets" + SET "stackId" = "asset_stack"."id" + FROM "asset_stack" + WHERE "assets"."stackParentId" = "asset_stack"."primaryAssetId"`, + ); + + // update constraints + await queryRunner.query(`DROP INDEX "public"."IDX_b463c8edb01364bf2beba08ef1"`); + await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "FK_b463c8edb01364bf2beba08ef19"`); + await queryRunner.query( + `ALTER TABLE "assets" ADD CONSTRAINT "FK_f15d48fa3ea5e4bda05ca8ab207" FOREIGN KEY ("stackId") REFERENCES "asset_stack"("id") ON DELETE SET NULL ON UPDATE CASCADE`, + ); + await queryRunner.query( + `ALTER TABLE "asset_stack" ADD CONSTRAINT "FK_91704e101438fd0653f582426dc" FOREIGN KEY ("primaryAssetId") REFERENCES "assets"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + + // drop "stackParentId" + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "stackParentId"`); + } + + public async down(queryRunner: QueryRunner): Promise { + // add "stackParentId" + await queryRunner.query(`ALTER TABLE "assets" ADD COLUMN "stackParentId" uuid`); + + // set "stackParentId" for parents + await queryRunner.query( + `UPDATE "assets" + SET "stackParentId" = "asset_stack"."primaryAssetId" + FROM "asset_stack" + WHERE "assets"."stackId" = "asset_stack"."id" and "assets"."id" != "asset_stack"."primaryAssetId"`, + ); + + // update constraints + await queryRunner.query( + `ALTER TABLE "assets" ADD CONSTRAINT "FK_b463c8edb01364bf2beba08ef19" FOREIGN KEY ("stackParentId") REFERENCES "assets"("id") ON DELETE SET NULL ON UPDATE CASCADE`, + ); + await queryRunner.query(`CREATE INDEX "IDX_b463c8edb01364bf2beba08ef1" ON "assets" ("stackParentId") `); + await queryRunner.query(`ALTER TABLE "asset_stack" DROP CONSTRAINT "FK_91704e101438fd0653f582426dc"`); + await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "FK_f15d48fa3ea5e4bda05ca8ab207"`); + + // drop table + await queryRunner.query(`DROP TABLE "asset_stack"`); + + // drop "stackId" + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "stackId"`); + } +} diff --git a/server/src/infra/repositories/asset-stack.repository.ts b/server/src/infra/repositories/asset-stack.repository.ts new file mode 100644 index 0000000000..4b23b9c1a5 --- /dev/null +++ b/server/src/infra/repositories/asset-stack.repository.ts @@ -0,0 +1,47 @@ +import { IAssetStackRepository } from '@app/domain'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AssetStackEntity } from '../entities'; + +@Injectable() +export class AssetStackRepository implements IAssetStackRepository { + constructor(@InjectRepository(AssetStackEntity) private repository: Repository) {} + + create(entity: Partial) { + return this.save(entity); + } + + async delete(id: string): Promise { + await this.repository.delete(id); + } + + update(entity: Partial) { + return this.save(entity); + } + + async getById(id: string): Promise { + return this.repository.findOne({ + where: { + id, + }, + relations: { + primaryAsset: true, + assets: true, + }, + }); + } + + private async save(entity: Partial) { + const { id } = await this.repository.save(entity); + return this.repository.findOneOrFail({ + where: { + id, + }, + relations: { + primaryAsset: true, + assets: true, + }, + }); + } +} diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 8de0ead6b5..715718f327 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -138,7 +138,7 @@ export class AssetRepository implements IAssetRepository { const withExif = Object.keys(exifWhere).length > 0 || _withExif; - const where = _.omitBy( + const where: FindOptionsWhere = _.omitBy( { ownerId, id, @@ -182,10 +182,6 @@ export class AssetRepository implements IAssetRepository { builder.leftJoinAndSelect('faces.person', 'person'); } - if (withStacked) { - builder.leftJoinAndSelect('asset.stack', 'stack'); - } - if (withSmartInfo) { builder.leftJoinAndSelect('asset.smartInfo', 'smartInfo'); } @@ -194,13 +190,20 @@ export class AssetRepository implements IAssetRepository { builder.withDeleted(); } - builder - .where(where) + builder.where(where); + + if (withStacked) { + builder + .leftJoinAndSelect('asset.stack', 'stack') + .leftJoinAndSelect('stack.assets', 'stackedAssets') + .andWhere(new Brackets((qb) => qb.where('stack.primaryAssetId = asset.id').orWhere('asset.stackId IS NULL'))); + } + + return builder .skip(size * (page - 1)) .take(size) - .orderBy('asset.fileCreatedAt', order ?? 'DESC'); - - return builder.getMany(); + .orderBy('asset.fileCreatedAt', order ?? 'DESC') + .getMany(); } create(asset: AssetCreate): Promise { @@ -279,7 +282,9 @@ export class AssetRepository implements IAssetRepository { faces: { person: true, }, - stack: true, + stack: { + assets: true, + }, }; } @@ -797,8 +802,14 @@ export class AssetRepository implements IAssetRepository { builder = builder.andWhere('asset.type = :assetType', { assetType }); } + let stackJoined = false; + if (exifInfo !== false) { - builder = builder.leftJoinAndSelect('asset.exifInfo', 'exifInfo').leftJoinAndSelect('asset.stack', 'stack'); + stackJoined = true; + builder = builder + .leftJoinAndSelect('asset.exifInfo', 'exifInfo') + .leftJoinAndSelect('asset.stack', 'stack') + .leftJoinAndSelect('stack.assets', 'stackedAssets'); } if (albumId) { @@ -829,7 +840,12 @@ export class AssetRepository implements IAssetRepository { } if (withStacked) { - builder = builder.andWhere('asset.stackParentId IS NULL'); + if (!stackJoined) { + builder = builder.leftJoinAndSelect('asset.stack', 'stack').leftJoinAndSelect('stack.assets', 'stackedAssets'); + } + builder = builder.andWhere( + new Brackets((qb) => qb.where('stack.primaryAssetId = asset.id').orWhere('asset.stackId IS NULL')), + ); } return builder; diff --git a/server/src/infra/repositories/index.ts b/server/src/infra/repositories/index.ts index 63b8f2afb2..21703ec8c8 100644 --- a/server/src/infra/repositories/index.ts +++ b/server/src/infra/repositories/index.ts @@ -2,6 +2,7 @@ export * from './access.repository'; export * from './activity.repository'; export * from './album.repository'; export * from './api-key.repository'; +export * from './asset-stack.repository'; export * from './asset.repository'; export * from './audit.repository'; export * from './communication.repository'; diff --git a/server/src/infra/sql/asset.repository.sql b/server/src/infra/sql/asset.repository.sql index 3459092730..a2e30c3e3e 100644 --- a/server/src/infra/sql/asset.repository.sql +++ b/server/src/infra/sql/asset.repository.sql @@ -30,7 +30,7 @@ SELECT "AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId", "AssetEntity"."originalFileName" AS "AssetEntity_originalFileName", "AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath", - "AssetEntity"."stackParentId" AS "AssetEntity_stackParentId", + "AssetEntity"."stackId" AS "AssetEntity_stackId", "AssetEntity__AssetEntity_exifInfo"."assetId" AS "AssetEntity__AssetEntity_exifInfo_assetId", "AssetEntity__AssetEntity_exifInfo"."description" AS "AssetEntity__AssetEntity_exifInfo_description", "AssetEntity__AssetEntity_exifInfo"."exifImageWidth" AS "AssetEntity__AssetEntity_exifInfo_exifImageWidth", @@ -45,6 +45,7 @@ SELECT "AssetEntity__AssetEntity_exifInfo"."projectionType" AS "AssetEntity__AssetEntity_exifInfo_projectionType", "AssetEntity__AssetEntity_exifInfo"."city" AS "AssetEntity__AssetEntity_exifInfo_city", "AssetEntity__AssetEntity_exifInfo"."livePhotoCID" AS "AssetEntity__AssetEntity_exifInfo_livePhotoCID", + "AssetEntity__AssetEntity_exifInfo"."autoStackId" AS "AssetEntity__AssetEntity_exifInfo_autoStackId", "AssetEntity__AssetEntity_exifInfo"."state" AS "AssetEntity__AssetEntity_exifInfo_state", "AssetEntity__AssetEntity_exifInfo"."country" AS "AssetEntity__AssetEntity_exifInfo_country", "AssetEntity__AssetEntity_exifInfo"."make" AS "AssetEntity__AssetEntity_exifInfo_make", @@ -105,7 +106,7 @@ SELECT "entity"."livePhotoVideoId" AS "entity_livePhotoVideoId", "entity"."originalFileName" AS "entity_originalFileName", "entity"."sidecarPath" AS "entity_sidecarPath", - "entity"."stackParentId" AS "entity_stackParentId", + "entity"."stackId" AS "entity_stackId", "exifInfo"."assetId" AS "exifInfo_assetId", "exifInfo"."description" AS "exifInfo_description", "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", @@ -120,6 +121,7 @@ SELECT "exifInfo"."projectionType" AS "exifInfo_projectionType", "exifInfo"."city" AS "exifInfo_city", "exifInfo"."livePhotoCID" AS "exifInfo_livePhotoCID", + "exifInfo"."autoStackId" AS "exifInfo_autoStackId", "exifInfo"."state" AS "exifInfo_state", "exifInfo"."country" AS "exifInfo_country", "exifInfo"."make" AS "exifInfo_make", @@ -187,7 +189,7 @@ SELECT "AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId", "AssetEntity"."originalFileName" AS "AssetEntity_originalFileName", "AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath", - "AssetEntity"."stackParentId" AS "AssetEntity_stackParentId", + "AssetEntity"."stackId" AS "AssetEntity_stackId", "AssetEntity__AssetEntity_exifInfo"."assetId" AS "AssetEntity__AssetEntity_exifInfo_assetId", "AssetEntity__AssetEntity_exifInfo"."description" AS "AssetEntity__AssetEntity_exifInfo_description", "AssetEntity__AssetEntity_exifInfo"."exifImageWidth" AS "AssetEntity__AssetEntity_exifInfo_exifImageWidth", @@ -202,6 +204,7 @@ SELECT "AssetEntity__AssetEntity_exifInfo"."projectionType" AS "AssetEntity__AssetEntity_exifInfo_projectionType", "AssetEntity__AssetEntity_exifInfo"."city" AS "AssetEntity__AssetEntity_exifInfo_city", "AssetEntity__AssetEntity_exifInfo"."livePhotoCID" AS "AssetEntity__AssetEntity_exifInfo_livePhotoCID", + "AssetEntity__AssetEntity_exifInfo"."autoStackId" AS "AssetEntity__AssetEntity_exifInfo_autoStackId", "AssetEntity__AssetEntity_exifInfo"."state" AS "AssetEntity__AssetEntity_exifInfo_state", "AssetEntity__AssetEntity_exifInfo"."country" AS "AssetEntity__AssetEntity_exifInfo_country", "AssetEntity__AssetEntity_exifInfo"."make" AS "AssetEntity__AssetEntity_exifInfo_make", @@ -242,34 +245,36 @@ SELECT "8258e303a73a72cf6abb13d73fb592dde0d68280"."faceAssetId" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_faceAssetId", "8258e303a73a72cf6abb13d73fb592dde0d68280"."isHidden" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_isHidden", "AssetEntity__AssetEntity_stack"."id" AS "AssetEntity__AssetEntity_stack_id", - "AssetEntity__AssetEntity_stack"."deviceAssetId" AS "AssetEntity__AssetEntity_stack_deviceAssetId", - "AssetEntity__AssetEntity_stack"."ownerId" AS "AssetEntity__AssetEntity_stack_ownerId", - "AssetEntity__AssetEntity_stack"."libraryId" AS "AssetEntity__AssetEntity_stack_libraryId", - "AssetEntity__AssetEntity_stack"."deviceId" AS "AssetEntity__AssetEntity_stack_deviceId", - "AssetEntity__AssetEntity_stack"."type" AS "AssetEntity__AssetEntity_stack_type", - "AssetEntity__AssetEntity_stack"."originalPath" AS "AssetEntity__AssetEntity_stack_originalPath", - "AssetEntity__AssetEntity_stack"."resizePath" AS "AssetEntity__AssetEntity_stack_resizePath", - "AssetEntity__AssetEntity_stack"."webpPath" AS "AssetEntity__AssetEntity_stack_webpPath", - "AssetEntity__AssetEntity_stack"."thumbhash" AS "AssetEntity__AssetEntity_stack_thumbhash", - "AssetEntity__AssetEntity_stack"."encodedVideoPath" AS "AssetEntity__AssetEntity_stack_encodedVideoPath", - "AssetEntity__AssetEntity_stack"."createdAt" AS "AssetEntity__AssetEntity_stack_createdAt", - "AssetEntity__AssetEntity_stack"."updatedAt" AS "AssetEntity__AssetEntity_stack_updatedAt", - "AssetEntity__AssetEntity_stack"."deletedAt" AS "AssetEntity__AssetEntity_stack_deletedAt", - "AssetEntity__AssetEntity_stack"."fileCreatedAt" AS "AssetEntity__AssetEntity_stack_fileCreatedAt", - "AssetEntity__AssetEntity_stack"."localDateTime" AS "AssetEntity__AssetEntity_stack_localDateTime", - "AssetEntity__AssetEntity_stack"."fileModifiedAt" AS "AssetEntity__AssetEntity_stack_fileModifiedAt", - "AssetEntity__AssetEntity_stack"."isFavorite" AS "AssetEntity__AssetEntity_stack_isFavorite", - "AssetEntity__AssetEntity_stack"."isArchived" AS "AssetEntity__AssetEntity_stack_isArchived", - "AssetEntity__AssetEntity_stack"."isExternal" AS "AssetEntity__AssetEntity_stack_isExternal", - "AssetEntity__AssetEntity_stack"."isReadOnly" AS "AssetEntity__AssetEntity_stack_isReadOnly", - "AssetEntity__AssetEntity_stack"."isOffline" AS "AssetEntity__AssetEntity_stack_isOffline", - "AssetEntity__AssetEntity_stack"."checksum" AS "AssetEntity__AssetEntity_stack_checksum", - "AssetEntity__AssetEntity_stack"."duration" AS "AssetEntity__AssetEntity_stack_duration", - "AssetEntity__AssetEntity_stack"."isVisible" AS "AssetEntity__AssetEntity_stack_isVisible", - "AssetEntity__AssetEntity_stack"."livePhotoVideoId" AS "AssetEntity__AssetEntity_stack_livePhotoVideoId", - "AssetEntity__AssetEntity_stack"."originalFileName" AS "AssetEntity__AssetEntity_stack_originalFileName", - "AssetEntity__AssetEntity_stack"."sidecarPath" AS "AssetEntity__AssetEntity_stack_sidecarPath", - "AssetEntity__AssetEntity_stack"."stackParentId" AS "AssetEntity__AssetEntity_stack_stackParentId" + "AssetEntity__AssetEntity_stack"."primaryAssetId" AS "AssetEntity__AssetEntity_stack_primaryAssetId", + "bd93d5747511a4dad4923546c51365bf1a803774"."id" AS "bd93d5747511a4dad4923546c51365bf1a803774_id", + "bd93d5747511a4dad4923546c51365bf1a803774"."deviceAssetId" AS "bd93d5747511a4dad4923546c51365bf1a803774_deviceAssetId", + "bd93d5747511a4dad4923546c51365bf1a803774"."ownerId" AS "bd93d5747511a4dad4923546c51365bf1a803774_ownerId", + "bd93d5747511a4dad4923546c51365bf1a803774"."libraryId" AS "bd93d5747511a4dad4923546c51365bf1a803774_libraryId", + "bd93d5747511a4dad4923546c51365bf1a803774"."deviceId" AS "bd93d5747511a4dad4923546c51365bf1a803774_deviceId", + "bd93d5747511a4dad4923546c51365bf1a803774"."type" AS "bd93d5747511a4dad4923546c51365bf1a803774_type", + "bd93d5747511a4dad4923546c51365bf1a803774"."originalPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_originalPath", + "bd93d5747511a4dad4923546c51365bf1a803774"."resizePath" AS "bd93d5747511a4dad4923546c51365bf1a803774_resizePath", + "bd93d5747511a4dad4923546c51365bf1a803774"."webpPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_webpPath", + "bd93d5747511a4dad4923546c51365bf1a803774"."thumbhash" AS "bd93d5747511a4dad4923546c51365bf1a803774_thumbhash", + "bd93d5747511a4dad4923546c51365bf1a803774"."encodedVideoPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_encodedVideoPath", + "bd93d5747511a4dad4923546c51365bf1a803774"."createdAt" AS "bd93d5747511a4dad4923546c51365bf1a803774_createdAt", + "bd93d5747511a4dad4923546c51365bf1a803774"."updatedAt" AS "bd93d5747511a4dad4923546c51365bf1a803774_updatedAt", + "bd93d5747511a4dad4923546c51365bf1a803774"."deletedAt" AS "bd93d5747511a4dad4923546c51365bf1a803774_deletedAt", + "bd93d5747511a4dad4923546c51365bf1a803774"."fileCreatedAt" AS "bd93d5747511a4dad4923546c51365bf1a803774_fileCreatedAt", + "bd93d5747511a4dad4923546c51365bf1a803774"."localDateTime" AS "bd93d5747511a4dad4923546c51365bf1a803774_localDateTime", + "bd93d5747511a4dad4923546c51365bf1a803774"."fileModifiedAt" AS "bd93d5747511a4dad4923546c51365bf1a803774_fileModifiedAt", + "bd93d5747511a4dad4923546c51365bf1a803774"."isFavorite" AS "bd93d5747511a4dad4923546c51365bf1a803774_isFavorite", + "bd93d5747511a4dad4923546c51365bf1a803774"."isArchived" AS "bd93d5747511a4dad4923546c51365bf1a803774_isArchived", + "bd93d5747511a4dad4923546c51365bf1a803774"."isExternal" AS "bd93d5747511a4dad4923546c51365bf1a803774_isExternal", + "bd93d5747511a4dad4923546c51365bf1a803774"."isReadOnly" AS "bd93d5747511a4dad4923546c51365bf1a803774_isReadOnly", + "bd93d5747511a4dad4923546c51365bf1a803774"."isOffline" AS "bd93d5747511a4dad4923546c51365bf1a803774_isOffline", + "bd93d5747511a4dad4923546c51365bf1a803774"."checksum" AS "bd93d5747511a4dad4923546c51365bf1a803774_checksum", + "bd93d5747511a4dad4923546c51365bf1a803774"."duration" AS "bd93d5747511a4dad4923546c51365bf1a803774_duration", + "bd93d5747511a4dad4923546c51365bf1a803774"."isVisible" AS "bd93d5747511a4dad4923546c51365bf1a803774_isVisible", + "bd93d5747511a4dad4923546c51365bf1a803774"."livePhotoVideoId" AS "bd93d5747511a4dad4923546c51365bf1a803774_livePhotoVideoId", + "bd93d5747511a4dad4923546c51365bf1a803774"."originalFileName" AS "bd93d5747511a4dad4923546c51365bf1a803774_originalFileName", + "bd93d5747511a4dad4923546c51365bf1a803774"."sidecarPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_sidecarPath", + "bd93d5747511a4dad4923546c51365bf1a803774"."stackId" AS "bd93d5747511a4dad4923546c51365bf1a803774_stackId" FROM "assets" "AssetEntity" LEFT JOIN "exif" "AssetEntity__AssetEntity_exifInfo" ON "AssetEntity__AssetEntity_exifInfo"."assetId" = "AssetEntity"."id" @@ -278,7 +283,8 @@ FROM LEFT JOIN "tags" "AssetEntity__AssetEntity_tags" ON "AssetEntity__AssetEntity_tags"."id" = "AssetEntity_AssetEntity__AssetEntity_tags"."tagsId" LEFT JOIN "asset_faces" "AssetEntity__AssetEntity_faces" ON "AssetEntity__AssetEntity_faces"."assetId" = "AssetEntity"."id" LEFT JOIN "person" "8258e303a73a72cf6abb13d73fb592dde0d68280" ON "8258e303a73a72cf6abb13d73fb592dde0d68280"."id" = "AssetEntity__AssetEntity_faces"."personId" - LEFT JOIN "assets" "AssetEntity__AssetEntity_stack" ON "AssetEntity__AssetEntity_stack"."stackParentId" = "AssetEntity"."id" + LEFT JOIN "asset_stack" "AssetEntity__AssetEntity_stack" ON "AssetEntity__AssetEntity_stack"."id" = "AssetEntity"."stackId" + LEFT JOIN "assets" "bd93d5747511a4dad4923546c51365bf1a803774" ON "bd93d5747511a4dad4923546c51365bf1a803774"."stackId" = "AssetEntity__AssetEntity_stack"."id" WHERE ("AssetEntity"."id" IN ($1)) @@ -317,7 +323,7 @@ SELECT "AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId", "AssetEntity"."originalFileName" AS "AssetEntity_originalFileName", "AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath", - "AssetEntity"."stackParentId" AS "AssetEntity_stackParentId" + "AssetEntity"."stackId" AS "AssetEntity_stackId" FROM "assets" "AssetEntity" LEFT JOIN "libraries" "AssetEntity__AssetEntity_library" ON "AssetEntity__AssetEntity_library"."id" = "AssetEntity"."libraryId" @@ -362,7 +368,7 @@ FROM "AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId", "AssetEntity"."originalFileName" AS "AssetEntity_originalFileName", "AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath", - "AssetEntity"."stackParentId" AS "AssetEntity_stackParentId" + "AssetEntity"."stackId" AS "AssetEntity_stackId" FROM "assets" "AssetEntity" LEFT JOIN "libraries" "AssetEntity__AssetEntity_library" ON "AssetEntity__AssetEntity_library"."id" = "AssetEntity"."libraryId" @@ -426,7 +432,7 @@ SELECT "AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId", "AssetEntity"."originalFileName" AS "AssetEntity_originalFileName", "AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath", - "AssetEntity"."stackParentId" AS "AssetEntity_stackParentId" + "AssetEntity"."stackId" AS "AssetEntity_stackId" FROM "assets" "AssetEntity" WHERE @@ -472,7 +478,7 @@ SELECT "AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId", "AssetEntity"."originalFileName" AS "AssetEntity_originalFileName", "AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath", - "AssetEntity"."stackParentId" AS "AssetEntity_stackParentId" + "AssetEntity"."stackId" AS "AssetEntity_stackId" FROM "assets" "AssetEntity" WHERE @@ -516,7 +522,7 @@ SELECT "AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId", "AssetEntity"."originalFileName" AS "AssetEntity_originalFileName", "AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath", - "AssetEntity"."stackParentId" AS "AssetEntity_stackParentId" + "AssetEntity"."stackId" AS "AssetEntity_stackId" FROM "assets" "AssetEntity" WHERE @@ -550,8 +556,9 @@ SELECT FROM "assets" "asset" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" - LEFT JOIN "assets" "stack" ON "stack"."stackParentId" = "asset"."id" - AND ("stack"."deletedAt" IS NULL) + LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" + LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" + AND ("stackedAssets"."deletedAt" IS NULL) WHERE ("asset"."isVisible" = true) AND ("asset"."deletedAt" IS NULL) @@ -600,7 +607,7 @@ SELECT "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId", "asset"."originalFileName" AS "asset_originalFileName", "asset"."sidecarPath" AS "asset_sidecarPath", - "asset"."stackParentId" AS "asset_stackParentId", + "asset"."stackId" AS "asset_stackId", "exifInfo"."assetId" AS "exifInfo_assetId", "exifInfo"."description" AS "exifInfo_description", "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", @@ -615,6 +622,7 @@ SELECT "exifInfo"."projectionType" AS "exifInfo_projectionType", "exifInfo"."city" AS "exifInfo_city", "exifInfo"."livePhotoCID" AS "exifInfo_livePhotoCID", + "exifInfo"."autoStackId" AS "exifInfo_autoStackId", "exifInfo"."state" AS "exifInfo_state", "exifInfo"."country" AS "exifInfo_country", "exifInfo"."make" AS "exifInfo_make", @@ -629,39 +637,42 @@ SELECT "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", "exifInfo"."fps" AS "exifInfo_fps", "stack"."id" AS "stack_id", - "stack"."deviceAssetId" AS "stack_deviceAssetId", - "stack"."ownerId" AS "stack_ownerId", - "stack"."libraryId" AS "stack_libraryId", - "stack"."deviceId" AS "stack_deviceId", - "stack"."type" AS "stack_type", - "stack"."originalPath" AS "stack_originalPath", - "stack"."resizePath" AS "stack_resizePath", - "stack"."webpPath" AS "stack_webpPath", - "stack"."thumbhash" AS "stack_thumbhash", - "stack"."encodedVideoPath" AS "stack_encodedVideoPath", - "stack"."createdAt" AS "stack_createdAt", - "stack"."updatedAt" AS "stack_updatedAt", - "stack"."deletedAt" AS "stack_deletedAt", - "stack"."fileCreatedAt" AS "stack_fileCreatedAt", - "stack"."localDateTime" AS "stack_localDateTime", - "stack"."fileModifiedAt" AS "stack_fileModifiedAt", - "stack"."isFavorite" AS "stack_isFavorite", - "stack"."isArchived" AS "stack_isArchived", - "stack"."isExternal" AS "stack_isExternal", - "stack"."isReadOnly" AS "stack_isReadOnly", - "stack"."isOffline" AS "stack_isOffline", - "stack"."checksum" AS "stack_checksum", - "stack"."duration" AS "stack_duration", - "stack"."isVisible" AS "stack_isVisible", - "stack"."livePhotoVideoId" AS "stack_livePhotoVideoId", - "stack"."originalFileName" AS "stack_originalFileName", - "stack"."sidecarPath" AS "stack_sidecarPath", - "stack"."stackParentId" AS "stack_stackParentId" + "stack"."primaryAssetId" AS "stack_primaryAssetId", + "stackedAssets"."id" AS "stackedAssets_id", + "stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId", + "stackedAssets"."ownerId" AS "stackedAssets_ownerId", + "stackedAssets"."libraryId" AS "stackedAssets_libraryId", + "stackedAssets"."deviceId" AS "stackedAssets_deviceId", + "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."originalPath" AS "stackedAssets_originalPath", + "stackedAssets"."resizePath" AS "stackedAssets_resizePath", + "stackedAssets"."webpPath" AS "stackedAssets_webpPath", + "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", + "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", + "stackedAssets"."createdAt" AS "stackedAssets_createdAt", + "stackedAssets"."updatedAt" AS "stackedAssets_updatedAt", + "stackedAssets"."deletedAt" AS "stackedAssets_deletedAt", + "stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt", + "stackedAssets"."localDateTime" AS "stackedAssets_localDateTime", + "stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt", + "stackedAssets"."isFavorite" AS "stackedAssets_isFavorite", + "stackedAssets"."isArchived" AS "stackedAssets_isArchived", + "stackedAssets"."isExternal" AS "stackedAssets_isExternal", + "stackedAssets"."isReadOnly" AS "stackedAssets_isReadOnly", + "stackedAssets"."isOffline" AS "stackedAssets_isOffline", + "stackedAssets"."checksum" AS "stackedAssets_checksum", + "stackedAssets"."duration" AS "stackedAssets_duration", + "stackedAssets"."isVisible" AS "stackedAssets_isVisible", + "stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId", + "stackedAssets"."originalFileName" AS "stackedAssets_originalFileName", + "stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath", + "stackedAssets"."stackId" AS "stackedAssets_stackId" FROM "assets" "asset" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" - LEFT JOIN "assets" "stack" ON "stack"."stackParentId" = "asset"."id" - AND ("stack"."deletedAt" IS NULL) + LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" + LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" + AND ("stackedAssets"."deletedAt" IS NULL) WHERE ( "asset"."isVisible" = true diff --git a/server/src/infra/sql/person.repository.sql b/server/src/infra/sql/person.repository.sql index f3db2d5f99..79c2518996 100644 --- a/server/src/infra/sql/person.repository.sql +++ b/server/src/infra/sql/person.repository.sql @@ -172,7 +172,7 @@ FROM "AssetFaceEntity__AssetFaceEntity_asset"."livePhotoVideoId" AS "AssetFaceEntity__AssetFaceEntity_asset_livePhotoVideoId", "AssetFaceEntity__AssetFaceEntity_asset"."originalFileName" AS "AssetFaceEntity__AssetFaceEntity_asset_originalFileName", "AssetFaceEntity__AssetFaceEntity_asset"."sidecarPath" AS "AssetFaceEntity__AssetFaceEntity_asset_sidecarPath", - "AssetFaceEntity__AssetFaceEntity_asset"."stackParentId" AS "AssetFaceEntity__AssetFaceEntity_asset_stackParentId" + "AssetFaceEntity__AssetFaceEntity_asset"."stackId" AS "AssetFaceEntity__AssetFaceEntity_asset_stackId" FROM "asset_faces" "AssetFaceEntity" LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId" @@ -270,7 +270,7 @@ FROM "AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId", "AssetEntity"."originalFileName" AS "AssetEntity_originalFileName", "AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath", - "AssetEntity"."stackParentId" AS "AssetEntity_stackParentId", + "AssetEntity"."stackId" AS "AssetEntity_stackId", "AssetEntity__AssetEntity_faces"."id" AS "AssetEntity__AssetEntity_faces_id", "AssetEntity__AssetEntity_faces"."assetId" AS "AssetEntity__AssetEntity_faces_assetId", "AssetEntity__AssetEntity_faces"."personId" AS "AssetEntity__AssetEntity_faces_personId", @@ -303,6 +303,7 @@ FROM "AssetEntity__AssetEntity_exifInfo"."projectionType" AS "AssetEntity__AssetEntity_exifInfo_projectionType", "AssetEntity__AssetEntity_exifInfo"."city" AS "AssetEntity__AssetEntity_exifInfo_city", "AssetEntity__AssetEntity_exifInfo"."livePhotoCID" AS "AssetEntity__AssetEntity_exifInfo_livePhotoCID", + "AssetEntity__AssetEntity_exifInfo"."autoStackId" AS "AssetEntity__AssetEntity_exifInfo_autoStackId", "AssetEntity__AssetEntity_exifInfo"."state" AS "AssetEntity__AssetEntity_exifInfo_state", "AssetEntity__AssetEntity_exifInfo"."country" AS "AssetEntity__AssetEntity_exifInfo_country", "AssetEntity__AssetEntity_exifInfo"."make" AS "AssetEntity__AssetEntity_exifInfo_make", @@ -376,7 +377,7 @@ SELECT "AssetFaceEntity__AssetFaceEntity_asset"."livePhotoVideoId" AS "AssetFaceEntity__AssetFaceEntity_asset_livePhotoVideoId", "AssetFaceEntity__AssetFaceEntity_asset"."originalFileName" AS "AssetFaceEntity__AssetFaceEntity_asset_originalFileName", "AssetFaceEntity__AssetFaceEntity_asset"."sidecarPath" AS "AssetFaceEntity__AssetFaceEntity_asset_sidecarPath", - "AssetFaceEntity__AssetFaceEntity_asset"."stackParentId" AS "AssetFaceEntity__AssetFaceEntity_asset_stackParentId" + "AssetFaceEntity__AssetFaceEntity_asset"."stackId" AS "AssetFaceEntity__AssetFaceEntity_asset_stackId" FROM "asset_faces" "AssetFaceEntity" LEFT JOIN "assets" "AssetFaceEntity__AssetFaceEntity_asset" ON "AssetFaceEntity__AssetFaceEntity_asset"."id" = "AssetFaceEntity"."assetId" diff --git a/server/src/infra/sql/shared.link.repository.sql b/server/src/infra/sql/shared.link.repository.sql index a1e827ddc1..ee3563b8b7 100644 --- a/server/src/infra/sql/shared.link.repository.sql +++ b/server/src/infra/sql/shared.link.repository.sql @@ -49,7 +49,7 @@ FROM "SharedLinkEntity__SharedLinkEntity_assets"."livePhotoVideoId" AS "SharedLinkEntity__SharedLinkEntity_assets_livePhotoVideoId", "SharedLinkEntity__SharedLinkEntity_assets"."originalFileName" AS "SharedLinkEntity__SharedLinkEntity_assets_originalFileName", "SharedLinkEntity__SharedLinkEntity_assets"."sidecarPath" AS "SharedLinkEntity__SharedLinkEntity_assets_sidecarPath", - "SharedLinkEntity__SharedLinkEntity_assets"."stackParentId" AS "SharedLinkEntity__SharedLinkEntity_assets_stackParentId", + "SharedLinkEntity__SharedLinkEntity_assets"."stackId" AS "SharedLinkEntity__SharedLinkEntity_assets_stackId", "9b1d35b344d838023994a3233afd6ffe098be6d8"."assetId" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_assetId", "9b1d35b344d838023994a3233afd6ffe098be6d8"."description" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_description", "9b1d35b344d838023994a3233afd6ffe098be6d8"."exifImageWidth" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_exifImageWidth", @@ -64,6 +64,7 @@ FROM "9b1d35b344d838023994a3233afd6ffe098be6d8"."projectionType" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_projectionType", "9b1d35b344d838023994a3233afd6ffe098be6d8"."city" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_city", "9b1d35b344d838023994a3233afd6ffe098be6d8"."livePhotoCID" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_livePhotoCID", + "9b1d35b344d838023994a3233afd6ffe098be6d8"."autoStackId" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_autoStackId", "9b1d35b344d838023994a3233afd6ffe098be6d8"."state" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_state", "9b1d35b344d838023994a3233afd6ffe098be6d8"."country" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_country", "9b1d35b344d838023994a3233afd6ffe098be6d8"."make" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_make", @@ -114,7 +115,7 @@ FROM "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."livePhotoVideoId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_livePhotoVideoId", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."originalFileName" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_originalFileName", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."sidecarPath" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_sidecarPath", - "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."stackParentId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_stackParentId", + "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."stackId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_stackId", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."assetId" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_assetId", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."description" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_description", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."exifImageWidth" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_exifImageWidth", @@ -129,6 +130,7 @@ FROM "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."projectionType" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_projectionType", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."city" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_city", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."livePhotoCID" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_livePhotoCID", + "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."autoStackId" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_autoStackId", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."state" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_state", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."country" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_country", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."make" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_make", @@ -236,7 +238,7 @@ SELECT "SharedLinkEntity__SharedLinkEntity_assets"."livePhotoVideoId" AS "SharedLinkEntity__SharedLinkEntity_assets_livePhotoVideoId", "SharedLinkEntity__SharedLinkEntity_assets"."originalFileName" AS "SharedLinkEntity__SharedLinkEntity_assets_originalFileName", "SharedLinkEntity__SharedLinkEntity_assets"."sidecarPath" AS "SharedLinkEntity__SharedLinkEntity_assets_sidecarPath", - "SharedLinkEntity__SharedLinkEntity_assets"."stackParentId" AS "SharedLinkEntity__SharedLinkEntity_assets_stackParentId", + "SharedLinkEntity__SharedLinkEntity_assets"."stackId" AS "SharedLinkEntity__SharedLinkEntity_assets_stackId", "SharedLinkEntity__SharedLinkEntity_album"."id" AS "SharedLinkEntity__SharedLinkEntity_album_id", "SharedLinkEntity__SharedLinkEntity_album"."ownerId" AS "SharedLinkEntity__SharedLinkEntity_album_ownerId", "SharedLinkEntity__SharedLinkEntity_album"."albumName" AS "SharedLinkEntity__SharedLinkEntity_album_albumName", diff --git a/server/src/infra/sql/smart.info.repository.sql b/server/src/infra/sql/smart.info.repository.sql index b844e22027..afb120bade 100644 --- a/server/src/infra/sql/smart.info.repository.sql +++ b/server/src/infra/sql/smart.info.repository.sql @@ -35,7 +35,7 @@ SELECT "a"."livePhotoVideoId" AS "a_livePhotoVideoId", "a"."originalFileName" AS "a_originalFileName", "a"."sidecarPath" AS "a_sidecarPath", - "a"."stackParentId" AS "a_stackParentId", + "a"."stackId" AS "a_stackId", "e"."assetId" AS "e_assetId", "e"."description" AS "e_description", "e"."exifImageWidth" AS "e_exifImageWidth", @@ -50,6 +50,7 @@ SELECT "e"."projectionType" AS "e_projectionType", "e"."city" AS "e_city", "e"."livePhotoCID" AS "e_livePhotoCID", + "e"."autoStackId" AS "e_autoStackId", "e"."state" AS "e_state", "e"."country" AS "e_country", "e"."make" AS "e_make", diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 7edc360e1e..fcc52df8f4 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -1,9 +1,18 @@ -import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; +import { AssetEntity, AssetStackEntity, AssetType, ExifEntity } from '@app/infra/entities'; import { authStub } from './auth.stub'; import { fileStub } from './file.stub'; import { libraryStub } from './library.stub'; import { userStub } from './user.stub'; +export const assetStackStub = (stackId: string, assets: AssetEntity[]): AssetStackEntity => { + return { + id: stackId, + assets: assets, + primaryAsset: assets[0], + primaryAssetId: assets[0].id, + }; +}; + export const assetStub = { noResizePath: Object.freeze({ id: 'asset-id', @@ -120,7 +129,7 @@ export const assetStub = { }), primaryImage: Object.freeze({ - id: 'asset-id', + id: 'primary-asset-id', deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -157,7 +166,11 @@ export const assetStub = { exifInfo: { fileSizeInByte: 5_000, } as ExifEntity, - stack: [{ id: 'stack-child-asset-1' } as AssetEntity, { id: 'stack-child-asset-2' } as AssetEntity], + stack: assetStackStub('stack-1', [ + { id: 'primary-asset-id' } as AssetEntity, + { id: 'stack-child-asset-1' } as AssetEntity, + { id: 'stack-child-asset-2' } as AssetEntity, + ]), }), image: Object.freeze({ diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index e4a487137e..61b44a544a 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -248,6 +248,7 @@ export const sharedLinkStub = { profileDescription: 'sRGB', bitsPerSample: 8, colorspace: 'sRGB', + autoStackId: null, }, tags: [], sharedLinks: [], diff --git a/server/test/repositories/asset-stack.repository.mock.ts b/server/test/repositories/asset-stack.repository.mock.ts new file mode 100644 index 0000000000..d87f0316f0 --- /dev/null +++ b/server/test/repositories/asset-stack.repository.mock.ts @@ -0,0 +1,10 @@ +import { IAssetStackRepository } from '@app/domain'; + +export const newAssetStackRepositoryMock = (): jest.Mocked => { + return { + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + getById: jest.fn(), + }; +}; diff --git a/server/test/repositories/index.ts b/server/test/repositories/index.ts index d7a7f3e0c4..e31a3a1c45 100644 --- a/server/test/repositories/index.ts +++ b/server/test/repositories/index.ts @@ -1,6 +1,7 @@ export * from './access.repository.mock'; export * from './album.repository.mock'; export * from './api-key.repository.mock'; +export * from './asset-stack.repository.mock'; export * from './asset.repository.mock'; export * from './audit.repository.mock'; export * from './communication.repository.mock';