Compare commits

...

21 Commits

Author SHA1 Message Date
github-actions
64d926581f chore: version v1.125.5 2025-01-27 20:04:50 +00:00
Alex
c139e05170 fix(mobile): locale option causes the datetime filter error out (#15704) 2025-01-27 14:02:23 -06:00
Alex
0fe62298e1 fix(server): duplicate detection (#15727) 2025-01-27 13:53:59 -06:00
github-actions
e5794e6cfc chore: version v1.125.4 2025-01-27 18:44:12 +00:00
Alex
f6cbc9db06 fix(server): cannot render album page when all assets of an album are in trash (#15690)
* fix(server): cannot render album page when all assets of an album are in trash

* inner join

* add e2e test

* check empty albums too

* render add to album button on empty album

* lint

* count 0 if undefined

* fix album card test

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2025-01-26 21:18:34 -06:00
Alex
8dab5d3798 chore(mobile): post release task (#15662) 2025-01-26 15:09:15 -06:00
Carsten Otto
e864811a85 fix(web): sort folders (#15691)
fixes #13145
2025-01-26 15:07:22 -06:00
github-actions
72a55c13b6 chore: version v1.125.3 2025-01-26 14:14:48 +00:00
sudbrack
206412267a fix(server): /search/random API returns same assets every call (#15682)
* Fix for server searchRandom function not returning random results

* Fix lint
2025-01-26 14:06:18 +00:00
Damiano Ferrari
f780a56e24 fix(mobile): Misaligned text icon in circle avatar (#15683)
style(mobile): Use `DefaultTextStyle` for the text icon in `CircleAvatar`
2025-01-26 07:51:46 -06:00
Alex
7bbffccf76 fix(web): neon overflow on mobile screen (#15676) 2025-01-26 08:06:26 -05:00
Mert
05a446c259 fix(server): avoid duplicate rows in album queries (#15670)
* avoid duplicate rows

* left join, handle null vs. undefined

* update sql
2025-01-25 22:37:19 -06:00
Carsten Otto
4f725b95e1 fix(server): do not count deleted assets for album summary (#15668)
fixes #15645
fixes #15646
2025-01-25 16:45:13 -06:00
Carsten Otto
64b92cb24c fix(server): do not reset fileCreatedDate (#15650)
When marking an offline asset as online again, do not reset the
fileCreatedAt value. This value contains the "true" date, copied
from exif.dateTimeOriginal. If we overwrite this value, we'd need
to run the metadata extraction job again. Instead, we just leave
the old (and correct) value in place.

fixes #15640
2025-01-25 13:50:37 -06:00
Gagan Yadav
19f2f888ee fix(mobile): improve timezone picker (#15615)
- Fix missing timezones

- Remove the UTC prefix from timezone display text to align with web app

- Remove unnecessary layout builder

- Created a custom `DropdownSearchMenu` widget

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-01-25 13:36:49 -06:00
Alex
d12b1c907d fix(server): bulk update location (#15642) 2025-01-25 11:58:07 -06:00
Robert Schütz
947c053c15 chore(server): add DB_URL supports Unix sockets unit test (#15629)
* test(server): DB_URL supports Unix sockets

* chore: format

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2025-01-25 10:38:00 +00:00
Regenxyz
79592701dd chore: fix typos in Thai Language Readme (#15637)
Update README_th_TH.md

Fixing weird Thai Translate
2025-01-25 10:30:53 +00:00
jdicioccio
39697cd973 fix: increase upload timeout (#15588)
Fix upload timeout issue

Fix an issue where when uploading a large file, the upload would consistently abort after 30 minutes. I changed this timeout from 30 minutes to 1 day. Maybe that's excessive, or maybe the timeout isn't even needed, but the current 30 minute timeout definitely seems way too short.
2025-01-25 04:26:52 -06:00
Jonathan Jogenfors
10e518db42 chore(server): print stack in case of worker error (#15632)
feat: show error stack
2025-01-24 22:45:55 -05:00
Mert
72fa31f9e9 fix(server): changing vector dim size (#15630) 2025-01-24 20:01:24 -05:00
47 changed files with 607 additions and 320 deletions

6
cli/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@immich/cli",
"version": "2.2.43",
"version": "2.2.46",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/cli",
"version": "2.2.43",
"version": "2.2.46",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"fast-glob": "^3.3.2",
@@ -52,7 +52,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.125.2",
"version": "1.125.5",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.43",
"version": "2.2.46",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",

View File

@@ -1,4 +1,16 @@
[
{
"label": "v1.125.5",
"url": "https://v1.125.5.archive.immich.app"
},
{
"label": "v1.125.4",
"url": "https://v1.125.4.archive.immich.app"
},
{
"label": "v1.125.3",
"url": "https://v1.125.3.archive.immich.app"
},
{
"label": "v1.125.2",
"url": "https://v1.125.2.archive.immich.app"

8
e2e/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "immich-e2e",
"version": "1.125.2",
"version": "1.125.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
"version": "1.125.2",
"version": "1.125.5",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
@@ -45,7 +45,7 @@
},
"../cli": {
"name": "@immich/cli",
"version": "2.2.43",
"version": "2.2.46",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
@@ -92,7 +92,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.125.2",
"version": "1.125.5",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.125.2",
"version": "1.125.5",
"description": "",
"main": "index.js",
"type": "module",

View File

@@ -22,79 +22,92 @@ const user1NotShared = 'user1NotShared';
const user2SharedUser = 'user2SharedUser';
const user2SharedLink = 'user2SharedLink';
const user2NotShared = 'user2NotShared';
const user4DeletedAsset = 'user4DeletedAsset';
const user4Empty = 'user4Empty';
describe('/albums', () => {
let admin: LoginResponseDto;
let user1: LoginResponseDto;
let user1Asset1: AssetMediaResponseDto;
let user1Asset2: AssetMediaResponseDto;
let user4Asset1: AssetMediaResponseDto;
let user1Albums: AlbumResponseDto[];
let user2: LoginResponseDto;
let user2Albums: AlbumResponseDto[];
let deletedAssetAlbum: AlbumResponseDto;
let user3: LoginResponseDto; // deleted
let user4: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
[user1, user2, user3] = await Promise.all([
[user1, user2, user3, user4] = await Promise.all([
utils.userSetup(admin.accessToken, createUserDto.user1),
utils.userSetup(admin.accessToken, createUserDto.user2),
utils.userSetup(admin.accessToken, createUserDto.user3),
utils.userSetup(admin.accessToken, createUserDto.user4),
]);
[user1Asset1, user1Asset2] = await Promise.all([
[user1Asset1, user1Asset2, user4Asset1] = await Promise.all([
utils.createAsset(user1.accessToken, { isFavorite: true }),
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
user1Albums = await Promise.all([
utils.createAlbum(user1.accessToken, {
albumName: user1SharedEditorUser,
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }],
assetIds: [user1Asset1.id],
}),
utils.createAlbum(user1.accessToken, {
albumName: user1SharedLink,
assetIds: [user1Asset1.id],
}),
utils.createAlbum(user1.accessToken, {
albumName: user1NotShared,
assetIds: [user1Asset1.id, user1Asset2.id],
}),
utils.createAlbum(user1.accessToken, {
albumName: user1SharedViewerUser,
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
assetIds: [user1Asset1.id],
[user1Albums, user2Albums, deletedAssetAlbum] = await Promise.all([
Promise.all([
utils.createAlbum(user1.accessToken, {
albumName: user1SharedEditorUser,
albumUsers: [
{ userId: admin.userId, role: AlbumUserRole.Editor },
{ userId: user2.userId, role: AlbumUserRole.Editor },
],
assetIds: [user1Asset1.id],
}),
utils.createAlbum(user1.accessToken, {
albumName: user1SharedLink,
assetIds: [user1Asset1.id],
}),
utils.createAlbum(user1.accessToken, {
albumName: user1NotShared,
assetIds: [user1Asset1.id, user1Asset2.id],
}),
utils.createAlbum(user1.accessToken, {
albumName: user1SharedViewerUser,
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
assetIds: [user1Asset1.id],
}),
]),
Promise.all([
utils.createAlbum(user2.accessToken, {
albumName: user2SharedUser,
albumUsers: [
{ userId: user1.userId, role: AlbumUserRole.Editor },
{ userId: user3.userId, role: AlbumUserRole.Editor },
],
}),
utils.createAlbum(user2.accessToken, { albumName: user2SharedLink }),
utils.createAlbum(user2.accessToken, { albumName: user2NotShared }),
]),
utils.createAlbum(user4.accessToken, { albumName: user4DeletedAsset }),
utils.createAlbum(user4.accessToken, { albumName: user4Empty }),
utils.createAlbum(user3.accessToken, {
albumName: 'Deleted',
albumUsers: [{ userId: user1.userId, role: AlbumUserRole.Editor }],
}),
]);
user2Albums = await Promise.all([
utils.createAlbum(user2.accessToken, {
albumName: user2SharedUser,
albumUsers: [
{ userId: user1.userId, role: AlbumUserRole.Editor },
{ userId: user3.userId, role: AlbumUserRole.Editor },
],
}),
utils.createAlbum(user2.accessToken, { albumName: user2SharedLink }),
utils.createAlbum(user2.accessToken, { albumName: user2NotShared }),
]);
await utils.createAlbum(user3.accessToken, {
albumName: 'Deleted',
albumUsers: [{ userId: user1.userId, role: AlbumUserRole.Editor }],
});
await addAssetsToAlbum(
{ id: user2Albums[0].id, bulkIdsDto: { ids: [user1Asset1.id, user1Asset2.id] } },
{ headers: asBearerAuth(user1.accessToken) },
);
user2Albums[0] = await getAlbumInfo({ id: user2Albums[0].id }, { headers: asBearerAuth(user2.accessToken) });
await Promise.all([
addAssetsToAlbum(
{ id: user2Albums[0].id, bulkIdsDto: { ids: [user1Asset1.id, user1Asset2.id] } },
{ headers: asBearerAuth(user1.accessToken) },
),
addAssetsToAlbum(
{ id: deletedAssetAlbum.id, bulkIdsDto: { ids: [user4Asset1.id] } },
{ headers: asBearerAuth(user4.accessToken) },
),
// add shared link to user1SharedLink album
utils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
@@ -107,7 +120,11 @@ describe('/albums', () => {
}),
]);
await deleteUserAdmin({ id: user3.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) });
[user2Albums[0]] = await Promise.all([
getAlbumInfo({ id: user2Albums[0].id }, { headers: asBearerAuth(user2.accessToken) }),
deleteUserAdmin({ id: user3.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) }),
utils.deleteAssets(user1.accessToken, [user4Asset1.id]),
]);
});
describe('GET /albums', () => {
@@ -284,6 +301,25 @@ describe('/albums', () => {
expect(status).toBe(200);
expect(body).toHaveLength(5);
});
it('should return empty albums and albums where all assets are deleted', async () => {
const { status, body } = await request(app).get('/albums').set('Authorization', `Bearer ${user4.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({
ownerId: user4.userId,
albumName: user4DeletedAsset,
shared: false,
}),
expect.objectContaining({
ownerId: user4.userId,
albumName: user4Empty,
shared: false,
}),
]),
);
});
});
describe('GET /albums/:id', () => {
@@ -362,6 +398,26 @@ describe('/albums', () => {
shared: true,
});
});
it('should not count trashed assets', async () => {
await utils.deleteAssets(user1.accessToken, [user1Asset2.id]);
const { status, body } = await request(app)
.get(`/albums/${user2Albums[0].id}?withoutAssets=true`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...user2Albums[0],
assets: [],
assetCount: 1,
lastModifiedAssetTimestamp: expect.any(String),
endDate: expect.any(String),
startDate: expect.any(String),
albumUsers: expect.any(Array),
shared: true,
});
});
});
describe('GET /albums/statistics', () => {

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.125.2"
version = "1.125.5"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 178,
"android.injected.version.name" => "1.125.2",
"android.injected.version.code" => 181,
"android.injected.version.name" => "1.125.5",
}
)
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')

View File

@@ -248,6 +248,7 @@
"download_waiting_to_retry": "Waiting to retry",
"edit_date_time_dialog_date_time": "Date and Time",
"edit_date_time_dialog_timezone": "Timezone",
"edit_date_time_dialog_search_timezone": "Search timezone...",
"edit_image_title": "Edit",
"edit_location_dialog_title": "Location",
"end_date": "End date",

View File

@@ -541,7 +541,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189;
CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -685,7 +685,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189;
CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -715,7 +715,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189;
CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -748,7 +748,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189;
CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -791,7 +791,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189;
CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -831,7 +831,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189;
CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;

View File

@@ -78,7 +78,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.125.1</string>
<string>1.125.2</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@@ -93,7 +93,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>189</string>
<string>190</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Release"
lane :release do
increment_version_number(
version_number: "1.125.2"
version_number: "1.125.5"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -277,7 +277,6 @@ class SearchPage extends HookConsumerWidget {
fieldEndHintText: 'end_date'.tr(),
initialEntryMode: DatePickerEntryMode.calendar,
keyboardType: TextInputType.text,
locale: context.locale,
);
if (date == null) {

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/widgets/common/dropdown_search_menu.dart';
import 'package:timezone/timezone.dart' as tz;
import 'package:timezone/timezone.dart';
@@ -24,7 +25,7 @@ Future<String?> showDateTimePicker({
}
String _getFormattedOffset(int offsetInMilli, tz.Location location) {
return "${location.name} (UTC${Duration(milliseconds: offsetInMilli).formatAsOffset()})";
return "${location.name} (${Duration(milliseconds: offsetInMilli).formatAsOffset()})";
}
class _DateTimePicker extends HookWidget {
@@ -73,7 +74,6 @@ class _DateTimePicker extends HookWidget {
// returns a list of location<name> along with it's offset in duration
List<_TimeZoneOffset> getAllTimeZones() {
return tz.timeZoneDatabase.locations.values
.where((l) => !l.currentTimeZone.abbreviation.contains("0"))
.map(_TimeZoneOffset.fromLocation)
.sorted()
.toList();
@@ -133,83 +133,78 @@ class _DateTimePicker extends HookWidget {
context.pop(dtWithOffset);
}
return LayoutBuilder(
builder: (context, constraint) => AlertDialog(
contentPadding:
const EdgeInsets.symmetric(vertical: 32, horizontal: 18),
actions: [
TextButton(
onPressed: () => context.pop(),
child: Text(
"action_common_cancel",
style: context.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.colorScheme.error,
return AlertDialog(
contentPadding: const EdgeInsets.symmetric(vertical: 32, horizontal: 18),
actions: [
TextButton(
onPressed: () => context.pop(),
child: Text(
"action_common_cancel",
style: context.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.colorScheme.error,
),
).tr(),
),
TextButton(
onPressed: popWithDateTime,
child: Text(
"action_common_update",
style: context.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
).tr(),
),
],
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"edit_date_time_dialog_date_time",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
).tr(),
const SizedBox(height: 32),
ListTile(
tileColor: context.colorScheme.surfaceContainerHighest,
shape: ShapeBorder.lerp(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
1,
),
trailing: Icon(
Icons.edit_outlined,
size: 18,
color: context.primaryColor,
),
title: Text(
DateFormat("dd-MM-yyyy hh:mm a").format(date.value),
style: context.textTheme.bodyMedium,
).tr(),
onTap: pickDate,
),
TextButton(
onPressed: popWithDateTime,
child: Text(
"action_common_update",
style: context.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
).tr(),
const SizedBox(height: 24),
DropdownSearchMenu(
trailingIcon: Icon(
Icons.arrow_drop_down,
color: context.primaryColor,
),
hintText: "edit_date_time_dialog_timezone".tr(),
label: const Text('edit_date_time_dialog_timezone').tr(),
textStyle: context.textTheme.bodyMedium,
onSelected: (value) => tzOffset.value = value,
initialSelection: tzOffset.value,
dropdownMenuEntries: menuEntries,
),
],
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"edit_date_time_dialog_date_time",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
).tr(),
const SizedBox(height: 32),
ListTile(
tileColor: context.colorScheme.surfaceContainerHighest,
shape: ShapeBorder.lerp(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
1,
),
trailing: Icon(
Icons.edit_outlined,
size: 18,
color: context.primaryColor,
),
title: Text(
DateFormat("dd-MM-yyyy hh:mm a").format(date.value),
style: context.textTheme.bodyMedium,
).tr(),
onTap: pickDate,
),
const SizedBox(height: 24),
DropdownMenu(
width: 275,
menuHeight: 300,
trailingIcon: Icon(
Icons.arrow_drop_down,
color: context.primaryColor,
),
hintText: "edit_date_time_dialog_timezone".tr(),
label: const Text('edit_date_time_dialog_timezone').tr(),
textStyle: context.textTheme.bodyMedium,
onSelected: (value) => tzOffset.value = value!,
initialSelection: tzOffset.value,
dropdownMenuEntries: menuEntries,
),
],
),
),
);
}

View File

@@ -0,0 +1,169 @@
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class DropdownSearchMenu<T> extends HookWidget {
const DropdownSearchMenu({
super.key,
required this.dropdownMenuEntries,
this.initialSelection,
this.onSelected,
this.trailingIcon,
this.hintText,
this.label,
this.textStyle,
this.menuConstraints,
});
final List<DropdownMenuEntry<T>> dropdownMenuEntries;
final T? initialSelection;
final ValueChanged<T>? onSelected;
final Widget? trailingIcon;
final String? hintText;
final Widget? label;
final TextStyle? textStyle;
final BoxConstraints? menuConstraints;
@override
Widget build(BuildContext context) {
final selectedItem = useState<DropdownMenuEntry<T>?>(
dropdownMenuEntries
.firstWhereOrNull((item) => item.value == initialSelection),
);
final showTimeZoneDropdown = useState<bool>(false);
final effectiveConstraints = menuConstraints ??
const BoxConstraints(
minWidth: 280,
maxWidth: 280,
minHeight: 0,
maxHeight: 280,
);
final inputDecoration = InputDecoration(
contentPadding: const EdgeInsets.fromLTRB(12, 4, 12, 4),
border: const OutlineInputBorder(),
suffixIcon: trailingIcon,
label: label,
hintText: hintText,
).applyDefaults(context.themeData.inputDecorationTheme);
if (!showTimeZoneDropdown.value) {
return ConstrainedBox(
constraints: effectiveConstraints,
child: GestureDetector(
onTap: () => showTimeZoneDropdown.value = true,
child: InputDecorator(
decoration: inputDecoration,
child: selectedItem.value != null
? Text(
selectedItem.value!.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textStyle,
)
: null,
),
),
);
}
return ConstrainedBox(
constraints: effectiveConstraints,
child: Autocomplete<DropdownMenuEntry<T>>(
displayStringForOption: (option) => option.label,
optionsBuilder: (textEditingValue) {
return dropdownMenuEntries.where(
(item) => item.label
.toLowerCase()
.trim()
.contains(textEditingValue.text.toLowerCase().trim()),
);
},
onSelected: (option) {
selectedItem.value = option;
showTimeZoneDropdown.value = false;
onSelected?.call(option.value);
},
fieldViewBuilder: (context, textEditingController, focusNode, _) {
return TextField(
autofocus: true,
focusNode: focusNode,
controller: textEditingController,
decoration: inputDecoration.copyWith(
hintText: "edit_date_time_dialog_search_timezone".tr(),
),
maxLines: 1,
style: context.textTheme.bodyMedium,
expands: false,
onTapOutside: (event) {
showTimeZoneDropdown.value = false;
focusNode.unfocus();
},
onSubmitted: (_) {
showTimeZoneDropdown.value = false;
},
);
},
optionsViewBuilder: (context, onSelected, options) {
// This widget is a copy of the default implementation.
// We have only changed the `constraints` parameter.
return Align(
alignment: Alignment.topLeft,
child: ConstrainedBox(
constraints: effectiveConstraints,
child: Material(
elevation: 4.0,
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (BuildContext context, int index) {
final option = options.elementAt(index);
return InkWell(
onTap: () => onSelected(option),
child: Builder(
builder: (BuildContext context) {
final bool highlight =
AutocompleteHighlightedOption.of(context) ==
index;
if (highlight) {
SchedulerBinding.instance.addPostFrameCallback(
(Duration timeStamp) {
Scrollable.ensureVisible(
context,
alignment: 0.5,
);
},
debugLabel: 'AutocompleteOptions.ensureVisible',
);
}
return Container(
color: highlight
? Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.12)
: null,
padding: const EdgeInsets.all(16.0),
child: Text(
option.label,
style: textStyle,
),
);
},
),
);
},
),
),
),
);
},
),
);
}
}

View File

@@ -28,8 +28,7 @@ class UserCircleAvatar extends ConsumerWidget {
final profileImageUrl =
'${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${Random().nextInt(1024)}';
final textIcon = Text(
user.name[0].toUpperCase(),
final textIcon = DefaultTextStyle(
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
@@ -37,6 +36,7 @@ class UserCircleAvatar extends ConsumerWidget {
? Colors.black
: Colors.white,
),
child: Text(user.name[0].toUpperCase()),
);
return CircleAvatar(
backgroundColor: user.avatarColor.toColor(),

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.125.2
- API version: 1.125.5
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.125.2+178
version: 1.125.5+181
environment:
sdk: '>=3.3.0 <4.0.0'

View File

@@ -7454,7 +7454,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.125.2",
"version": "1.125.5",
"contact": {}
},
"tags": [],

View File

@@ -1,12 +1,12 @@
{
"name": "@immich/sdk",
"version": "1.125.2",
"version": "1.125.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
"version": "1.125.2",
"version": "1.125.5",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "1.125.2",
"version": "1.125.5",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",

View File

@@ -1,6 +1,6 @@
/**
* Immich
* 1.125.2
* 1.125.5
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/

View File

@@ -40,12 +40,12 @@
<a href="README_vi_VN.md">Tiếng Việt</a>
</p>
## ข้อจำกัดความรับผิดชอบ
## ข้อควรระวัง
- ⚠️ โพรเจกต์นี้กำลังอยู่ระหว่างการพัฒนา**ที่มีการเปลี่ยนแปลงบ่อยมาก**
- ⚠️ คาดว่าจะมีข้อผิดพลาดและการเปลี่ยนแปลงที่ส่งผลเสีย
- ⚠️ **ห้ามใช้แอปนี้เป็นวิธีการเดียวในการจัดเก็บภาพถ่ายและวิดีโอของคุณ**
- ⚠️ ปฏิบัติตามแผนการสำรองข้อมูลแบบ [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) สำหรับภาพถ่ายและวิดีโอที่สำคัญของคุณอยู่เสมอ!
- ⚠️ โพรเจกต์นี้กำลังอยู่ระหว่างการพัฒนา**มีการเปลี่ยนแปลงบ่อยมาก**
- ⚠️ อาจจะเกิดข้อผิดพลาดและการเปลี่ยนแปลงที่ส่งผลเสีย
- ⚠️ **ห้ามใช้ระบบนี้เป็นวิธีการเดียวในการจัดเก็บภาพถ่ายและวิดีโอของคุณ**
- ⚠️ ปฏิบัติตามแผนการสำรองข้อมูลแบบ [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) สำหรับภาพถ่ายและวิดีโอที่สำคัญของคุณอยู่เสมอ
> [!NOTE]
> คุณสามารถหาคู่มือหลัก รวมถึงคู่มือการติดตั้ง ได้ที่ https://immich.app/
@@ -79,15 +79,15 @@
| :----------------------------------------- | ------ | ------ |
| อัปโหลดและดูวิดีโอและภาพถ่าย | ใช่ | ใช่ |
| การสำรองข้อมูลอัตโนมัติเมื่อเปิดแอป | ใช่ | N/A |
| ป้องกันการซ้ำซ้อนของไฟล์ | ใช่ | ใช่ |
| ป้องกันการซ้ำของไฟล์ | ใช่ | ใช่ |
| เลือกอัลบั้มสำหรับสำรองข้อมูล | ใช่ | N/A |
| ดาวน์โหลดภาพถ่ายและวิดีโอไปยังอุปกรณ์ | ใช่ | ใช่ |
| รองรับผู้ใช้หลายคน | ใช่ | ใช่ |
| อัลบั้มและอัลบั้มแชร์ | ใช่ | ใช่ |
| แถบเลื่อนแบบลากได้ | ใช่ | ใช่ |
| รองรับรูปแบบไฟล์ RAW | ใช่ | ใช่ |
| ดูข้อมูลเมตา (EXIF, แผนที่) | ใช่ | ใช่ |
| ค้นหาจากข้อมูลเมตา วัตถุ ใบหน้า และ CLIP | ใช่ | ใช่ |
| ดูข้อมูลเมตาดาต้า (EXIF, แผนที่) | ใช่ | ใช่ |
| ค้นหาจากข้อมูลเมตาดาต้า วัตถุ ใบหน้า และ CLIP | ใช่ | ใช่ |
| ฟังก์ชันการจัดการผู้ดูแลระบบ | ไม่ใช่ | ใช่ |
| การสำรองข้อมูลพื้นหลัง | ใช่ | N/A |
| การเลื่อนแบบเสมือน | ใช่ | ใช่ |
@@ -100,7 +100,7 @@
| การจัดเก็บและรายการโปรด | ใช่ | ใช่ |
| แผนที่ทั่วโลก | ใช่ | ใช่ |
| การแชร์กับคู่หู | ใช่ | ใช่ |
| การจดจำใบหน้าและการจัดกลุ่ม | ใช่ | ใช่ |
| ระบบจดจำใบหน้าและการจัดกลุ่ม | ใช่ | ใช่ |
| ความทรงจำ (x ปีที่แล้ว) | ใช่ | ใช่ |
| รองรับแบบออฟไลน์ | ใช่ | ไม่ใช่ |
| แกลเลอรีแบบอ่านอย่างเดียว | ใช่ | ใช่ |
@@ -108,13 +108,13 @@
## การแปลภาษา
อ่านเพิ่มเติมเกี่ยวกับการแปลภาษา [ที่นี่](https://immich.app/docs/developer/translations)
อ่านเพิ่มเติมเกี่ยวกับการแปล [ที่นี่](https://immich.app/docs/developer/translations)
<a href="https://hosted.weblate.org/engage/immich/">
<img src="https://hosted.weblate.org/widget/immich/immich/multi-auto.svg" alt="สถานะการแปล" />
</a>
## กิจกรรมของคลังเก็บข้อมูล
## กิจกรรมของ Repository
![กิจกรรม](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "ภาพการวิเคราะห์ของ Repobeats")

View File

@@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.125.2",
"version": "1.125.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.125.2",
"version": "1.125.5",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@nestjs/bullmq": "^11.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.125.2",
"version": "1.125.5",
"description": "",
"author": "",
"private": true,

View File

@@ -193,7 +193,7 @@ export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
export function withSmartSearch<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
return qb
.leftJoin('smart_search', 'assets.id', 'smart_search.assetId')
.select(sql<number[]>`smart_search.embedding`.as('embedding'));
.select((eb) => eb.fn.toJson(eb.table('smart_search')).as('smartSearch'));
}
export function withFaces(eb: ExpressionBuilder<DB, 'assets'>) {

View File

@@ -9,8 +9,8 @@ export const IAlbumRepository = 'IAlbumRepository';
export interface AlbumAssetCount {
albumId: string;
assetCount: number;
startDate: Date | undefined;
endDate: Date | undefined;
startDate: Date | null;
endDate: Date | null;
}
export interface AlbumInfoOptions {

View File

@@ -13,7 +13,7 @@ if (immichApp) {
let apiProcess: ChildProcess | undefined;
const onError = (name: string, error: Error) => {
console.error(`${name} worker error: ${error}`);
console.error(`${name} worker error: ${error}, stack: ${error.stack}`);
};
const onExit = (name: string, exitCode: number | null) => {

View File

@@ -90,7 +90,7 @@ select
(
select
"assets".*,
to_json("exif") as "exifInfo"
"exif" as "exifInfo"
from
"assets"
inner join "exif" on "assets"."id" = "exif"."assetId"
@@ -180,19 +180,20 @@ select
) as "albumUsers"
from
"albums"
left join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id"
left join "albums_shared_users_users" as "album_users" on "album_users"."albumsId" = "albums"."id"
inner join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id"
where
(
(
"albums"."ownerId" = $1
and "album_assets"."assetsId" = $2
)
or (
"album_users"."usersId" = $3
and "album_assets"."assetsId" = $4
"albums"."ownerId" = $1
or exists (
select
from
"albums_shared_users_users" as "album_users"
where
"album_users"."albumsId" = "albums"."id"
and "album_users"."usersId" = $2
)
)
and "album_assets"."assetsId" = $3
and "albums"."deletedAt" is null
order by
"albums"."createdAt" desc,
@@ -200,16 +201,17 @@ order by
-- AlbumRepository.getMetadataForIds
select
"albums"."id",
"albums"."id" as "albumId",
min("assets"."fileCreatedAt") as "startDate",
max("assets"."fileCreatedAt") as "endDate",
count("assets"."id") as "assetCount"
count("assets"."id")::int as "assetCount"
from
"albums"
left join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id"
left join "assets" on "assets"."id" = "album_assets"."assetsId"
inner join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id"
inner join "assets" on "assets"."id" = "album_assets"."assetsId"
where
"albums"."id" in ($1)
and "assets"."deletedAt" is null
group by
"albums"."id"
@@ -305,8 +307,8 @@ order by
"albums"."createdAt" desc
-- AlbumRepository.getShared
select distinct
on ("albums"."createdAt") "albums".*,
select
"albums".*,
(
select
coalesce(json_agg(agg), '[]')
@@ -389,15 +391,26 @@ select distinct
) as "sharedLinks"
from
"albums"
left join "albums_shared_users_users" as "shared_albums" on "shared_albums"."albumsId" = "albums"."id"
left join "shared_links" on "shared_links"."albumId" = "albums"."id"
where
(
"shared_albums"."usersId" = $1
or "shared_links"."userId" = $2
or (
"albums"."ownerId" = $3
and "shared_albums"."usersId" is not null
exists (
select
from
"albums_shared_users_users" as "album_users"
where
"album_users"."albumsId" = "albums"."id"
and (
"albums"."ownerId" = $1
or "album_users"."usersId" = $2
)
)
or exists (
select
from
"shared_links"
where
"shared_links"."albumId" = "albums"."id"
and "shared_links"."userId" = $3
)
)
and "albums"."deletedAt" is null
@@ -405,48 +418,8 @@ order by
"albums"."createdAt" desc
-- AlbumRepository.getNotShared
select distinct
on ("albums"."createdAt") "albums".*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"album_users".*,
(
select
to_json(obj)
from
(
select
"id",
"email",
"createdAt",
"profileImagePath",
"isAdmin",
"shouldChangePassword",
"deletedAt",
"oauthId",
"updatedAt",
"storageLabel",
"name",
"quotaSizeInBytes",
"quotaUsageInBytes",
"status",
"profileChangedAt"
from
"users"
where
"users"."id" = "album_users"."usersId"
) as obj
) as "user"
from
"albums_shared_users_users" as "album_users"
where
"album_users"."albumsId" = "albums"."id"
) as agg
) as "albumUsers",
select
"albums".*,
(
select
to_json(obj)
@@ -473,29 +446,26 @@ select distinct
where
"users"."id" = "albums"."ownerId"
) as obj
) as "owner",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
from
"shared_links"
where
"shared_links"."albumId" = "albums"."id"
) as agg
) as "sharedLinks"
) as "owner"
from
"albums"
left join "albums_shared_users_users" as "shared_albums" on "shared_albums"."albumsId" = "albums"."id"
left join "shared_links" on "shared_links"."albumId" = "albums"."id"
where
"albums"."ownerId" = $1
and "shared_albums"."usersId" is null
and "shared_links"."userId" is null
and "albums"."deletedAt" is null
and not exists (
select
from
"albums_shared_users_users" as "album_users"
where
"album_users"."albumsId" = "albums"."id"
)
and not exists (
select
from
"shared_links"
where
"shared_links"."albumId" = "albums"."id"
)
order by
"albums"."createdAt" desc

View File

@@ -36,7 +36,7 @@ offset
and "assets"."deletedAt" is null
and "assets"."id" < $6
order by
"assets"."id"
random()
limit
$7
)
@@ -56,7 +56,7 @@ union all
and "assets"."deletedAt" is null
and "assets"."id" > $13
order by
"assets"."id"
random()
limit
$14
)

View File

@@ -59,7 +59,7 @@ const withAssets = (eb: ExpressionBuilder<DB, 'albums'>) => {
.selectFrom('assets')
.selectAll('assets')
.innerJoin('exif', 'assets.id', 'exif.assetId')
.select((eb) => eb.fn.toJson('exif').as('exifInfo'))
.select((eb) => eb.table('exif').as('exifInfo'))
.innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id')
.whereRef('albums_assets_assets.albumsId', '=', 'albums.id')
.where('assets.deletedAt', 'is', null)
@@ -93,14 +93,19 @@ export class AlbumRepository implements IAlbumRepository {
return this.db
.selectFrom('albums')
.selectAll('albums')
.leftJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id')
.leftJoin('albums_shared_users_users as album_users', 'album_users.albumsId', 'albums.id')
.innerJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id')
.where((eb) =>
eb.or([
eb.and([eb('albums.ownerId', '=', ownerId), eb('album_assets.assetsId', '=', assetId)]),
eb.and([eb('album_users.usersId', '=', ownerId), eb('album_assets.assetsId', '=', assetId)]),
eb('albums.ownerId', '=', ownerId),
eb.exists(
eb
.selectFrom('albums_shared_users_users as album_users')
.whereRef('album_users.albumsId', '=', 'albums.id')
.where('album_users.usersId', '=', ownerId),
),
]),
)
.where('album_assets.assetsId', '=', assetId)
.where('albums.deletedAt', 'is', null)
.orderBy('albums.createdAt', 'desc')
.select(withOwner)
@@ -117,24 +122,18 @@ export class AlbumRepository implements IAlbumRepository {
return [];
}
const metadatas = await this.db
return this.db
.selectFrom('albums')
.leftJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id')
.leftJoin('assets', 'assets.id', 'album_assets.assetsId')
.select('albums.id')
.innerJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id')
.innerJoin('assets', 'assets.id', 'album_assets.assetsId')
.select('albums.id as albumId')
.select((eb) => eb.fn.min('assets.fileCreatedAt').as('startDate'))
.select((eb) => eb.fn.max('assets.fileCreatedAt').as('endDate'))
.select((eb) => eb.fn.count('assets.id').as('assetCount'))
.select((eb) => sql<number>`${eb.fn.count('assets.id')}::int`.as('assetCount'))
.where('albums.id', 'in', ids)
.where('assets.deletedAt', 'is', null)
.groupBy('albums.id')
.execute();
return metadatas.map((metadatas) => ({
albumId: metadatas.id,
assetCount: Number(metadatas.assetCount),
startDate: metadatas.startDate ? new Date(metadatas.startDate) : undefined,
endDate: metadatas.endDate ? new Date(metadatas.endDate) : undefined,
}));
}
@GenerateSql({ params: [DummyValue.UUID] })
@@ -159,14 +158,20 @@ export class AlbumRepository implements IAlbumRepository {
return this.db
.selectFrom('albums')
.selectAll('albums')
.distinctOn('albums.createdAt')
.leftJoin('albums_shared_users_users as shared_albums', 'shared_albums.albumsId', 'albums.id')
.leftJoin('shared_links', 'shared_links.albumId', 'albums.id')
.where((eb) =>
eb.or([
eb('shared_albums.usersId', '=', ownerId),
eb('shared_links.userId', '=', ownerId),
eb.and([eb('albums.ownerId', '=', ownerId), eb('shared_albums.usersId', 'is not', null)]),
eb.exists(
eb
.selectFrom('albums_shared_users_users as album_users')
.whereRef('album_users.albumsId', '=', 'albums.id')
.where((eb) => eb.or([eb('albums.ownerId', '=', ownerId), eb('album_users.usersId', '=', ownerId)])),
),
eb.exists(
eb
.selectFrom('shared_links')
.whereRef('shared_links.albumId', '=', 'albums.id')
.where('shared_links.userId', '=', ownerId),
),
]),
)
.where('albums.deletedAt', 'is', null)
@@ -185,16 +190,21 @@ export class AlbumRepository implements IAlbumRepository {
return this.db
.selectFrom('albums')
.selectAll('albums')
.distinctOn('albums.createdAt')
.leftJoin('albums_shared_users_users as shared_albums', 'shared_albums.albumsId', 'albums.id')
.leftJoin('shared_links', 'shared_links.albumId', 'albums.id')
.where('albums.ownerId', '=', ownerId)
.where('shared_albums.usersId', 'is', null)
.where('shared_links.userId', 'is', null)
.where('albums.deletedAt', 'is', null)
.select(withAlbumUsers)
.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom('albums_shared_users_users as album_users')
.whereRef('album_users.albumsId', '=', 'albums.id'),
),
),
)
.where((eb) =>
eb.not(eb.exists(eb.selectFrom('shared_links').whereRef('shared_links.albumId', '=', 'albums.id'))),
)
.select(withOwner)
.select(withSharedLink)
.orderBy('albums.createdAt', 'desc')
.execute() as unknown as Promise<AlbumEntity[]>;
}
@@ -281,7 +291,6 @@ export class AlbumRepository implements IAlbumRepository {
.selectAll()
.where('id', '=', newAlbum.id)
.select(withOwner)
.select(withSharedLink)
.select(withAssets)
.select(withAlbumUsers)
.executeTakeFirst() as unknown as Promise<AlbumEntity>;
@@ -291,7 +300,7 @@ export class AlbumRepository implements IAlbumRepository {
update(id: string, album: Updateable<Albums>): Promise<AlbumEntity> {
return this.db
.updateTable('albums')
.set({ ...album, updatedAt: new Date() })
.set(album)
.where('id', '=', id)
.returningAll('albums')
.returning(withOwner)
@@ -334,7 +343,6 @@ export class AlbumRepository implements IAlbumRepository {
.select('album_assets.assetsId')
.orderBy('assets.fileCreatedAt', 'desc')
.limit(1),
updatedAt: new Date(),
}))
.where((eb) =>
eb.or([

View File

@@ -172,6 +172,28 @@ describe('getEnv', () => {
expect(() => getEnv()).toThrowError('Invalid ssl option: invalid');
});
it('should handle socket: URLs', () => {
process.env.DB_URL = 'socket:/run/postgresql?db=database1';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({
host: '/run/postgresql',
database: 'database1',
});
});
it('should handle sockets in postgres: URLs', () => {
process.env.DB_URL = 'postgres:///database2?host=/path/to/socket';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({
host: '/path/to/socket',
database: 'database2',
});
});
});
describe('redis', () => {

View File

@@ -72,8 +72,14 @@ export class SearchRepository implements ISearchRepository {
async searchRandom(size: number, options: AssetSearchOptions): Promise<AssetEntity[]> {
const uuid = randomUUID();
const builder = searchAssetBuilder(this.db, options);
const lessThan = builder.where('assets.id', '<', uuid).orderBy('assets.id').limit(size);
const greaterThan = builder.where('assets.id', '>', uuid).orderBy('assets.id').limit(size);
const lessThan = builder
.where('assets.id', '<', uuid)
.orderBy(sql`random()`)
.limit(size);
const greaterThan = builder
.where('assets.id', '>', uuid)
.orderBy(sql`random()`)
.limit(size);
const { rows } = await sql`${lessThan} union all ${greaterThan} limit ${size}`.execute(this.db);
return rows as any as AssetEntity[];
}
@@ -292,7 +298,7 @@ export class SearchRepository implements ISearchRepository {
await sql`truncate ${sql.table('smart_search')}`.execute(trx);
await trx.schema
.alterTable('smart_search')
.alterColumn('embedding', (col) => col.setDataType(sql.lit(`vector(${dimSize})`)))
.alterColumn('embedding', (col) => col.setDataType(sql.raw(`vector(${dimSize})`)))
.execute();
await sql`reindex index clip_index`.execute(trx);
});

View File

@@ -52,8 +52,8 @@ describe(AlbumService.name, () => {
it('gets list of albums for auth user', async () => {
albumMock.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]);
albumMock.getMetadataForIds.mockResolvedValue([
{ albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined },
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined },
{ albumId: albumStub.empty.id, assetCount: 0, startDate: null, endDate: null },
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: null, endDate: null },
]);
const result = await sut.getAll(authStub.admin, {});
@@ -82,7 +82,7 @@ describe(AlbumService.name, () => {
it('gets list of albums that are shared', async () => {
albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]);
albumMock.getMetadataForIds.mockResolvedValue([
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined },
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: null, endDate: null },
]);
const result = await sut.getAll(authStub.admin, { shared: true });
@@ -94,7 +94,7 @@ describe(AlbumService.name, () => {
it('gets list of albums that are NOT shared', async () => {
albumMock.getNotShared.mockResolvedValue([albumStub.empty]);
albumMock.getMetadataForIds.mockResolvedValue([
{ albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined },
{ albumId: albumStub.empty.id, assetCount: 0, startDate: null, endDate: null },
]);
const result = await sut.getAll(authStub.admin, { shared: false });

View File

@@ -55,13 +55,7 @@ export class AlbumService extends BaseService {
const results = await this.albumRepository.getMetadataForIds(albums.map((album) => album.id));
const albumMetadata: Record<string, AlbumAssetCount> = {};
for (const metadata of results) {
const { albumId, assetCount, startDate, endDate } = metadata;
albumMetadata[albumId] = {
albumId,
assetCount,
startDate,
endDate,
};
albumMetadata[metadata.albumId] = metadata;
}
return Promise.all(
@@ -70,9 +64,9 @@ export class AlbumService extends BaseService {
return {
...mapAlbumWithoutAssets(album),
sharedLinks: undefined,
startDate: albumMetadata[album.id].startDate,
endDate: albumMetadata[album.id].endDate,
assetCount: albumMetadata[album.id].assetCount,
startDate: albumMetadata[album.id]?.startDate ?? undefined,
endDate: albumMetadata[album.id]?.endDate ?? undefined,
assetCount: albumMetadata[album.id]?.assetCount ?? 0,
lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt,
};
}),
@@ -89,9 +83,9 @@ export class AlbumService extends BaseService {
return {
...mapAlbum(album, withAssets, auth),
startDate: albumMetadataForIds.startDate,
endDate: albumMetadataForIds.endDate,
assetCount: albumMetadataForIds.assetCount,
startDate: albumMetadataForIds?.startDate ?? undefined,
endDate: albumMetadataForIds?.endDate ?? undefined,
assetCount: albumMetadataForIds?.assetCount ?? 0,
lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt,
};
}

View File

@@ -416,6 +416,34 @@ describe(AssetService.name, () => {
await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true });
expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true });
});
it('should not update Assets table if no relevant fields are provided', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
await sut.updateAll(authStub.admin, {
ids: ['asset-1'],
latitude: 0,
longitude: 0,
isArchived: undefined,
isFavorite: undefined,
duplicateId: undefined,
rating: undefined,
});
expect(assetMock.updateAll).not.toHaveBeenCalled();
});
it('should update Assets table if isArchived field is provided', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
await sut.updateAll(authStub.admin, {
ids: ['asset-1'],
latitude: 0,
longitude: 0,
isArchived: undefined,
isFavorite: false,
duplicateId: undefined,
rating: undefined,
});
expect(assetMock.updateAll).toHaveBeenCalled();
});
});
describe('deleteAll', () => {

View File

@@ -142,7 +142,14 @@ export class AssetService extends BaseService {
await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude });
}
await this.assetRepository.updateAll(ids, options);
if (
options.isArchived != undefined ||
options.isFavorite != undefined ||
options.duplicateId != undefined ||
options.rating != undefined
) {
await this.assetRepository.updateAll(ids, options);
}
}
@OnJob({ name: JobName.ASSET_DELETION_CHECK, queue: QueueName.BACKGROUND_TASK })

View File

@@ -337,12 +337,31 @@ describe(LibraryService.name, () => {
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.trashedOffline.id], {
deletedAt: null,
fileCreatedAt: assetStub.trashedOffline.fileModifiedAt,
fileModifiedAt: assetStub.trashedOffline.fileModifiedAt,
isOffline: false,
originalFileName: 'path.jpg',
});
});
it('should not touch fileCreatedAt when un-trashing an asset previously marked as offline', async () => {
const mockAssetJob: ILibraryAssetJob = {
id: assetStub.external.id,
importPaths: ['/'],
exclusionPatterns: [],
};
assetMock.getById.mockResolvedValue(assetStub.trashedOffline);
storageMock.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats);
await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.updateAll).toHaveBeenCalledWith(
[assetStub.trashedOffline.id],
expect.not.objectContaining({
fileCreatedAt: expect.anything(),
}),
);
});
});
it('should update file when mtime has changed', async () => {
@@ -360,7 +379,6 @@ describe(LibraryService.name, () => {
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], {
fileModifiedAt: newMTime,
fileCreatedAt: newMTime,
isOffline: false,
originalFileName: 'photo.jpg',
deletedAt: null,

View File

@@ -511,7 +511,6 @@ export class LibraryService extends BaseService {
await this.assetRepository.updateAll([asset.id], {
isOffline: false,
deletedAt: null,
fileCreatedAt: mtime,
fileModifiedAt: mtime,
originalFileName: parse(asset.originalPath).base,
});

View File

@@ -62,7 +62,7 @@ async function bootstrap() {
app.use(app.get(ApiService).ssr(excludePaths));
const server = await (host ? app.listen(port, host) : app.listen(port));
server.requestTimeout = 30 * 60 * 1000;
server.requestTimeout = 24 * 60 * 60 * 1000;
logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${environment}] `);
}

6
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "immich-web",
"version": "1.125.2",
"version": "1.125.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-web",
"version": "1.125.2",
"version": "1.125.5",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8",
@@ -75,7 +75,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.125.2",
"version": "1.125.5",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"

View File

@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "1.125.2",
"version": "1.125.5",
"license": "GNU Affero General Public License version 3",
"scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3000",

View File

@@ -11,7 +11,11 @@
<section class="min-w-screen flex min-h-dvh items-center justify-center relative">
<div class="absolute -z-10 w-full h-full flex place-items-center place-content-center">
<img src={immichLogo} class="max-w-screen-md mx-auto h-full mb-2 antialiased -z-10" alt="Immich logo" />
<img
src={immichLogo}
class="max-w-screen-md mx-auto h-full mb-2 antialiased -z-10 overflow-hidden"
alt="Immich logo"
/>
<div
class="w-full h-[99%] absolute left-0 top-0 backdrop-blur-[200px] bg-transparent dark:bg-immich-dark-bg/20"
></div>

View File

@@ -15,7 +15,7 @@
</script>
<ul class="list-none ml-2">
{#each Object.entries(items) as [path, tree]}
{#each Object.entries(items).sort() as [path, tree]}
{@const value = normalizeTreePath(`${parent}/${path}`)}
{@const key = value + getColor(value)}
{#key key}

View File

@@ -24,7 +24,6 @@ class FoldersStore {
const uniquePaths = await getUniqueOriginalPaths();
this.uniquePaths.push(...uniquePaths);
this.uniquePaths.sort();
}
bustAssetCache() {

View File

@@ -44,7 +44,7 @@
let pathSegments = $derived(data.path ? data.path.split('/') : []);
let tree = $derived(buildTree(foldersStore.uniquePaths));
let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || '');
let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree));
let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree).sort());
const assetInteraction = new AssetInteraction();

View File

@@ -34,7 +34,7 @@ export const load = (async ({ params, url }) => {
return {
asset,
path,
currentFolders: Object.keys(tree || {}),
currentFolders: Object.keys(tree || {}).sort(),
pathAssets,
meta: {
title: $t('folders'),