diff --git a/i18n/en.json b/i18n/en.json index 4b9e25c077..8da28364ac 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1954,6 +1954,7 @@ "shared_album_activity_remove_title": "Delete Activity", "shared_album_section_people_action_error": "Error leaving/removing from album", "shared_album_section_people_title": "PEOPLE", + "shared_assets_count": "{count, plural, one {# shared asset} other {# shared assets}}", "shared_by": "Shared by", "shared_by_user": "Shared by {user}", "shared_by_you": "Shared by you", diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f05c539426..bc53c832a4 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -21591,10 +21591,24 @@ "maximum": 9007199254740991, "minimum": -9007199254740991, "type": "integer" + }, + "ownedAssets": { + "description": "Number of assets owned by the current user", + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "sharedAssets": { + "description": "Number of assets owned by other users, visible through shared albums", + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" } }, "required": [ - "assets" + "assets", + "ownedAssets", + "sharedAssets" ], "type": "object" }, diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 6ed3a22b8d..856cd2bc14 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -1537,6 +1537,10 @@ export type AssetFaceUpdateDto = { export type PersonStatisticsResponseDto = { /** Number of assets */ assets: number; + /** Number of assets owned by the current user */ + ownedAssets: number; + /** Number of assets owned by other users, visible through shared albums */ + sharedAssets: number; }; export type PluginMethodResponseDto = { /** Description */ diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index 0379b6f3b0..a0eddaa497 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -147,6 +147,8 @@ const AssetFaceDeleteSchema = z const PersonStatisticsResponseSchema = z .object({ assets: z.int().describe('Number of assets'), + ownedAssets: z.int().describe('Number of assets owned by the current user'), + sharedAssets: z.int().describe('Number of assets owned by other users, visible through shared albums'), }) .meta({ id: 'PersonStatisticsResponseDto' }); diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 90067594a8..e5370496d8 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -288,16 +288,35 @@ where -- PersonRepository.getStatistics select - count(distinct ("asset"."id")) as "count" + count(distinct ("asset"."id")) as "count", + count(distinct ("asset"."id")) filter ( + where + "asset"."ownerId" = $1::uuid + ) as "ownedCount" from "asset_face" left join "asset" on "asset"."id" = "asset_face"."assetId" and "asset"."visibility" = 'timeline' and "asset"."deletedAt" is null + and ( + "asset"."ownerId" = $2::uuid + or exists ( + select + 1 as "exists" + from + "album_asset" + inner join "album" on "album"."id" = "album_asset"."albumId" + and "album"."deletedAt" is null + inner join "album_user" on "album_user"."albumId" = "album"."id" + and "album_user"."userId" = $3::uuid + where + "album_asset"."assetId" = "asset"."id" + ) + ) where "asset_face"."deletedAt" is null and "asset_face"."isVisible" is true - and "asset_face"."personGroupId" = $1 + and "asset_face"."personGroupId" = $4 -- PersonRepository.getNumberOfPeople select diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index f6fa35e4b0..f662201a02 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -10,7 +10,7 @@ import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table'; import { PersonGroupTable } from 'src/schema/tables/person-group.table'; import { PersonTable } from 'src/schema/tables/person.table'; -import { dummy, removeUndefinedKeys, withFilePath } from 'src/utils/database'; +import { asUuid, dummy, inSharedAlbum, removeUndefinedKeys, withFilePath } from 'src/utils/database'; import { paginationHelper, PaginationOptions } from 'src/utils/pagination'; export interface PersonSearchOptions { @@ -40,6 +40,8 @@ export interface UpdateFacesData { export interface PersonStatistics { assets: number; + ownedAssets: number; + sharedAssets: number; } export interface DeleteFacesOptions { @@ -415,24 +417,36 @@ export class PersonRepository { .execute(); } - @GenerateSql({ params: [DummyValue.UUID] }) - async getStatistics(personGroupId: string): Promise { + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) + async getStatistics(personGroupId: string, userId: string): Promise { const result = await this.db .selectFrom('asset_face') .leftJoin('asset', (join) => join .onRef('asset.id', '=', 'asset_face.assetId') .on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline)) - .on('asset.deletedAt', 'is', null), + .on('asset.deletedAt', 'is', null) + .on((eb) => eb.or([eb('asset.ownerId', '=', asUuid(userId)), inSharedAlbum(eb, userId)])), ) - .select((eb) => eb.fn.count(eb.fn('distinct', ['asset.id'])).as('count')) + .select((eb) => [ + eb.fn.count(eb.fn('distinct', ['asset.id'])).as('count'), + eb.fn + .count(eb.fn('distinct', ['asset.id'])) + .filterWhere('asset.ownerId', '=', asUuid(userId)) + .as('ownedCount'), + ]) .where('asset_face.deletedAt', 'is', null) .where('asset_face.isVisible', 'is', true) .where('asset_face.personGroupId', '=', personGroupId) .executeTakeFirst(); + const assets = result ? Number(result.count) : 0; + const ownedAssets = result ? Number(result.ownedCount) : 0; + return { - assets: result ? Number(result.count) : 0, + assets, + ownedAssets, + sharedAssets: assets - ownedAssets, }; } diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 32168b65ce..d14ff3f789 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -1407,9 +1407,14 @@ describe(PersonService.name, () => { const person = PersonFactory.create(); mocks.person.getByGroupId.mockResolvedValue(person); - mocks.person.getStatistics.mockResolvedValue({ assets: 3 }); + mocks.person.getStatistics.mockResolvedValue({ assets: 3, ownedAssets: 2, sharedAssets: 1 }); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); - await expect(sut.getStatistics(auth, person.personGroupId)).resolves.toEqual({ assets: 3 }); + await expect(sut.getStatistics(auth, person.personGroupId)).resolves.toEqual({ + assets: 3, + ownedAssets: 2, + sharedAssets: 1, + }); + expect(mocks.person.getStatistics).toHaveBeenCalledWith(person.personGroupId, auth.user.id); expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId])); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 1486d9a9ce..7af7d8b69b 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -163,7 +163,7 @@ export class PersonService extends BaseService { async getStatistics(auth: AuthDto, personGroupId: string): Promise { await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [personGroupId] }); - return this.personRepository.getStatistics(personGroupId); + return this.personRepository.getStatistics(personGroupId, auth.user.id); } async getThumbnail(auth: AuthDto, personGroupId: string): Promise { diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index e4389f09dc..f54d8ac82a 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -63,6 +63,7 @@ let { data }: Props = $props(); let numberOfAssets = $derived(data.statistics.assets); + let numberOfSharedAssets = $derived(data.statistics.sharedAssets); let person = $derived(data.person); let thumbnailData = $derived(getPeopleThumbnailUrl(person)); @@ -395,6 +396,9 @@

{person.name || $t('add_a_name')}

{$t('assets_count', { values: { count: numberOfAssets } })} + {#if numberOfSharedAssets > 0} + ยท {$t('shared_assets_count', { values: { count: numberOfSharedAssets } })} + {/if}

{#if person.birthDate}