diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 0d37b64ebb..969034b21f 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -147,8 +147,6 @@ export type AssetPathEntity = Pick; - getUniqueOriginalPaths(userId: string): Promise; create(asset: AssetCreate): Promise; getByIds( ids: string[], diff --git a/server/src/interfaces/view.interface.ts b/server/src/interfaces/view.interface.ts new file mode 100644 index 0000000000..f819160002 --- /dev/null +++ b/server/src/interfaces/view.interface.ts @@ -0,0 +1,8 @@ +import { AssetEntity } from 'src/entities/asset.entity'; + +export const IViewRepository = 'IViewRepository'; + +export interface IViewRepository { + getAssetsByOriginalPath(userId: string, partialPath: string): Promise; + getUniqueOriginalPaths(userId: string): Promise; +} diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index da5ec1d4d1..2ef5caee52 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -1134,109 +1134,6 @@ WHERE AND "asset"."ownerId" IN ($1) AND "asset"."updatedAt" > $2 --- AssetRepository.getAssetsByOriginalPath -SELECT - "asset"."id" AS "asset_id", - "asset"."deviceAssetId" AS "asset_deviceAssetId", - "asset"."ownerId" AS "asset_ownerId", - "asset"."libraryId" AS "asset_libraryId", - "asset"."deviceId" AS "asset_deviceId", - "asset"."type" AS "asset_type", - "asset"."originalPath" AS "asset_originalPath", - "asset"."thumbhash" AS "asset_thumbhash", - "asset"."encodedVideoPath" AS "asset_encodedVideoPath", - "asset"."createdAt" AS "asset_createdAt", - "asset"."updatedAt" AS "asset_updatedAt", - "asset"."deletedAt" AS "asset_deletedAt", - "asset"."fileCreatedAt" AS "asset_fileCreatedAt", - "asset"."localDateTime" AS "asset_localDateTime", - "asset"."fileModifiedAt" AS "asset_fileModifiedAt", - "asset"."isFavorite" AS "asset_isFavorite", - "asset"."isArchived" AS "asset_isArchived", - "asset"."isExternal" AS "asset_isExternal", - "asset"."isOffline" AS "asset_isOffline", - "asset"."checksum" AS "asset_checksum", - "asset"."duration" AS "asset_duration", - "asset"."isVisible" AS "asset_isVisible", - "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId", - "asset"."originalFileName" AS "asset_originalFileName", - "asset"."sidecarPath" AS "asset_sidecarPath", - "asset"."stackId" AS "asset_stackId", - "asset"."duplicateId" AS "asset_duplicateId", - "exifInfo"."assetId" AS "exifInfo_assetId", - "exifInfo"."description" AS "exifInfo_description", - "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", - "exifInfo"."exifImageHeight" AS "exifInfo_exifImageHeight", - "exifInfo"."fileSizeInByte" AS "exifInfo_fileSizeInByte", - "exifInfo"."orientation" AS "exifInfo_orientation", - "exifInfo"."dateTimeOriginal" AS "exifInfo_dateTimeOriginal", - "exifInfo"."modifyDate" AS "exifInfo_modifyDate", - "exifInfo"."timeZone" AS "exifInfo_timeZone", - "exifInfo"."latitude" AS "exifInfo_latitude", - "exifInfo"."longitude" AS "exifInfo_longitude", - "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", - "exifInfo"."model" AS "exifInfo_model", - "exifInfo"."lensModel" AS "exifInfo_lensModel", - "exifInfo"."fNumber" AS "exifInfo_fNumber", - "exifInfo"."focalLength" AS "exifInfo_focalLength", - "exifInfo"."iso" AS "exifInfo_iso", - "exifInfo"."exposureTime" AS "exifInfo_exposureTime", - "exifInfo"."profileDescription" AS "exifInfo_profileDescription", - "exifInfo"."colorspace" AS "exifInfo_colorspace", - "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", - "exifInfo"."rating" AS "exifInfo_rating", - "exifInfo"."fps" AS "exifInfo_fps", - "stack"."id" AS "stack_id", - "stack"."ownerId" AS "stack_ownerId", - "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"."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"."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", - "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" -FROM - "assets" "asset" - LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" - 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"."ownerId" = $1 - AND ( - "asset"."originalPath" LIKE $2 - AND "asset"."originalPath" NOT LIKE $3 - ) -ORDER BY - regexp_replace("asset"."originalPath", '.*/(.+)', '\1') ASC - -- AssetRepository.upsertFile INSERT INTO "asset_files" ( diff --git a/server/src/queries/view.repository.sql b/server/src/queries/view.repository.sql new file mode 100644 index 0000000000..5542f782c7 --- /dev/null +++ b/server/src/queries/view.repository.sql @@ -0,0 +1,78 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- ViewRepository.getAssetsByOriginalPath +SELECT + "asset"."id" AS "asset_id", + "asset"."deviceAssetId" AS "asset_deviceAssetId", + "asset"."ownerId" AS "asset_ownerId", + "asset"."libraryId" AS "asset_libraryId", + "asset"."deviceId" AS "asset_deviceId", + "asset"."type" AS "asset_type", + "asset"."originalPath" AS "asset_originalPath", + "asset"."thumbhash" AS "asset_thumbhash", + "asset"."encodedVideoPath" AS "asset_encodedVideoPath", + "asset"."createdAt" AS "asset_createdAt", + "asset"."updatedAt" AS "asset_updatedAt", + "asset"."deletedAt" AS "asset_deletedAt", + "asset"."fileCreatedAt" AS "asset_fileCreatedAt", + "asset"."localDateTime" AS "asset_localDateTime", + "asset"."fileModifiedAt" AS "asset_fileModifiedAt", + "asset"."isFavorite" AS "asset_isFavorite", + "asset"."isArchived" AS "asset_isArchived", + "asset"."isExternal" AS "asset_isExternal", + "asset"."isOffline" AS "asset_isOffline", + "asset"."checksum" AS "asset_checksum", + "asset"."duration" AS "asset_duration", + "asset"."isVisible" AS "asset_isVisible", + "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId", + "asset"."originalFileName" AS "asset_originalFileName", + "asset"."sidecarPath" AS "asset_sidecarPath", + "asset"."stackId" AS "asset_stackId", + "asset"."duplicateId" AS "asset_duplicateId", + "exifInfo"."assetId" AS "exifInfo_assetId", + "exifInfo"."description" AS "exifInfo_description", + "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", + "exifInfo"."exifImageHeight" AS "exifInfo_exifImageHeight", + "exifInfo"."fileSizeInByte" AS "exifInfo_fileSizeInByte", + "exifInfo"."orientation" AS "exifInfo_orientation", + "exifInfo"."dateTimeOriginal" AS "exifInfo_dateTimeOriginal", + "exifInfo"."modifyDate" AS "exifInfo_modifyDate", + "exifInfo"."timeZone" AS "exifInfo_timeZone", + "exifInfo"."latitude" AS "exifInfo_latitude", + "exifInfo"."longitude" AS "exifInfo_longitude", + "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", + "exifInfo"."model" AS "exifInfo_model", + "exifInfo"."lensModel" AS "exifInfo_lensModel", + "exifInfo"."fNumber" AS "exifInfo_fNumber", + "exifInfo"."focalLength" AS "exifInfo_focalLength", + "exifInfo"."iso" AS "exifInfo_iso", + "exifInfo"."exposureTime" AS "exifInfo_exposureTime", + "exifInfo"."profileDescription" AS "exifInfo_profileDescription", + "exifInfo"."colorspace" AS "exifInfo_colorspace", + "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", + "exifInfo"."rating" AS "exifInfo_rating", + "exifInfo"."fps" AS "exifInfo_fps" +FROM + "assets" "asset" + LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" +WHERE + ( + ( + "asset"."isVisible" = $1 + AND "asset"."isArchived" = $2 + AND "asset"."ownerId" = $3 + ) + AND ( + "asset"."originalPath" LIKE $4 + AND "asset"."originalPath" NOT LIKE $5 + ) + ) + AND ("asset"."deletedAt" IS NULL) +ORDER BY + regexp_replace("asset"."originalPath", '.*/(.+)', '\1') ASC diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index ac9dab6fbc..f9c01953ef 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -836,50 +836,6 @@ export class AssetRepository implements IAssetRepository { return builder.getMany(); } - async getUniqueOriginalPaths(userId: string): Promise { - const builder = this.getBuilder({ - userIds: [userId], - exifInfo: false, - withStacked: false, - isArchived: false, - isTrashed: false, - }); - - const results = await builder - .select("DISTINCT substring(asset.originalPath FROM '^(.*/)[^/]*$')", 'directoryPath') - .getRawMany(); - - return results.map((row: { directoryPath: string }) => row.directoryPath.replaceAll(/^\/|\/$/g, '')); - } - - @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - async getAssetsByOriginalPath(userId: string, partialPath: string): Promise { - const normalizedPath = partialPath.replaceAll(/^\/|\/$/g, ''); - - const builder = this.getBuilder({ - userIds: [userId], - exifInfo: true, - withStacked: false, - isArchived: false, - isTrashed: false, - }); - - const assets = await builder - .where('asset.ownerId = :userId', { userId }) - .andWhere( - new Brackets((qb) => { - qb.where('asset.originalPath LIKE :likePath', { likePath: `%${normalizedPath}/%` }).andWhere( - 'asset.originalPath NOT LIKE :notLikePath', - { notLikePath: `%${normalizedPath}/%/%` }, - ); - }), - ) - .orderBy(String.raw`regexp_replace(asset.originalPath, '.*/(.+)', '\1')`, 'ASC') - .getMany(); - - return assets; - } - @GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] }) async upsertFile({ assetId, type, path }: { assetId: string; type: AssetFileType; path: string }): Promise { await this.fileRepository.upsert({ assetId, type, path }, { conflictPaths: ['assetId', 'type'] }); diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 3be6b375a0..6f19914d40 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -30,6 +30,7 @@ import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ITagRepository } from 'src/interfaces/tag.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { IViewRepository } from 'src/interfaces/view.interface'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; @@ -62,6 +63,7 @@ import { StorageRepository } from 'src/repositories/storage.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { TagRepository } from 'src/repositories/tag.repository'; import { UserRepository } from 'src/repositories/user.repository'; +import { ViewRepository } from 'src/repositories/view-repository'; export const repositories = [ { provide: IAccessRepository, useClass: AccessRepository }, @@ -96,4 +98,5 @@ export const repositories = [ { provide: ISystemMetadataRepository, useClass: SystemMetadataRepository }, { provide: ITagRepository, useClass: TagRepository }, { provide: IUserRepository, useClass: UserRepository }, + { provide: IViewRepository, useClass: ViewRepository }, ]; diff --git a/server/src/repositories/view-repository.ts b/server/src/repositories/view-repository.ts new file mode 100644 index 0000000000..3645e3638a --- /dev/null +++ b/server/src/repositories/view-repository.ts @@ -0,0 +1,48 @@ +import { InjectRepository } from '@nestjs/typeorm'; +import { DummyValue, GenerateSql } from 'src/decorators'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { IViewRepository } from 'src/interfaces/view.interface'; +import { Brackets, Repository } from 'typeorm'; + +export class ViewRepository implements IViewRepository { + constructor(@InjectRepository(AssetEntity) private assetRepository: Repository) {} + + async getUniqueOriginalPaths(userId: string): Promise { + const results = await this.assetRepository + .createQueryBuilder('asset') + .where({ + isVisible: true, + isArchived: false, + ownerId: userId, + }) + .select("DISTINCT substring(asset.originalPath FROM '^(.*/)[^/]*$')", 'directoryPath') + .getRawMany(); + + return results.map((row: { directoryPath: string }) => row.directoryPath.replaceAll(/^\/|\/$/g, '')); + } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) + async getAssetsByOriginalPath(userId: string, partialPath: string): Promise { + const normalizedPath = partialPath.replaceAll(/^\/|\/$/g, ''); + const assets = await this.assetRepository + .createQueryBuilder('asset') + .where({ + isVisible: true, + isArchived: false, + ownerId: userId, + }) + .leftJoinAndSelect('asset.exifInfo', 'exifInfo') + .andWhere( + new Brackets((qb) => { + qb.where('asset.originalPath LIKE :likePath', { likePath: `%${normalizedPath}/%` }).andWhere( + 'asset.originalPath NOT LIKE :notLikePath', + { notLikePath: `%${normalizedPath}/%/%` }, + ); + }), + ) + .orderBy(String.raw`regexp_replace(asset.originalPath, '.*/(.+)', '\1')`, 'ASC') + .getMany(); + + return assets; + } +} diff --git a/server/src/services/view.service.spec.ts b/server/src/services/view.service.spec.ts index 3f9aa9f2f5..8d17e4d897 100644 --- a/server/src/services/view.service.spec.ts +++ b/server/src/services/view.service.spec.ts @@ -1,21 +1,20 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; - +import { IViewRepository } from 'src/interfaces/view.interface'; import { ViewService } from 'src/services/view.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; +import { newViewRepositoryMock } from 'test/repositories/view.repository.mock'; import { Mocked } from 'vitest'; describe(ViewService.name, () => { let sut: ViewService; - let assetMock: Mocked; + let viewMock: Mocked; beforeEach(() => { - assetMock = newAssetRepositoryMock(); + viewMock = newViewRepositoryMock(); - sut = new ViewService(assetMock); + sut = new ViewService(viewMock); }); it('should work', () => { @@ -25,12 +24,12 @@ describe(ViewService.name, () => { describe('getUniqueOriginalPaths', () => { it('should return unique original paths', async () => { const mockPaths = ['path1', 'path2', 'path3']; - assetMock.getUniqueOriginalPaths.mockResolvedValue(mockPaths); + viewMock.getUniqueOriginalPaths.mockResolvedValue(mockPaths); const result = await sut.getUniqueOriginalPaths(authStub.admin); expect(result).toEqual(mockPaths); - expect(assetMock.getUniqueOriginalPaths).toHaveBeenCalledWith(authStub.admin.user.id); + expect(viewMock.getUniqueOriginalPaths).toHaveBeenCalledWith(authStub.admin.user.id); }); }); @@ -45,11 +44,11 @@ describe(ViewService.name, () => { const mockAssetReponseDto = mockAssets.map((a) => mapAsset(a, { auth: authStub.admin })); - assetMock.getAssetsByOriginalPath.mockResolvedValue(mockAssets); + viewMock.getAssetsByOriginalPath.mockResolvedValue(mockAssets); const result = await sut.getAssetsByOriginalPath(authStub.admin, path); expect(result).toEqual(mockAssetReponseDto); - await expect(assetMock.getAssetsByOriginalPath(authStub.admin.user.id, path)).resolves.toEqual(mockAssets); + await expect(viewMock.getAssetsByOriginalPath(authStub.admin.user.id, path)).resolves.toEqual(mockAssets); }); }); }); diff --git a/server/src/services/view.service.ts b/server/src/services/view.service.ts index 1bf9a3408c..d870f9fd2e 100644 --- a/server/src/services/view.service.ts +++ b/server/src/services/view.service.ts @@ -1,18 +1,17 @@ import { Inject } from '@nestjs/common'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { IViewRepository } from 'src/interfaces/view.interface'; export class ViewService { - constructor(@Inject(IAssetRepository) private assetRepository: IAssetRepository) {} + constructor(@Inject(IViewRepository) private viewRepository: IViewRepository) {} getUniqueOriginalPaths(auth: AuthDto): Promise { - return this.assetRepository.getUniqueOriginalPaths(auth.user.id); + return this.viewRepository.getUniqueOriginalPaths(auth.user.id); } async getAssetsByOriginalPath(auth: AuthDto, path: string): Promise { - const assets = await this.assetRepository.getAssetsByOriginalPath(auth.user.id, path); - + const assets = await this.viewRepository.getAssetsByOriginalPath(auth.user.id, path); return assets.map((asset) => mapAsset(asset, { auth })); } } diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 69f07bf105..9320639b93 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -43,7 +43,5 @@ export const newAssetRepositoryMock = (): Mocked => { getChangedDeltaSync: vitest.fn(), getDuplicates: vitest.fn(), upsertFile: vitest.fn(), - getAssetsByOriginalPath: vitest.fn(), - getUniqueOriginalPaths: vitest.fn(), }; }; diff --git a/server/test/repositories/view.repository.mock.ts b/server/test/repositories/view.repository.mock.ts new file mode 100644 index 0000000000..a002362ae7 --- /dev/null +++ b/server/test/repositories/view.repository.mock.ts @@ -0,0 +1,9 @@ +import { IViewRepository } from 'src/interfaces/view.interface'; +import { Mocked, vitest } from 'vitest'; + +export const newViewRepositoryMock = (): Mocked => { + return { + getAssetsByOriginalPath: vitest.fn(), + getUniqueOriginalPaths: vitest.fn(), + }; +};