mirror of
https://github.com/immich-app/immich.git
synced 2026-07-25 14:00:45 +03:00
Compare commits
6 Commits
fcast
...
fix/min-fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70f9d4ae02 | ||
|
|
4a5f13d0e5 | ||
|
|
5fa920a5f0 | ||
|
|
acc7e6b299 | ||
|
|
3af94a4805 | ||
|
|
f488a28018 |
@@ -140,8 +140,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-structured-queries",
|
||||
"state" : {
|
||||
"revision" : "8da8818fccd9959bd683934ddc62cf45bb65b3c8",
|
||||
"version" : "0.31.1"
|
||||
"revision" : "dafddd843f10630aa6b5be47c1b6a59cc4ab6586",
|
||||
"version" : "0.34.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user