Compare commits

..

1 Commits

Author SHA1 Message Date
Yaros
70f9d4ae02 fix: minFaces not filtering correctly 2026-07-24 16:25:26 +02:00
15 changed files with 129 additions and 274 deletions

View File

@@ -17,67 +17,50 @@ struct ImmichWidgetView: View {
var entry: ImageEntry
var body: some View {
if let image = entry.image {
ImmichWidgetContentView(image: image, subtitle: entry.metadata.subtitle, deepLink: entry.metadata.deepLink)
} else {
ImmichWidgetLoadingView(message: entry.metadata.error?.errorDescription)
}
}
}
private struct ImmichWidgetLoadingView: View {
let message: String?
var body: some View {
let messageText = Text(message ?? "")
.minimumScaleFactor(0.25)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
VStack(spacing: 8) {
// This is used as a nicer way to center the image, rather than using offsets
messageText.hidden()
if entry.image == nil {
Image("LaunchImage")
.tintedWidgetImageModifier()
messageText
}
}
}
private struct ImmichWidgetContentView: View {
let image: UIImage
let subtitle: String?
let deepLink: URL?
var body: some View {
ZStack(alignment: .leading) {
Color.clear.overlay(
Image(uiImage: image)
.resizable()
.tintedWidgetImageModifier()
.scaledToFill()
)
VStack {
Spacer()
if let subtitle {
Text(subtitle)
.foregroundColor(.white)
.padding(6)
.background(ContainerRelativeShape().fill(Color.black.opacity(0.6)))
.font(.system(size: 16))
.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(
Image(uiImage: entry.image!)
.resizable()
.tintedWidgetImageModifier()
.scaledToFill()
)
VStack {
Spacer()
if let subtitle = entry.metadata.subtitle {
Text(subtitle)
.foregroundColor(.white)
.padding(8)
.background(Color.black.opacity(0.6))
.cornerRadius(8)
.font(.system(size: 16))
}
}
.padding(16)
}
.padding(16)
.widgetURL(entry.metadata.deepLink)
}
.widgetURL(deepLink)
}
}
#Preview(
"Medium",
as: .systemMedium,
widget: {
ImmichRandomWidget()
@@ -86,93 +69,10 @@ private struct ImmichWidgetContentView: View {
let date = Date()
ImageEntry(
date: date,
image: UIImage(named: "LaunchImage"),
image: UIImage(named: "ImmichLogo"),
metadata: EntryMetadata(
subtitle: "1 year ago"
)
)
}
)
#Preview(
"Medium No Data",
as: .systemMedium,
widget: {
ImmichRandomWidget()
},
timeline: {
let date = Date()
ImageEntry(
date: date,
image: nil
)
}
)
#Preview(
"Medium No Data Error",
as: .systemMedium,
widget: {
ImmichRandomWidget()
},
timeline: {
let date = Date()
ImageEntry(
date: date,
image: nil,
metadata: EntryMetadata(error: WidgetError.fetchFailed)
)
}
)
#Preview(
"Small",
as: .systemSmall,
widget: {
ImmichRandomWidget()
},
timeline: {
let date = Date()
ImageEntry(
date: date,
image: UIImage(named: "LaunchImage"),
metadata: EntryMetadata(
subtitle: "Yesterday"
)
)
}
)
#Preview(
"Small No Data Error",
as: .systemSmall,
widget: {
ImmichRandomWidget()
},
timeline: {
let date = Date()
ImageEntry(
date: date,
image: nil,
metadata: EntryMetadata(error: WidgetError.fetchFailed)
)
}
)
#Preview(
"Large",
as: .systemLarge,
widget: {
ImmichRandomWidget()
},
timeline: {
let date = Date()
ImageEntry(
date: date,
image: UIImage(named: "LaunchImage"),
metadata: EntryMetadata(
subtitle: "2000 seconds ago"
)
)
}
)

View File

