diff --git a/e2e/package.json b/e2e/package.json
index 3d4f072d4..25be88ada 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
- "version": "3.3.1",
+ "version": "3.3.3",
"description": "",
"main": "index.js",
"type": "module",
diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml
index a227c2959..42091b6bf 100644
--- a/machine-learning/pyproject.toml
+++ b/machine-learning/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "immich-ml"
-version = "3.3.1"
+version = "3.3.3"
description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.12,<4.0"
diff --git a/machine-learning/uv.lock b/machine-learning/uv.lock
index 505de7f6a..70580a9f4 100644
--- a/machine-learning/uv.lock
+++ b/machine-learning/uv.lock
@@ -676,7 +676,7 @@ wheels = [
[[package]]
name = "immich-ml"
-version = "3.3.1"
+version = "3.3.3"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile
index 2a5f18ae3..54143abfc 100644
--- a/mobile/android/fastlane/Fastfile
+++ b/mobile/android/fastlane/Fastfile
@@ -22,8 +22,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
- "android.injected.version.code" => 3030199,
- "android.injected.version.name" => "3.3.1",
+ "android.injected.version.code" => 3030399,
+ "android.injected.version.name" => "3.3.3",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab', track: 'beta')
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
- "android.injected.version.code" => 3030199,
- "android.injected.version.name" => "3.3.1",
+ "android.injected.version.code" => 3030399,
+ "android.injected.version.name" => "3.3.3",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist
index e2ceb531f..4fef21539 100644
--- a/mobile/ios/Runner/Info.plist
+++ b/mobile/ios/Runner/Info.plist
@@ -78,7 +78,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 3.3.1
+ 3.3.3
CFBundleSignature
????
CFBundleURLTypes
diff --git a/mobile/lib/presentation/widgets/memory/memory_title.widget.dart b/mobile/lib/presentation/widgets/memory/memory_title.widget.dart
index 8d6d1948b..4976545fd 100644
--- a/mobile/lib/presentation/widgets/memory/memory_title.widget.dart
+++ b/mobile/lib/presentation/widgets/memory/memory_title.widget.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/memory.model.dart';
+import 'package:immich_mobile/extensions/datetime_extensions.dart';
import 'package:immich_mobile/extensions/object_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:intl/intl.dart';
@@ -10,7 +11,7 @@ String getMemoryTitle(Translations t, Memory memory, {RemoteAsset? asset, bool p
switch (memory.type) {
MemoryTypeEnum.onThisDay =>
preferDate
- ? DateFormat.yMMMMd().format(memory.memoryAt)
+ ? DateFormat.yMMMMd(resolvedDateTimeLocale()).format(memory.memoryAt)
: t.years_ago(years: DateTime.now().year - memory.data.year),
MemoryTypeEnum.birthday => _getBirthdayTitle(t, memory.data, asset),
};
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index 000720842..b8c2b8e2b 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
-version: 3.3.1-local.3+3030201
+version: 3.3.3-local.1+3030400
environment:
sdk: '>=3.12.0 <4.0.0'
diff --git a/mobile/test/unit/presentation/memory_list_page_test.dart b/mobile/test/unit/presentation/memory_list_page_test.dart
index 235a38650..ddc154b12 100644
--- a/mobile/test/unit/presentation/memory_list_page_test.dart
+++ b/mobile/test/unit/presentation/memory_list_page_test.dart
@@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/presentation/pages/memory_list.page.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
+import 'package:intl/intl.dart';
import '../factories/remote_asset_factory.dart';
import 'presentation_context.dart';
@@ -44,6 +45,15 @@ void main() {
expect(find.textContaining('ago'), findsNothing);
});
+ testWidgets('shows the date when intl has no date formats for the app language', (tester) async {
+ Intl.defaultLocale = 'kab';
+ addTearDown(() => Intl.defaultLocale = null);
+ await pumpPage(tester, [newMemory(type: MemoryTypeEnum.onThisDay, memoryAt: DateTime(2025, 10, 7))]);
+ await tester.pump();
+
+ expect(find.text('October 7, 2025'), findsOneWidget);
+ });
+
testWidgets('shows the person name for a birthday memory', (tester) async {
await pumpPage(tester, [
newMemory(type: MemoryTypeEnum.birthday, memoryAt: DateTime(2025, 10, 7), personName: 'Alice'),
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index 229e03141..d9b315d0d 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -18211,7 +18211,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
- "version": "3.3.1",
+ "version": "3.3.3",
"contact": {}
},
"tags": [
diff --git a/package.json b/package.json
index 5f893a278..95f9b54b9 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "immich-monorepo",
- "version": "3.3.1",
+ "version": "3.3.3",
"description": "Monorepo for Immich",
"type": "module",
"private": true,
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 5f1903fe1..8928bd6a6 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
- "version": "3.3.1",
+ "version": "3.3.3",
"description": "Command Line Interface (CLI) for Immich",
"repository": {
"type": "git",
diff --git a/packages/sdk/package.json b/packages/sdk/package.json
index 09816ca2a..519ce59c9 100644
--- a/packages/sdk/package.json
+++ b/packages/sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
- "version": "3.3.1",
+ "version": "3.3.3",
"description": "Auto-generated TypeScript SDK for the Immich API",
"repository": {
"type": "git",
diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts
index f5686dade..6fa324eed 100644
--- a/packages/sdk/src/fetch-client.ts
+++ b/packages/sdk/src/fetch-client.ts
@@ -1,6 +1,6 @@
/**
* Immich
- * 3.3.1
+ * 3.3.3
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
diff --git a/server/package.json b/server/package.json
index 30499e046..7946093e2 100644
--- a/server/package.json
+++ b/server/package.json
@@ -1,6 +1,6 @@
{
"name": "immich",
- "version": "3.3.1",
+ "version": "3.3.3",
"description": "",
"author": "",
"private": true,
diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql
index 7eb7c3bd1..9218f6a79 100644
--- a/server/src/queries/person.repository.sql
+++ b/server/src/queries/person.repository.sql
@@ -239,38 +239,52 @@ from
"person_group"
inner join "person" as "owned" on "owned"."personGroupId" = "person_group"."id"
and "owned"."ownerId" = $5
- inner join "asset_face" on "asset_face"."personGroupId" = "person_group"."id"
- and "asset_face"."deletedAt" is null
- and "asset_face"."isVisible" is true
- inner join "asset" on "asset"."id" = "asset_face"."assetId"
- and "asset"."visibility" = 'timeline'
- and "asset"."deletedAt" is null
- and (
- "asset"."ownerId" = any ($6::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" = $7::uuid
- where
- "album_asset"."assetId" = "asset"."id"
- )
- )
+ inner join (
+ select
+ "asset_face"."personGroupId",
+ count("asset"."id") as "faceCount"
+ from
+ "asset_face"
+ inner join "asset" on "asset"."id" = "asset_face"."assetId"
+ and "asset"."visibility" = 'timeline'
+ and "asset"."deletedAt" is null
+ and (
+ "asset"."ownerId" = any ($6::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" = $7::uuid
+ where
+ "album_asset"."assetId" = "asset"."id"
+ )
+ )
+ where
+ "asset_face"."personGroupId" = any (
+ array(
+ (
+ select
+ "person"."personGroupId"
+ from
+ "person"
+ where
+ "person"."ownerId" = $8
+ )
+ )
+ )
+ and "asset_face"."deletedAt" is null
+ and "asset_face"."isVisible" is true
+ group by
+ "asset_face"."personGroupId"
+ ) as "faces" on "faces"."personGroupId" = "person_group"."id"
where
- "owned"."isHidden" = $8
- and 1 = 1
-group by
- "person_group"."id",
- "owned"."ownerId",
- "owned"."personGroupId"
-having
(
"owned"."name" != $9
- or count("asset"."id") >= COALESCE(
+ or "faces"."faceCount" >= COALESCE(
(
SELECT
value -> 'people' ->> 'minimumFaces'
@@ -283,17 +297,19 @@ having
'3'
)::int
)
+ and "owned"."isHidden" = $11
+ and 1 = 1
order by
"owned"."isHidden" asc,
"owned"."isFavorite" desc,
NULLIF("owned"."name", '') is null asc,
- count("asset"."id") desc,
+ "faces"."faceCount" desc,
NULLIF("owned"."name", '') asc nulls last,
"owned"."createdAt"
limit
- $11
-offset
$12
+offset
+ $13
-- PersonRepository.getAllWithoutFaces
select
@@ -901,49 +917,65 @@ from
"person_group"
inner join "person" as "owned" on "owned"."personGroupId" = "person_group"."id"
and "owned"."ownerId" = $2
- inner join "asset_face" on "asset_face"."personGroupId" = "person_group"."id"
- and "asset_face"."deletedAt" is null
- and "asset_face"."isVisible" is true
- inner join "asset" on "asset"."id" = "asset_face"."assetId"
- and "asset"."visibility" = 'timeline'
- and "asset"."deletedAt" is null
- and (
- "asset"."ownerId" = any ($3::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" = $4::uuid
- where
- "album_asset"."assetId" = "asset"."id"
- )
- )
+ inner join (
+ select
+ "asset_face"."personGroupId",
+ count("asset"."id") as "faceCount"
+ from
+ "asset_face"
+ inner join "asset" on "asset"."id" = "asset_face"."assetId"
+ and "asset"."visibility" = 'timeline'
+ and "asset"."deletedAt" is null
+ and (
+ "asset"."ownerId" = any ($3::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" = $4::uuid
+ where
+ "album_asset"."assetId" = "asset"."id"
+ )
+ )
+ where
+ "asset_face"."personGroupId" = any (
+ array(
+ (
+ select
+ "person"."personGroupId"
+ from
+ "person"
+ where
+ "person"."ownerId" = $5
+ )
+ )
+ )
+ and "asset_face"."deletedAt" is null
+ and "asset_face"."isVisible" is true
+ group by
+ "asset_face"."personGroupId"
+ ) as "faces" on "faces"."personGroupId" = "person_group"."id"
where
- 1 = 1
- group by
- "person_group"."id",
- "owned"."ownerId",
- "owned"."personGroupId"
- having
(
- "owned"."name" != $5
- or count("asset"."id") >= COALESCE(
+ "owned"."name" != $6
+ or "faces"."faceCount" >= COALESCE(
(
SELECT
value -> 'people' ->> 'minimumFaces'
FROM
user_metadata
WHERE
- "userId" = $6
+ "userId" = $7
AND key = 'preferences'
),
'3'
)::int
)
+ and 1 = 1
) as "people"
-- PersonRepository.createGroup
diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts
index 9b9235b8b..d6378d6ec 100644
--- a/server/src/repositories/person.repository.ts
+++ b/server/src/repositories/person.repository.ts
@@ -224,8 +224,6 @@ const asPerson = ({ ownedPerson, otherPeople, sharedBy, sharedWith }: PersonGrou
sharedWith,
});
-const faceCount = (eb: ExpressionBuilder) => eb.fn.count('asset.id');
-
const withPerson = ({ viewingUserId }: WithPersonOptions) => {
return (eb: ExpressionBuilder) =>
jsonObjectFrom(
@@ -448,25 +446,40 @@ export class PersonRepository {
.innerJoin('person as owned', (join) =>
join.onRef('owned.personGroupId', '=', 'person_group.id').on('owned.ownerId', '=', userId),
)
- .innerJoin('asset_face', (join) =>
- join
- .onRef('asset_face.personGroupId', '=', 'person_group.id')
- .on('asset_face.deletedAt', 'is', null)
- .on('asset_face.isVisible', 'is', true),
+ .innerJoin(
+ (eb) =>
+ eb
+ .selectFrom('asset_face')
+ .innerJoin('asset', (join) =>
+ join
+ .onRef('asset.id', '=', 'asset_face.assetId')
+ .on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
+ .on('asset.deletedAt', 'is', null)
+ .on((eb) =>
+ eb.or([eb('asset.ownerId', '=', anyUuid([userId, ...partnerIds])), inSharedAlbum(eb, userId)]),
+ ),
+ )
+ .select('asset_face.personGroupId')
+ .select((eb) => eb.fn.count('asset.id').as('faceCount'))
+ // an array instead of a join or `in`, which the planner may turn into a scan of every face in the instance
+ .where('asset_face.personGroupId', '=', (eb) => {
+ const ownedGroupIds = eb
+ .selectFrom('person')
+ .select('person.personGroupId')
+ .where('person.ownerId', '=', userId);
+ return eb.fn.any(eb.fn('array', [ownedGroupIds]));
+ })
+ .where('asset_face.deletedAt', 'is', null)
+ .where('asset_face.isVisible', 'is', true)
+ .groupBy('asset_face.personGroupId')
+ .as('faces'),
+ (join) => join.onRef('faces.personGroupId', '=', 'person_group.id'),
)
- .innerJoin('asset', (join) =>
- join
- .onRef('asset.id', '=', 'asset_face.assetId')
- .on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
- .on('asset.deletedAt', 'is', null)
- .on((eb) => eb.or([eb('asset.ownerId', '=', anyUuid([userId, ...partnerIds])), inSharedAlbum(eb, userId)])),
- )
- .groupBy(['person_group.id', 'owned.ownerId', 'owned.personGroupId'])
- .having((eb) =>
+ .where((eb) =>
eb.or([
eb('owned.name', '!=', ''),
eb(
- faceCount,
+ 'faces.faceCount',
'>=',
sql`COALESCE(
(SELECT value -> 'people' ->> 'minimumFaces'
@@ -509,7 +522,7 @@ export class PersonRepository {
.$if(!options.closestFaceAssetId, (qb) =>
qb
.orderBy(sql`NULLIF("owned"."name", '') is null`, 'asc')
- .orderBy(faceCount, 'desc')
+ .orderBy('faces.faceCount', 'desc')
.orderBy(sql`NULLIF("owned"."name", '')`, (om) => om.asc().nullsLast())
.orderBy('owned.createdAt'),
)
diff --git a/web/package.json b/web/package.json
index 51853ea0e..985b9290c 100644
--- a/web/package.json
+++ b/web/package.json
@@ -1,6 +1,6 @@
{
"name": "immich-web",
- "version": "3.3.1",
+ "version": "3.3.3",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {