Compare commits

...

9 Commits

Author SHA1 Message Date
Alex Tran c0de9943e0 fix(server): cannot fetch album with deleted assets 2025-01-25 16:41:32 -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
13 changed files with 332 additions and 92 deletions
+1
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",
+69 -74
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,
),
],
),
),
);
}
@@ -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,
),
);
},
),
);
},
),
),
),
);
},
),
);
}
}
+11 -11
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")
+1 -1
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) => {
@@ -126,6 +126,7 @@ export class AlbumRepository implements IAlbumRepository {
.select((eb) => eb.fn.max('assets.fileCreatedAt').as('endDate'))
.select((eb) => eb.fn.count('assets.id').as('assetCount'))
.where('albums.id', 'in', ids)
.where('assets.deletedAt', 'is', null)
.groupBy('albums.id')
.execute();
@@ -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', () => {
+1 -1
View File
@@ -292,7 +292,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);
});
+28
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', () => {
+8 -1
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 })
+20 -2
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,
-1
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,
});
+1 -1
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}] `);
}