Compare commits

...

5 Commits

Author SHA1 Message Date
Yaros
70f9d4ae02 fix: minFaces not filtering correctly 2026-07-24 16:25:26 +02:00
bo0tzz
4a5f13d0e5 fix: don't skip person thumbnail generation if ML is disabled (#30194) 2026-07-24 10:06:32 -04:00
NOBOIKE
5fa920a5f0 fix(web): use RTL transform origin in AdaptiveImage (#30182) 2026-07-24 10:44:33 +02:00
Jason Rasmussen
acc7e6b299 fix: min faces user preference (#30177) 2026-07-23 17:06:09 -04:00
Adam Gastineau
3af94a4805 fix(mobile): properly align iOS widget loading state (#30173) 2026-07-23 12:31:33 -07:00
9 changed files with 110 additions and 26 deletions

View File

@@ -18,15 +18,21 @@ struct ImmichWidgetView: View {
var body: some View {
if entry.image == nil {
VStack {
Image("LaunchImage")
.tintedWidgetImageModifier()
Text(entry.metadata.error?.errorDescription ?? "")
.minimumScaleFactor(0.25)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
.padding(16)
Image("LaunchImage")
.tintedWidgetImageModifier()
.overlay(alignment: .bottom) {
if let error = entry.metadata.error?.errorDescription {
Text(error)
.minimumScaleFactor(0.25)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.fixedSize()
.alignmentGuide(.bottom) { dimensions in
// Place the text below the bottom of the image
dimensions[.top] - 8
}
}
}
} else {
ZStack(alignment: .leading) {
Color.clear.overlay(

View File

@@ -50,10 +50,12 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
faces.isVisible.equals(true) &
faces.deletedAt.isNull(),
)
..groupBy([people.id], having: faces.id.count().isBiggerOrEqualValue(minFaces) | people.name.equals('').not())
..groupBy([
people.id,
], having: faces.assetId.count(distinct: true).isBiggerOrEqualValue(minFaces) | people.name.equals('').not())
..orderBy([
OrderingTerm(expression: people.name.equals('').not(), mode: OrderingMode.desc),
OrderingTerm(expression: faces.id.count(), mode: OrderingMode.desc),
OrderingTerm(expression: faces.assetId.count(distinct: true), mode: OrderingMode.desc),
]);
return query.map((row) {

View File

@@ -74,4 +74,53 @@ void main() {
expect(people, isEmpty);
});
});
group('getAllPeople', () {
test('counts distinct assets, not face records, against minFaces', () async {
// Regression check: a person can have multiple face records on the same asset
// (e.g., metadata import + ML detection), which must not inflate the count used
// to compare against minFaces. An unnamed person with 2 distinct photos but 3
// face records (2 of them on the same photo) must not pass a minFaces of 3.
final user = await ctx.newUser();
final asset1 = await ctx.newRemoteAsset(ownerId: user.id);
final asset2 = await ctx.newRemoteAsset(ownerId: user.id);
final person = await ctx.newPerson(ownerId: user.id, name: '');
await ctx.newFace(assetId: asset1.id, personId: person.id);
await ctx.newFace(assetId: asset1.id, personId: person.id);
await ctx.newFace(assetId: asset2.id, personId: person.id);
final people = await sut.getAllPeople(minFaces: 3);
expect(people, isEmpty);
});
test('returns unnamed people who meet minFaces based on distinct assets', () async {
final user = await ctx.newUser();
final asset1 = await ctx.newRemoteAsset(ownerId: user.id);
final asset2 = await ctx.newRemoteAsset(ownerId: user.id);
final asset3 = await ctx.newRemoteAsset(ownerId: user.id);
final person = await ctx.newPerson(ownerId: user.id, name: '');
await ctx.newFace(assetId: asset1.id, personId: person.id);
await ctx.newFace(assetId: asset2.id, personId: person.id);
await ctx.newFace(assetId: asset3.id, personId: person.id);
final people = await sut.getAllPeople(minFaces: 3);
expect(people.map((p) => p.id), [person.id]);
});
test('always returns named people regardless of minFaces', () async {
final user = await ctx.newUser();
final asset = await ctx.newRemoteAsset(ownerId: user.id);
final person = await ctx.newPerson(ownerId: user.id, name: 'Jane');
await ctx.newFace(assetId: asset.id, personId: person.id);
final people = await sut.getAllPeople(minFaces: 3);
expect(people.map((p) => p.id), [person.id]);
});
});
}

View File

@@ -42,7 +42,7 @@ group by
having
(
"person"."name" != $3
or count("asset_face"."assetId") >= COALESCE(
or count(distinct ("asset_face"."assetId")) >= COALESCE(
(
SELECT
value -> 'people' ->> 'minimumFaces'
@@ -59,7 +59,7 @@ order by
"person"."isHidden" asc,
"person"."isFavorite" desc,
NULLIF(person.name, '') is null asc,
count("asset_face"."assetId") desc,
count(distinct ("asset_face"."assetId")) desc,
NULLIF(person.name, '') asc nulls last,
"person"."createdAt"
limit

View File

@@ -168,7 +168,7 @@ export class PersonRepository {
eb.or([
eb('person.name', '!=', ''),
eb(
(innerEb) => innerEb.fn.count('asset_face.assetId'),
(innerEb) => innerEb.fn.count(innerEb.fn('distinct', ['asset_face.assetId'])),
'>=',
sql<number>`COALESCE(
(SELECT value -> 'people' ->> 'minimumFaces'
@@ -201,7 +201,7 @@ export class PersonRepository {
.$if(!options?.closestFaceAssetId, (qb) =>
qb
.orderBy(sql`NULLIF(person.name, '') is null`, 'asc')
.orderBy((eb) => eb.fn.count('asset_face.assetId'), 'desc')
.orderBy((eb) => eb.fn.count(eb.fn('distinct', ['asset_face.assetId'])), 'desc')
.orderBy(sql`NULLIF(person.name, '')`, (om) => om.asc().nullsLast())
.orderBy('person.createdAt'),
)

View File

@@ -0,0 +1,25 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`
INSERT INTO "user_metadata" ("userId", "key", "value")
SELECT "user"."id", 'preferences', jsonb_build_object('people', jsonb_build_object('minimumFaces', "config"."minFaces"))
FROM "user"
CROSS JOIN (
SELECT "value"->'machineLearning'->'facialRecognition'->'minFaces' AS "minFaces"
FROM "system_metadata"
WHERE "key" = 'system-config'
AND "value"->'machineLearning'->'facialRecognition'->'minFaces' IS NOT NULL
) AS "config"
ON CONFLICT ("userId", "key") DO UPDATE
SET "value" = "user_metadata"."value" || jsonb_build_object(
'people',
COALESCE("user_metadata"."value"->'people', '{}'::jsonb) || (EXCLUDED."value"->'people')
)
WHERE "user_metadata"."value"->'people'->'minimumFaces' IS NULL
`.execute(db);
}
export async function down(): Promise<void> {
// not supported
}

View File

@@ -1507,12 +1507,17 @@ describe(MediaService.name, () => {
});
describe('handleGeneratePersonThumbnail', () => {
it('should skip if machine learning is disabled', async () => {
it('should generate a thumbnail even if machine learning is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled);
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle);
mocks.media.generateThumbnail.mockResolvedValue();
mocks.media.decodeImage.mockResolvedValue({
data: Buffer.from(''),
info: { width: 1000, height: 1000 } as OutputInfo,
});
await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(JobStatus.Skipped);
expect(mocks.asset.getByIds).not.toHaveBeenCalled();
expect(mocks.systemMetadata.get).toHaveBeenCalled();
await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(JobStatus.Success);
expect(mocks.media.generateThumbnail).toHaveBeenCalled();
});
it('should skip a person not found', async () => {

View File

@@ -43,7 +43,7 @@ import { getAssetFile, getDimensions } from 'src/utils/asset.util';
import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor';
import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
import { mimeTypes } from 'src/utils/mime-types';
import { clamp, isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
import { clamp } from 'src/utils/misc';
import { getOutputDimensions } from 'src/utils/transform';
interface UpsertFileOptions {
@@ -410,11 +410,7 @@ export class MediaService extends BaseService {
@OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration })
async handleGeneratePersonThumbnail({ id }: JobOf<JobName.PersonGenerateThumbnail>): Promise<JobStatus> {
const { machineLearning, metadata, image } = await this.getConfig({ withCache: true });
if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) {
return JobStatus.Skipped;
}
const { image } = await this.getConfig({ withCache: true });
const data = await this.personRepository.getDataForThumbnailGenerationJob(id);
if (!data) {
this.logger.error(`Could not generate person thumbnail for ${id}: missing data`);

View File

@@ -63,6 +63,7 @@
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
import { untrack, type Snippet } from 'svelte';
import { languageManager } from '$lib/managers/language-manager.svelte';
type Props = {
asset: AssetResponseDto;
@@ -232,7 +233,7 @@
style:width={rasterWidth}
style:height={rasterHeight}
style:transform="scale({rasterScale})"
style:transform-origin="0 0"
style:transform-origin={languageManager.rtl ? 'right top' : 'left top'}
style:will-change={maxRasterPixels > 0 ? 'transform' : undefined}
>
{#if show.alphaBackground}