feat: shared asset count

This commit is contained in:
Jason Rasmussen 2026-08-14 12:20:13 -04:00
parent 19d0f5b7aa
commit 0398a30edb
No known key found for this signature in database
GPG key ID: 75AD31BF84C94773
9 changed files with 75 additions and 12 deletions

View file

@ -1954,6 +1954,7 @@
"shared_album_activity_remove_title": "Delete Activity", "shared_album_activity_remove_title": "Delete Activity",
"shared_album_section_people_action_error": "Error leaving/removing from album", "shared_album_section_people_action_error": "Error leaving/removing from album",
"shared_album_section_people_title": "PEOPLE", "shared_album_section_people_title": "PEOPLE",
"shared_assets_count": "{count, plural, one {# shared asset} other {# shared assets}}",
"shared_by": "Shared by", "shared_by": "Shared by",
"shared_by_user": "Shared by {user}", "shared_by_user": "Shared by {user}",
"shared_by_you": "Shared by you", "shared_by_you": "Shared by you",

View file

@ -21591,10 +21591,24 @@
"maximum": 9007199254740991, "maximum": 9007199254740991,
"minimum": -9007199254740991, "minimum": -9007199254740991,
"type": "integer" "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": [ "required": [
"assets" "assets",
"ownedAssets",
"sharedAssets"
], ],
"type": "object" "type": "object"
}, },

View file

@ -1537,6 +1537,10 @@ export type AssetFaceUpdateDto = {
export type PersonStatisticsResponseDto = { export type PersonStatisticsResponseDto = {
/** Number of assets */ /** Number of assets */
assets: number; 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 = { export type PluginMethodResponseDto = {
/** Description */ /** Description */

View file

@ -147,6 +147,8 @@ const AssetFaceDeleteSchema = z
const PersonStatisticsResponseSchema = z const PersonStatisticsResponseSchema = z
.object({ .object({
assets: z.int().describe('Number of assets'), 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' }); .meta({ id: 'PersonStatisticsResponseDto' });

View file

@ -288,16 +288,35 @@ where
-- PersonRepository.getStatistics -- PersonRepository.getStatistics
select 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 from
"asset_face" "asset_face"
left join "asset" on "asset"."id" = "asset_face"."assetId" left join "asset" on "asset"."id" = "asset_face"."assetId"
and "asset"."visibility" = 'timeline' and "asset"."visibility" = 'timeline'
and "asset"."deletedAt" is null 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 where
"asset_face"."deletedAt" is null "asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true and "asset_face"."isVisible" is true
and "asset_face"."personGroupId" = $1 and "asset_face"."personGroupId" = $4
-- PersonRepository.getNumberOfPeople -- PersonRepository.getNumberOfPeople
select select

View file

@ -10,7 +10,7 @@ import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { PersonGroupTable } from 'src/schema/tables/person-group.table'; import { PersonGroupTable } from 'src/schema/tables/person-group.table';
import { PersonTable } from 'src/schema/tables/person.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'; import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
export interface PersonSearchOptions { export interface PersonSearchOptions {
@ -40,6 +40,8 @@ export interface UpdateFacesData {
export interface PersonStatistics { export interface PersonStatistics {
assets: number; assets: number;
ownedAssets: number;
sharedAssets: number;
} }
export interface DeleteFacesOptions { export interface DeleteFacesOptions {
@ -415,24 +417,36 @@ export class PersonRepository {
.execute(); .execute();
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async getStatistics(personGroupId: string): Promise<PersonStatistics> { async getStatistics(personGroupId: string, userId: string): Promise<PersonStatistics> {
const result = await this.db const result = await this.db
.selectFrom('asset_face') .selectFrom('asset_face')
.leftJoin('asset', (join) => .leftJoin('asset', (join) =>
join join
.onRef('asset.id', '=', 'asset_face.assetId') .onRef('asset.id', '=', 'asset_face.assetId')
.on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline)) .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.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true) .where('asset_face.isVisible', 'is', true)
.where('asset_face.personGroupId', '=', personGroupId) .where('asset_face.personGroupId', '=', personGroupId)
.executeTakeFirst(); .executeTakeFirst();
const assets = result ? Number(result.count) : 0;
const ownedAssets = result ? Number(result.ownedCount) : 0;
return { return {
assets: result ? Number(result.count) : 0, assets,
ownedAssets,
sharedAssets: assets - ownedAssets,
}; };
} }

View file

@ -1407,9 +1407,14 @@ describe(PersonService.name, () => {
const person = PersonFactory.create(); const person = PersonFactory.create();
mocks.person.getByGroupId.mockResolvedValue(person); 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])); 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])); expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId]));
}); });

View file

@ -163,7 +163,7 @@ export class PersonService extends BaseService {
async getStatistics(auth: AuthDto, personGroupId: string): Promise<PersonStatisticsResponseDto> { async getStatistics(auth: AuthDto, personGroupId: string): Promise<PersonStatisticsResponseDto> {
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [personGroupId] }); 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<ImmichFileResponse> { async getThumbnail(auth: AuthDto, personGroupId: string): Promise<ImmichFileResponse> {

View file

@ -63,6 +63,7 @@
let { data }: Props = $props(); let { data }: Props = $props();
let numberOfAssets = $derived(data.statistics.assets); let numberOfAssets = $derived(data.statistics.assets);
let numberOfSharedAssets = $derived(data.statistics.sharedAssets);
let person = $derived(data.person); let person = $derived(data.person);
let thumbnailData = $derived(getPeopleThumbnailUrl(person)); let thumbnailData = $derived(getPeopleThumbnailUrl(person));
@ -395,6 +396,9 @@
<p class="w-40 truncate font-medium sm:w-72">{person.name || $t('add_a_name')}</p> <p class="w-40 truncate font-medium sm:w-72">{person.name || $t('add_a_name')}</p>
<p class="text-sm text-gray-500 dark:text-gray-400"> <p class="text-sm text-gray-500 dark:text-gray-400">
{$t('assets_count', { values: { count: numberOfAssets } })} {$t('assets_count', { values: { count: numberOfAssets } })}
{#if numberOfSharedAssets > 0}
· {$t('shared_assets_count', { values: { count: numberOfSharedAssets } })}
{/if}
</p> </p>
{#if person.birthDate} {#if person.birthDate}
<p class="text-sm text-gray-500 dark:text-gray-400"> <p class="text-sm text-gray-500 dark:text-gray-400">