diff --git a/server/src/domain/album/album.service.spec.ts b/server/src/domain/album/album.service.spec.ts index 414826cb2c..c133428979 100644 --- a/server/src/domain/album/album.service.spec.ts +++ b/server/src/domain/album/album.service.spec.ts @@ -58,9 +58,9 @@ describe(AlbumService.name, () => { describe('getAll', () => { it('gets list of albums for auth user', async () => { albumMock.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]); - albumMock.getAssetCountForIds.mockResolvedValue([ - { albumId: albumStub.empty.id, assetCount: 0 }, - { albumId: albumStub.sharedWithUser.id, assetCount: 0 }, + albumMock.getMetadataForIds.mockResolvedValue([ + { albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined }, + { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined }, ]); albumMock.getInvalidThumbnail.mockResolvedValue([]); @@ -72,7 +72,14 @@ describe(AlbumService.name, () => { it('gets list of albums that have a specific asset', async () => { albumMock.getByAssetId.mockResolvedValue([albumStub.oneAsset]); - albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.oneAsset.id, assetCount: 1 }]); + albumMock.getMetadataForIds.mockResolvedValue([ + { + albumId: albumStub.oneAsset.id, + assetCount: 1, + startDate: new Date('1970-01-01'), + endDate: new Date('1970-01-01'), + }, + ]); albumMock.getInvalidThumbnail.mockResolvedValue([]); const result = await sut.getAll(authStub.admin, { assetId: albumStub.oneAsset.id }); @@ -83,7 +90,9 @@ describe(AlbumService.name, () => { it('gets list of albums that are shared', async () => { albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]); - albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.sharedWithUser.id, assetCount: 0 }]); + albumMock.getMetadataForIds.mockResolvedValue([ + { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined }, + ]); albumMock.getInvalidThumbnail.mockResolvedValue([]); const result = await sut.getAll(authStub.admin, { shared: true }); @@ -94,7 +103,9 @@ describe(AlbumService.name, () => { it('gets list of albums that are NOT shared', async () => { albumMock.getNotShared.mockResolvedValue([albumStub.empty]); - albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.empty.id, assetCount: 0 }]); + albumMock.getMetadataForIds.mockResolvedValue([ + { albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined }, + ]); albumMock.getInvalidThumbnail.mockResolvedValue([]); const result = await sut.getAll(authStub.admin, { shared: false }); @@ -106,7 +117,14 @@ describe(AlbumService.name, () => { it('counts assets correctly', async () => { albumMock.getOwned.mockResolvedValue([albumStub.oneAsset]); - albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.oneAsset.id, assetCount: 1 }]); + albumMock.getMetadataForIds.mockResolvedValue([ + { + albumId: albumStub.oneAsset.id, + assetCount: 1, + startDate: new Date('1970-01-01'), + endDate: new Date('1970-01-01'), + }, + ]); albumMock.getInvalidThumbnail.mockResolvedValue([]); const result = await sut.getAll(authStub.admin, {}); @@ -118,8 +136,13 @@ describe(AlbumService.name, () => { it('updates the album thumbnail by listing all albums', async () => { albumMock.getOwned.mockResolvedValue([albumStub.oneAssetInvalidThumbnail]); - albumMock.getAssetCountForIds.mockResolvedValue([ - { albumId: albumStub.oneAssetInvalidThumbnail.id, assetCount: 1 }, + albumMock.getMetadataForIds.mockResolvedValue([ + { + albumId: albumStub.oneAssetInvalidThumbnail.id, + assetCount: 1, + startDate: new Date('1970-01-01'), + endDate: new Date('1970-01-01'), + }, ]); albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.oneAssetInvalidThumbnail.id]); albumMock.update.mockResolvedValue(albumStub.oneAssetValidThumbnail); @@ -134,8 +157,13 @@ describe(AlbumService.name, () => { it('removes the thumbnail for an empty album', async () => { albumMock.getOwned.mockResolvedValue([albumStub.emptyWithInvalidThumbnail]); - albumMock.getAssetCountForIds.mockResolvedValue([ - { albumId: albumStub.emptyWithInvalidThumbnail.id, assetCount: 1 }, + albumMock.getMetadataForIds.mockResolvedValue([ + { + albumId: albumStub.emptyWithInvalidThumbnail.id, + assetCount: 1, + startDate: new Date('1970-01-01'), + endDate: new Date('1970-01-01'), + }, ]); albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.emptyWithInvalidThumbnail.id]); albumMock.update.mockResolvedValue(albumStub.emptyWithValidThumbnail); @@ -413,10 +441,18 @@ describe(AlbumService.name, () => { it('should get a shared album', async () => { albumMock.getById.mockResolvedValue(albumStub.oneAsset); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id])); + albumMock.getMetadataForIds.mockResolvedValue([ + { + albumId: albumStub.oneAsset.id, + assetCount: 1, + startDate: new Date('1970-01-01'), + endDate: new Date('1970-01-01'), + }, + ]); await sut.get(authStub.admin, albumStub.oneAsset.id, {}); - expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: true }); + expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: false }); expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.id, new Set([albumStub.oneAsset.id]), @@ -426,10 +462,18 @@ describe(AlbumService.name, () => { it('should get a shared album via a shared link', async () => { albumMock.getById.mockResolvedValue(albumStub.oneAsset); accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123'])); + albumMock.getMetadataForIds.mockResolvedValue([ + { + albumId: albumStub.oneAsset.id, + assetCount: 1, + startDate: new Date('1970-01-01'), + endDate: new Date('1970-01-01'), + }, + ]); await sut.get(authStub.adminSharedLink, 'album-123', {}); - expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true }); + expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: false }); expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalledWith( authStub.adminSharedLink.sharedLinkId, new Set(['album-123']), @@ -439,10 +483,18 @@ describe(AlbumService.name, () => { it('should get a shared album via shared with user', async () => { albumMock.getById.mockResolvedValue(albumStub.oneAsset); accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123'])); + albumMock.getMetadataForIds.mockResolvedValue([ + { + albumId: albumStub.oneAsset.id, + assetCount: 1, + startDate: new Date('1970-01-01'), + endDate: new Date('1970-01-01'), + }, + ]); await sut.get(authStub.user1, 'album-123', {}); - expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true }); + expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: false }); expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith(authStub.user1.id, new Set(['album-123'])); }); diff --git a/server/src/domain/album/album.service.ts b/server/src/domain/album/album.service.ts index 0d92dae04d..6d9fa4e707 100644 --- a/server/src/domain/album/album.service.ts +++ b/server/src/domain/album/album.service.ts @@ -6,6 +6,7 @@ import { AuthUserDto } from '../auth'; import { setUnion } from '../domain.util'; import { JobName } from '../job'; import { + AlbumAssetCount, AlbumInfoOptions, IAccessRepository, IAlbumRepository, @@ -69,11 +70,19 @@ export class AlbumService { // Get asset count for each album. Then map the result to an object: // { [albumId]: assetCount } - const albumsAssetCount = await this.albumRepository.getAssetCountForIds(albums.map((album) => album.id)); - const albumsAssetCountObj = albumsAssetCount.reduce((obj: Record, { albumId, assetCount }) => { - obj[albumId] = assetCount; - return obj; - }, {}); + const albumMetadataForIds = await this.albumRepository.getMetadataForIds(albums.map((album) => album.id)); + const albumMetadataForIdsObj: Record = albumMetadataForIds.reduce( + (obj: Record, { albumId, assetCount, startDate, endDate }) => { + obj[albumId] = { + albumId, + assetCount, + startDate, + endDate, + }; + return obj; + }, + {}, + ); return Promise.all( albums.map(async (album) => { @@ -81,7 +90,9 @@ export class AlbumService { return { ...mapAlbumWithoutAssets(album), sharedLinks: undefined, - assetCount: albumsAssetCountObj[album.id], + startDate: albumMetadataForIdsObj[album.id].startDate, + endDate: albumMetadataForIdsObj[album.id].endDate, + assetCount: albumMetadataForIdsObj[album.id].assetCount, lastModifiedAssetTimestamp: lastModifiedAsset?.fileModifiedAt, }; }), @@ -91,7 +102,16 @@ export class AlbumService { async get(authUser: AuthUserDto, id: string, dto: AlbumInfoDto): Promise { await this.access.requirePermission(authUser, Permission.ALBUM_READ, id); await this.albumRepository.updateThumbnails(); - return mapAlbum(await this.findOrFail(id, { withAssets: true }), !dto.withoutAssets); + const withAssets = dto.withoutAssets === undefined ? false : !dto.withoutAssets; + const album = await this.findOrFail(id, { withAssets }); + const [albumMetadataForIds] = await this.albumRepository.getMetadataForIds([album.id]); + + return { + ...mapAlbum(album, withAssets), + startDate: albumMetadataForIds.startDate, + endDate: albumMetadataForIds.endDate, + assetCount: albumMetadataForIds.assetCount, + }; } async create(authUser: AuthUserDto, dto: CreateAlbumDto): Promise { diff --git a/server/src/domain/repositories/album.repository.ts b/server/src/domain/repositories/album.repository.ts index d3ca62da12..10b789b4b3 100644 --- a/server/src/domain/repositories/album.repository.ts +++ b/server/src/domain/repositories/album.repository.ts @@ -5,6 +5,8 @@ export const IAlbumRepository = 'IAlbumRepository'; export interface AlbumAssetCount { albumId: string; assetCount: number; + startDate: Date | undefined; + endDate: Date | undefined; } export interface AlbumInfoOptions { @@ -30,7 +32,7 @@ export interface IAlbumRepository { hasAsset(asset: AlbumAsset): Promise; removeAsset(assetId: string): Promise; removeAssets(assets: AlbumAssets): Promise; - getAssetCountForIds(ids: string[]): Promise; + getMetadataForIds(ids: string[]): Promise; getInvalidThumbnail(): Promise; getOwned(ownerId: string): Promise; getShared(ownerId: string): Promise; diff --git a/server/src/infra/repositories/album.repository.ts b/server/src/infra/repositories/album.repository.ts index 69df226859..e6c2797261 100644 --- a/server/src/infra/repositories/album.repository.ts +++ b/server/src/infra/repositories/album.repository.ts @@ -59,25 +59,30 @@ export class AlbumRepository implements IAlbumRepository { }); } - async getAssetCountForIds(ids: string[]): Promise { + async getMetadataForIds(ids: string[]): Promise { // Guard against running invalid query when ids list is empty. if (!ids.length) { return []; } // Only possible with query builder because of GROUP BY. - const countByAlbums = await this.repository + const albumMetadatas = await this.repository .createQueryBuilder('album') .select('album.id') - .addSelect('COUNT(albums_assets.assetsId)', 'asset_count') - .leftJoin('albums_assets_assets', 'albums_assets', 'albums_assets.albumsId = album.id') + .addSelect('MIN(assets.fileCreatedAt)', 'start_date') + .addSelect('MAX(assets.fileCreatedAt)', 'end_date') + .addSelect('COUNT(album_assets.assetsId)', 'asset_count') + .leftJoin('albums_assets_assets', 'album_assets', 'album_assets.albumsId = album.id') + .leftJoin('assets', 'assets', 'assets.id = album_assets.assetsId') .where('album.id IN (:...ids)', { ids }) .groupBy('album.id') .getRawMany(); - return countByAlbums.map((albumCount) => ({ - albumId: albumCount['album_id'], - assetCount: Number(albumCount['asset_count']), + return albumMetadatas.map((metadatas) => ({ + albumId: metadatas['album_id'], + assetCount: Number(metadatas['asset_count']), + startDate: metadatas['end_date'] ? new Date(metadatas['start_date']) : undefined, + endDate: metadatas['end_date'] ? new Date(metadatas['end_date']) : undefined, })); } diff --git a/server/test/e2e/album.e2e-spec.ts b/server/test/e2e/album.e2e-spec.ts index f5f6bb66ec..775bb0b687 100644 --- a/server/test/e2e/album.e2e-spec.ts +++ b/server/test/e2e/album.e2e-spec.ts @@ -246,7 +246,7 @@ describe(`${AlbumController.name} (e2e)`, () => { it('should return album info for own album', async () => { const { status, body } = await request(server) - .get(`/album/${user1Albums[0].id}`) + .get(`/album/${user1Albums[0].id}?withoutAssets=false`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); @@ -255,7 +255,7 @@ describe(`${AlbumController.name} (e2e)`, () => { it('should return album info for shared album', async () => { const { status, body } = await request(server) - .get(`/album/${user2Albums[0].id}`) + .get(`/album/${user2Albums[0].id}?withoutAssets=false`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); diff --git a/server/test/repositories/album.repository.mock.ts b/server/test/repositories/album.repository.mock.ts index 7cd0a846b3..36c3afb297 100644 --- a/server/test/repositories/album.repository.mock.ts +++ b/server/test/repositories/album.repository.mock.ts @@ -5,7 +5,7 @@ export const newAlbumRepositoryMock = (): jest.Mocked => { getById: jest.fn(), getByIds: jest.fn(), getByAssetId: jest.fn(), - getAssetCountForIds: jest.fn(), + getMetadataForIds: jest.fn(), getInvalidThumbnail: jest.fn(), getOwned: jest.fn(), getShared: jest.fn(), diff --git a/web/src/lib/components/elements/table-header.svelte b/web/src/lib/components/elements/table-header.svelte index c89bff3db2..0b68dd0e52 100644 --- a/web/src/lib/components/elements/table-header.svelte +++ b/web/src/lib/components/elements/table-header.svelte @@ -5,10 +5,10 @@ export let option: Sort; const handleSort = () => { - if (albumViewSettings === option.sortTitle) { + if (albumViewSettings === option.title) { option.sortDesc = !option.sortDesc; } else { - albumViewSettings = option.sortTitle; + albumViewSettings = option.title; } }; @@ -18,12 +18,12 @@ class="rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50" on:click={() => handleSort()} > - {#if albumViewSettings === option.sortTitle} + {#if albumViewSettings === option.title} {#if option.sortDesc} ↓ {:else} ↑ {/if} - {/if}{option.table} diff --git a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte index 621c981326..f4a21a5408 100644 --- a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte +++ b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte @@ -7,13 +7,14 @@ import { createEventDispatcher } from 'svelte'; import { goto } from '$app/navigation'; import { mdiCircleEditOutline, mdiContentCopy, mdiDelete, mdiOpenInNew } from '@mdi/js'; + import noThumbnailUrl from '$lib/assets/no-thumbnail.png'; export let link: SharedLinkResponseDto; let expirationCountdown: luxon.DurationObjectUnits; const dispatch = createEventDispatcher(); - const getAssetInfo = async (): Promise => { + const getThumbnail = async (): Promise => { let assetId = ''; if (link.album?.albumThumbnailAssetId) { @@ -60,18 +61,28 @@ class="flex w-full gap-4 border-b border-gray-200 py-4 transition-all hover:border-immich-primary dark:border-gray-600 dark:text-immich-gray dark:hover:border-immich-dark-primary" >
- {#await getAssetInfo()} - - {:then asset} + {#if link?.album?.albumThumbnailAssetId || link.assets.length > 0} + {#await getThumbnail()} + + {:then asset} + {asset.id} + {/await} + {:else} {asset.id} - {/await} + {/if}
diff --git a/web/src/routes/(user)/albums/+page.svelte b/web/src/routes/(user)/albums/+page.svelte index 4d17ed326b..c7506a6158 100644 --- a/web/src/routes/(user)/albums/+page.svelte +++ b/web/src/routes/(user)/albums/+page.svelte @@ -1,9 +1,6 @@