diff --git a/server/src/domain/album/album-response.dto.ts b/server/src/domain/album/album-response.dto.ts index 671922408e..e6967ec6c7 100644 --- a/server/src/domain/album/album-response.dto.ts +++ b/server/src/domain/album/album-response.dto.ts @@ -37,15 +37,6 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean): AlbumRespons const hasSharedLink = entity.sharedLinks?.length > 0; const hasSharedUser = sharedUsers.length > 0; - let startDate = assets.at(0)?.fileCreatedAt || undefined; - let endDate = assets.at(-1)?.fileCreatedAt || undefined; - // Swap dates if start date is greater than end date. - if (startDate && endDate && startDate > endDate) { - const temp = startDate; - startDate = endDate; - endDate = temp; - } - return { albumName: entity.albumName, description: entity.description, @@ -58,10 +49,10 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean): AlbumRespons sharedUsers, shared: hasSharedUser || hasSharedLink, hasSharedLink, - startDate, - endDate, + startDate: entity.startDate ? entity.startDate : undefined, + endDate: entity.endDate ? entity.endDate : undefined, assets: (withAssets ? assets : []).map((asset) => mapAsset(asset)), - assetCount: entity.assets?.length || 0, + assetCount: entity.assetCount, isActivityEnabled: entity.isActivityEnabled, }; }; diff --git a/server/src/domain/album/album.service.spec.ts b/server/src/domain/album/album.service.spec.ts index a93cb0ad17..8182df0402 100644 --- a/server/src/domain/album/album.service.spec.ts +++ b/server/src/domain/album/album.service.spec.ts @@ -58,10 +58,6 @@ 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.getInvalidThumbnail.mockResolvedValue([]); const result = await sut.getAll(authStub.admin, {}); @@ -72,7 +68,6 @@ 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.getInvalidThumbnail.mockResolvedValue([]); const result = await sut.getAll(authStub.admin, { assetId: albumStub.oneAsset.id }); @@ -83,7 +78,6 @@ 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.getInvalidThumbnail.mockResolvedValue([]); const result = await sut.getAll(authStub.admin, { shared: true }); @@ -94,7 +88,6 @@ 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.getInvalidThumbnail.mockResolvedValue([]); const result = await sut.getAll(authStub.admin, { shared: false }); @@ -106,7 +99,6 @@ describe(AlbumService.name, () => { it('counts assets correctly', async () => { albumMock.getOwned.mockResolvedValue([albumStub.oneAsset]); - albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.oneAsset.id, assetCount: 1 }]); albumMock.getInvalidThumbnail.mockResolvedValue([]); const result = await sut.getAll(authStub.admin, {}); @@ -118,9 +110,6 @@ 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.getInvalidThumbnail.mockResolvedValue([albumStub.oneAssetInvalidThumbnail.id]); albumMock.update.mockResolvedValue(albumStub.oneAssetValidThumbnail); assetMock.getFirstAssetForAlbumId.mockResolvedValue(albumStub.oneAssetInvalidThumbnail.assets[0]); @@ -134,9 +123,6 @@ 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.getInvalidThumbnail.mockResolvedValue([albumStub.emptyWithInvalidThumbnail.id]); albumMock.update.mockResolvedValue(albumStub.emptyWithValidThumbnail); assetMock.getFirstAssetForAlbumId.mockResolvedValue(null); diff --git a/server/src/domain/album/album.service.ts b/server/src/domain/album/album.service.ts index 0fb0391ef4..8a66aaa33d 100644 --- a/server/src/domain/album/album.service.ts +++ b/server/src/domain/album/album.service.ts @@ -66,21 +66,12 @@ export class AlbumService { albums = await this.albumRepository.getOwned(ownerId); } - // 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; - }, {}); - return Promise.all( albums.map(async (album) => { const lastModifiedAsset = await this.assetRepository.getLastUpdatedAssetForAlbumId(album.id); return { ...mapAlbumWithoutAssets(album), sharedLinks: undefined, - assetCount: albumsAssetCountObj[album.id], lastModifiedAssetTimestamp: lastModifiedAsset?.fileModifiedAt, }; }), diff --git a/server/src/domain/repositories/album.repository.ts b/server/src/domain/repositories/album.repository.ts index d3ca62da12..3f37be69f7 100644 --- a/server/src/domain/repositories/album.repository.ts +++ b/server/src/domain/repositories/album.repository.ts @@ -30,7 +30,6 @@ export interface IAlbumRepository { hasAsset(asset: AlbumAsset): Promise; removeAsset(assetId: string): Promise; removeAssets(assets: AlbumAssets): Promise; - getAssetCountForIds(ids: string[]): Promise; getInvalidThumbnail(): Promise; getOwned(ownerId: string): Promise; getShared(ownerId: string): Promise; diff --git a/server/src/domain/shared-link/shared-link-response.dto.ts b/server/src/domain/shared-link/shared-link-response.dto.ts index b16a578f41..67e8e66c69 100644 --- a/server/src/domain/shared-link/shared-link-response.dto.ts +++ b/server/src/domain/shared-link/shared-link-response.dto.ts @@ -40,7 +40,7 @@ export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseD createdAt: sharedLink.createdAt, expiresAt: sharedLink.expiresAt, assets: assets.map((asset) => mapAsset(asset)), - album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined, + album: sharedLink.album?.id ? mapAlbumWithoutAssets(sharedLink.album) : undefined, allowUpload: sharedLink.allowUpload, allowDownload: sharedLink.allowDownload, showMetadata: sharedLink.showExif, diff --git a/server/src/infra/entities/album.entity.ts b/server/src/infra/entities/album.entity.ts index fbc125351a..2e16b7f7c2 100644 --- a/server/src/infra/entities/album.entity.ts +++ b/server/src/infra/entities/album.entity.ts @@ -9,6 +9,7 @@ import { OneToMany, PrimaryGeneratedColumn, UpdateDateColumn, + VirtualColumn, } from 'typeorm'; import { AssetEntity } from './asset.entity'; import { SharedLinkEntity } from './shared-link.entity'; @@ -59,4 +60,34 @@ export class AlbumEntity { @Column({ default: true }) isActivityEnabled!: boolean; + + @VirtualColumn({ + query: (alias) => ` + SELECT MIN(assets."fileCreatedAt") + FROM "assets" assets + JOIN "albums_assets_assets" aa ON aa."assetsId" = assets.id + WHERE aa."albumsId" = ${alias}.id + `, + }) + startDate!: Date | null; + + @VirtualColumn({ + query: (alias) => ` + SELECT MAX(assets."fileCreatedAt") + FROM "assets" assets + JOIN "albums_assets_assets" aa ON aa."assetsId" = assets.id + WHERE aa."albumsId" = ${alias}.id + `, + }) + endDate!: Date | null; + + @VirtualColumn({ + query: (alias) => ` + SELECT COUNT(assets."id") + FROM "assets" assets + JOIN "albums_assets_assets" aa ON aa."assetsId" = assets.id + WHERE aa."albumsId" = ${alias}.id + `, + }) + assetCount!: number; } diff --git a/server/src/infra/repositories/album.repository.ts b/server/src/infra/repositories/album.repository.ts index 69df226859..735b2d5849 100644 --- a/server/src/infra/repositories/album.repository.ts +++ b/server/src/infra/repositories/album.repository.ts @@ -1,4 +1,4 @@ -import { AlbumAsset, AlbumAssetCount, AlbumAssets, AlbumInfoOptions, IAlbumRepository } from '@app/domain'; +import { AlbumAsset, AlbumAssets, AlbumInfoOptions, IAlbumRepository } from '@app/domain'; import { Injectable } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { DataSource, FindOptionsOrder, FindOptionsRelations, In, IsNull, Not, Repository } from 'typeorm'; @@ -59,28 +59,6 @@ export class AlbumRepository implements IAlbumRepository { }); } - async getAssetCountForIds(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 - .createQueryBuilder('album') - .select('album.id') - .addSelect('COUNT(albums_assets.assetsId)', 'asset_count') - .leftJoin('albums_assets_assets', 'albums_assets', 'albums_assets.albumsId = album.id') - .where('album.id IN (:...ids)', { ids }) - .groupBy('album.id') - .getRawMany(); - - return countByAlbums.map((albumCount) => ({ - albumId: albumCount['album_id'], - assetCount: Number(albumCount['asset_count']), - })); - } - /** * Returns the album IDs that have an invalid thumbnail, when: * - Thumbnail references an asset outside the album diff --git a/server/test/fixtures/album.stub.ts b/server/test/fixtures/album.stub.ts index fd4464d191..ea7a3887e0 100644 --- a/server/test/fixtures/album.stub.ts +++ b/server/test/fixtures/album.stub.ts @@ -19,6 +19,9 @@ export const albumStub = { sharedLinks: [], sharedUsers: [], isActivityEnabled: true, + startDate: null, + endDate: null, + assetCount: 0, }), sharedWithUser: Object.freeze({ id: 'album-2', @@ -35,6 +38,9 @@ export const albumStub = { sharedLinks: [], sharedUsers: [userStub.user1], isActivityEnabled: true, + startDate: null, + endDate: null, + assetCount: 0, }), sharedWithMultiple: Object.freeze({ id: 'album-3', @@ -51,6 +57,9 @@ export const albumStub = { sharedLinks: [], sharedUsers: [userStub.user1, userStub.user2], isActivityEnabled: true, + startDate: null, + endDate: null, + assetCount: 0, }), sharedWithAdmin: Object.freeze({ id: 'album-3', @@ -67,6 +76,9 @@ export const albumStub = { sharedLinks: [], sharedUsers: [userStub.admin], isActivityEnabled: true, + startDate: null, + endDate: null, + assetCount: 0, }), oneAsset: Object.freeze({ id: 'album-4', @@ -83,6 +95,9 @@ export const albumStub = { sharedLinks: [], sharedUsers: [], isActivityEnabled: true, + startDate: assetStub.image.fileCreatedAt, + endDate: assetStub.image.fileCreatedAt, + assetCount: 1, }), twoAssets: Object.freeze({ id: 'album-4a', @@ -99,6 +114,9 @@ export const albumStub = { sharedLinks: [], sharedUsers: [], isActivityEnabled: true, + startDate: assetStub.withLocation.fileCreatedAt, + endDate: assetStub.image.fileCreatedAt, + assetCount: 2, }), emptyWithInvalidThumbnail: Object.freeze({ id: 'album-5', @@ -115,6 +133,9 @@ export const albumStub = { sharedLinks: [], sharedUsers: [], isActivityEnabled: true, + startDate: null, + endDate: null, + assetCount: 0, }), emptyWithValidThumbnail: Object.freeze({ id: 'album-5', @@ -131,6 +152,9 @@ export const albumStub = { sharedLinks: [], sharedUsers: [], isActivityEnabled: true, + startDate: null, + endDate: null, + assetCount: 0, }), oneAssetInvalidThumbnail: Object.freeze({ id: 'album-6', @@ -147,6 +171,9 @@ export const albumStub = { sharedLinks: [], sharedUsers: [], isActivityEnabled: true, + startDate: assetStub.image.fileCreatedAt, + endDate: assetStub.image.fileCreatedAt, + assetCount: 1, }), oneAssetValidThumbnail: Object.freeze({ id: 'album-6', @@ -163,5 +190,8 @@ export const albumStub = { sharedLinks: [], sharedUsers: [], isActivityEnabled: true, + startDate: assetStub.image.fileCreatedAt, + endDate: assetStub.image.fileCreatedAt, + assetCount: 1, }), }; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 56a0c10450..ea34534616 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -181,6 +181,9 @@ export const sharedLinkStub = { sharedUsers: [], sharedLinks: [], isActivityEnabled: true, + startDate: today, + endDate: today, + assetCount: 1, assets: [ { id: 'id_1', diff --git a/server/test/repositories/album.repository.mock.ts b/server/test/repositories/album.repository.mock.ts index 7cd0a846b3..a97393c0b0 100644 --- a/server/test/repositories/album.repository.mock.ts +++ b/server/test/repositories/album.repository.mock.ts @@ -5,7 +5,6 @@ export const newAlbumRepositoryMock = (): jest.Mocked => { getById: jest.fn(), getByIds: jest.fn(), getByAssetId: jest.fn(), - getAssetCountForIds: 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..1dda332c2a 100644 --- a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte +++ b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte @@ -7,6 +7,7 @@ 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; @@ -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?.assetCount && link?.album?.assetCount > 0) || link.assets.length > 0} + {#await getAssetInfo()} + + {: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 3877d026f0..83c44a9cda 100644 --- a/web/src/routes/(user)/albums/+page.svelte +++ b/web/src/routes/(user)/albums/+page.svelte @@ -1,9 +1,6 @@