@@ -25,7 +25,7 @@ extension WidgetError: LocalizedError {
return "Login to Immich"
case .fetchFailed:
return "Unable to connect to Immich"
return "Unable to connect to your Immich instance"
case .albumNotFound:
return "Album not found"

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

@@ -1,30 +1,29 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools."aqua:flutter/flutter"]]
version = "3.44.8"
version = "3.44.6"
backend = "aqua:flutter/flutter"
[tools."aqua:flutter/flutter"."platforms.linux-arm64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.8-stable.tar.xz"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.6-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-arm64-musl"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.8-stable.tar.xz"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.6-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.8-stable.tar.xz"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.6-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-x64-musl"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.8-stable.tar.xz"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.6-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.macos-arm64"]
checksum = "blake3:79c780876c64f5a66c015845f544bdb20275f7f3bf819e6c831f23df3e96b8db"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.44.8-stable.zip"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.44.6-stable.zip"
[tools."aqua:flutter/flutter"."platforms.macos-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.44.8-stable.zip"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.44.6-stable.zip"
[tools."aqua:flutter/flutter"."platforms.windows-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.44.8-stable.zip"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.44.6-stable.zip"
[[tools."github:CQLabs/homebrew-dcm"]]
version = "1.37.0"

View File

@@ -1,5 +1,5 @@
[tools]
"aqua:flutter/flutter" = "3.44.8"
"aqua:flutter/flutter" = "3.44.6"
java = "21.0.2"
[tools."github:CQLabs/homebrew-dcm"]

View File

@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:core';
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
@@ -9,7 +8,6 @@ class ImmichColumnButton extends StatefulWidget {
final IconData icon;
final String label;
final FutureOr<void> Function() onPressed;
final FutureOr<void> Function()? onLongPress;
final bool disabled;
final bool? loading;
@@ -18,7 +16,6 @@ class ImmichColumnButton extends StatefulWidget {
required this.icon,
required this.label,
required this.onPressed,
this.onLongPress,
this.disabled = false,
this.loading,
});
@@ -28,44 +25,26 @@ class ImmichColumnButton extends StatefulWidget {
}
class _ImmichColumnButtonState extends State<ImmichColumnButton> {
bool _running = false;
bool get _isLoading => widget.loading ?? _running;
bool get _isDisabled => widget.disabled || _isLoading;
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
Future<void> _runAction(FutureOr<void> Function() action) async {
setState(() => _running = true);
Future<void> _onPressed() async {
setState(() => _loading = true);
try {
await action();
await widget.onPressed();
} finally {
if (mounted) {
setState(() => _running = false);
setState(() => _loading = false);
}
}
}
Future<void>? _onPressed() {
if (_isDisabled) {
return null;
}
return _runAction(widget.onPressed);
}
Future<void>? _onLongPress() {
if (_isDisabled || widget.onLongPress == null) {
return null;
}
return _runAction(widget.onLongPress!);
}
@override
Widget build(BuildContext context) {
final foreground = context.colorOverride ?? Theme.of(context).colorScheme.onSurface;
return TextButton(
onPressed: _onPressed,
onLongPress: _onLongPress,
onPressed: widget.disabled || _isLoading ? null : _onPressed,
style: TextButton.styleFrom(
foregroundColor: foreground,
padding: const .symmetric(horizontal: ImmichSpacing.sm, vertical: ImmichSpacing.md),

View File

@@ -7,7 +7,6 @@ import 'package:immich_ui/src/internal.dart';
class ImmichIconButton extends StatefulWidget {
final IconData icon;
final FutureOr<void> Function() onPressed;
final FutureOr<void> Function()? onLongPress;
final ImmichVariant variant;
final ImmichColor color;
final bool disabled;
@@ -17,7 +16,6 @@ class ImmichIconButton extends StatefulWidget {
super.key,
required this.icon,
required this.onPressed,
this.onLongPress,
this.color = .primary,
this.variant = .filled,
this.disabled = false,
@@ -29,37 +27,20 @@ class ImmichIconButton extends StatefulWidget {
}
class _ImmichIconButtonState extends State<ImmichIconButton> {
bool _running = false;
bool get _isLoading => widget.loading ?? _running;
bool get _isDisabled => widget.disabled || _isLoading;
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
Future<void> _runAction(FutureOr<void> Function() action) async {
setState(() => _running = true);
Future<void> _onPressed() async {
setState(() => _loading = true);
try {
await action();
await widget.onPressed();
} finally {
if (mounted) {
setState(() => _running = false);
setState(() => _loading = false);
}
}
}
Future<void>? _onPressed() {
if (_isDisabled) {
return null;
}
return _runAction(widget.onPressed);
}
Future<void>? _onLongPress() {
if (_isDisabled || widget.onLongPress == null) {
return null;
}
return _runAction(widget.onLongPress!);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
@@ -92,8 +73,7 @@ class _ImmichIconButtonState extends State<ImmichIconButton> {
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.md),
)
: Icon(widget.icon),
onPressed: _onPressed,
onLongPress: _onLongPress,
onPressed: widget.disabled || _isLoading ? null : _onPressed,
style: IconButton.styleFrom(backgroundColor: background, foregroundColor: foreground),
);
}

View File

@@ -7,7 +7,6 @@ class ImmichTextButton extends StatefulWidget {
final String labelText;
final IconData? icon;
final FutureOr<void> Function() onPressed;
final FutureOr<void> Function()? onLongPress;
final ImmichVariant variant;
final bool expanded;
final bool disabled;
@@ -18,7 +17,6 @@ class ImmichTextButton extends StatefulWidget {
required this.labelText,
this.icon,
required this.onPressed,
this.onLongPress,
this.variant = .filled,
this.expanded = true,
@@ -31,37 +29,20 @@ class ImmichTextButton extends StatefulWidget {
}
class _ImmichTextButtonState extends State<ImmichTextButton> {
bool _running = false;
bool get _isLoading => widget.loading ?? _running;
bool get _isDisabled => widget.disabled || _isLoading;
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
Future<void> _runAction(FutureOr<void> Function() action) async {
setState(() => _running = true);
Future<void> _onPressed() async {
setState(() => _loading = true);
try {
await action();
await widget.onPressed();
} finally {
if (mounted) {
setState(() => _running = false);
setState(() => _loading = false);
}
}
}
Future<void>? _onPressed() {
if (_isDisabled) {
return null;
}
return _runAction(widget.onPressed);
}
Future<void>? _onLongPress() {
if (_isDisabled || widget.onLongPress == null) {
return null;
}
return _runAction(widget.onLongPress!);
}
@override
Widget build(BuildContext context) {
final Widget? icon = _isLoading
@@ -78,22 +59,11 @@ class _ImmichTextButtonState extends State<ImmichTextButton> {
style: const .new(fontSize: ImmichTextSize.body, fontWeight: .bold),
);
final style = ElevatedButton.styleFrom(padding: const .symmetric(vertical: ImmichSpacing.md));
final onPressed = widget.disabled || _isLoading ? null : _onPressed;
final button = switch (widget.variant) {
ImmichVariant.filled => ElevatedButton.icon(
style: style,
onPressed: _onPressed,
onLongPress: _onLongPress,
icon: icon,
label: label,
),
ImmichVariant.ghost => TextButton.icon(
style: style,
onPressed: _onPressed,
onLongPress: _onLongPress,
icon: icon,
label: label,
),
ImmichVariant.filled => ElevatedButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
ImmichVariant.ghost => TextButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
};
if (widget.expanded) {

View File

@@ -2014,4 +2014,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.12.0 <4.0.0"
flutter: "3.44.8"
flutter: "3.44.6"

View File

@@ -6,7 +6,7 @@ version: 3.0.3+3056
environment:
sdk: '>=3.12.0 <4.0.0'
flutter: 3.44.8
flutter: 3.44.6
dependencies:
async: ^2.13.1

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

@@ -4,7 +4,6 @@ import { createHash, randomBytes } from 'node:crypto';
import { Stats } from 'node:fs';
import { resolve } from 'node:path';
import { Writable } from 'node:stream';
import { SystemConfig } from 'src/config';
import { AssetFace } from 'src/database';
import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto';
import { AssetEditActionItem, AssetEditsCreateDto } from 'src/dtos/editing.dto';
@@ -77,7 +76,6 @@ import { BASE_SERVICE_DEPENDENCIES, BaseService } from 'src/services/base.servic
import { MetadataService } from 'src/services/metadata.service';
import { SyncService } from 'src/services/sync.service';
import { ClassConstructor, UploadFile } from 'src/types';
import { getConfig, updateConfig } from 'src/utils/config';
import { mockEnvData } from 'test/repositories/config.repository.mock';
import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock';
import { factory, newDate, newEmbedding, newUuid } from 'test/small.factory';
@@ -304,28 +302,6 @@ export class MediumTestContext<S extends BaseService = BaseService> {
const edits = await this.get(AssetEditRepository).replaceAll(assetId, dto.edits as AssetEditActionItem[]);
return { edits };
}
async getConfig({ withCache = true }: { withCache?: boolean } = {}) {
return getConfig(
{
configRepo: this.get(ConfigRepository),
metadataRepo: this.get(SystemMetadataRepository),
logger: this.get(LoggingRepository),
},
{ withCache },
);
}
async updateConfig(config: SystemConfig) {
return updateConfig(
{
configRepo: this.get(ConfigRepository),
metadataRepo: this.get(SystemMetadataRepository),
logger: this.get(LoggingRepository),
},
config,
);
}
}
export class SyncTestContext extends MediumTestContext<SyncService> {

View File

@@ -324,7 +324,7 @@
</div>
{/if}
{#if currentAlbum && currentAlbum.albumUsers.length > 1 && asset.owner}
{#if currentAlbum && currentAlbum.albumUsers.length > 0 && asset.owner}
<section class="mt-4 px-6 dark:text-immich-dark-fg">
<Text size="small" color="muted">{$t('shared_by')}</Text>
<div class="flex gap-4 pt-4">