diff --git a/e2e/src/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts index 5615a312f..c4f06edd9 100644 --- a/e2e/src/api/specs/album.e2e-spec.ts +++ b/e2e/src/api/specs/album.e2e-spec.ts @@ -136,6 +136,7 @@ describe('/albums', () => { expect(body).toEqual({ ...user1Albums[0], assets: [expect.objectContaining({ isFavorite: false })], + contributorCounts: [{ userId: user1.userId, assetCount: 1 }], lastModifiedAssetTimestamp: expect.any(String), startDate: expect.any(String), endDate: expect.any(String), @@ -310,6 +311,7 @@ describe('/albums', () => { expect(body).toEqual({ ...user1Albums[0], assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })], + contributorCounts: [{ userId: user1.userId, assetCount: 1 }], lastModifiedAssetTimestamp: expect.any(String), startDate: expect.any(String), endDate: expect.any(String), @@ -345,6 +347,7 @@ describe('/albums', () => { expect(body).toEqual({ ...user1Albums[0], assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })], + contributorCounts: [{ userId: user1.userId, assetCount: 1 }], lastModifiedAssetTimestamp: expect.any(String), startDate: expect.any(String), endDate: expect.any(String), @@ -362,6 +365,7 @@ describe('/albums', () => { expect(body).toEqual({ ...user1Albums[0], assets: [], + contributorCounts: [{ userId: user1.userId, assetCount: 1 }], assetCount: 1, lastModifiedAssetTimestamp: expect.any(String), endDate: expect.any(String), @@ -382,6 +386,7 @@ describe('/albums', () => { expect(body).toEqual({ ...user2Albums[0], assets: [], + contributorCounts: [{ userId: user1.userId, assetCount: 1 }], assetCount: 1, lastModifiedAssetTimestamp: expect.any(String), endDate: expect.any(String), diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 4933c2c2b..698b9774d 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index df2c2226b..a3117ede8 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 06d27593c..33056cf14 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index 547a6a70f..2f53706e7 100644 Binary files a/mobile/openapi/lib/model/album_response_dto.dart and b/mobile/openapi/lib/model/album_response_dto.dart differ diff --git a/mobile/openapi/lib/model/contributor_count_response_dto.dart b/mobile/openapi/lib/model/contributor_count_response_dto.dart new file mode 100644 index 000000000..e0e16ee42 Binary files /dev/null and b/mobile/openapi/lib/model/contributor_count_response_dto.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 2c045a73f..b9da330ee 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -10096,6 +10096,12 @@ }, "type": "array" }, + "contributorCounts": { + "items": { + "$ref": "#/components/schemas/ContributorCountResponseDto" + }, + "type": "array" + }, "createdAt": { "format": "date-time", "type": "string" @@ -11471,6 +11477,21 @@ ], "type": "string" }, + "ContributorCountResponseDto": { + "properties": { + "assetCount": { + "type": "integer" + }, + "userId": { + "type": "string" + } + }, + "required": [ + "assetCount", + "userId" + ], + "type": "object" + }, "CreateAlbumDto": { "properties": { "albumName": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 38a57e4b5..60d72fb32 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -356,12 +356,17 @@ export type AssetResponseDto = { updatedAt: string; visibility: AssetVisibility; }; +export type ContributorCountResponseDto = { + assetCount: number; + userId: string; +}; export type AlbumResponseDto = { albumName: string; albumThumbnailAssetId: string | null; albumUsers: AlbumUserResponseDto[]; assetCount: number; assets: AssetResponseDto[]; + contributorCounts?: ContributorCountResponseDto[]; createdAt: string; description: string; endDate?: string; diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 00f5759aa..2f3f22099 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -128,6 +128,14 @@ export class AlbumUserResponseDto { role!: AlbumUserRole; } +export class ContributorCountResponseDto { + @ApiProperty() + userId!: string; + + @ApiProperty({ type: 'integer' }) + assetCount!: number; +} + export class AlbumResponseDto { id!: string; ownerId!: string; @@ -149,6 +157,11 @@ export class AlbumResponseDto { isActivityEnabled!: boolean; @ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', optional: true }) order?: AssetOrder; + + // Optional per-user contribution counts for shared albums + @Type(() => ContributorCountResponseDto) + @ApiProperty({ type: [ContributorCountResponseDto], required: false }) + contributorCounts?: ContributorCountResponseDto[]; } export type MapAlbumDto = { diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index 36c44414d..008721773 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -407,3 +407,18 @@ from where "album_asset"."albumsId" = $1 and "album_asset"."assetsId" in ($2) + +-- AlbumRepository.getContributorCounts +select + "asset"."ownerId" as "userId", + count(*) as "assetCount" +from + "album_asset" + inner join "asset" on "asset"."id" = "assetsId" +where + "asset"."deletedAt" is null + and "album_asset"."albumsId" = $1 +group by + "asset"."ownerId" +order by + "assetCount" desc diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index b023068f1..00c1dfda7 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -379,4 +379,22 @@ export class AlbumRepository { ) .whereRef('album_asset.albumsId', '=', 'album.id'); } + + /** + * Get per-user asset contribution counts for a single album. + * Excludes deleted assets, orders by count desc. + */ + @GenerateSql({ params: [DummyValue.UUID] }) + getContributorCounts(id: string) { + return this.db + .selectFrom('album_asset') + .innerJoin('asset', 'asset.id', 'assetsId') + .where('asset.deletedAt', 'is', sql.lit(null)) + .where('album_asset.albumsId', '=', id) + .select('asset.ownerId as userId') + .select((eb) => eb.fn.countAll().as('assetCount')) + .groupBy('asset.ownerId') + .orderBy('assetCount', 'desc') + .execute(); + } } diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index d7b857d66..dd12e3189 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -79,12 +79,17 @@ export class AlbumService extends BaseService { const album = await this.findOrFail(id, { withAssets }); const [albumMetadataForIds] = await this.albumRepository.getMetadataForIds([album.id]); + const hasSharedUsers = album.albumUsers && album.albumUsers.length > 0; + const hasSharedLink = album.sharedLinks && album.sharedLinks.length > 0; + const isShared = hasSharedUsers || hasSharedLink; + return { ...mapAlbum(album, withAssets, auth), startDate: albumMetadataForIds?.startDate ?? undefined, endDate: albumMetadataForIds?.endDate ?? undefined, assetCount: albumMetadataForIds?.assetCount ?? 0, lastModifiedAssetTimestamp: albumMetadataForIds?.lastModifiedAssetTimestamp ?? undefined, + contributorCounts: isShared ? await this.albumRepository.getContributorCounts(album.id) : undefined, }; } diff --git a/web/src/lib/modals/AlbumUsersModal.svelte b/web/src/lib/modals/AlbumUsersModal.svelte index 3cc0f03d5..7dd744224 100644 --- a/web/src/lib/modals/AlbumUsersModal.svelte +++ b/web/src/lib/modals/AlbumUsersModal.svelte @@ -31,6 +31,14 @@ let isOwned = $derived(currentUser?.id == album.ownerId); + // Build a map of contributor counts by user id; avoid casts/derived + const contributorCounts: Record = {}; + if (album.contributorCounts) { + for (const { userId, assetCount } of album.contributorCounts) { + contributorCounts[userId] = assetCount; + } + } + onMount(async () => { try { currentUser = await getMyUser(); @@ -111,6 +119,10 @@ {:else} {$t('role_editor')} {/if} + {#if user.id in contributorCounts} + - + {$t('items_count', { values: { count: contributorCounts[user.id] } })} + {/if}