? children,
}) : super(
@@ -1429,7 +1429,7 @@ class VideoViewerRouteArgs {
this.onPaused,
this.placeholder,
this.showControls = true,
- this.hideControlsTimer = const Duration(seconds: 5),
+ this.hideControlsTimer = const Duration(milliseconds: 1500),
this.showDownloadingIndicator = true,
});
diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart
index f2183602c1..3c3c4df82f 100644
--- a/mobile/lib/shared/models/asset.dart
+++ b/mobile/lib/shared/models/asset.dart
@@ -175,6 +175,11 @@ class Asset {
int? stackCount;
+ /// Aspect ratio of the asset
+ @ignore
+ double? get aspectRatio =>
+ width == null || height == null ? 0 : width! / height!;
+
/// `true` if this [Asset] is present on the device
@ignore
bool get isLocal => localId != null;
diff --git a/mobile/lib/shared/models/logger_message.model.dart b/mobile/lib/shared/models/logger_message.model.dart
index cb1d45a580..f657257eab 100644
--- a/mobile/lib/shared/models/logger_message.model.dart
+++ b/mobile/lib/shared/models/logger_message.model.dart
@@ -9,6 +9,7 @@ part 'logger_message.model.g.dart';
class LoggerMessage {
Id id = Isar.autoIncrement;
String message;
+ String? details;
@Enumerated(EnumType.ordinal)
LogLevel level = LogLevel.INFO;
DateTime createdAt;
@@ -17,6 +18,7 @@ class LoggerMessage {
LoggerMessage({
required this.message,
+ required this.details,
required this.level,
required this.createdAt,
required this.context1,
diff --git a/mobile/lib/shared/models/logger_message.model.g.dart b/mobile/lib/shared/models/logger_message.model.g.dart
index a6b960eece..76c823704c 100644
--- a/mobile/lib/shared/models/logger_message.model.g.dart
+++ b/mobile/lib/shared/models/logger_message.model.g.dart
@@ -32,14 +32,19 @@ const LoggerMessageSchema = CollectionSchema(
name: r'createdAt',
type: IsarType.dateTime,
),
- r'level': PropertySchema(
+ r'details': PropertySchema(
id: 3,
+ name: r'details',
+ type: IsarType.string,
+ ),
+ r'level': PropertySchema(
+ id: 4,
name: r'level',
type: IsarType.byte,
enumMap: _LoggerMessagelevelEnumValueMap,
),
r'message': PropertySchema(
- id: 4,
+ id: 5,
name: r'message',
type: IsarType.string,
)
@@ -76,6 +81,12 @@ int _loggerMessageEstimateSize(
bytesCount += 3 + value.length * 3;
}
}
+ {
+ final value = object.details;
+ if (value != null) {
+ bytesCount += 3 + value.length * 3;
+ }
+ }
bytesCount += 3 + object.message.length * 3;
return bytesCount;
}
@@ -89,8 +100,9 @@ void _loggerMessageSerialize(
writer.writeString(offsets[0], object.context1);
writer.writeString(offsets[1], object.context2);
writer.writeDateTime(offsets[2], object.createdAt);
- writer.writeByte(offsets[3], object.level.index);
- writer.writeString(offsets[4], object.message);
+ writer.writeString(offsets[3], object.details);
+ writer.writeByte(offsets[4], object.level.index);
+ writer.writeString(offsets[5], object.message);
}
LoggerMessage _loggerMessageDeserialize(
@@ -103,9 +115,10 @@ LoggerMessage _loggerMessageDeserialize(
context1: reader.readStringOrNull(offsets[0]),
context2: reader.readStringOrNull(offsets[1]),
createdAt: reader.readDateTime(offsets[2]),
- level: _LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offsets[3])] ??
+ details: reader.readStringOrNull(offsets[3]),
+ level: _LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offsets[4])] ??
LogLevel.ALL,
- message: reader.readString(offsets[4]),
+ message: reader.readString(offsets[5]),
);
object.id = id;
return object;
@@ -125,9 +138,11 @@ P _loggerMessageDeserializeProp(
case 2:
return (reader.readDateTime(offset)) as P;
case 3:
+ return (reader.readStringOrNull(offset)) as P;
+ case 4:
return (_LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offset)] ??
LogLevel.ALL) as P;
- case 4:
+ case 5:
return (reader.readString(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
@@ -619,6 +634,160 @@ extension LoggerMessageQueryFilter
});
}
+ QueryBuilder
+ detailsIsNull() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(const FilterCondition.isNull(
+ property: r'details',
+ ));
+ });
+ }
+
+ QueryBuilder
+ detailsIsNotNull() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(const FilterCondition.isNotNull(
+ property: r'details',
+ ));
+ });
+ }
+
+ QueryBuilder
+ detailsEqualTo(
+ String? value, {
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.equalTo(
+ property: r'details',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder
+ detailsGreaterThan(
+ String? value, {
+ bool include = false,
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.greaterThan(
+ include: include,
+ property: r'details',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder
+ detailsLessThan(
+ String? value, {
+ bool include = false,
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.lessThan(
+ include: include,
+ property: r'details',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder
+ detailsBetween(
+ String? lower,
+ String? upper, {
+ bool includeLower = true,
+ bool includeUpper = true,
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.between(
+ property: r'details',
+ lower: lower,
+ includeLower: includeLower,
+ upper: upper,
+ includeUpper: includeUpper,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder
+ detailsStartsWith(
+ String value, {
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.startsWith(
+ property: r'details',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder
+ detailsEndsWith(
+ String value, {
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.endsWith(
+ property: r'details',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder
+ detailsContains(String value, {bool caseSensitive = true}) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.contains(
+ property: r'details',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder
+ detailsMatches(String pattern, {bool caseSensitive = true}) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.matches(
+ property: r'details',
+ wildcard: pattern,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder
+ detailsIsEmpty() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.equalTo(
+ property: r'details',
+ value: '',
+ ));
+ });
+ }
+
+ QueryBuilder
+ detailsIsNotEmpty() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.greaterThan(
+ property: r'details',
+ value: '',
+ ));
+ });
+ }
+
QueryBuilder idEqualTo(
Id value) {
return QueryBuilder.apply(this, (query) {
@@ -913,6 +1082,18 @@ extension LoggerMessageQuerySortBy
});
}
+ QueryBuilder sortByDetails() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'details', Sort.asc);
+ });
+ }
+
+ QueryBuilder sortByDetailsDesc() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'details', Sort.desc);
+ });
+ }
+
QueryBuilder sortByLevel() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'level', Sort.asc);
@@ -979,6 +1160,18 @@ extension LoggerMessageQuerySortThenBy
});
}
+ QueryBuilder thenByDetails() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'details', Sort.asc);
+ });
+ }
+
+ QueryBuilder thenByDetailsDesc() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'details', Sort.desc);
+ });
+ }
+
QueryBuilder thenById() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.asc);
@@ -1038,6 +1231,13 @@ extension LoggerMessageQueryWhereDistinct
});
}
+ QueryBuilder distinctByDetails(
+ {bool caseSensitive = true}) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addDistinctBy(r'details', caseSensitive: caseSensitive);
+ });
+ }
+
QueryBuilder distinctByLevel() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'level');
@@ -1078,6 +1278,12 @@ extension LoggerMessageQueryProperty
});
}
+ QueryBuilder detailsProperty() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addPropertyName(r'details');
+ });
+ }
+
QueryBuilder levelProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'level');
diff --git a/mobile/lib/shared/services/asset.service.dart b/mobile/lib/shared/services/asset.service.dart
index 64a0f28ab7..3086ab9246 100644
--- a/mobile/lib/shared/services/asset.service.dart
+++ b/mobile/lib/shared/services/asset.service.dart
@@ -90,7 +90,7 @@ class AssetService {
return allAssets;
} catch (error, stack) {
log.severe(
- 'Error while getting remote assets: ${error.toString()}',
+ 'Error while getting remote assets',
error,
stack,
);
@@ -117,7 +117,7 @@ class AssetService {
);
return true;
} catch (error, stack) {
- log.severe("Error deleteAssets ${error.toString()}", error, stack);
+ log.severe("Error while deleting assets", error, stack);
}
return false;
}
diff --git a/mobile/lib/shared/services/immich_logger.service.dart b/mobile/lib/shared/services/immich_logger.service.dart
index b66177e570..967ab2d5f2 100644
--- a/mobile/lib/shared/services/immich_logger.service.dart
+++ b/mobile/lib/shared/services/immich_logger.service.dart
@@ -12,7 +12,7 @@ import 'package:share_plus/share_plus.dart';
/// [ImmichLogger] is a custom logger that is built on top of the [logging] package.
/// The logs are written to the database and onto console, using `debugPrint` method.
///
-/// The logs are deleted when exceeding the `maxLogEntries` (default 200) property
+/// The logs are deleted when exceeding the `maxLogEntries` (default 500) property
/// in the class.
///
/// Logs can be shared by calling the `shareLogs` method, which will open a share dialog
@@ -58,6 +58,7 @@ class ImmichLogger {
debugPrint('[${record.level.name}] [${record.time}] ${record.message}');
final lm = LoggerMessage(
message: record.message,
+ details: record.error?.toString(),
level: record.level.toLogLevel(),
createdAt: record.time,
context1: record.loggerName,
diff --git a/mobile/lib/shared/services/share.service.dart b/mobile/lib/shared/services/share.service.dart
index d7daa51b86..be7c0c168d 100644
--- a/mobile/lib/shared/services/share.service.dart
+++ b/mobile/lib/shared/services/share.service.dart
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:logging/logging.dart';
@@ -41,7 +42,8 @@ class ShareService {
if (res.statusCode != 200) {
_log.severe(
- "Asset download failed with status - ${res.statusCode} and response - ${res.body}",
+ "Asset download for ${asset.fileName} failed",
+ res.toLoggerString(),
);
continue;
}
@@ -68,7 +70,7 @@ class ShareService {
);
return true;
} catch (error) {
- _log.severe("Share failed with error $error");
+ _log.severe("Share failed", error);
}
return false;
}
diff --git a/mobile/lib/shared/services/sync.service.dart b/mobile/lib/shared/services/sync.service.dart
index d039b34094..a441091d37 100644
--- a/mobile/lib/shared/services/sync.service.dart
+++ b/mobile/lib/shared/services/sync.service.dart
@@ -140,7 +140,7 @@ class SyncService {
try {
await _db.writeTxn(() => a.put(_db));
} on IsarError catch (e) {
- _log.severe("Failed to put new asset into db: $e");
+ _log.severe("Failed to put new asset into db", e);
return false;
}
return true;
@@ -173,7 +173,7 @@ class SyncService {
}
return false;
} on IsarError catch (e) {
- _log.severe("Failed to sync remote assets to db: $e");
+ _log.severe("Failed to sync remote assets to db", e);
}
return null;
}
@@ -232,7 +232,7 @@ class SyncService {
await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete));
await upsertAssetsWithExif(toAdd + toUpdate);
} on IsarError catch (e) {
- _log.severe("Failed to sync remote assets to db: $e");
+ _log.severe("Failed to sync remote assets to db", e);
}
await _updateUserAssetsETag(user, now);
return true;
@@ -364,7 +364,7 @@ class SyncService {
});
_log.info("Synced changes of remote album ${album.name} to DB");
} on IsarError catch (e) {
- _log.severe("Failed to sync remote album to database $e");
+ _log.severe("Failed to sync remote album to database", e);
}
if (album.shared || dto.shared) {
@@ -441,7 +441,7 @@ class SyncService {
assert(ok);
_log.info("Removed local album $album from DB");
} catch (e) {
- _log.severe("Failed to remove local album $album from DB");
+ _log.severe("Failed to remove local album $album from DB", e);
}
}
@@ -577,7 +577,7 @@ class SyncService {
});
_log.info("Synced changes of local album ${ape.name} to DB");
} on IsarError catch (e) {
- _log.severe("Failed to update synced album ${ape.name} in DB: $e");
+ _log.severe("Failed to update synced album ${ape.name} in DB", e);
}
return true;
@@ -623,7 +623,7 @@ class SyncService {
});
_log.info("Fast synced local album ${ape.name} to DB");
} on IsarError catch (e) {
- _log.severe("Failed to fast sync local album ${ape.name} to DB: $e");
+ _log.severe("Failed to fast sync local album ${ape.name} to DB", e);
return false;
}
@@ -656,7 +656,7 @@ class SyncService {
await _db.writeTxn(() => _db.albums.store(a));
_log.info("Added a new local album to DB: ${ape.name}");
} on IsarError catch (e) {
- _log.severe("Failed to add new local album ${ape.name} to DB: $e");
+ _log.severe("Failed to add new local album ${ape.name} to DB", e);
}
}
@@ -706,9 +706,7 @@ class SyncService {
});
_log.info("Upserted ${assets.length} assets into the DB");
} on IsarError catch (e) {
- _log.severe(
- "Failed to upsert ${assets.length} assets into the DB: ${e.toString()}",
- );
+ _log.severe("Failed to upsert ${assets.length} assets into the DB", e);
// give details on the errors
assets.sort(Asset.compareByOwnerChecksum);
final inDb = await _db.assets.getAllByOwnerIdChecksum(
@@ -776,7 +774,7 @@ class SyncService {
});
return true;
} catch (e) {
- _log.severe("Failed to remove all local albums and assets: $e");
+ _log.severe("Failed to remove all local albums and assets", e);
return false;
}
}
diff --git a/mobile/lib/shared/services/user.service.dart b/mobile/lib/shared/services/user.service.dart
index 4d398c3a88..ae65ed31db 100644
--- a/mobile/lib/shared/services/user.service.dart
+++ b/mobile/lib/shared/services/user.service.dart
@@ -42,7 +42,7 @@ class UserService {
final dto = await _apiService.userApi.getAllUsers(isAll);
return dto?.map(User.fromUserDto).toList();
} catch (e) {
- _log.warning("Failed get all users:\n$e");
+ _log.warning("Failed get all users", e);
return null;
}
}
@@ -65,7 +65,7 @@ class UserService {
),
);
} catch (e) {
- _log.warning("Failed to upload profile image:\n$e");
+ _log.warning("Failed to upload profile image", e);
return null;
}
}
diff --git a/mobile/lib/shared/ui/delayed_loading_indicator.dart b/mobile/lib/shared/ui/delayed_loading_indicator.dart
new file mode 100644
index 0000000000..b4d9f4c806
--- /dev/null
+++ b/mobile/lib/shared/ui/delayed_loading_indicator.dart
@@ -0,0 +1,40 @@
+import 'package:flutter/material.dart';
+import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
+
+class DelayedLoadingIndicator extends StatelessWidget {
+ /// The delay to avoid showing the loading indicator
+ final Duration delay;
+
+ /// Defaults to using the [ImmichLoadingIndicator]
+ final Widget? child;
+
+ /// An optional fade in duration to animate the loading
+ final Duration? fadeInDuration;
+
+ const DelayedLoadingIndicator({
+ super.key,
+ this.delay = const Duration(seconds: 3),
+ this.child,
+ this.fadeInDuration,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return AnimatedSwitcher(
+ duration: fadeInDuration ?? Duration.zero,
+ child: FutureBuilder(
+ future: Future.delayed(delay),
+ builder: (context, snapshot) {
+ if (snapshot.connectionState == ConnectionState.done) {
+ return child ??
+ const ImmichLoadingIndicator(
+ key: ValueKey('loading'),
+ );
+ }
+
+ return Container(key: const ValueKey('hiding'));
+ },
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/shared/views/app_log_detail_page.dart b/mobile/lib/shared/views/app_log_detail_page.dart
index 126f46c8ff..6b99d7f0af 100644
--- a/mobile/lib/shared/views/app_log_detail_page.dart
+++ b/mobile/lib/shared/views/app_log_detail_page.dart
@@ -15,7 +15,7 @@ class AppLogDetailPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
var isDarkTheme = context.isDarkTheme;
- buildStackMessage(String stackTrace) {
+ buildTextWithCopyButton(String header, String text) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
@@ -28,7 +28,7 @@ class AppLogDetailPage extends HookConsumerWidget {
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
- "STACK TRACES",
+ header,
style: TextStyle(
fontSize: 12.0,
color: context.primaryColor,
@@ -38,8 +38,7 @@ class AppLogDetailPage extends HookConsumerWidget {
),
IconButton(
onPressed: () {
- Clipboard.setData(ClipboardData(text: stackTrace))
- .then((_) {
+ Clipboard.setData(ClipboardData(text: text)).then((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
@@ -68,73 +67,7 @@ class AppLogDetailPage extends HookConsumerWidget {
child: Padding(
padding: const EdgeInsets.all(8.0),
child: SelectableText(
- stackTrace,
- style: const TextStyle(
- fontSize: 12.0,
- fontWeight: FontWeight.bold,
- fontFamily: "Inconsolata",
- ),
- ),
- ),
- ),
- ],
- ),
- );
- }
-
- buildLogMessage(String message) {
- return Padding(
- padding: const EdgeInsets.all(8.0),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- Padding(
- padding: const EdgeInsets.only(bottom: 8.0),
- child: Text(
- "MESSAGE",
- style: TextStyle(
- fontSize: 12.0,
- color: context.primaryColor,
- fontWeight: FontWeight.bold,
- ),
- ),
- ),
- IconButton(
- onPressed: () {
- Clipboard.setData(ClipboardData(text: message)).then((_) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(
- content: Text(
- "Copied to clipboard",
- style: context.textTheme.bodyLarge?.copyWith(
- color: context.primaryColor,
- ),
- ),
- ),
- );
- });
- },
- icon: Icon(
- Icons.copy,
- size: 16.0,
- color: context.primaryColor,
- ),
- ),
- ],
- ),
- Container(
- decoration: BoxDecoration(
- color: isDarkTheme ? Colors.grey[900] : Colors.grey[200],
- borderRadius: BorderRadius.circular(15.0),
- ),
- child: Padding(
- padding: const EdgeInsets.all(8.0),
- child: SelectableText(
- message,
+ text,
style: const TextStyle(
fontSize: 12.0,
fontWeight: FontWeight.bold,
@@ -194,11 +127,16 @@ class AppLogDetailPage extends HookConsumerWidget {
body: SafeArea(
child: ListView(
children: [
- buildLogMessage(logMessage.message),
+ buildTextWithCopyButton("MESSAGE", logMessage.message),
+ if (logMessage.details != null)
+ buildTextWithCopyButton("DETAILS", logMessage.details.toString()),
if (logMessage.context1 != null)
buildLogContext1(logMessage.context1.toString()),
if (logMessage.context2 != null)
- buildStackMessage(logMessage.context2.toString()),
+ buildTextWithCopyButton(
+ "STACK TRACE",
+ logMessage.context2.toString(),
+ ),
],
),
),
diff --git a/mobile/lib/shared/views/app_log_page.dart b/mobile/lib/shared/views/app_log_page.dart
index a0c4553f98..993b25c7cf 100644
--- a/mobile/lib/shared/views/app_log_page.dart
+++ b/mobile/lib/shared/views/app_log_page.dart
@@ -69,9 +69,9 @@ class AppLogPage extends HookConsumerWidget {
return Scaffold(
appBar: AppBar(
- title: Text(
- "Logs - ${logMessages.value.length}",
- style: const TextStyle(
+ title: const Text(
+ "Logs",
+ style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16.0,
),
@@ -135,29 +135,15 @@ class AppLogPage extends HookConsumerWidget {
dense: true,
tileColor: getTileColor(logMessage.level),
minLeadingWidth: 10,
- title: Text.rich(
- TextSpan(
- children: [
- TextSpan(
- text: "#$index ",
- style: TextStyle(
- color: isDarkTheme ? Colors.white70 : Colors.grey[600],
- fontSize: 14.0,
- fontWeight: FontWeight.bold,
- ),
- ),
- TextSpan(
- text: truncateLogMessage(logMessage.message, 4),
- style: const TextStyle(
- fontSize: 14.0,
- ),
- ),
- ],
+ title: Text(
+ truncateLogMessage(logMessage.message, 4),
+ style: const TextStyle(
+ fontSize: 14.0,
+ fontFamily: "Inconsolata",
),
- style: const TextStyle(fontSize: 14.0, fontFamily: "Inconsolata"),
),
subtitle: Text(
- "[${logMessage.context1}] Logged on ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)}",
+ "at ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)} in ${logMessage.context1}",
style: TextStyle(
fontSize: 12.0,
color: Colors.grey[600],
diff --git a/mobile/lib/shared/views/immich_loading_overlay.dart b/mobile/lib/shared/views/immich_loading_overlay.dart
index 85f0123ed9..c600d2a724 100644
--- a/mobile/lib/shared/views/immich_loading_overlay.dart
+++ b/mobile/lib/shared/views/immich_loading_overlay.dart
@@ -1,7 +1,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/shared/ui/immich_loading_indicator.dart';
+import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
final _loadingEntry = OverlayEntry(
builder: (context) => SizedBox.square(
@@ -9,7 +9,12 @@ final _loadingEntry = OverlayEntry(
child: DecoratedBox(
decoration:
BoxDecoration(color: context.colorScheme.surface.withAlpha(200)),
- child: const Center(child: ImmichLoadingIndicator()),
+ child: const Center(
+ child: DelayedLoadingIndicator(
+ delay: Duration(seconds: 1),
+ fadeInDuration: Duration(milliseconds: 400),
+ ),
+ ),
),
),
);
@@ -27,19 +32,19 @@ class _LoadingOverlay extends Hook> {
class _LoadingOverlayState
extends HookState, _LoadingOverlay> {
- late final _isProcessing = ValueNotifier(false)..addListener(_listener);
- OverlayEntry? overlayEntry;
+ late final _isLoading = ValueNotifier(false)..addListener(_listener);
+ OverlayEntry? _loadingOverlay;
void _listener() {
setState(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
- if (_isProcessing.value) {
- overlayEntry?.remove();
- overlayEntry = _loadingEntry;
+ if (_isLoading.value) {
+ _loadingOverlay?.remove();
+ _loadingOverlay = _loadingEntry;
Overlay.of(context).insert(_loadingEntry);
} else {
- overlayEntry?.remove();
- overlayEntry = null;
+ _loadingOverlay?.remove();
+ _loadingOverlay = null;
}
});
});
@@ -47,17 +52,17 @@ class _LoadingOverlayState
@override
ValueNotifier build(BuildContext context) {
- return _isProcessing;
+ return _isLoading;
}
@override
void dispose() {
- _isProcessing.dispose();
+ _isLoading.dispose();
super.dispose();
}
@override
- Object? get debugValue => _isProcessing.value;
+ Object? get debugValue => _isLoading.value;
@override
String get debugLabel => 'useProcessingOverlay<>';
diff --git a/mobile/lib/shared/views/splash_screen.dart b/mobile/lib/shared/views/splash_screen.dart
index 8dddb60aaa..3c0d65bde9 100644
--- a/mobile/lib/shared/views/splash_screen.dart
+++ b/mobile/lib/shared/views/splash_screen.dart
@@ -35,10 +35,10 @@ class SplashScreenPage extends HookConsumerWidget {
deviceIsOffline = true;
log.fine("Device seems to be offline upon launch");
} else {
- log.severe(e);
+ log.severe("Failed to resolve endpoint", e);
}
} catch (e) {
- log.severe(e);
+ log.severe("Failed to resolve endpoint", e);
}
try {
@@ -53,7 +53,7 @@ class SplashScreenPage extends HookConsumerWidget {
ref.read(authenticationProvider.notifier).logout();
log.severe(
- 'Cannot set success login info: $error',
+ 'Cannot set success login info',
error,
stackTrace,
);
diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES
index 0679a1749d..ea413b4870 100644
--- a/mobile/openapi/.openapi-generator/FILES
+++ b/mobile/openapi/.openapi-generator/FILES
@@ -108,6 +108,7 @@ doc/PersonResponseDto.md
doc/PersonStatisticsResponseDto.md
doc/PersonUpdateDto.md
doc/PersonWithFacesResponseDto.md
+doc/PlacesResponseDto.md
doc/QueueStatusDto.md
doc/ReactionLevel.md
doc/ReactionType.md
@@ -308,6 +309,7 @@ lib/model/person_response_dto.dart
lib/model/person_statistics_response_dto.dart
lib/model/person_update_dto.dart
lib/model/person_with_faces_response_dto.dart
+lib/model/places_response_dto.dart
lib/model/queue_status_dto.dart
lib/model/reaction_level.dart
lib/model/reaction_type.dart
@@ -485,6 +487,7 @@ test/person_response_dto_test.dart
test/person_statistics_response_dto_test.dart
test/person_update_dto_test.dart
test/person_with_faces_response_dto_test.dart
+test/places_response_dto_test.dart
test/queue_status_dto_test.dart
test/reaction_level_test.dart
test/reaction_type_test.dart
diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index a2b155bcd9..41e65ee8b3 100644
--- a/mobile/openapi/README.md
+++ b/mobile/openapi/README.md
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
-- API version: 1.95.0
+- API version: 1.95.1
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements
@@ -166,6 +166,7 @@ Class | Method | HTTP request | Description
*SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search |
*SearchApi* | [**searchMetadata**](doc//SearchApi.md#searchmetadata) | **POST** /search/metadata |
*SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person |
+*SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places |
*SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **POST** /search/smart |
*ServerInfoApi* | [**getServerConfig**](doc//ServerInfoApi.md#getserverconfig) | **GET** /server-info/config |
*ServerInfoApi* | [**getServerFeatures**](doc//ServerInfoApi.md#getserverfeatures) | **GET** /server-info/features |
@@ -306,6 +307,7 @@ Class | Method | HTTP request | Description
- [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md)
- [PersonUpdateDto](doc//PersonUpdateDto.md)
- [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md)
+ - [PlacesResponseDto](doc//PlacesResponseDto.md)
- [QueueStatusDto](doc//QueueStatusDto.md)
- [ReactionLevel](doc//ReactionLevel.md)
- [ReactionType](doc//ReactionType.md)
diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md
index 5691965bc7..93b758a595 100644
--- a/mobile/openapi/doc/AssetApi.md
+++ b/mobile/openapi/doc/AssetApi.md
@@ -1153,7 +1153,7 @@ Name | Type | Description | Notes
**updatedAfter** | **DateTime**| | [optional]
**updatedBefore** | **DateTime**| | [optional]
**webpPath** | **String**| | [optional]
- **withArchived** | **bool**| | [optional]
+ **withArchived** | **bool**| | [optional] [default to false]
**withDeleted** | **bool**| | [optional]
**withExif** | **bool**| | [optional]
**withPeople** | **bool**| | [optional]
diff --git a/mobile/openapi/doc/MetadataSearchDto.md b/mobile/openapi/doc/MetadataSearchDto.md
index bfbf81749e..d1d098fb0e 100644
--- a/mobile/openapi/doc/MetadataSearchDto.md
+++ b/mobile/openapi/doc/MetadataSearchDto.md
@@ -46,7 +46,7 @@ Name | Type | Description | Notes
**updatedAfter** | [**DateTime**](DateTime.md) | | [optional]
**updatedBefore** | [**DateTime**](DateTime.md) | | [optional]
**webpPath** | **String** | | [optional]
-**withArchived** | **bool** | | [optional]
+**withArchived** | **bool** | | [optional] [default to false]
**withDeleted** | **bool** | | [optional]
**withExif** | **bool** | | [optional]
**withPeople** | **bool** | | [optional]
diff --git a/mobile/openapi/doc/PeopleResponseDto.md b/mobile/openapi/doc/PeopleResponseDto.md
index 2f87f19993..78f9b2207c 100644
--- a/mobile/openapi/doc/PeopleResponseDto.md
+++ b/mobile/openapi/doc/PeopleResponseDto.md
@@ -8,6 +8,7 @@ import 'package:openapi/api.dart';
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
+**hidden** | **int** | |
**people** | [**List**](PersonResponseDto.md) | | [default to const []]
**total** | **int** | |
diff --git a/mobile/openapi/doc/PlacesResponseDto.md b/mobile/openapi/doc/PlacesResponseDto.md
new file mode 100644
index 0000000000..a4bf36493c
--- /dev/null
+++ b/mobile/openapi/doc/PlacesResponseDto.md
@@ -0,0 +1,19 @@
+# openapi.model.PlacesResponseDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**admin1name** | **String** | | [optional]
+**admin2name** | **String** | | [optional]
+**latitude** | **num** | |
+**longitude** | **num** | |
+**name** | **String** | |
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+
diff --git a/mobile/openapi/doc/SearchApi.md b/mobile/openapi/doc/SearchApi.md
index f975e94484..f63488222b 100644
--- a/mobile/openapi/doc/SearchApi.md
+++ b/mobile/openapi/doc/SearchApi.md
@@ -14,6 +14,7 @@ Method | HTTP request | Description
[**search**](SearchApi.md#search) | **GET** /search |
[**searchMetadata**](SearchApi.md#searchmetadata) | **POST** /search/metadata |
[**searchPerson**](SearchApi.md#searchperson) | **GET** /search/person |
+[**searchPlaces**](SearchApi.md#searchplaces) | **GET** /search/places |
[**searchSmart**](SearchApi.md#searchsmart) | **POST** /search/smart |
@@ -316,6 +317,61 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+# **searchPlaces**
+> List searchPlaces(name)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure API key authorization: cookie
+//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure API key authorization: api_key
+//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = SearchApi();
+final name = name_example; // String |
+
+try {
+ final result = api_instance.searchPlaces(name);
+ print(result);
+} catch (e) {
+ print('Exception when calling SearchApi->searchPlaces: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description | Notes
+------------- | ------------- | ------------- | -------------
+ **name** | **String**| |
+
+### Return type
+
+[**List**](PlacesResponseDto.md)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
# **searchSmart**
> SearchResponseDto searchSmart(smartSearchDto)
diff --git a/mobile/openapi/doc/SmartSearchDto.md b/mobile/openapi/doc/SmartSearchDto.md
index 5d34143df2..d4ec1a70f6 100644
--- a/mobile/openapi/doc/SmartSearchDto.md
+++ b/mobile/openapi/doc/SmartSearchDto.md
@@ -18,6 +18,7 @@ Name | Type | Description | Notes
**isExternal** | **bool** | | [optional]
**isFavorite** | **bool** | | [optional]
**isMotion** | **bool** | | [optional]
+**isNotInAlbum** | **bool** | | [optional]
**isOffline** | **bool** | | [optional]
**isReadOnly** | **bool** | | [optional]
**isVisible** | **bool** | | [optional]
@@ -36,7 +37,7 @@ Name | Type | Description | Notes
**type** | [**AssetTypeEnum**](AssetTypeEnum.md) | | [optional]
**updatedAfter** | [**DateTime**](DateTime.md) | | [optional]
**updatedBefore** | [**DateTime**](DateTime.md) | | [optional]
-**withArchived** | **bool** | | [optional]
+**withArchived** | **bool** | | [optional] [default to false]
**withDeleted** | **bool** | | [optional]
**withExif** | **bool** | | [optional]
diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index 72a6567648..56bd907e0a 100644
--- a/mobile/openapi/lib/api.dart
+++ b/mobile/openapi/lib/api.dart
@@ -142,6 +142,7 @@ part 'model/person_response_dto.dart';
part 'model/person_statistics_response_dto.dart';
part 'model/person_update_dto.dart';
part 'model/person_with_faces_response_dto.dart';
+part 'model/places_response_dto.dart';
part 'model/queue_status_dto.dart';
part 'model/reaction_level.dart';
part 'model/reaction_type.dart';
diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart
index 062ca4a50b..3a0bc56bb6 100644
--- a/mobile/openapi/lib/api/search_api.dart
+++ b/mobile/openapi/lib/api/search_api.dart
@@ -360,6 +360,58 @@ class SearchApi {
return null;
}
+ /// Performs an HTTP 'GET /search/places' operation and returns the [Response].
+ /// Parameters:
+ ///
+ /// * [String] name (required):
+ Future searchPlacesWithHttpInfo(String name,) async {
+ // ignore: prefer_const_declarations
+ final path = r'/search/places';
+
+ // ignore: prefer_final_locals
+ Object? postBody;
+
+ final queryParams = [];
+ final headerParams = {};
+ final formParams = {};
+
+ queryParams.addAll(_queryParams('', 'name', name));
+
+ const contentTypes = [];
+
+
+ return apiClient.invokeAPI(
+ path,
+ 'GET',
+ queryParams,
+ postBody,
+ headerParams,
+ formParams,
+ contentTypes.isEmpty ? null : contentTypes.first,
+ );
+ }
+
+ /// Parameters:
+ ///
+ /// * [String] name (required):
+ Future?> searchPlaces(String name,) async {
+ final response = await searchPlacesWithHttpInfo(name,);
+ if (response.statusCode >= HttpStatus.badRequest) {
+ throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+ }
+ // When a remote server returns no body with a status of 204, we shall not decode it.
+ // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+ // FormatException when trying to decode an empty string.
+ if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+ final responseBody = await _decodeBodyBytes(response);
+ return (await apiClient.deserializeAsync(responseBody, 'List') as List)
+ .cast()
+ .toList(growable: false);
+
+ }
+ return null;
+ }
+
/// Performs an HTTP 'POST /search/smart' operation and returns the [Response].
/// Parameters:
///
diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart
index 2df5e67119..24cffb7cff 100644
--- a/mobile/openapi/lib/api_client.dart
+++ b/mobile/openapi/lib/api_client.dart
@@ -366,6 +366,8 @@ class ApiClient {
return PersonUpdateDto.fromJson(value);
case 'PersonWithFacesResponseDto':
return PersonWithFacesResponseDto.fromJson(value);
+ case 'PlacesResponseDto':
+ return PlacesResponseDto.fromJson(value);
case 'QueueStatusDto':
return QueueStatusDto.fromJson(value);
case 'ReactionLevel':
diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart
index 47756cd527..86a2856e66 100644
--- a/mobile/openapi/lib/model/metadata_search_dto.dart
+++ b/mobile/openapi/lib/model/metadata_search_dto.dart
@@ -51,7 +51,7 @@ class MetadataSearchDto {
this.updatedAfter,
this.updatedBefore,
this.webpPath,
- this.withArchived,
+ this.withArchived = false,
this.withDeleted,
this.withExif,
this.withPeople,
@@ -356,13 +356,7 @@ class MetadataSearchDto {
///
String? webpPath;
- ///
- /// Please note: This property should have been non-nullable! Since the specification file
- /// does not include a default value (using the "default:" property), however, the generated
- /// source code must fall back to having a nullable type.
- /// Consider adding a "default:" property in the specification file to hide this note.
- ///
- bool? withArchived;
+ bool withArchived;
///
/// Please note: This property should have been non-nullable! Since the specification file
@@ -483,7 +477,7 @@ class MetadataSearchDto {
(updatedAfter == null ? 0 : updatedAfter!.hashCode) +
(updatedBefore == null ? 0 : updatedBefore!.hashCode) +
(webpPath == null ? 0 : webpPath!.hashCode) +
- (withArchived == null ? 0 : withArchived!.hashCode) +
+ (withArchived.hashCode) +
(withDeleted == null ? 0 : withDeleted!.hashCode) +
(withExif == null ? 0 : withExif!.hashCode) +
(withPeople == null ? 0 : withPeople!.hashCode) +
@@ -680,11 +674,7 @@ class MetadataSearchDto {
} else {
// json[r'webpPath'] = null;
}
- if (this.withArchived != null) {
json[r'withArchived'] = this.withArchived;
- } else {
- // json[r'withArchived'] = null;
- }
if (this.withDeleted != null) {
json[r'withDeleted'] = this.withDeleted;
} else {
@@ -756,7 +746,7 @@ class MetadataSearchDto {
updatedAfter: mapDateTime(json, r'updatedAfter', r''),
updatedBefore: mapDateTime(json, r'updatedBefore', r''),
webpPath: mapValueOfType(json, r'webpPath'),
- withArchived: mapValueOfType(json, r'withArchived'),
+ withArchived: mapValueOfType(json, r'withArchived') ?? false,
withDeleted: mapValueOfType(json, r'withDeleted'),
withExif: mapValueOfType(json, r'withExif'),
withPeople: mapValueOfType(json, r'withPeople'),
diff --git a/mobile/openapi/lib/model/people_response_dto.dart b/mobile/openapi/lib/model/people_response_dto.dart
index 80abedfc72..02a82cadf1 100644
--- a/mobile/openapi/lib/model/people_response_dto.dart
+++ b/mobile/openapi/lib/model/people_response_dto.dart
@@ -13,30 +13,36 @@ part of openapi.api;
class PeopleResponseDto {
/// Returns a new [PeopleResponseDto] instance.
PeopleResponseDto({
+ required this.hidden,
this.people = const [],
required this.total,
});
+ int hidden;
+
List people;
int total;
@override
bool operator ==(Object other) => identical(this, other) || other is PeopleResponseDto &&
+ other.hidden == hidden &&
_deepEquality.equals(other.people, people) &&
other.total == total;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
+ (hidden.hashCode) +
(people.hashCode) +
(total.hashCode);
@override
- String toString() => 'PeopleResponseDto[people=$people, total=$total]';
+ String toString() => 'PeopleResponseDto[hidden=$hidden, people=$people, total=$total]';
Map toJson() {
final json = {};
+ json[r'hidden'] = this.hidden;
json[r'people'] = this.people;
json[r'total'] = this.total;
return json;
@@ -50,6 +56,7 @@ class PeopleResponseDto {
final json = value.cast();
return PeopleResponseDto(
+ hidden: mapValueOfType(json, r'hidden')!,
people: PersonResponseDto.listFromJson(json[r'people']),
total: mapValueOfType(json, r'total')!,
);
@@ -99,6 +106,7 @@ class PeopleResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = {
+ 'hidden',
'people',
'total',
};
diff --git a/mobile/openapi/lib/model/places_response_dto.dart b/mobile/openapi/lib/model/places_response_dto.dart
new file mode 100644
index 0000000000..a2d8378883
--- /dev/null
+++ b/mobile/openapi/lib/model/places_response_dto.dart
@@ -0,0 +1,148 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class PlacesResponseDto {
+ /// Returns a new [PlacesResponseDto] instance.
+ PlacesResponseDto({
+ this.admin1name,
+ this.admin2name,
+ required this.latitude,
+ required this.longitude,
+ required this.name,
+ });
+
+ ///
+ /// Please note: This property should have been non-nullable! Since the specification file
+ /// does not include a default value (using the "default:" property), however, the generated
+ /// source code must fall back to having a nullable type.
+ /// Consider adding a "default:" property in the specification file to hide this note.
+ ///
+ String? admin1name;
+
+ ///
+ /// Please note: This property should have been non-nullable! Since the specification file
+ /// does not include a default value (using the "default:" property), however, the generated
+ /// source code must fall back to having a nullable type.
+ /// Consider adding a "default:" property in the specification file to hide this note.
+ ///
+ String? admin2name;
+
+ num latitude;
+
+ num longitude;
+
+ String name;
+
+ @override
+ bool operator ==(Object other) => identical(this, other) || other is PlacesResponseDto &&
+ other.admin1name == admin1name &&
+ other.admin2name == admin2name &&
+ other.latitude == latitude &&
+ other.longitude == longitude &&
+ other.name == name;
+
+ @override
+ int get hashCode =>
+ // ignore: unnecessary_parenthesis
+ (admin1name == null ? 0 : admin1name!.hashCode) +
+ (admin2name == null ? 0 : admin2name!.hashCode) +
+ (latitude.hashCode) +
+ (longitude.hashCode) +
+ (name.hashCode);
+
+ @override
+ String toString() => 'PlacesResponseDto[admin1name=$admin1name, admin2name=$admin2name, latitude=$latitude, longitude=$longitude, name=$name]';
+
+ Map toJson() {
+ final json = {};
+ if (this.admin1name != null) {
+ json[r'admin1name'] = this.admin1name;
+ } else {
+ // json[r'admin1name'] = null;
+ }
+ if (this.admin2name != null) {
+ json[r'admin2name'] = this.admin2name;
+ } else {
+ // json[r'admin2name'] = null;
+ }
+ json[r'latitude'] = this.latitude;
+ json[r'longitude'] = this.longitude;
+ json[r'name'] = this.name;
+ return json;
+ }
+
+ /// Returns a new [PlacesResponseDto] instance and imports its values from
+ /// [value] if it's a [Map], null otherwise.
+ // ignore: prefer_constructors_over_static_methods
+ static PlacesResponseDto? fromJson(dynamic value) {
+ if (value is Map) {
+ final json = value.cast();
+
+ return PlacesResponseDto(
+ admin1name: mapValueOfType(json, r'admin1name'),
+ admin2name: mapValueOfType(json, r'admin2name'),
+ latitude: num.parse('${json[r'latitude']}'),
+ longitude: num.parse('${json[r'longitude']}'),
+ name: mapValueOfType(json, r'name')!,
+ );
+ }
+ return null;
+ }
+
+ static List listFromJson(dynamic json, {bool growable = false,}) {
+ final result = [];
+ if (json is List && json.isNotEmpty) {
+ for (final row in json) {
+ final value = PlacesResponseDto.fromJson(row);
+ if (value != null) {
+ result.add(value);
+ }
+ }
+ }
+ return result.toList(growable: growable);
+ }
+
+ static Map mapFromJson(dynamic json) {
+ final map = {};
+ if (json is Map && json.isNotEmpty) {
+ json = json.cast(); // ignore: parameter_assignments
+ for (final entry in json.entries) {
+ final value = PlacesResponseDto.fromJson(entry.value);
+ if (value != null) {
+ map[entry.key] = value;
+ }
+ }
+ }
+ return map;
+ }
+
+ // maps a json object with a list of PlacesResponseDto-objects as value to a dart map
+ static Map> mapListFromJson(dynamic json, {bool growable = false,}) {
+ final map = >{};
+ if (json is Map && json.isNotEmpty) {
+ // ignore: parameter_assignments
+ json = json.cast();
+ for (final entry in json.entries) {
+ map[entry.key] = PlacesResponseDto.listFromJson(entry.value, growable: growable,);
+ }
+ }
+ return map;
+ }
+
+ /// The list of required keys that must be present in a JSON.
+ static const requiredKeys = {
+ 'latitude',
+ 'longitude',
+ 'name',
+ };
+}
+
diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart
index b82a3345fb..664850db82 100644
--- a/mobile/openapi/lib/model/smart_search_dto.dart
+++ b/mobile/openapi/lib/model/smart_search_dto.dart
@@ -23,6 +23,7 @@ class SmartSearchDto {
this.isExternal,
this.isFavorite,
this.isMotion,
+ this.isNotInAlbum,
this.isOffline,
this.isReadOnly,
this.isVisible,
@@ -41,7 +42,7 @@ class SmartSearchDto {
this.type,
this.updatedAfter,
this.updatedBefore,
- this.withArchived,
+ this.withArchived = false,
this.withDeleted,
this.withExif,
});
@@ -126,6 +127,14 @@ class SmartSearchDto {
///
bool? isMotion;
+ ///
+ /// Please note: This property should have been non-nullable! Since the specification file
+ /// does not include a default value (using the "default:" property), however, the generated
+ /// source code must fall back to having a nullable type.
+ /// Consider adding a "default:" property in the specification file to hide this note.
+ ///
+ bool? isNotInAlbum;
+
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
@@ -264,13 +273,7 @@ class SmartSearchDto {
///
DateTime? updatedBefore;
- ///
- /// Please note: This property should have been non-nullable! Since the specification file
- /// does not include a default value (using the "default:" property), however, the generated
- /// source code must fall back to having a nullable type.
- /// Consider adding a "default:" property in the specification file to hide this note.
- ///
- bool? withArchived;
+ bool withArchived;
///
/// Please note: This property should have been non-nullable! Since the specification file
@@ -300,6 +303,7 @@ class SmartSearchDto {
other.isExternal == isExternal &&
other.isFavorite == isFavorite &&
other.isMotion == isMotion &&
+ other.isNotInAlbum == isNotInAlbum &&
other.isOffline == isOffline &&
other.isReadOnly == isReadOnly &&
other.isVisible == isVisible &&
@@ -335,6 +339,7 @@ class SmartSearchDto {
(isExternal == null ? 0 : isExternal!.hashCode) +
(isFavorite == null ? 0 : isFavorite!.hashCode) +
(isMotion == null ? 0 : isMotion!.hashCode) +
+ (isNotInAlbum == null ? 0 : isNotInAlbum!.hashCode) +
(isOffline == null ? 0 : isOffline!.hashCode) +
(isReadOnly == null ? 0 : isReadOnly!.hashCode) +
(isVisible == null ? 0 : isVisible!.hashCode) +
@@ -353,12 +358,12 @@ class SmartSearchDto {
(type == null ? 0 : type!.hashCode) +
(updatedAfter == null ? 0 : updatedAfter!.hashCode) +
(updatedBefore == null ? 0 : updatedBefore!.hashCode) +
- (withArchived == null ? 0 : withArchived!.hashCode) +
+ (withArchived.hashCode) +
(withDeleted == null ? 0 : withDeleted!.hashCode) +
(withExif == null ? 0 : withExif!.hashCode);
@override
- String toString() => 'SmartSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isExternal=$isExternal, isFavorite=$isFavorite, isMotion=$isMotion, isOffline=$isOffline, isReadOnly=$isReadOnly, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, query=$query, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif]';
+ String toString() => 'SmartSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isExternal=$isExternal, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isReadOnly=$isReadOnly, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, query=$query, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif]';
Map toJson() {
final json = {};
@@ -412,6 +417,11 @@ class SmartSearchDto {
} else {
// json[r'isMotion'] = null;
}
+ if (this.isNotInAlbum != null) {
+ json[r'isNotInAlbum'] = this.isNotInAlbum;
+ } else {
+ // json[r'isNotInAlbum'] = null;
+ }
if (this.isOffline != null) {
json[r'isOffline'] = this.isOffline;
} else {
@@ -498,11 +508,7 @@ class SmartSearchDto {
} else {
// json[r'updatedBefore'] = null;
}
- if (this.withArchived != null) {
json[r'withArchived'] = this.withArchived;
- } else {
- // json[r'withArchived'] = null;
- }
if (this.withDeleted != null) {
json[r'withDeleted'] = this.withDeleted;
} else {
@@ -534,6 +540,7 @@ class SmartSearchDto {
isExternal: mapValueOfType(json, r'isExternal'),
isFavorite: mapValueOfType(json, r'isFavorite'),
isMotion: mapValueOfType(json, r'isMotion'),
+ isNotInAlbum: mapValueOfType(json, r'isNotInAlbum'),
isOffline: mapValueOfType(json, r'isOffline'),
isReadOnly: mapValueOfType(json, r'isReadOnly'),
isVisible: mapValueOfType(json, r'isVisible'),
@@ -552,7 +559,7 @@ class SmartSearchDto {
type: AssetTypeEnum.fromJson(json[r'type']),
updatedAfter: mapDateTime(json, r'updatedAfter', r''),
updatedBefore: mapDateTime(json, r'updatedBefore', r''),
- withArchived: mapValueOfType(json, r'withArchived'),
+ withArchived: mapValueOfType(json, r'withArchived') ?? false,
withDeleted: mapValueOfType(json, r'withDeleted'),
withExif: mapValueOfType(json, r'withExif'),
);
diff --git a/mobile/openapi/test/metadata_search_dto_test.dart b/mobile/openapi/test/metadata_search_dto_test.dart
index f1635de4e0..f817b7da74 100644
--- a/mobile/openapi/test/metadata_search_dto_test.dart
+++ b/mobile/openapi/test/metadata_search_dto_test.dart
@@ -206,7 +206,7 @@ void main() {
// TODO
});
- // bool withArchived
+ // bool withArchived (default value: false)
test('to test the property `withArchived`', () async {
// TODO
});
diff --git a/mobile/openapi/test/people_response_dto_test.dart b/mobile/openapi/test/people_response_dto_test.dart
index ad669eeced..94db6eb86b 100644
--- a/mobile/openapi/test/people_response_dto_test.dart
+++ b/mobile/openapi/test/people_response_dto_test.dart
@@ -16,6 +16,11 @@ void main() {
// final instance = PeopleResponseDto();
group('test PeopleResponseDto', () {
+ // int hidden
+ test('to test the property `hidden`', () async {
+ // TODO
+ });
+
// List people (default value: const [])
test('to test the property `people`', () async {
// TODO
diff --git a/mobile/openapi/test/places_response_dto_test.dart b/mobile/openapi/test/places_response_dto_test.dart
new file mode 100644
index 0000000000..5a320fce64
--- /dev/null
+++ b/mobile/openapi/test/places_response_dto_test.dart
@@ -0,0 +1,47 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for PlacesResponseDto
+void main() {
+ // final instance = PlacesResponseDto();
+
+ group('test PlacesResponseDto', () {
+ // String admin1name
+ test('to test the property `admin1name`', () async {
+ // TODO
+ });
+
+ // String admin2name
+ test('to test the property `admin2name`', () async {
+ // TODO
+ });
+
+ // num latitude
+ test('to test the property `latitude`', () async {
+ // TODO
+ });
+
+ // num longitude
+ test('to test the property `longitude`', () async {
+ // TODO
+ });
+
+ // String name
+ test('to test the property `name`', () async {
+ // TODO
+ });
+
+
+ });
+
+}
diff --git a/mobile/openapi/test/search_api_test.dart b/mobile/openapi/test/search_api_test.dart
index 14169e461d..aa4a94847b 100644
--- a/mobile/openapi/test/search_api_test.dart
+++ b/mobile/openapi/test/search_api_test.dart
@@ -42,6 +42,11 @@ void main() {
// TODO
});
+ //Future> searchPlaces(String name) async
+ test('test searchPlaces', () async {
+ // TODO
+ });
+
//Future searchSmart(SmartSearchDto smartSearchDto) async
test('test searchSmart', () async {
// TODO
diff --git a/mobile/openapi/test/smart_search_dto_test.dart b/mobile/openapi/test/smart_search_dto_test.dart
index 858c7769c8..4db3ac0808 100644
--- a/mobile/openapi/test/smart_search_dto_test.dart
+++ b/mobile/openapi/test/smart_search_dto_test.dart
@@ -66,6 +66,11 @@ void main() {
// TODO
});
+ // bool isNotInAlbum
+ test('to test the property `isNotInAlbum`', () async {
+ // TODO
+ });
+
// bool isOffline
test('to test the property `isOffline`', () async {
// TODO
@@ -156,7 +161,7 @@ void main() {
// TODO
});
- // bool withArchived
+ // bool withArchived (default value: false)
test('to test the property `withArchived`', () async {
// TODO
});
diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock
index 7608a3ab6c..9e379d4653 100644
--- a/mobile/pubspec.lock
+++ b/mobile/pubspec.lock
@@ -413,10 +413,10 @@ packages:
dependency: transitive
description:
name: file
- sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d"
+ sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c"
url: "https://pub.dev"
source: hosted
- version: "6.1.4"
+ version: "7.0.0"
file_selector_linux:
dependency: transitive
description:
@@ -569,10 +569,10 @@ packages:
dependency: "direct main"
description:
name: flutter_udid
- sha256: "666412097b86d9a6f9803073d0f0ba70de9b198fe6493d89d352a1f8cd6c5c84"
+ sha256: "63384bd96203aaefccfd7137fab642edda18afede12b0e9e1a2c96fe2589fd07"
url: "https://pub.dev"
source: hosted
- version: "2.1.1"
+ version: "3.0.0"
flutter_web_auth:
dependency: "direct main"
description:
@@ -619,10 +619,10 @@ packages:
dependency: "direct main"
description:
name: geolocator
- sha256: e946395fc608842bb2f6c914807e9183f86f3cb787f6b8f832753e5251036f02
+ sha256: "694ec58afe97787b5b72b8a0ab78c1a9244811c3c10e72c4362ef3c0ceb005cd"
url: "https://pub.dev"
source: hosted
- version: "10.1.0"
+ version: "11.0.0"
geolocator_android:
dependency: transitive
description:
@@ -651,10 +651,10 @@ packages:
dependency: transitive
description:
name: geolocator_web
- sha256: "59083f7e0871b78299918d92bf930a14377f711d2d1156c558cd5ebae6c20d58"
+ sha256: "49d8f846ebeb5e2b6641fe477a7e97e5dd73f03cbfef3fd5c42177b7300fb0ed"
url: "https://pub.dev"
source: hosted
- version: "2.2.0"
+ version: "3.0.0"
geolocator_windows:
dependency: transitive
description:
@@ -860,6 +860,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.8.1"
+ leak_tracker:
+ dependency: transitive
+ description:
+ name: leak_tracker
+ sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa"
+ url: "https://pub.dev"
+ source: hosted
+ version: "10.0.0"
+ leak_tracker_flutter_testing:
+ dependency: transitive
+ description:
+ name: leak_tracker_flutter_testing
+ sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.1"
+ leak_tracker_testing:
+ dependency: transitive
+ description:
+ name: leak_tracker_testing
+ sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.1"
lints:
dependency: transitive
description:
@@ -907,18 +931,18 @@ packages:
dependency: transitive
description:
name: matcher
- sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e"
+ sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
url: "https://pub.dev"
source: hosted
- version: "0.12.16"
+ version: "0.12.16+1"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
- sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41"
+ sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a"
url: "https://pub.dev"
source: hosted
- version: "0.5.0"
+ version: "0.8.0"
meta:
dependency: "direct overridden"
description:
@@ -1002,10 +1026,10 @@ packages:
dependency: "direct main"
description:
name: path
- sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917"
+ sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
url: "https://pub.dev"
source: hosted
- version: "1.8.3"
+ version: "1.9.0"
path_provider:
dependency: "direct main"
description:
@@ -1138,10 +1162,10 @@ packages:
dependency: transitive
description:
name: platform
- sha256: ae68c7bfcd7383af3629daafb32fb4e8681c7154428da4febcff06200585f102
+ sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec"
url: "https://pub.dev"
source: hosted
- version: "3.1.2"
+ version: "3.1.4"
plugin_platform_interface:
dependency: transitive
description:
@@ -1170,10 +1194,10 @@ packages:
dependency: transitive
description:
name: process
- sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09"
+ sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32"
url: "https://pub.dev"
source: hosted
- version: "4.2.4"
+ version: "5.0.2"
provider:
dependency: transitive
description:
@@ -1298,10 +1322,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_linux
- sha256: "71d6806d1449b0a9d4e85e0c7a917771e672a3d5dc61149cc9fac871115018e1"
+ sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa"
url: "https://pub.dev"
source: hosted
- version: "2.3.0"
+ version: "2.3.2"
shared_preferences_platform_interface:
dependency: transitive
description:
@@ -1322,10 +1346,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_windows
- sha256: f95e6a43162bce43c9c3405f3eb6f39e5b5d11f65fab19196cf8225e2777624d
+ sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59"
url: "https://pub.dev"
source: hosted
- version: "2.3.0"
+ version: "2.3.2"
shelf:
dependency: transitive
description:
@@ -1639,10 +1663,10 @@ packages:
dependency: transitive
description:
name: vm_service
- sha256: c538be99af830f478718b51630ec1b6bee5e74e52c8a802d328d9e71d35d2583
+ sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957
url: "https://pub.dev"
source: hosted
- version: "11.10.0"
+ version: "13.0.0"
wakelock_plus:
dependency: "direct main"
description:
@@ -1687,10 +1711,10 @@ packages:
dependency: transitive
description:
name: webdriver
- sha256: "3c923e918918feeb90c4c9fdf1fe39220fa4c0e8e2c0fffaded174498ef86c49"
+ sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e"
url: "https://pub.dev"
source: hosted
- version: "3.0.2"
+ version: "3.0.3"
win32:
dependency: transitive
description:
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index 2e1e0dd07e..50d170904f 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: 1.95.0+122
+version: 1.95.1+123
isar_version: &isar_version 3.1.0+1
environment:
@@ -32,8 +32,8 @@ dependencies:
git:
url: https://github.com/maplibre/flutter-maplibre-gl.git
ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5
- geolocator: ^10.1.0 # used to move to current location in map view
- flutter_udid: ^2.1.1
+ geolocator: ^11.0.0 # used to move to current location in map view
+ flutter_udid: ^3.0.0
package_info_plus: ^5.0.1
url_launcher: ^6.2.4
http: 0.13.5
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index 6870e140ca..8fec893270 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -2463,6 +2463,7 @@
"required": false,
"in": "query",
"schema": {
+ "default": false,
"type": "boolean"
}
},
@@ -4690,6 +4691,50 @@
]
}
},
+ "/search/places": {
+ "get": {
+ "operationId": "searchPlaces",
+ "parameters": [
+ {
+ "name": "name",
+ "required": true,
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "items": {
+ "$ref": "#/components/schemas/PlacesResponseDto"
+ },
+ "type": "array"
+ }
+ }
+ },
+ "description": ""
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ },
+ {
+ "cookie": []
+ },
+ {
+ "api_key": []
+ }
+ ],
+ "tags": [
+ "Search"
+ ]
+ }
+ },
"/search/smart": {
"post": {
"operationId": "searchSmart",
@@ -6413,7 +6458,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
- "version": "1.95.0",
+ "version": "1.95.1",
"contact": {}
},
"tags": [],
@@ -8429,6 +8474,7 @@
"type": "string"
},
"withArchived": {
+ "default": false,
"type": "boolean"
},
"withDeleted": {
@@ -8591,6 +8637,9 @@
},
"PeopleResponseDto": {
"properties": {
+ "hidden": {
+ "type": "integer"
+ },
"people": {
"items": {
"$ref": "#/components/schemas/PersonResponseDto"
@@ -8602,6 +8651,7 @@
}
},
"required": [
+ "hidden",
"people",
"total"
],
@@ -8750,6 +8800,31 @@
],
"type": "object"
},
+ "PlacesResponseDto": {
+ "properties": {
+ "admin1name": {
+ "type": "string"
+ },
+ "admin2name": {
+ "type": "string"
+ },
+ "latitude": {
+ "type": "number"
+ },
+ "longitude": {
+ "type": "number"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "latitude",
+ "longitude",
+ "name"
+ ],
+ "type": "object"
+ },
"QueueStatusDto": {
"properties": {
"isActive": {
@@ -9435,6 +9510,9 @@
"isMotion": {
"type": "boolean"
},
+ "isNotInAlbum": {
+ "type": "boolean"
+ },
"isOffline": {
"type": "boolean"
},
@@ -9497,6 +9575,7 @@
"type": "string"
},
"withArchived": {
+ "default": false,
"type": "boolean"
},
"withDeleted": {
diff --git a/open-api/typescript-sdk/axios-client/api.ts b/open-api/typescript-sdk/axios-client/api.ts
index 622820c752..ad36bb4932 100644
--- a/open-api/typescript-sdk/axios-client/api.ts
+++ b/open-api/typescript-sdk/axios-client/api.ts
@@ -4,7 +4,7 @@
* Immich
* Immich API
*
- * The version of the OpenAPI document: 1.95.0
+ * The version of the OpenAPI document: 1.95.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@@ -2801,6 +2801,12 @@ export type PathType = typeof PathType[keyof typeof PathType];
* @interface PeopleResponseDto
*/
export interface PeopleResponseDto {
+ /**
+ *
+ * @type {number}
+ * @memberof PeopleResponseDto
+ */
+ 'hidden': number;
/**
*
* @type {Array}
@@ -2988,6 +2994,43 @@ export interface PersonWithFacesResponseDto {
*/
'thumbnailPath': string;
}
+/**
+ *
+ * @export
+ * @interface PlacesResponseDto
+ */
+export interface PlacesResponseDto {
+ /**
+ *
+ * @type {string}
+ * @memberof PlacesResponseDto
+ */
+ 'admin1name'?: string;
+ /**
+ *
+ * @type {string}
+ * @memberof PlacesResponseDto
+ */
+ 'admin2name'?: string;
+ /**
+ *
+ * @type {number}
+ * @memberof PlacesResponseDto
+ */
+ 'latitude': number;
+ /**
+ *
+ * @type {number}
+ * @memberof PlacesResponseDto
+ */
+ 'longitude': number;
+ /**
+ *
+ * @type {string}
+ * @memberof PlacesResponseDto
+ */
+ 'name': string;
+}
/**
*
* @export
@@ -3880,6 +3923,12 @@ export interface SmartSearchDto {
* @memberof SmartSearchDto
*/
'isMotion'?: boolean;
+ /**
+ *
+ * @type {boolean}
+ * @memberof SmartSearchDto
+ */
+ 'isNotInAlbum'?: boolean;
/**
*
* @type {boolean}
@@ -15435,6 +15484,51 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
+ setSearchParams(localVarUrlObj, localVarQueryParameter);
+ let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+ localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+ return {
+ url: toPathString(localVarUrlObj),
+ options: localVarRequestOptions,
+ };
+ },
+ /**
+ *
+ * @param {string} name
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ searchPlaces: async (name: string, options: RawAxiosRequestConfig = {}): Promise => {
+ // verify required parameter 'name' is not null or undefined
+ assertParamExists('searchPlaces', 'name', name)
+ const localVarPath = `/search/places`;
+ // use dummy base URL string because the URL constructor only accepts absolute URLs.
+ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+ let baseOptions;
+ if (configuration) {
+ baseOptions = configuration.baseOptions;
+ }
+
+ const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
+ const localVarHeaderParameter = {} as any;
+ const localVarQueryParameter = {} as any;
+
+ // authentication cookie required
+
+ // authentication api_key required
+ await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+ // authentication bearer required
+ // http bearer authentication required
+ await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+ if (name !== undefined) {
+ localVarQueryParameter['name'] = name;
+ }
+
+
+
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -15572,6 +15666,18 @@ export const SearchApiFp = function(configuration?: Configuration) {
const operationBasePath = operationServerMap['SearchApi.searchPerson']?.[index]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath);
},
+ /**
+ *
+ * @param {string} name
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ async searchPlaces(name: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> {
+ const localVarAxiosArgs = await localVarAxiosParamCreator.searchPlaces(name, options);
+ const index = configuration?.serverIndex ?? 0;
+ const operationBasePath = operationServerMap['SearchApi.searchPlaces']?.[index]?.url;
+ return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath);
+ },
/**
*
* @param {SmartSearchDto} smartSearchDto
@@ -15639,6 +15745,15 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: RawAxiosRequestConfig): AxiosPromise> {
return localVarFp.searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(axios, basePath));
},
+ /**
+ *
+ * @param {SearchApiSearchPlacesRequest} requestParameters Request parameters.
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ searchPlaces(requestParameters: SearchApiSearchPlacesRequest, options?: RawAxiosRequestConfig): AxiosPromise> {
+ return localVarFp.searchPlaces(requestParameters.name, options).then((request) => request(axios, basePath));
+ },
/**
*
* @param {SearchApiSearchSmartRequest} requestParameters Request parameters.
@@ -15805,6 +15920,20 @@ export interface SearchApiSearchPersonRequest {
readonly withHidden?: boolean
}
+/**
+ * Request parameters for searchPlaces operation in SearchApi.
+ * @export
+ * @interface SearchApiSearchPlacesRequest
+ */
+export interface SearchApiSearchPlacesRequest {
+ /**
+ *
+ * @type {string}
+ * @memberof SearchApiSearchPlaces
+ */
+ readonly name: string
+}
+
/**
* Request parameters for searchSmart operation in SearchApi.
* @export
@@ -15881,6 +16010,17 @@ export class SearchApi extends BaseAPI {
return SearchApiFp(this.configuration).searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(this.axios, this.basePath));
}
+ /**
+ *
+ * @param {SearchApiSearchPlacesRequest} requestParameters Request parameters.
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ * @memberof SearchApi
+ */
+ public searchPlaces(requestParameters: SearchApiSearchPlacesRequest, options?: RawAxiosRequestConfig) {
+ return SearchApiFp(this.configuration).searchPlaces(requestParameters.name, options).then((request) => request(this.axios, this.basePath));
+ }
+
/**
*
* @param {SearchApiSearchSmartRequest} requestParameters Request parameters.
diff --git a/open-api/typescript-sdk/axios-client/base.ts b/open-api/typescript-sdk/axios-client/base.ts
index d16f428a39..d353309457 100644
--- a/open-api/typescript-sdk/axios-client/base.ts
+++ b/open-api/typescript-sdk/axios-client/base.ts
@@ -4,7 +4,7 @@
* Immich
* Immich API
*
- * The version of the OpenAPI document: 1.95.0
+ * The version of the OpenAPI document: 1.95.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
diff --git a/open-api/typescript-sdk/axios-client/common.ts b/open-api/typescript-sdk/axios-client/common.ts
index 743c3cf16b..120ebca552 100644
--- a/open-api/typescript-sdk/axios-client/common.ts
+++ b/open-api/typescript-sdk/axios-client/common.ts
@@ -4,7 +4,7 @@
* Immich
* Immich API
*
- * The version of the OpenAPI document: 1.95.0
+ * The version of the OpenAPI document: 1.95.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
diff --git a/open-api/typescript-sdk/axios-client/configuration.ts b/open-api/typescript-sdk/axios-client/configuration.ts
index 0e2dec06f5..cd67c859c7 100644
--- a/open-api/typescript-sdk/axios-client/configuration.ts
+++ b/open-api/typescript-sdk/axios-client/configuration.ts
@@ -4,7 +4,7 @@
* Immich
* Immich API
*
- * The version of the OpenAPI document: 1.95.0
+ * The version of the OpenAPI document: 1.95.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
diff --git a/open-api/typescript-sdk/axios-client/index.ts b/open-api/typescript-sdk/axios-client/index.ts
index ccee244935..0918c8124d 100644
--- a/open-api/typescript-sdk/axios-client/index.ts
+++ b/open-api/typescript-sdk/axios-client/index.ts
@@ -4,7 +4,7 @@
* Immich
* Immich API
*
- * The version of the OpenAPI document: 1.95.0
+ * The version of the OpenAPI document: 1.95.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
diff --git a/open-api/typescript-sdk/fetch-client.ts b/open-api/typescript-sdk/fetch-client.ts
index 9b3a359863..d023f8ef0a 100644
--- a/open-api/typescript-sdk/fetch-client.ts
+++ b/open-api/typescript-sdk/fetch-client.ts
@@ -1,6 +1,6 @@
/**
* Immich
- * 1.95.0
+ * 1.95.1
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@@ -524,6 +524,7 @@ export type UpdatePartnerDto = {
inTimeline: boolean;
};
export type PeopleResponseDto = {
+ hidden: number;
people: PersonResponseDto[];
total: number;
};
@@ -645,6 +646,13 @@ export type MetadataSearchDto = {
withPeople?: boolean;
withStacked?: boolean;
};
+export type PlacesResponseDto = {
+ admin1name?: string;
+ admin2name?: string;
+ latitude: number;
+ longitude: number;
+ name: string;
+};
export type SmartSearchDto = {
city?: string;
country?: string;
@@ -656,6 +664,7 @@ export type SmartSearchDto = {
isExternal?: boolean;
isFavorite?: boolean;
isMotion?: boolean;
+ isNotInAlbum?: boolean;
isOffline?: boolean;
isReadOnly?: boolean;
isVisible?: boolean;
@@ -2196,6 +2205,18 @@ export function searchPerson({ name, withHidden }: {
...opts
}));
}
+export function searchPlaces({ name }: {
+ name: string;
+}, opts?: Oazapfts.RequestOpts) {
+ return oazapfts.ok(oazapfts.fetchJson<{
+ status: 200;
+ data: PlacesResponseDto[];
+ }>(`/search/places${QS.query(QS.explode({
+ name
+ }))}`, {
+ ...opts
+ }));
+}
export function searchSmart({ smartSearchDto }: {
smartSearchDto: SmartSearchDto;
}, opts?: Oazapfts.RequestOpts) {
diff --git a/open-api/typescript-sdk/fetch-errors.ts b/open-api/typescript-sdk/fetch-errors.ts
new file mode 100644
index 0000000000..f21f0ed1c4
--- /dev/null
+++ b/open-api/typescript-sdk/fetch-errors.ts
@@ -0,0 +1,15 @@
+import { HttpError } from '@oazapfts/runtime';
+
+export interface ApiExceptionResponse {
+ message: string;
+ error?: string;
+ statusCode: number;
+}
+
+export interface ApiHttpError extends HttpError {
+ data: ApiExceptionResponse;
+}
+
+export function isHttpError(error: unknown): error is ApiHttpError {
+ return error instanceof HttpError;
+}
diff --git a/open-api/typescript-sdk/fetch.ts b/open-api/typescript-sdk/fetch.ts
index 5441cd8268..5759e66ad9 100644
--- a/open-api/typescript-sdk/fetch.ts
+++ b/open-api/typescript-sdk/fetch.ts
@@ -1 +1,2 @@
export * from './fetch-client';
+export * from './fetch-errors';
diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json
index 5346a47086..a918e2d2c9 100644
--- a/open-api/typescript-sdk/package-lock.json
+++ b/open-api/typescript-sdk/package-lock.json
@@ -29,9 +29,9 @@
"dev": true
},
"node_modules/@types/node": {
- "version": "20.11.17",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.17.tgz",
- "integrity": "sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==",
+ "version": "20.11.19",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz",
+ "integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
diff --git a/server/Dockerfile b/server/Dockerfile
index 9a7fc31fa2..7ea2795ea7 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -1,5 +1,5 @@
# dev build
-FROM ghcr.io/immich-app/base-server-dev:20240213@sha256:16646a37bae065b51e68cb2ba7a63027b29504d43a30644625382afbe326114a as dev
+FROM ghcr.io/immich-app/base-server-dev:20240222@sha256:2ff467d6ae5c00a2317eb7b13cb40ba5be0fd33c160175dba621b1bf72bc1cd1 as dev
RUN apt-get install --no-install-recommends -yqq tini
WORKDIR /usr/src/app
@@ -40,7 +40,7 @@ RUN npm run build
# prod build
-FROM ghcr.io/immich-app/base-server-prod:20240213@sha256:61d159d069c5b522f16de9733fb79feb0e82c0b099d16f026196f344d12a1e5e
+FROM ghcr.io/immich-app/base-server-prod:20240222@sha256:9ae5eebf95cf7759eec9dcfbd9e48a722701075ac855209f2e0b01c631b76f5c
WORKDIR /usr/src/app
ENV NODE_ENV=production \
diff --git a/server/e2e/api/specs/asset.e2e-spec.ts b/server/e2e/api/specs/asset.e2e-spec.ts
index 5993a70400..0e09a68be5 100644
--- a/server/e2e/api/specs/asset.e2e-spec.ts
+++ b/server/e2e/api/specs/asset.e2e-spec.ts
@@ -50,6 +50,7 @@ describe(`${AssetController.name} (e2e)`, () => {
let asset3: AssetResponseDto;
let asset4: AssetResponseDto;
let asset5: AssetResponseDto;
+ let asset6: AssetResponseDto;
const createAsset = async (
loginResponse: LoginResponseDto,
@@ -96,12 +97,11 @@ describe(`${AssetController.name} (e2e)`, () => {
beforeEach(async () => {
await testApp.reset({ entities: [AssetEntity, AssetStackEntity] });
- [asset1, asset2, asset3, asset4, asset5] = await Promise.all([
+ [asset1, asset2, asset3, asset4, asset5, asset6] = await Promise.all([
createAsset(user1, new Date('1970-01-01')),
createAsset(user1, new Date('1970-02-10')),
createAsset(user1, new Date('1970-02-11'), {
isFavorite: true,
- isArchived: true,
isExternal: true,
isReadOnly: true,
type: AssetType.VIDEO,
@@ -118,6 +118,9 @@ describe(`${AssetController.name} (e2e)`, () => {
createAsset(user1, new Date('1970-01-01'), {
deletedAt: yesterday.toJSDate(),
}),
+ createAsset(user1, new Date('1970-02-11'), {
+ isArchived: true,
+ }),
]);
await assetRepository.upsertExif({
@@ -275,14 +278,14 @@ describe(`${AssetController.name} (e2e)`, () => {
should: 'should search by isArchived (true)',
deferred: () => ({
query: { isArchived: true },
- assets: [asset3],
+ assets: [asset6],
}),
},
{
should: 'should search by isArchived (false)',
deferred: () => ({
query: { isArchived: false },
- assets: [asset2, asset1],
+ assets: [asset3, asset2, asset1],
}),
},
{
@@ -313,6 +316,20 @@ describe(`${AssetController.name} (e2e)`, () => {
assets: [asset3],
}),
},
+ {
+ should: 'should search by withArchived (true)',
+ deferred: () => ({
+ query: { withArchived: true },
+ assets: [asset3, asset6, asset2, asset1],
+ }),
+ },
+ {
+ should: 'should search by withArchived (false)',
+ deferred: () => ({
+ query: { withArchived: false },
+ assets: [asset3, asset2, asset1],
+ }),
+ },
{
should: 'should search by createdBefore',
deferred: () => ({
@@ -514,8 +531,8 @@ describe(`${AssetController.name} (e2e)`, () => {
expect(status).toBe(200);
expect(body.length).toBe(assets.length);
- for (let i = 0; i < assets.length; i++) {
- expect(body[i]).toEqual(expect.objectContaining({ id: assets[i].id }));
+ for (const [i, asset] of assets.entries()) {
+ expect(body[i]).toEqual(expect.objectContaining({ id: asset.id }));
}
});
}
@@ -682,7 +699,7 @@ describe(`${AssetController.name} (e2e)`, () => {
it("should not upload to another user's library", async () => {
const content = randomBytes(32);
- const library = (await api.libraryApi.getAll(server, user2.accessToken))[0];
+ const [library] = await api.libraryApi.getAll(server, user2.accessToken);
await api.assetApi.upload(server, user1.accessToken, 'example-image', { content });
const { body, status } = await request(server)
@@ -902,7 +919,7 @@ describe(`${AssetController.name} (e2e)`, () => {
.get('/asset/statistics')
.set('Authorization', `Bearer ${user1.accessToken}`);
- expect(body).toEqual({ images: 5, videos: 1, total: 6 });
+ expect(body).toEqual({ images: 6, videos: 1, total: 7 });
expect(status).toBe(200);
});
@@ -923,7 +940,7 @@ describe(`${AssetController.name} (e2e)`, () => {
.query({ isArchived: true });
expect(status).toBe(200);
- expect(body).toEqual({ images: 2, videos: 1, total: 3 });
+ expect(body).toEqual({ images: 3, videos: 0, total: 3 });
});
it('should return stats of all favored and archived assets', async () => {
@@ -933,7 +950,7 @@ describe(`${AssetController.name} (e2e)`, () => {
.query({ isFavorite: true, isArchived: true });
expect(status).toBe(200);
- expect(body).toEqual({ images: 1, videos: 1, total: 2 });
+ expect(body).toEqual({ images: 1, videos: 0, total: 1 });
});
it('should return stats of all assets neither favored nor archived', async () => {
@@ -1041,7 +1058,7 @@ describe(`${AssetController.name} (e2e)`, () => {
expect.arrayContaining([
{ count: 1, timeBucket: '2023-11-01T00:00:00.000Z' },
{ count: 1, timeBucket: '1970-01-01T00:00:00.000Z' },
- { count: 1, timeBucket: '1970-02-01T00:00:00.000Z' },
+ { count: 2, timeBucket: '1970-02-01T00:00:00.000Z' },
]),
);
});
@@ -1198,8 +1215,13 @@ describe(`${AssetController.name} (e2e)`, () => {
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
- expect(body).toHaveLength(1);
- expect(body).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset2.id })]));
+ expect(body).toHaveLength(2);
+ expect(body).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: asset2.id }),
+ expect.objectContaining({ id: asset3.id }),
+ ]),
+ );
});
it('should get all map markers', async () => {
@@ -1209,8 +1231,13 @@ describe(`${AssetController.name} (e2e)`, () => {
.query({ isArchived: false });
expect(status).toBe(200);
- expect(body).toHaveLength(1);
- expect(body).toEqual([expect.objectContaining({ id: asset2.id })]);
+ expect(body).toHaveLength(2);
+ expect(body).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: asset2.id }),
+ expect.objectContaining({ id: asset3.id }),
+ ]),
+ );
});
});
diff --git a/server/e2e/api/specs/person.e2e-spec.ts b/server/e2e/api/specs/person.e2e-spec.ts
deleted file mode 100644
index 73adcfab71..0000000000
--- a/server/e2e/api/specs/person.e2e-spec.ts
+++ /dev/null
@@ -1,191 +0,0 @@
-import { IPersonRepository, LoginResponseDto } from '@app/domain';
-import { PersonController } from '@app/immich';
-import { PersonEntity } from '@app/infra/entities';
-import { INestApplication } from '@nestjs/common';
-import { errorStub, uuidStub } from '@test/fixtures';
-import request from 'supertest';
-import { api } from '../../client';
-import { testApp } from '../utils';
-
-describe(`${PersonController.name}`, () => {
- let app: INestApplication;
- let server: any;
- let loginResponse: LoginResponseDto;
- let accessToken: string;
- let personRepository: IPersonRepository;
- let visiblePerson: PersonEntity;
- let hiddenPerson: PersonEntity;
-
- beforeAll(async () => {
- app = await testApp.create();
- server = app.getHttpServer();
- personRepository = app.get(IPersonRepository);
- });
-
- afterAll(async () => {
- await testApp.teardown();
- });
-
- beforeEach(async () => {
- await testApp.reset();
- await api.authApi.adminSignUp(server);
- loginResponse = await api.authApi.adminLogin(server);
- accessToken = loginResponse.accessToken;
-
- const faceAsset = await api.assetApi.upload(server, accessToken, 'face_asset');
- visiblePerson = await personRepository.create({
- ownerId: loginResponse.userId,
- name: 'visible_person',
- thumbnailPath: '/thumbnail/face_asset',
- });
- await personRepository.createFaces([
- {
- assetId: faceAsset.id,
- personId: visiblePerson.id,
- embedding: Array.from({ length: 512 }, Math.random),
- },
- ]);
-
- hiddenPerson = await personRepository.create({
- ownerId: loginResponse.userId,
- name: 'hidden_person',
- isHidden: true,
- thumbnailPath: '/thumbnail/face_asset',
- });
- await personRepository.createFaces([
- {
- assetId: faceAsset.id,
- personId: hiddenPerson.id,
- embedding: Array.from({ length: 512 }, Math.random),
- },
- ]);
- });
-
- describe('GET /person', () => {
- beforeEach(async () => {});
-
- it('should require authentication', async () => {
- const { status, body } = await request(server).get('/person');
-
- expect(status).toBe(401);
- expect(body).toEqual(errorStub.unauthorized);
- });
-
- it('should return all people (including hidden)', async () => {
- const { status, body } = await request(server)
- .get('/person')
- .set('Authorization', `Bearer ${accessToken}`)
- .query({ withHidden: true });
-
- expect(status).toBe(200);
- expect(body).toEqual({
- total: 2,
- people: [
- expect.objectContaining({ name: 'visible_person' }),
- expect.objectContaining({ name: 'hidden_person' }),
- ],
- });
- });
-
- it('should return only visible people', async () => {
- const { status, body } = await request(server).get('/person').set('Authorization', `Bearer ${accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual({
- total: 2,
- people: [expect.objectContaining({ name: 'visible_person' })],
- });
- });
- });
-
- describe('GET /person/:id', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(server).get(`/person/${uuidStub.notFound}`);
-
- expect(status).toBe(401);
- expect(body).toEqual(errorStub.unauthorized);
- });
-
- it('should throw error if person with id does not exist', async () => {
- const { status, body } = await request(server)
- .get(`/person/${uuidStub.notFound}`)
- .set('Authorization', `Bearer ${accessToken}`);
-
- expect(status).toBe(400);
- expect(body).toEqual(errorStub.badRequest());
- });
-
- it('should return person information', async () => {
- const { status, body } = await request(server)
- .get(`/person/${visiblePerson.id}`)
- .set('Authorization', `Bearer ${accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toEqual(expect.objectContaining({ id: visiblePerson.id }));
- });
- });
-
- describe('PUT /person/:id', () => {
- it('should require authentication', async () => {
- const { status, body } = await request(server).put(`/person/${uuidStub.notFound}`);
- expect(status).toBe(401);
- expect(body).toEqual(errorStub.unauthorized);
- });
-
- for (const { key, type } of [
- { key: 'name', type: 'string' },
- { key: 'featureFaceAssetId', type: 'string' },
- { key: 'isHidden', type: 'boolean value' },
- ]) {
- it(`should not allow null ${key}`, async () => {
- const { status, body } = await request(server)
- .put(`/person/${visiblePerson.id}`)
- .set('Authorization', `Bearer ${accessToken}`)
- .send({ [key]: null });
- expect(status).toBe(400);
- expect(body).toEqual(errorStub.badRequest([`${key} must be a ${type}`]));
- });
- }
-
- it('should not accept invalid birth dates', async () => {
- for (const { birthDate, response } of [
- { birthDate: false, response: 'Not found or no person.write access' },
- { birthDate: 'false', response: ['birthDate must be a Date instance'] },
- { birthDate: '123567', response: 'Not found or no person.write access' },
- { birthDate: 123567, response: 'Not found or no person.write access' },
- ]) {
- const { status, body } = await request(server)
- .put(`/person/${uuidStub.notFound}`)
- .set('Authorization', `Bearer ${accessToken}`)
- .send({ birthDate });
- expect(status).toBe(400);
- expect(body).toEqual(errorStub.badRequest(response));
- }
- });
-
- it('should update a date of birth', async () => {
- const { status, body } = await request(server)
- .put(`/person/${visiblePerson.id}`)
- .set('Authorization', `Bearer ${accessToken}`)
- .send({ birthDate: '1990-01-01T05:00:00.000Z' });
- expect(status).toBe(200);
- expect(body).toMatchObject({ birthDate: '1990-01-01' });
- });
-
- it('should clear a date of birth', async () => {
- const person = await personRepository.create({
- birthDate: new Date('1990-01-01'),
- ownerId: loginResponse.userId,
- });
-
- expect(person.birthDate).toBeDefined();
-
- const { status, body } = await request(server)
- .put(`/person/${person.id}`)
- .set('Authorization', `Bearer ${accessToken}`)
- .send({ birthDate: null });
- expect(status).toBe(200);
- expect(body).toMatchObject({ birthDate: null });
- });
- });
-});
diff --git a/server/e2e/api/specs/search.e2e-spec.ts b/server/e2e/api/specs/search.e2e-spec.ts
index 74988396d7..0e5cc428cc 100644
--- a/server/e2e/api/specs/search.e2e-spec.ts
+++ b/server/e2e/api/specs/search.e2e-spec.ts
@@ -44,7 +44,7 @@ describe(`${SearchController.name}`, () => {
describe('GET /search (exif)', () => {
beforeEach(async () => {
- const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id;
+ const { id: assetId } = await assetRepository.create(generateAsset(loginResponse.userId, libraries));
await assetRepository.upsertExif({ assetId, ...searchStub.exif });
const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true });
@@ -166,7 +166,7 @@ describe(`${SearchController.name}`, () => {
describe('GET /search (smart info)', () => {
beforeEach(async () => {
- const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id;
+ const { id: assetId } = await assetRepository.create(generateAsset(loginResponse.userId, libraries));
await assetRepository.upsertExif({ assetId, ...searchStub.exif });
await smartInfoRepository.upsert({ assetId, ...searchStub.smartInfo }, Array.from({ length: 512 }, Math.random));
@@ -215,7 +215,7 @@ describe(`${SearchController.name}`, () => {
describe('GET /search (file name)', () => {
beforeEach(async () => {
- const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id;
+ const { id: assetId } = await assetRepository.create(generateAsset(loginResponse.userId, libraries));
await assetRepository.upsertExif({ assetId, ...searchStub.exif });
const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true });
diff --git a/server/e2e/client/activity-api.ts b/server/e2e/client/activity-api.ts
deleted file mode 100644
index f7cac45624..0000000000
--- a/server/e2e/client/activity-api.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { ActivityCreateDto, ActivityResponseDto } from '@app/domain';
-import request from 'supertest';
-
-export const activityApi = {
- create: async (server: any, accessToken: string, dto: ActivityCreateDto) => {
- const res = await request(server).post('/activity').set('Authorization', `Bearer ${accessToken}`).send(dto);
- expect(res.status === 200 || res.status === 201).toBe(true);
- return res.body as ActivityResponseDto;
- },
- delete: async (server: any, accessToken: string, id: string) => {
- const res = await request(server).delete(`/activity/${id}`).set('Authorization', `Bearer ${accessToken}`);
- expect(res.status).toEqual(204);
- },
-};
diff --git a/server/e2e/client/album-api.ts b/server/e2e/client/album-api.ts
deleted file mode 100644
index 92c75dc64b..0000000000
--- a/server/e2e/client/album-api.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { AddUsersDto, AlbumResponseDto, BulkIdResponseDto, BulkIdsDto, CreateAlbumDto } from '@app/domain';
-import request from 'supertest';
-
-export const albumApi = {
- create: async (server: any, accessToken: string, dto: CreateAlbumDto) => {
- const res = await request(server).post('/album').set('Authorization', `Bearer ${accessToken}`).send(dto);
- expect(res.status).toEqual(201);
- return res.body as AlbumResponseDto;
- },
- addAssets: async (server: any, accessToken: string, id: string, dto: BulkIdsDto) => {
- const res = await request(server)
- .put(`/album/${id}/assets`)
- .set('Authorization', `Bearer ${accessToken}`)
- .send(dto);
- expect(res.status).toEqual(200);
- return res.body as BulkIdResponseDto[];
- },
- addUsers: async (server: any, accessToken: string, id: string, dto: AddUsersDto) => {
- const res = await request(server).put(`/album/${id}/users`).set('Authorization', `Bearer ${accessToken}`).send(dto);
- expect(res.status).toEqual(200);
- return res.body as AlbumResponseDto;
- },
- getAllAlbums: async (server: any, accessToken: string) => {
- const res = await request(server).get(`/album/`).set('Authorization', `Bearer ${accessToken}`).send();
- expect(res.status).toEqual(200);
- return res.body as AlbumResponseDto[];
- },
-};
diff --git a/server/e2e/client/api-key-api.ts b/server/e2e/client/api-key-api.ts
deleted file mode 100644
index a35f13f7d9..0000000000
--- a/server/e2e/client/api-key-api.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { APIKeyCreateResponseDto } from '@app/domain';
-import { apiKeyCreateStub } from '@test';
-import request from 'supertest';
-
-export const apiKeyApi = {
- createApiKey: async (server: any, accessToken: string) => {
- const { status, body } = await request(server)
- .post('/api-key')
- .set('Authorization', `Bearer ${accessToken}`)
- .send(apiKeyCreateStub);
-
- expect(status).toBe(201);
-
- return body as APIKeyCreateResponseDto;
- },
-};
diff --git a/server/e2e/client/auth-api.ts b/server/e2e/client/auth-api.ts
index 3043c941f2..f0206d3376 100644
--- a/server/e2e/client/auth-api.ts
+++ b/server/e2e/client/auth-api.ts
@@ -1,4 +1,4 @@
-import { AuthDeviceResponseDto, LoginCredentialDto, LoginResponseDto, UserResponseDto } from '@app/domain';
+import { LoginCredentialDto, LoginResponseDto, UserResponseDto } from '@app/domain';
import { adminSignupStub, loginResponseStub, loginStub } from '@test';
import request from 'supertest';
@@ -27,19 +27,4 @@ export const authApi = {
return body as LoginResponseDto;
},
- getAuthDevices: async (server: any, accessToken: string) => {
- const { status, body } = await request(server).get('/auth/devices').set('Authorization', `Bearer ${accessToken}`);
-
- expect(body).toEqual(expect.any(Array));
- expect(status).toBe(200);
-
- return body as AuthDeviceResponseDto[];
- },
- validateToken: async (server: any, accessToken: string) => {
- const { status, body } = await request(server)
- .post('/auth/validateToken')
- .set('Authorization', `Bearer ${accessToken}`);
- expect(body).toEqual({ authStatus: true });
- expect(status).toBe(200);
- },
};
diff --git a/server/e2e/client/index.ts b/server/e2e/client/index.ts
index b9c0f2ff38..b0464a34d8 100644
--- a/server/e2e/client/index.ts
+++ b/server/e2e/client/index.ts
@@ -1,25 +1,15 @@
-import { activityApi } from './activity-api';
-import { albumApi } from './album-api';
-import { apiKeyApi } from './api-key-api';
import { assetApi } from './asset-api';
import { authApi } from './auth-api';
import { libraryApi } from './library-api';
-import { partnerApi } from './partner-api';
-import { serverInfoApi } from './server-info-api';
import { sharedLinkApi } from './shared-link-api';
import { trashApi } from './trash-api';
import { userApi } from './user-api';
export const api = {
- activityApi,
authApi,
- apiKeyApi,
assetApi,
libraryApi,
- serverInfoApi,
sharedLinkApi,
trashApi,
- albumApi,
userApi,
- partnerApi,
};
diff --git a/server/e2e/client/partner-api.ts b/server/e2e/client/partner-api.ts
deleted file mode 100644
index 97a9558c5f..0000000000
--- a/server/e2e/client/partner-api.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { PartnerResponseDto } from '@app/domain';
-import request from 'supertest';
-
-export const partnerApi = {
- create: async (server: any, accessToken: string, id: string) => {
- const { status, body } = await request(server).post(`/partner/${id}`).set('Authorization', `Bearer ${accessToken}`);
- expect(status).toBe(201);
- return body as PartnerResponseDto;
- },
-};
diff --git a/server/e2e/client/server-info-api.ts b/server/e2e/client/server-info-api.ts
deleted file mode 100644
index f885bc856f..0000000000
--- a/server/e2e/client/server-info-api.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { ServerConfigDto } from '@app/domain';
-import request from 'supertest';
-
-export const serverInfoApi = {
- getConfig: async (server: any) => {
- const res = await request(server).get('/server-info/config');
- expect(res.status).toBe(200);
- return res.body as ServerConfigDto;
- },
-};
diff --git a/server/e2e/client/shared-link-api.ts b/server/e2e/client/shared-link-api.ts
index d6179f6b6f..c34093b0ac 100644
--- a/server/e2e/client/shared-link-api.ts
+++ b/server/e2e/client/shared-link-api.ts
@@ -10,11 +10,4 @@ export const sharedLinkApi = {
expect(status).toBe(201);
return body as SharedLinkResponseDto;
},
-
- getMySharedLink: async (server: any, key: string) => {
- const { status, body } = await request(server).get('/shared-link/me').query({ key });
-
- expect(status).toBe(200);
- return body as SharedLinkResponseDto;
- },
};
diff --git a/server/e2e/client/user-api.ts b/server/e2e/client/user-api.ts
index 5ed0838f75..9123b06219 100644
--- a/server/e2e/client/user-api.ts
+++ b/server/e2e/client/user-api.ts
@@ -18,16 +18,6 @@ export const userApi = {
return body as UserResponseDto;
},
- get: async (server: any, accessToken: string, id: string) => {
- const { status, body } = await request(server)
- .get(`/user/info/${id}`)
- .set('Authorization', `Bearer ${accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toMatchObject({ id });
-
- return body as UserResponseDto;
- },
update: async (server: any, accessToken: string, dto: UpdateUserDto) => {
const { status, body } = await request(server).put('/user').set('Authorization', `Bearer ${accessToken}`).send(dto);
@@ -39,12 +29,4 @@ export const userApi = {
setExternalPath: async (server: any, accessToken: string, id: string, externalPath: string) => {
return await userApi.update(server, accessToken, { id, externalPath });
},
- delete: async (server: any, accessToken: string, id: string) => {
- const { status, body } = await request(server).delete(`/user/${id}`).set('Authorization', `Bearer ${accessToken}`);
-
- expect(status).toBe(200);
- expect(body).toMatchObject({ id, deletedAt: expect.any(String) });
-
- return body as UserResponseDto;
- },
};
diff --git a/server/e2e/jobs/specs/formats.e2e-spec.ts b/server/e2e/jobs/specs/formats.e2e-spec.ts
index 5f6ffba311..c8b14d588a 100644
--- a/server/e2e/jobs/specs/formats.e2e-spec.ts
+++ b/server/e2e/jobs/specs/formats.e2e-spec.ts
@@ -1,7 +1,7 @@
import { LoginResponseDto } from '@app/domain';
import { AssetType } from '@app/infra/entities';
-import { readFile } from 'fs/promises';
-import { basename, join } from 'path';
+import { readFile } from 'node:fs/promises';
+import { basename, join } from 'node:path';
import { IMMICH_TEST_ASSET_PATH, testApp } from '../../../src/test-utils/utils';
import { api } from '../../client';
@@ -19,7 +19,7 @@ const JPEG = {
iso: 200,
fNumber: 11,
exposureTime: '1/160',
- fileSizeInByte: 53493,
+ fileSizeInByte: 53_493,
make: 'SONY',
model: 'DSLR-A550',
orientation: null,
@@ -42,11 +42,11 @@ const tests = [
exifImageWidth: 4032,
exifImageHeight: 3024,
latitude: 41.2203,
- longitude: -96.071625,
+ longitude: -96.071_625,
make: 'Apple',
model: 'iPhone 7',
lensModel: 'iPhone 7 back camera 3.99mm f/1.8',
- fileSizeInByte: 880703,
+ fileSizeInByte: 880_703,
exposureTime: '1/887',
iso: 20,
focalLength: 3.99,
@@ -66,7 +66,7 @@ const tests = [
exifImageHeight: 800,
latitude: null,
longitude: null,
- fileSizeInByte: 25408,
+ fileSizeInByte: 25_408,
},
},
},
@@ -84,7 +84,7 @@ const tests = [
fNumber: 10,
focalLength: 18,
iso: 100,
- fileSizeInByte: 9057784,
+ fileSizeInByte: 9_057_784,
dateTimeOriginal: '2010-07-20T17:27:12.000Z',
latitude: null,
longitude: null,
@@ -106,7 +106,7 @@ const tests = [
fNumber: 11,
focalLength: 85,
iso: 200,
- fileSizeInByte: 15856335,
+ fileSizeInByte: 15_856_335,
dateTimeOriginal: '2016-09-22T22:10:29.060Z',
latitude: null,
longitude: null,
diff --git a/server/e2e/jobs/specs/library-watcher.e2e-spec.ts b/server/e2e/jobs/specs/library-watcher.e2e-spec.ts
index cb7dd5f894..0215a4976e 100644
--- a/server/e2e/jobs/specs/library-watcher.e2e-spec.ts
+++ b/server/e2e/jobs/specs/library-watcher.e2e-spec.ts
@@ -1,7 +1,7 @@
import { LibraryResponseDto, LibraryService, LoginResponseDto } from '@app/domain';
import { AssetType, LibraryType } from '@app/infra/entities';
-import fs from 'fs/promises';
-import path from 'path';
+import fs from 'node:fs/promises';
+import path from 'node:path';
import {
IMMICH_TEST_ASSET_PATH,
IMMICH_TEST_ASSET_TEMP_PATH,
@@ -20,7 +20,8 @@ describe(`Library watcher (e2e)`, () => {
beforeAll(async () => {
process.env.IMMICH_CONFIG_FILE = path.normalize(`${__dirname}/../config/library-watcher-e2e-config.json`);
- server = (await testApp.create()).getHttpServer();
+ const app = await testApp.create();
+ server = app.getHttpServer();
libraryService = testApp.get(LibraryService);
});
diff --git a/server/package-lock.json b/server/package-lock.json
index ae129549b3..97c9dca58e 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "immich",
- "version": "1.95.0",
+ "version": "1.95.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
- "version": "1.95.0",
+ "version": "1.95.1",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@babel/runtime": "^7.22.11",
@@ -31,8 +31,8 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
- "exiftool-vendored": "~24.4.0",
- "exiftool-vendored.pl": "12.73",
+ "exiftool-vendored": "~24.5.0",
+ "exiftool-vendored.pl": "12.76",
"fluent-ffmpeg": "^2.1.2",
"geo-tz": "^8.0.0",
"glob": "^10.3.3",
@@ -2705,9 +2705,9 @@
}
},
"node_modules/@photostructure/tz-lookup": {
- "version": "9.0.0",
- "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-9.0.0.tgz",
- "integrity": "sha512-gM3Xrs+XhD8ojDN0TgybuzSjsQb9UvF8j9DvR75E2zHlJQNiOztzILvfhVwadgA8JJbSMNzE+kYUnwP8aQnlXw=="
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-9.0.1.tgz",
+ "integrity": "sha512-inMfhc1QVKheq/PHF0v2vRPnPzTljxscuOKK95o3VlZA4T4w4DeYIpu7dC4W1EyjUhfZJlCBUudpmnFGNCqTog=="
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
@@ -3179,9 +3179,9 @@
}
},
"node_modules/@types/node": {
- "version": "20.11.17",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.17.tgz",
- "integrity": "sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==",
+ "version": "20.11.19",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz",
+ "integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==",
"dependencies": {
"undici-types": "~5.26.4"
}
@@ -4280,9 +4280,9 @@
}
},
"node_modules/batch-cluster": {
- "version": "12.1.0",
- "resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-12.1.0.tgz",
- "integrity": "sha512-whGyJU4tr7kyg2USByu0/51mML5HsLAeNz5s03kMDYZNsQsGgDJgI47RdY3r7MciCjPkTaTD5O4eOVqOfEO7pg==",
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-13.0.0.tgz",
+ "integrity": "sha512-EreW0Vi8TwovhYUHBXXRA5tthuU2ynGsZFlboyMJHCCUXYa2AjgwnE3ubBOJs2xJLcuXFJbi6c/8pH5+FVj8Og==",
"engines": {
"node": ">=14"
}
@@ -5998,34 +5998,34 @@
"dev": true
},
"node_modules/exiftool-vendored": {
- "version": "24.4.0",
- "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.4.0.tgz",
- "integrity": "sha512-n9GjZ+t0sD4mFGyxVCuyVKkyc4wDQraGE9XE3TbWqJBResbIggMBwcYeo9Q5oVBXe2K8EA/WraWDTqHN+C+52g==",
+ "version": "24.5.0",
+ "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.5.0.tgz",
+ "integrity": "sha512-uLGYfeshak3mYn2ucCsebXfNFdOpeAULlMb84wiJv+4B236n+ypgK/vr8bJgAcsIPSRJXFSz9WonvjjQYYqR3w==",
"dependencies": {
- "@photostructure/tz-lookup": "^9.0.0",
- "@types/luxon": "^3.4.1",
- "batch-cluster": "^12.1.0",
+ "@photostructure/tz-lookup": "^9.0.1",
+ "@types/luxon": "^3.4.2",
+ "batch-cluster": "^13.0.0",
"he": "^1.2.0",
"luxon": "^3.4.4"
},
"optionalDependencies": {
- "exiftool-vendored.exe": "12.73.0",
- "exiftool-vendored.pl": "12.73.0"
+ "exiftool-vendored.exe": "12.76.0",
+ "exiftool-vendored.pl": "12.76.0"
}
},
"node_modules/exiftool-vendored.exe": {
- "version": "12.73.0",
- "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.73.0.tgz",
- "integrity": "sha512-7za1Iv1hBnO92A+Yua04PHicCcwrP4edCzHaYnDOuea2DhmpIhqCgUUgrShcm4tG58ueRBa3GVvhRW/gCm8n4g==",
+ "version": "12.76.0",
+ "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.76.0.tgz",
+ "integrity": "sha512-lbKPPs31qpjnhFiMRaVxJX+iNcJ+p0NrRSFLHHaX6KTsfMba6e5i6NykSvU3wMiafzUTef1Fen3XQ+8n1tjjNw==",
"optional": true,
"os": [
"win32"
]
},
"node_modules/exiftool-vendored.pl": {
- "version": "12.73.0",
- "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.73.0.tgz",
- "integrity": "sha512-qX6kiGUTuQ/HFwuP3VJHU///BSwlaHSWm+yrDTHHQG+w+yvjFdtajDTJ96CUvPA/ecehtbTUMqCUz5xgMmHfBw==",
+ "version": "12.76.0",
+ "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.76.0.tgz",
+ "integrity": "sha512-4DxqgnvL71YziVoY27ZMgVfLAWDH3pQLljuV5+ffTnTPvz/BWeV+/bVFwRvDqCD3lkCWds0YfVcsycfJgbQ5fA==",
"os": [
"!win32"
]
@@ -14280,9 +14280,9 @@
}
},
"@photostructure/tz-lookup": {
- "version": "9.0.0",
- "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-9.0.0.tgz",
- "integrity": "sha512-gM3Xrs+XhD8ojDN0TgybuzSjsQb9UvF8j9DvR75E2zHlJQNiOztzILvfhVwadgA8JJbSMNzE+kYUnwP8aQnlXw=="
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-9.0.1.tgz",
+ "integrity": "sha512-inMfhc1QVKheq/PHF0v2vRPnPzTljxscuOKK95o3VlZA4T4w4DeYIpu7dC4W1EyjUhfZJlCBUudpmnFGNCqTog=="
},
"@pkgjs/parseargs": {
"version": "0.11.0",
@@ -14730,9 +14730,9 @@
}
},
"@types/node": {
- "version": "20.11.17",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.17.tgz",
- "integrity": "sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==",
+ "version": "20.11.19",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz",
+ "integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==",
"requires": {
"undici-types": "~5.26.4"
}
@@ -15601,9 +15601,9 @@
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="
},
"batch-cluster": {
- "version": "12.1.0",
- "resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-12.1.0.tgz",
- "integrity": "sha512-whGyJU4tr7kyg2USByu0/51mML5HsLAeNz5s03kMDYZNsQsGgDJgI47RdY3r7MciCjPkTaTD5O4eOVqOfEO7pg=="
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-13.0.0.tgz",
+ "integrity": "sha512-EreW0Vi8TwovhYUHBXXRA5tthuU2ynGsZFlboyMJHCCUXYa2AjgwnE3ubBOJs2xJLcuXFJbi6c/8pH5+FVj8Og=="
},
"bcrypt": {
"version": "5.1.1",
@@ -16841,15 +16841,15 @@
}
},
"exiftool-vendored": {
- "version": "24.4.0",
- "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.4.0.tgz",
- "integrity": "sha512-n9GjZ+t0sD4mFGyxVCuyVKkyc4wDQraGE9XE3TbWqJBResbIggMBwcYeo9Q5oVBXe2K8EA/WraWDTqHN+C+52g==",
+ "version": "24.5.0",
+ "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.5.0.tgz",
+ "integrity": "sha512-uLGYfeshak3mYn2ucCsebXfNFdOpeAULlMb84wiJv+4B236n+ypgK/vr8bJgAcsIPSRJXFSz9WonvjjQYYqR3w==",
"requires": {
- "@photostructure/tz-lookup": "^9.0.0",
- "@types/luxon": "^3.4.1",
- "batch-cluster": "^12.1.0",
- "exiftool-vendored.exe": "12.73.0",
- "exiftool-vendored.pl": "12.73.0",
+ "@photostructure/tz-lookup": "^9.0.1",
+ "@types/luxon": "^3.4.2",
+ "batch-cluster": "^13.0.0",
+ "exiftool-vendored.exe": "12.76.0",
+ "exiftool-vendored.pl": "12.76.0",
"he": "^1.2.0",
"luxon": "^3.4.4"
},
@@ -16862,15 +16862,15 @@
}
},
"exiftool-vendored.exe": {
- "version": "12.73.0",
- "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.73.0.tgz",
- "integrity": "sha512-7za1Iv1hBnO92A+Yua04PHicCcwrP4edCzHaYnDOuea2DhmpIhqCgUUgrShcm4tG58ueRBa3GVvhRW/gCm8n4g==",
+ "version": "12.76.0",
+ "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.76.0.tgz",
+ "integrity": "sha512-lbKPPs31qpjnhFiMRaVxJX+iNcJ+p0NrRSFLHHaX6KTsfMba6e5i6NykSvU3wMiafzUTef1Fen3XQ+8n1tjjNw==",
"optional": true
},
"exiftool-vendored.pl": {
- "version": "12.73.0",
- "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.73.0.tgz",
- "integrity": "sha512-qX6kiGUTuQ/HFwuP3VJHU///BSwlaHSWm+yrDTHHQG+w+yvjFdtajDTJ96CUvPA/ecehtbTUMqCUz5xgMmHfBw=="
+ "version": "12.76.0",
+ "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.76.0.tgz",
+ "integrity": "sha512-4DxqgnvL71YziVoY27ZMgVfLAWDH3pQLljuV5+ffTnTPvz/BWeV+/bVFwRvDqCD3lkCWds0YfVcsycfJgbQ5fA=="
},
"exit": {
"version": "0.1.2",
diff --git a/server/package.json b/server/package.json
index 5ea2a345df..e7d84dc5af 100644
--- a/server/package.json
+++ b/server/package.json
@@ -1,6 +1,6 @@
{
"name": "immich",
- "version": "1.95.0",
+ "version": "1.95.1",
"description": "",
"author": "",
"private": true,
@@ -56,8 +56,8 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
- "exiftool-vendored": "~24.4.0",
- "exiftool-vendored.pl": "12.73",
+ "exiftool-vendored": "~24.5.0",
+ "exiftool-vendored.pl": "12.76",
"fluent-ffmpeg": "^2.1.2",
"geo-tz": "^8.0.0",
"glob": "^10.3.3",
diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts
index 325bb8ea4c..f328b5dcf6 100644
--- a/server/src/domain/asset/asset.service.ts
+++ b/server/src/domain/asset/asset.service.ts
@@ -326,7 +326,7 @@ export class AssetService {
const stackIdsToCheckForDelete: string[] = [];
if (removeParent) {
(options as Partial).stack = null;
- const assets = await this.assetRepository.getByIds(ids);
+ const assets = await this.assetRepository.getByIds(ids, { stack: true });
stackIdsToCheckForDelete.push(...new Set(assets.filter((a) => !!a.stackId).map((a) => a.stackId!)));
// This updates the updatedAt column of the parents to indicate that one of its children is removed
// All the unique parent's -> parent is set to null
diff --git a/server/src/domain/asset/response-dto/asset-response.dto.ts b/server/src/domain/asset/response-dto/asset-response.dto.ts
index 94a9f8a42d..4cc0bd6672 100644
--- a/server/src/domain/asset/response-dto/asset-response.dto.ts
+++ b/server/src/domain/asset/response-dto/asset-response.dto.ts
@@ -73,23 +73,21 @@ const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[]
export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto {
const { stripMetadata = false, withStack = false } = options;
- const sanitizedAssetResponse: SanitizedAssetResponseDto = {
- id: entity.id,
- type: entity.type,
- thumbhash: entity.thumbhash?.toString('base64') ?? null,
- localDateTime: entity.localDateTime,
- resized: !!entity.resizePath,
- duration: entity.duration ?? '0:00:00.00000',
- livePhotoVideoId: entity.livePhotoVideoId,
- hasMetadata: false,
- };
-
if (stripMetadata) {
+ const sanitizedAssetResponse: SanitizedAssetResponseDto = {
+ id: entity.id,
+ type: entity.type,
+ thumbhash: entity.thumbhash?.toString('base64') ?? null,
+ localDateTime: entity.localDateTime,
+ resized: !!entity.resizePath,
+ duration: entity.duration ?? '0:00:00.00000',
+ livePhotoVideoId: entity.livePhotoVideoId,
+ hasMetadata: false,
+ };
return sanitizedAssetResponse as AssetResponseDto;
}
return {
- ...sanitizedAssetResponse,
id: entity.id,
deviceAssetId: entity.deviceAssetId,
ownerId: entity.ownerId,
diff --git a/server/src/domain/audit/audit.service.ts b/server/src/domain/audit/audit.service.ts
index 887b72e2cd..a7c003fad6 100644
--- a/server/src/domain/audit/audit.service.ts
+++ b/server/src/domain/audit/audit.service.ts
@@ -167,7 +167,7 @@ export class AuditService {
`Found ${libraryFiles.size} original files, ${thumbFiles.size} thumbnails, ${videoFiles.size} encoded videos, ${profileFiles.size} profile files`,
);
const pagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (options) =>
- this.assetRepository.getAll(options, { withDeleted: true }),
+ this.assetRepository.getAll(options, { withDeleted: true, withArchived: true }),
);
let assetCount = 0;
diff --git a/server/src/domain/database/database.service.ts b/server/src/domain/database/database.service.ts
index 8cd08acd75..d697d032b3 100644
--- a/server/src/domain/database/database.service.ts
+++ b/server/src/domain/database/database.service.ts
@@ -72,7 +72,7 @@ export class DatabaseService {
In this case, please run 'CREATE EXTENSION IF NOT EXISTS ${this.vectorExt}' manually as a superuser.
See https://immich.app/docs/guides/database-queries for how to query the database.
- Alternatively, if your Postgres instance has ${extName[otherExt]}, you may use this instead by setting the environment variable 'DB_VECTOR_EXTENSION=${otherExt}'.
+ Alternatively, if your Postgres instance has ${extName[otherExt]}, you may use this instead by setting the environment variable 'DB_VECTOR_EXTENSION=${extName[otherExt]}'.
Note that switching between the two extensions after a successful startup is not supported.
The exception is if your version of Immich prior to upgrading was 1.90.2 or earlier.
In this case, you may set either extension now, but you will not be able to switch to the other extension following a successful startup.
diff --git a/server/src/domain/domain.constant.ts b/server/src/domain/domain.constant.ts
index 4e7c4d5524..0dc9c54140 100644
--- a/server/src/domain/domain.constant.ts
+++ b/server/src/domain/domain.constant.ts
@@ -91,7 +91,7 @@ export const citiesFile = 'cities500.txt';
export const geodataDatePath = join(GEODATA_ROOT_PATH, 'geodata-date.txt');
export const geodataAdmin1Path = join(GEODATA_ROOT_PATH, 'admin1CodesASCII.txt');
export const geodataAdmin2Path = join(GEODATA_ROOT_PATH, 'admin2Codes.txt');
-export const geodataCitites500Path = join(GEODATA_ROOT_PATH, citiesFile);
+export const geodataCities500Path = join(GEODATA_ROOT_PATH, citiesFile);
const image: Record = {
'.3fr': ['image/3fr', 'image/x-hasselblad-3fr'],
diff --git a/server/src/domain/media/media.service.spec.ts b/server/src/domain/media/media.service.spec.ts
index aa48568b90..f4c9aa53e7 100644
--- a/server/src/domain/media/media.service.spec.ts
+++ b/server/src/domain/media/media.service.spec.ts
@@ -1,5 +1,6 @@
import {
AssetType,
+ AudioCodec,
Colorspace,
ExifEntity,
SystemConfigKey,
@@ -475,7 +476,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -542,7 +543,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -571,7 +572,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -629,7 +630,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -706,7 +707,10 @@ describe(MediaService.name, () => {
it('should copy video stream when video matches target', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
- configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }]);
+ configMock.load.mockResolvedValue([
+ { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
+ { key: SystemConfigKey.FFMPEG_ACCEPTED_AUDIO_CODECS, value: [AudioCodec.AAC] },
+ ]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
@@ -770,7 +774,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -836,7 +840,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -868,7 +872,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -897,7 +901,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -928,7 +932,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v vp9',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -962,7 +966,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v vp9',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -994,7 +998,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v vp9',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1026,7 +1030,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v vp9',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1057,7 +1061,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v vp9',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1087,7 +1091,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1117,7 +1121,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1147,7 +1151,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v hevc',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1181,7 +1185,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v hevc',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1248,7 +1252,7 @@ describe(MediaService.name, () => {
'-rc-lookahead 20',
'-i_qfactor 0.75',
`-c:v h264_nvenc`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1286,7 +1290,7 @@ describe(MediaService.name, () => {
'-rc-lookahead 20',
'-i_qfactor 0.75',
`-c:v h264_nvenc`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1320,7 +1324,7 @@ describe(MediaService.name, () => {
'-rc-lookahead 20',
'-i_qfactor 0.75',
`-c:v h264_nvenc`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1355,7 +1359,7 @@ describe(MediaService.name, () => {
'-rc-lookahead 20',
'-i_qfactor 0.75',
`-c:v h264_nvenc`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1386,7 +1390,7 @@ describe(MediaService.name, () => {
'-rc-lookahead 20',
'-i_qfactor 0.75',
`-c:v h264_nvenc`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1418,7 +1422,7 @@ describe(MediaService.name, () => {
inputOptions: ['-init_hw_device qsv=hw', '-filter_hw_device hw'],
outputOptions: [
`-c:v h264_qsv`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1455,7 +1459,7 @@ describe(MediaService.name, () => {
inputOptions: ['-init_hw_device qsv=hw,child_device=/dev/dri/renderD128', '-filter_hw_device hw'],
outputOptions: [
`-c:v h264_qsv`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1491,7 +1495,7 @@ describe(MediaService.name, () => {
inputOptions: ['-init_hw_device qsv=hw', '-filter_hw_device hw'],
outputOptions: [
`-c:v h264_qsv`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1524,7 +1528,7 @@ describe(MediaService.name, () => {
inputOptions: ['-init_hw_device qsv=hw', '-filter_hw_device hw'],
outputOptions: [
`-c:v vp9_qsv`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1568,7 +1572,7 @@ describe(MediaService.name, () => {
inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel'],
outputOptions: [
`-c:v h264_vaapi`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1600,7 +1604,7 @@ describe(MediaService.name, () => {
inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel'],
outputOptions: [
`-c:v h264_vaapi`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1634,7 +1638,7 @@ describe(MediaService.name, () => {
inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel'],
outputOptions: [
`-c:v h264_vaapi`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1664,7 +1668,7 @@ describe(MediaService.name, () => {
inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/card1', '-filter_hw_device accel'],
outputOptions: [
`-c:v h264_vaapi`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1690,7 +1694,7 @@ describe(MediaService.name, () => {
inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/renderD129', '-filter_hw_device accel'],
outputOptions: [
`-c:v h264_vaapi`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1724,7 +1728,7 @@ describe(MediaService.name, () => {
inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel'],
outputOptions: [
`-c:v h264_vaapi`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1757,7 +1761,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1798,7 +1802,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
`-c:v hevc_rkmpp_encoder`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1838,7 +1842,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
`-c:v h264_rkmpp_encoder`,
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1872,7 +1876,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1899,7 +1903,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
@@ -1926,7 +1930,7 @@ describe(MediaService.name, () => {
inputOptions: [],
outputOptions: [
'-c:v h264',
- '-c:a aac',
+ '-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
diff --git a/server/src/domain/metadata/metadata.service.ts b/server/src/domain/metadata/metadata.service.ts
index 94c3e9ae3f..562568adf6 100644
--- a/server/src/domain/metadata/metadata.service.ts
+++ b/server/src/domain/metadata/metadata.service.ts
@@ -493,7 +493,7 @@ export class MetadataService {
model: tags.Model ?? null,
modifyDate: exifDate(tags.ModifyDate) ?? asset.fileModifiedAt,
orientation: validate(tags.Orientation)?.toString() ?? null,
- profileDescription: tags.ProfileDescription || tags.ProfileName || null,
+ profileDescription: tags.ProfileDescription || null,
projectionType: tags.ProjectionType ? String(tags.ProjectionType).toUpperCase() : null,
timeZone: tags.tz ?? null,
};
diff --git a/server/src/domain/person/person.dto.ts b/server/src/domain/person/person.dto.ts
index 360a9b2348..b8ad8f0451 100644
--- a/server/src/domain/person/person.dto.ts
+++ b/server/src/domain/person/person.dto.ts
@@ -127,7 +127,8 @@ export class PersonStatisticsResponseDto {
export class PeopleResponseDto {
@ApiProperty({ type: 'integer' })
total!: number;
-
+ @ApiProperty({ type: 'integer' })
+ hidden!: number;
people!: PersonResponseDto[];
}
diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts
index 5da8666016..ffda9034bd 100644
--- a/server/src/domain/person/person.service.spec.ts
+++ b/server/src/domain/person/person.service.spec.ts
@@ -114,35 +114,12 @@ describe(PersonService.name, () => {
});
describe('getAll', () => {
- it('should get all people with thumbnails', async () => {
- personMock.getAllForUser.mockResolvedValue([personStub.withName, personStub.noThumbnail]);
- personMock.getNumberOfPeople.mockResolvedValue(1);
- await expect(sut.getAll(authStub.admin, { withHidden: undefined })).resolves.toEqual({
- total: 1,
- people: [responseDto],
- });
- expect(personMock.getAllForUser).toHaveBeenCalledWith(authStub.admin.user.id, {
- minimumFaceCount: 3,
- withHidden: false,
- });
- });
- it('should get all visible people with thumbnails', async () => {
- personMock.getAllForUser.mockResolvedValue([personStub.withName, personStub.hidden]);
- personMock.getNumberOfPeople.mockResolvedValue(2);
- await expect(sut.getAll(authStub.admin, { withHidden: false })).resolves.toEqual({
- total: 2,
- people: [responseDto],
- });
- expect(personMock.getAllForUser).toHaveBeenCalledWith(authStub.admin.user.id, {
- minimumFaceCount: 3,
- withHidden: false,
- });
- });
it('should get all hidden and visible people with thumbnails', async () => {
personMock.getAllForUser.mockResolvedValue([personStub.withName, personStub.hidden]);
- personMock.getNumberOfPeople.mockResolvedValue(2);
+ personMock.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 });
await expect(sut.getAll(authStub.admin, { withHidden: true })).resolves.toEqual({
total: 2,
+ hidden: 1,
people: [
responseDto,
{
diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts
index 6fbc409bf8..6300cc743c 100644
--- a/server/src/domain/person/person.service.ts
+++ b/server/src/domain/person/person.service.ts
@@ -82,15 +82,12 @@ export class PersonService {
minimumFaceCount: machineLearning.facialRecognition.minFaces,
withHidden: dto.withHidden || false,
});
- const total = await this.repository.getNumberOfPeople(auth.user.id);
- const persons: PersonResponseDto[] = people
- // with thumbnails
- .filter((person) => !!person.thumbnailPath)
- .map((person) => mapPerson(person));
+ const { total, hidden } = await this.repository.getNumberOfPeople(auth.user.id);
return {
- people: persons.filter((person) => dto.withHidden || !person.isHidden),
+ people: people.map((person) => mapPerson(person)),
total,
+ hidden,
};
}
diff --git a/server/src/domain/repositories/person.repository.ts b/server/src/domain/repositories/person.repository.ts
index 80240091a9..85c11fe921 100644
--- a/server/src/domain/repositories/person.repository.ts
+++ b/server/src/domain/repositories/person.repository.ts
@@ -28,6 +28,11 @@ export interface PersonStatistics {
assets: number;
}
+export interface PeopleStatistics {
+ total: number;
+ hidden: number;
+}
+
export interface IPersonRepository {
getAll(pagination: PaginationOptions, options?: FindManyOptions): Paginated;
getAllForUser(userId: string, options: PersonSearchOptions): Promise;
@@ -54,7 +59,7 @@ export interface IPersonRepository {
getRandomFace(personId: string): Promise;
getStatistics(personId: string): Promise;
reassignFace(assetFaceId: string, newPersonId: string): Promise;
- getNumberOfPeople(userId: string): Promise;
+ getNumberOfPeople(userId: string): Promise;
reassignFaces(data: UpdateFacesData): Promise;
update(entity: Partial): Promise;
}
diff --git a/server/src/domain/repositories/search.repository.ts b/server/src/domain/repositories/search.repository.ts
index 7183e9e3fe..8566fcd8e5 100644
--- a/server/src/domain/repositories/search.repository.ts
+++ b/server/src/domain/repositories/search.repository.ts
@@ -1,4 +1,4 @@
-import { AssetEntity, AssetFaceEntity, AssetType, SmartInfoEntity } from '@app/infra/entities';
+import { AssetEntity, AssetFaceEntity, AssetType, GeodataPlacesEntity, SmartInfoEntity } from '@app/infra/entities';
import { Paginated } from '../domain.util';
export const ISearchRepository = 'ISearchRepository';
@@ -186,4 +186,5 @@ export interface ISearchRepository {
searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated;
searchFaces(search: FaceEmbeddingSearch): Promise;
upsert(smartInfo: Partial, embedding?: Embedding): Promise;
+ searchPlaces(placeName: string): Promise;
}
diff --git a/server/src/domain/search/dto/search.dto.ts b/server/src/domain/search/dto/search.dto.ts
index 5aa73433d9..877a494e4d 100644
--- a/server/src/domain/search/dto/search.dto.ts
+++ b/server/src/domain/search/dto/search.dto.ts
@@ -1,5 +1,5 @@
import { AssetOrder } from '@app/domain/asset/dto/asset.dto';
-import { AssetType } from '@app/infra/entities';
+import { AssetType, GeodataPlacesEntity } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
@@ -23,6 +23,7 @@ class BaseSearchDto {
isArchived?: boolean;
@QueryBoolean({ optional: true })
+ @ApiProperty({ default: false })
withArchived?: boolean;
@QueryBoolean({ optional: true })
@@ -118,6 +119,9 @@ class BaseSearchDto {
@Type(() => Number)
@Optional()
size?: number;
+
+ @QueryBoolean({ optional: true })
+ isNotInAlbum?: boolean;
}
export class MetadataSearchDto extends BaseSearchDto {
@@ -170,9 +174,6 @@ export class MetadataSearchDto extends BaseSearchDto {
@ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder })
order?: AssetOrder;
- @QueryBoolean({ optional: true })
- isNotInAlbum?: boolean;
-
@Optional()
personIds?: string[];
}
@@ -240,6 +241,12 @@ export class SearchDto {
size?: number;
}
+export class SearchPlacesDto {
+ @IsString()
+ @IsNotEmpty()
+ name!: string;
+}
+
export class SearchPeopleDto {
@IsString()
@IsNotEmpty()
@@ -250,3 +257,21 @@ export class SearchPeopleDto {
@Optional()
withHidden?: boolean;
}
+
+export class PlacesResponseDto {
+ name!: string;
+ latitude!: number;
+ longitude!: number;
+ admin1name?: string;
+ admin2name?: string;
+}
+
+export function mapPlaces(place: GeodataPlacesEntity): PlacesResponseDto {
+ return {
+ name: place.name,
+ latitude: place.latitude,
+ longitude: place.longitude,
+ admin1name: place.admin1Name,
+ admin2name: place.admin2Name,
+ };
+}
diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts
index 452c556f41..5b56399981 100644
--- a/server/src/domain/search/search.service.ts
+++ b/server/src/domain/search/search.service.ts
@@ -16,7 +16,15 @@ import {
SearchStrategy,
} from '../repositories';
import { FeatureFlag, SystemConfigCore } from '../system-config';
-import { MetadataSearchDto, SearchDto, SearchPeopleDto, SmartSearchDto } from './dto';
+import {
+ MetadataSearchDto,
+ PlacesResponseDto,
+ SearchDto,
+ SearchPeopleDto,
+ SearchPlacesDto,
+ SmartSearchDto,
+ mapPlaces,
+} from './dto';
import { SearchSuggestionRequestDto, SearchSuggestionType } from './dto/search-suggestion.dto';
import { SearchResponseDto } from './response-dto';
@@ -41,6 +49,11 @@ export class SearchService {
return this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden });
}
+ async searchPlaces(dto: SearchPlacesDto): Promise {
+ const places = await this.searchRepository.searchPlaces(dto.name);
+ return places.map((place) => mapPlaces(place));
+ }
+
async getExploreData(auth: AuthDto): Promise[]> {
await this.configCore.requireFeature(FeatureFlag.SEARCH);
const options = { maxFields: 12, minAssetsPerField: 5 };
@@ -182,26 +195,22 @@ export class SearchService {
}
async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto): Promise {
- if (dto.type === SearchSuggestionType.COUNTRY) {
- return this.metadataRepository.getCountries(auth.user.id);
+ switch (dto.type) {
+ case SearchSuggestionType.COUNTRY: {
+ return this.metadataRepository.getCountries(auth.user.id);
+ }
+ case SearchSuggestionType.STATE: {
+ return this.metadataRepository.getStates(auth.user.id, dto.country);
+ }
+ case SearchSuggestionType.CITY: {
+ return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state);
+ }
+ case SearchSuggestionType.CAMERA_MAKE: {
+ return this.metadataRepository.getCameraMakes(auth.user.id, dto.model);
+ }
+ case SearchSuggestionType.CAMERA_MODEL: {
+ return this.metadataRepository.getCameraModels(auth.user.id, dto.make);
+ }
}
-
- if (dto.type === SearchSuggestionType.STATE) {
- return this.metadataRepository.getStates(auth.user.id, dto.country);
- }
-
- if (dto.type === SearchSuggestionType.CITY) {
- return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state);
- }
-
- if (dto.type === SearchSuggestionType.CAMERA_MAKE) {
- return this.metadataRepository.getCameraMakes(auth.user.id, dto.model);
- }
-
- if (dto.type === SearchSuggestionType.CAMERA_MODEL) {
- return this.metadataRepository.getCameraModels(auth.user.id, dto.make);
- }
-
- return [];
}
}
diff --git a/server/src/domain/storage-template/storage-template.service.ts b/server/src/domain/storage-template/storage-template.service.ts
index d696982540..857d1df327 100644
--- a/server/src/domain/storage-template/storage-template.service.ts
+++ b/server/src/domain/storage-template/storage-template.service.ts
@@ -117,7 +117,7 @@ export class StorageTemplateService {
return true;
}
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
- this.assetRepository.getAll(pagination),
+ this.assetRepository.getAll(pagination, { withExif: true }),
);
const users = await this.userRepository.getList();
diff --git a/server/src/domain/system-config/system-config.core.ts b/server/src/domain/system-config/system-config.core.ts
index 1591e87d63..eb661133b2 100644
--- a/server/src/domain/system-config/system-config.core.ts
+++ b/server/src/domain/system-config/system-config.core.ts
@@ -33,7 +33,7 @@ export const defaults = Object.freeze({
targetVideoCodec: VideoCodec.H264,
acceptedVideoCodecs: [VideoCodec.H264],
targetAudioCodec: AudioCodec.AAC,
- acceptedAudioCodecs: [AudioCodec.AAC],
+ acceptedAudioCodecs: [AudioCodec.AAC, AudioCodec.MP3, AudioCodec.LIBOPUS],
targetResolution: '720',
maxBitrate: '0',
bframes: -1,
diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts
index 8addc63a0f..ec0b4b8f4f 100644
--- a/server/src/domain/system-config/system-config.service.spec.ts
+++ b/server/src/domain/system-config/system-config.service.spec.ts
@@ -43,7 +43,7 @@ const updatedConfig = Object.freeze({
threads: 0,
preset: 'ultrafast',
targetAudioCodec: AudioCodec.AAC,
- acceptedAudioCodecs: [AudioCodec.AAC],
+ acceptedAudioCodecs: [AudioCodec.AAC, AudioCodec.MP3, AudioCodec.LIBOPUS],
targetResolution: '720',
targetVideoCodec: VideoCodec.H264,
acceptedVideoCodecs: [VideoCodec.H264],
diff --git a/server/src/immich/controllers/search.controller.ts b/server/src/immich/controllers/search.controller.ts
index 4e57cfaa62..b807da9665 100644
--- a/server/src/immich/controllers/search.controller.ts
+++ b/server/src/immich/controllers/search.controller.ts
@@ -2,9 +2,11 @@ import {
AuthDto,
MetadataSearchDto,
PersonResponseDto,
+ PlacesResponseDto,
SearchDto,
SearchExploreResponseDto,
SearchPeopleDto,
+ SearchPlacesDto,
SearchResponseDto,
SearchService,
SmartSearchDto,
@@ -48,6 +50,11 @@ export class SearchController {
return this.service.searchPerson(auth, dto);
}
+ @Get('places')
+ searchPlaces(@Query() dto: SearchPlacesDto): Promise {
+ return this.service.searchPlaces(dto);
+ }
+
@Get('suggestions')
getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise {
return this.service.getSearchSuggestions(auth, dto);
diff --git a/server/src/infra/entities/geodata-admin1.entity.ts b/server/src/infra/entities/geodata-admin1.entity.ts
deleted file mode 100644
index 36cf0a805e..0000000000
--- a/server/src/infra/entities/geodata-admin1.entity.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { Column, Entity, PrimaryColumn } from 'typeorm';
-
-@Entity('geodata_admin1')
-export class GeodataAdmin1Entity {
- @PrimaryColumn({ type: 'varchar' })
- key!: string;
-
- @Column({ type: 'varchar' })
- name!: string;
-}
diff --git a/server/src/infra/entities/geodata-admin2.entity.ts b/server/src/infra/entities/geodata-admin2.entity.ts
deleted file mode 100644
index bd03e83776..0000000000
--- a/server/src/infra/entities/geodata-admin2.entity.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { Column, Entity, PrimaryColumn } from 'typeorm';
-
-@Entity('geodata_admin2')
-export class GeodataAdmin2Entity {
- @PrimaryColumn({ type: 'varchar' })
- key!: string;
-
- @Column({ type: 'varchar' })
- name!: string;
-}
diff --git a/server/src/infra/entities/geodata-places.entity.ts b/server/src/infra/entities/geodata-places.entity.ts
index 244e4261b0..966a50d5c9 100644
--- a/server/src/infra/entities/geodata-places.entity.ts
+++ b/server/src/infra/entities/geodata-places.entity.ts
@@ -1,6 +1,4 @@
-import { GeodataAdmin1Entity } from '@app/infra/entities/geodata-admin1.entity';
-import { GeodataAdmin2Entity } from '@app/infra/entities/geodata-admin2.entity';
-import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm';
+import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity('geodata_places', { synchronize: false })
export class GeodataPlacesEntity {
@@ -21,7 +19,7 @@ export class GeodataPlacesEntity {
// asExpression: 'll_to_earth((latitude)::double precision, (longitude)::double precision)',
// type: 'earth',
// })
- earthCoord!: unknown;
+ // earthCoord!: unknown;
@Column({ type: 'char', length: 2 })
countryCode!: string;
@@ -32,27 +30,14 @@ export class GeodataPlacesEntity {
@Column({ type: 'varchar', length: 80, nullable: true })
admin2Code!: string;
- @Column({
- type: 'varchar',
- generatedType: 'STORED',
- asExpression: `"countryCode" || '.' || "admin1Code"`,
- nullable: true,
- })
- admin1Key!: string;
+ @Column({ type: 'varchar', nullable: true })
+ admin1Name!: string;
- @ManyToOne(() => GeodataAdmin1Entity, { eager: true, nullable: true, createForeignKeyConstraints: false })
- admin1!: GeodataAdmin1Entity;
+ @Column({ type: 'varchar', nullable: true })
+ admin2Name!: string;
- @Column({
- type: 'varchar',
- generatedType: 'STORED',
- asExpression: `"countryCode" || '.' || "admin1Code" || '.' || "admin2Code"`,
- nullable: true,
- })
- admin2Key!: string;
-
- @ManyToOne(() => GeodataAdmin2Entity, { eager: true, nullable: true, createForeignKeyConstraints: false })
- admin2!: GeodataAdmin2Entity;
+ @Column({ type: 'varchar', nullable: true })
+ alternateNames!: string;
@Column({ type: 'date' })
modificationDate!: Date;
diff --git a/server/src/infra/entities/index.ts b/server/src/infra/entities/index.ts
index 957e15a887..af620790ef 100644
--- a/server/src/infra/entities/index.ts
+++ b/server/src/infra/entities/index.ts
@@ -7,8 +7,6 @@ import { AssetStackEntity } from './asset-stack.entity';
import { AssetEntity } from './asset.entity';
import { AuditEntity } from './audit.entity';
import { ExifEntity } from './exif.entity';
-import { GeodataAdmin1Entity } from './geodata-admin1.entity';
-import { GeodataAdmin2Entity } from './geodata-admin2.entity';
import { GeodataPlacesEntity } from './geodata-places.entity';
import { LibraryEntity } from './library.entity';
import { MoveEntity } from './move.entity';
@@ -32,8 +30,6 @@ export * from './asset-stack.entity';
export * from './asset.entity';
export * from './audit.entity';
export * from './exif.entity';
-export * from './geodata-admin1.entity';
-export * from './geodata-admin2.entity';
export * from './geodata-places.entity';
export * from './library.entity';
export * from './move.entity';
@@ -59,8 +55,6 @@ export const databaseEntities = [
AuditEntity,
ExifEntity,
GeodataPlacesEntity,
- GeodataAdmin1Entity,
- GeodataAdmin2Entity,
MoveEntity,
PartnerEntity,
PersonEntity,
diff --git a/server/src/infra/entities/system-config.entity.ts b/server/src/infra/entities/system-config.entity.ts
index 1515630cea..8307a0328e 100644
--- a/server/src/infra/entities/system-config.entity.ts
+++ b/server/src/infra/entities/system-config.entity.ts
@@ -7,7 +7,7 @@ export class SystemConfigEntity {
key!: SystemConfigKey;
@Column({ type: 'varchar', nullable: true, transformer: { to: JSON.stringify, from: JSON.parse } })
- value!: T;
+ value!: T | T[];
}
export type SystemConfigValue = string | number | boolean;
diff --git a/server/src/infra/infra.utils.ts b/server/src/infra/infra.utils.ts
index ab7d744317..745f5a38ff 100644
--- a/server/src/infra/infra.utils.ts
+++ b/server/src/infra/infra.utils.ts
@@ -183,7 +183,7 @@ export function searchAssetBuilder(
_.omitBy(
{
...status,
- isArchived: isArchived ?? withArchived,
+ isArchived: isArchived ?? (withArchived ? undefined : false),
encodedVideoPath: isEncoded ? Not(IsNull()) : undefined,
livePhotoVideoId: isMotion ? Not(IsNull()) : undefined,
},
@@ -213,9 +213,9 @@ export function searchAssetBuilder(
if (personIds && personIds.length > 0) {
builder
.leftJoin(`${builder.alias}.faces`, 'faces')
- .andWhere('faces.personId IN (:...personIds)', { personIds: personIds })
+ .andWhere('faces.personId IN (:...personIds)', { personIds })
.addGroupBy(`${builder.alias}.id`)
- .having('COUNT(faces.id) = :personCount', { personCount: personIds.length });
+ .having('COUNT(DISTINCT faces.personId) = :personCount', { personCount: personIds.length });
if (withExif) {
builder.addGroupBy('exifInfo.assetId');
diff --git a/server/src/infra/migrations/1707000751533-AddVectorsToSearchPath.ts b/server/src/infra/migrations/1707000751533-AddVectorsToSearchPath.ts
index e83e4b4fb0..11c84cf970 100644
--- a/server/src/infra/migrations/1707000751533-AddVectorsToSearchPath.ts
+++ b/server/src/infra/migrations/1707000751533-AddVectorsToSearchPath.ts
@@ -4,11 +4,11 @@ export class AddVectorsToSearchPath1707000751533 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise {
const res = await queryRunner.query(`SELECT current_database() as db`);
const databaseName = res[0]['db'];
- await queryRunner.query(`ALTER DATABASE ${databaseName} SET search_path TO "$user", public, vectors`);
+ await queryRunner.query(`ALTER DATABASE "${databaseName}" SET search_path TO "$user", public, vectors`);
}
public async down(queryRunner: QueryRunner): Promise {
const databaseName = await queryRunner.query(`SELECT current_database()`);
- await queryRunner.query(`ALTER DATABASE ${databaseName} SET search_path TO "$user", public`);
+ await queryRunner.query(`ALTER DATABASE "${databaseName}" SET search_path TO "$user", public`);
}
}
diff --git a/server/src/infra/migrations/1708059341865-GeodataLocationSearch.ts b/server/src/infra/migrations/1708059341865-GeodataLocationSearch.ts
new file mode 100644
index 0000000000..136ca2598d
--- /dev/null
+++ b/server/src/infra/migrations/1708059341865-GeodataLocationSearch.ts
@@ -0,0 +1,152 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class GeodataLocationSearch1708059341865 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS pg_trgm`);
+ await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS unaccent`);
+
+ // https://stackoverflow.com/a/11007216
+ await queryRunner.query(`
+ CREATE OR REPLACE FUNCTION f_unaccent(text)
+ RETURNS text
+ LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
+ RETURN unaccent('unaccent', $1)`);
+
+ await queryRunner.query(`ALTER TABLE geodata_places ADD COLUMN "admin1Name" varchar`);
+ await queryRunner.query(`ALTER TABLE geodata_places ADD COLUMN "admin2Name" varchar`);
+
+ await queryRunner.query(`
+ UPDATE geodata_places
+ SET "admin1Name" = admin1.name
+ FROM geodata_admin1 admin1
+ WHERE admin1.key = "admin1Key"`);
+
+ await queryRunner.query(`
+ UPDATE geodata_places
+ SET "admin2Name" = admin2.name
+ FROM geodata_admin2 admin2
+ WHERE admin2.key = "admin2Key"`);
+
+ await queryRunner.query(`DROP TABLE geodata_admin1 CASCADE`);
+ await queryRunner.query(`DROP TABLE geodata_admin2 CASCADE`);
+
+ await queryRunner.query(`
+ ALTER TABLE geodata_places
+ DROP COLUMN "admin1Key",
+ DROP COLUMN "admin2Key"`);
+
+ await queryRunner.query(`
+ CREATE INDEX idx_geodata_places_name
+ ON geodata_places
+ USING gin (f_unaccent(name) gin_trgm_ops)`);
+
+ await queryRunner.query(`
+ CREATE INDEX idx_geodata_places_admin1_name
+ ON geodata_places
+ USING gin (f_unaccent("admin1Name") gin_trgm_ops)`);
+
+ await queryRunner.query(`
+ CREATE INDEX idx_geodata_places_admin2_name
+ ON geodata_places
+ USING gin (f_unaccent("admin2Name") gin_trgm_ops)`);
+
+ await queryRunner.query(
+ `
+ DELETE FROM "typeorm_metadata"
+ WHERE
+ "type" = $1 AND
+ "name" = $2 AND
+ "database" = $3 AND
+ "schema" = $4 AND
+ "table" = $5`,
+ ['GENERATED_COLUMN', 'admin1Key', 'immich', 'public', 'geodata_places'],
+ );
+
+ await queryRunner.query(
+ `
+ DELETE FROM "typeorm_metadata"
+ WHERE
+ "type" = $1 AND
+ "name" = $2 AND
+ "database" = $3 AND
+ "schema" = $4 AND
+ "table" = $5`,
+ ['GENERATED_COLUMN', 'admin2Key', 'immich', 'public', 'geodata_places'],
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ CREATE TABLE "geodata_admin1" (
+ "key" character varying NOT NULL,
+ "name" character varying NOT NULL,
+ CONSTRAINT "PK_3fe3a89c5aac789d365871cb172" PRIMARY KEY ("key")
+ )`);
+
+ await queryRunner.query(`
+ CREATE TABLE "geodata_admin2" (
+ "key" character varying NOT NULL,
+ "name" character varying NOT NULL,
+ CONSTRAINT "PK_1e3886455dbb684d6f6b4756726" PRIMARY KEY ("key")
+ )`);
+
+ await queryRunner.query(`
+ ALTER TABLE geodata_places
+ ADD COLUMN "admin1Key" character varying
+ GENERATED ALWAYS AS ("countryCode" || '.' || "admin1Code") STORED,
+ ADD COLUMN "admin2Key" character varying
+ GENERATED ALWAYS AS ("countryCode" || '.' || "admin1Code" || '.' || "admin2Code") STORED`);
+
+ await queryRunner.query(
+ `
+ INSERT INTO "geodata_admin1"
+ SELECT DISTINCT
+ "admin1Key" AS "key",
+ "admin1Name" AS "name"
+ FROM geodata_places
+ WHERE "admin1Name" IS NOT NULL`,
+ );
+
+ await queryRunner.query(
+ `
+ INSERT INTO "geodata_admin2"
+ SELECT DISTINCT
+ "admin2Key" AS "key",
+ "admin2Name" AS "name"
+ FROM geodata_places
+ WHERE "admin2Name" IS NOT NULL`,
+ );
+
+ await queryRunner.query(`
+ UPDATE geodata_places
+ SET "admin1Name" = admin1.name
+ FROM geodata_admin1 admin1
+ WHERE admin1.key = "admin1Key"`);
+
+ await queryRunner.query(`
+ UPDATE geodata_places
+ SET "admin2Name" = admin2.name
+ FROM geodata_admin2 admin2
+ WHERE admin2.key = "admin2Key";`);
+
+ await queryRunner.query(
+ `
+ INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value")
+ VALUES ($1, $2, $3, $4, $5, $6)`,
+ ['immich', 'public', 'geodata_places', 'GENERATED_COLUMN', 'admin1Key', '"countryCode" || \'.\' || "admin1Code"'],
+ );
+
+ await queryRunner.query(
+ `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value")
+ VALUES ($1, $2, $3, $4, $5, $6)`,
+ [
+ 'immich',
+ 'public',
+ 'geodata_places',
+ 'GENERATED_COLUMN',
+ 'admin2Key',
+ '"countryCode" || \'.\' || "admin1Code" || \'.\' || "admin2Code"',
+ ],
+ );
+ }
+}
diff --git a/server/src/infra/migrations/1708116312820-GeonamesEnhancement.ts b/server/src/infra/migrations/1708116312820-GeonamesEnhancement.ts
new file mode 100644
index 0000000000..0cea9a0411
--- /dev/null
+++ b/server/src/infra/migrations/1708116312820-GeonamesEnhancement.ts
@@ -0,0 +1,18 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class GeonamesEnhancement1708116312820 implements MigrationInterface {
+ name = 'GeonamesEnhancement1708116312820'
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE geodata_places ADD COLUMN "alternateNames" varchar`);
+ await queryRunner.query(`
+ CREATE INDEX idx_geodata_places_admin2_alternate_names
+ ON geodata_places
+ USING gin (f_unaccent("alternateNames") gin_trgm_ops)`);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE geodata_places DROP COLUMN "alternateNames"`);
+ }
+
+}
diff --git a/server/src/infra/repositories/metadata.repository.ts b/server/src/infra/repositories/metadata.repository.ts
index 6a90ad1081..4abfe0eace 100644
--- a/server/src/infra/repositories/metadata.repository.ts
+++ b/server/src/infra/repositories/metadata.repository.ts
@@ -2,7 +2,7 @@ import {
citiesFile,
geodataAdmin1Path,
geodataAdmin2Path,
- geodataCitites500Path,
+ geodataCities500Path,
geodataDatePath,
GeoPoint,
IMetadataRepository,
@@ -10,13 +10,7 @@ import {
ISystemMetadataRepository,
ReverseGeocodeResult,
} from '@app/domain';
-import {
- ExifEntity,
- GeodataAdmin1Entity,
- GeodataAdmin2Entity,
- GeodataPlacesEntity,
- SystemMetadataKey,
-} from '@app/infra/entities';
+import { ExifEntity, GeodataPlacesEntity, SystemMetadataKey } from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger';
import { Inject } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
@@ -26,19 +20,16 @@ import { getName } from 'i18n-iso-countries';
import { createReadStream, existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import * as readLine from 'node:readline';
-import { DataSource, DeepPartial, QueryRunner, Repository } from 'typeorm';
+import { DataSource, QueryRunner, Repository } from 'typeorm';
+import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
import { DummyValue, GenerateSql } from '../infra.util';
-type GeoEntity = GeodataPlacesEntity | GeodataAdmin1Entity | GeodataAdmin2Entity;
-type GeoEntityClass = typeof GeodataPlacesEntity | typeof GeodataAdmin1Entity | typeof GeodataAdmin2Entity;
-
export class MetadataRepository implements IMetadataRepository {
constructor(
@InjectRepository(ExifEntity) private exifRepository: Repository,
@InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository,
- @InjectRepository(GeodataAdmin1Entity) private readonly geodataAdmin1Repository: Repository,
- @InjectRepository(GeodataAdmin2Entity) private readonly geodataAdmin2Repository: Repository,
- @Inject(ISystemMetadataRepository) private readonly systemMetadataRepository: ISystemMetadataRepository,
+ @Inject(ISystemMetadataRepository)
+ private readonly systemMetadataRepository: ISystemMetadataRepository,
@InjectDataSource() private dataSource: DataSource,
) {}
@@ -54,7 +45,6 @@ export class MetadataRepository implements IMetadataRepository {
return;
}
- this.logger.log('Importing geodata to database from file');
await this.importGeodata();
await this.systemMetadataRepository.set(SystemMetadataKey.REVERSE_GEOCODING_STATE, {
@@ -69,12 +59,14 @@ export class MetadataRepository implements IMetadataRepository {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
+ const admin1 = await this.loadAdmin(geodataAdmin1Path);
+ const admin2 = await this.loadAdmin(geodataAdmin2Path);
+
try {
await queryRunner.startTransaction();
- await this.loadCities500(queryRunner);
- await this.loadAdmin1(queryRunner);
- await this.loadAdmin2(queryRunner);
+ await queryRunner.manager.clear(GeodataPlacesEntity);
+ await this.loadCities500(queryRunner, admin1, admin2);
await queryRunner.commitTransaction();
} catch (error) {
@@ -86,76 +78,73 @@ export class MetadataRepository implements IMetadataRepository {
}
}
- private async loadGeodataToTableFromFile(
+ private async loadGeodataToTableFromFile(
queryRunner: QueryRunner,
- lineToEntityMapper: (lineSplit: string[]) => T,
+ lineToEntityMapper: (lineSplit: string[]) => GeodataPlacesEntity,
filePath: string,
- entity: GeoEntityClass,
) {
if (!existsSync(filePath)) {
this.logger.error(`Geodata file ${filePath} not found`);
throw new Error(`Geodata file ${filePath} not found`);
}
- await queryRunner.manager.clear(entity);
const input = createReadStream(filePath);
- let buffer: DeepPartial[] = [];
- const lineReader = readLine.createInterface({ input: input });
+ let bufferGeodata: QueryDeepPartialEntity[] = [];
+ const lineReader = readLine.createInterface({ input });
for await (const line of lineReader) {
const lineSplit = line.split('\t');
- buffer.push(lineToEntityMapper(lineSplit));
- if (buffer.length > 1000) {
- await queryRunner.manager.save(buffer);
- buffer = [];
+ const geoData = lineToEntityMapper(lineSplit);
+ bufferGeodata.push(geoData);
+ if (bufferGeodata.length > 1000) {
+ await queryRunner.manager.upsert(GeodataPlacesEntity, bufferGeodata, ['id']);
+ bufferGeodata = [];
}
}
- await queryRunner.manager.save(buffer);
+ await queryRunner.manager.upsert(GeodataPlacesEntity, bufferGeodata, ['id']);
}
- private async loadCities500(queryRunner: QueryRunner) {
- await this.loadGeodataToTableFromFile(
+ private async loadCities500(
+ queryRunner: QueryRunner,
+ admin1Map: Map,
+ admin2Map: Map,
+ ) {
+ await this.loadGeodataToTableFromFile(
queryRunner,
(lineSplit: string[]) =>
this.geodataPlacesRepository.create({
id: Number.parseInt(lineSplit[0]),
name: lineSplit[1],
+ alternateNames: lineSplit[3],
latitude: Number.parseFloat(lineSplit[4]),
longitude: Number.parseFloat(lineSplit[5]),
countryCode: lineSplit[8],
admin1Code: lineSplit[10],
admin2Code: lineSplit[11],
modificationDate: lineSplit[18],
+ admin1Name: admin1Map.get(`${lineSplit[8]}.${lineSplit[10]}`),
+ admin2Name: admin2Map.get(`${lineSplit[8]}.${lineSplit[10]}.${lineSplit[11]}`),
}),
- geodataCitites500Path,
- GeodataPlacesEntity,
+ geodataCities500Path,
);
}
- private async loadAdmin1(queryRunner: QueryRunner) {
- await this.loadGeodataToTableFromFile(
- queryRunner,
- (lineSplit: string[]) =>
- this.geodataAdmin1Repository.create({
- key: lineSplit[0],
- name: lineSplit[1],
- }),
- geodataAdmin1Path,
- GeodataAdmin1Entity,
- );
- }
+ private async loadAdmin(filePath: string) {
+ if (!existsSync(filePath)) {
+ this.logger.error(`Geodata file ${filePath} not found`);
+ throw new Error(`Geodata file ${filePath} not found`);
+ }
- private async loadAdmin2(queryRunner: QueryRunner) {
- await this.loadGeodataToTableFromFile(
- queryRunner,
- (lineSplit: string[]) =>
- this.geodataAdmin2Repository.create({
- key: lineSplit[0],
- name: lineSplit[1],
- }),
- geodataAdmin2Path,
- GeodataAdmin2Entity,
- );
+ const input = createReadStream(filePath);
+ const lineReader = readLine.createInterface({ input: input });
+
+ const adminMap = new Map();
+ for await (const line of lineReader) {
+ const lineSplit = line.split('\t');
+ adminMap.set(lineSplit[0], lineSplit[1]);
+ }
+
+ return adminMap;
}
async teardown() {
@@ -167,8 +156,6 @@ export class MetadataRepository implements IMetadataRepository {
const response = await this.geodataPlacesRepository
.createQueryBuilder('geoplaces')
- .leftJoinAndSelect('geoplaces.admin1', 'admin1')
- .leftJoinAndSelect('geoplaces.admin2', 'admin2')
.where('earth_box(ll_to_earth(:latitude, :longitude), 25000) @> "earthCoord"', point)
.orderBy('earth_distance(ll_to_earth(:latitude, :longitude), "earthCoord")')
.limit(1)
@@ -183,9 +170,9 @@ export class MetadataRepository implements IMetadataRepository {
this.logger.verbose(`Raw: ${JSON.stringify(response, null, 2)}`);
- const { countryCode, name: city, admin1, admin2 } = response;
+ const { countryCode, name: city, admin1Name, admin2Name } = response;
const country = getName(countryCode, 'en') ?? null;
- const stateParts = [admin2?.name, admin1?.name].filter((name) => !!name);
+ const stateParts = [admin2Name, admin1Name].filter((name) => !!name);
const state = stateParts.length > 0 ? stateParts.join(', ') : null;
return { country, state, city };
diff --git a/server/src/infra/repositories/person.repository.ts b/server/src/infra/repositories/person.repository.ts
index 85423b74dd..63b3d570ef 100644
--- a/server/src/infra/repositories/person.repository.ts
+++ b/server/src/infra/repositories/person.repository.ts
@@ -3,6 +3,7 @@ import {
IPersonRepository,
Paginated,
PaginationOptions,
+ PeopleStatistics,
PersonNameSearchOptions,
PersonSearchOptions,
PersonStatistics,
@@ -69,6 +70,7 @@ export class PersonRepository implements IPersonRepository {
.addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC')
.addOrderBy('COUNT(face.assetId)', 'DESC')
.addOrderBy("NULLIF(person.name, '')", 'ASC', 'NULLS LAST')
+ .andWhere("person.thumbnailPath != ''")
.having("person.name != '' OR COUNT(face.assetId) >= :faces", { faces: options?.minimumFaceCount || 1 })
.groupBy('person.id')
.limit(500);
@@ -207,15 +209,25 @@ export class PersonRepository implements IPersonRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
- async getNumberOfPeople(userId: string): Promise {
- return this.personRepository
+ async getNumberOfPeople(userId: string): Promise {
+ const items = await this.personRepository
.createQueryBuilder('person')
.leftJoin('person.faces', 'face')
.where('person.ownerId = :userId', { userId })
+ .innerJoin('face.asset', 'asset')
+ .andWhere('asset.isArchived = false')
+ .andWhere("person.thumbnailPath != ''")
+ .select('COUNT(DISTINCT(person.id))', 'total')
+ .addSelect('COUNT(DISTINCT(person.id)) FILTER (WHERE person.isHidden = true)', 'hidden')
.having('COUNT(face.assetId) != 0')
- .groupBy('person.id')
- .withDeleted()
- .getCount();
+ .getRawOne();
+
+ const result: PeopleStatistics = {
+ total: items ? Number.parseInt(items.total) : 0,
+ hidden: items ? Number.parseInt(items.hidden) : 0,
+ };
+
+ return result;
}
create(entity: Partial): Promise {
diff --git a/server/src/infra/repositories/search.repository.ts b/server/src/infra/repositories/search.repository.ts
index a30c96b10d..089640128c 100644
--- a/server/src/infra/repositories/search.repository.ts
+++ b/server/src/infra/repositories/search.repository.ts
@@ -12,7 +12,13 @@ import {
SmartSearchOptions,
} from '@app/domain';
import { getCLIPModelInfo } from '@app/domain/smart-info/smart-info.constant';
-import { AssetEntity, AssetFaceEntity, SmartInfoEntity, SmartSearchEntity } from '@app/infra/entities';
+import {
+ AssetEntity,
+ AssetFaceEntity,
+ GeodataPlacesEntity,
+ SmartInfoEntity,
+ SmartSearchEntity,
+} from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
@@ -31,6 +37,7 @@ export class SearchRepository implements ISearchRepository {
@InjectRepository(AssetEntity) private assetRepository: Repository,
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository,
@InjectRepository(SmartSearchEntity) private smartSearchRepository: Repository,
+ @InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository,
) {
this.faceColumns = this.assetFaceRepository.manager.connection
.getMetadata(AssetFaceEntity)
@@ -172,6 +179,27 @@ export class SearchRepository implements ISearchRepository {
}));
}
+ @GenerateSql({ params: [DummyValue.STRING] })
+ async searchPlaces(placeName: string): Promise {
+ return await this.geodataPlacesRepository
+ .createQueryBuilder('geoplaces')
+ .where(`f_unaccent(name) %>> f_unaccent(:placeName)`)
+ .orWhere(`f_unaccent("admin2Name") %>> f_unaccent(:placeName)`)
+ .orWhere(`f_unaccent("admin1Name") %>> f_unaccent(:placeName)`)
+ .orWhere(`f_unaccent("alternateNames") %>> f_unaccent(:placeName)`)
+ .orderBy(
+ `
+ COALESCE(f_unaccent(name) <->>> f_unaccent(:placeName), 0) +
+ COALESCE(f_unaccent("admin2Name") <->>> f_unaccent(:placeName), 0) +
+ COALESCE(f_unaccent("admin1Name") <->>> f_unaccent(:placeName), 0) +
+ COALESCE(f_unaccent("alternateNames") <->>> f_unaccent(:placeName), 0)
+ `,
+ )
+ .setParameters({ placeName })
+ .limit(20)
+ .getMany();
+ }
+
async upsert(smartInfo: Partial, embedding?: Embedding): Promise {
await this.repository.upsert(smartInfo, { conflictPaths: ['assetId'] });
if (!smartInfo.assetId || !embedding) {
diff --git a/server/src/infra/sql/asset.repository.sql b/server/src/infra/sql/asset.repository.sql
index d971129e75..e5cf6771fd 100644
--- a/server/src/infra/sql/asset.repository.sql
+++ b/server/src/infra/sql/asset.repository.sql
@@ -434,7 +434,7 @@ WHERE
AND 1 = 1
AND "asset"."ownerId" IN ($2)
AND 1 = 1
- AND 1 = 1
+ AND "asset"."isArchived" = $3
)
AND ("asset"."deletedAt" IS NULL)
ORDER BY
diff --git a/server/src/infra/sql/person.repository.sql b/server/src/infra/sql/person.repository.sql
index bd4a523e86..c2cc45ee88 100644
--- a/server/src/infra/sql/person.repository.sql
+++ b/server/src/infra/sql/person.repository.sql
@@ -26,6 +26,7 @@ FROM
WHERE
"person"."ownerId" = $1
AND "asset"."isArchived" = false
+ AND "person"."thumbnailPath" != ''
AND "person"."isHidden" = false
GROUP BY
"person"."id"
@@ -344,12 +345,20 @@ LIMIT
-- PersonRepository.getNumberOfPeople
SELECT
- COUNT(DISTINCT ("person"."id")) AS "cnt"
+ COUNT(DISTINCT ("person"."id")) AS "total",
+ COUNT(DISTINCT ("person"."id")) FILTER (
+ WHERE
+ "person"."isHidden" = true
+ ) AS "hidden"
FROM
"person" "person"
LEFT JOIN "asset_faces" "face" ON "face"."personId" = "person"."id"
+ INNER JOIN "assets" "asset" ON "asset"."id" = "face"."assetId"
+ AND ("asset"."deletedAt" IS NULL)
WHERE
"person"."ownerId" = $1
+ AND "asset"."isArchived" = false
+ AND "person"."thumbnailPath" != ''
HAVING
COUNT("face"."assetId") != 0
diff --git a/server/src/infra/sql/search.repository.sql b/server/src/infra/sql/search.repository.sql
index ebae46f65b..c45d90a7a3 100644
--- a/server/src/infra/sql/search.repository.sql
+++ b/server/src/infra/sql/search.repository.sql
@@ -79,7 +79,10 @@ FROM
AND "exifInfo"."lensModel" = $2
AND 1 = 1
AND 1 = 1
- AND "asset"."isFavorite" = $3
+ AND (
+ "asset"."isFavorite" = $3
+ AND "asset"."isArchived" = $4
+ )
AND (
"stack"."primaryAssetId" = "asset"."id"
OR "asset"."stackId" IS NULL
@@ -177,16 +180,19 @@ WHERE
AND "exifInfo"."lensModel" = $2
AND 1 = 1
AND 1 = 1
- AND "asset"."isFavorite" = $3
+ AND (
+ "asset"."isFavorite" = $3
+ AND "asset"."isArchived" = $4
+ )
AND (
"stack"."primaryAssetId" = "asset"."id"
OR "asset"."stackId" IS NULL
)
- AND "asset"."ownerId" IN ($4)
+ AND "asset"."ownerId" IN ($5)
)
AND ("asset"."deletedAt" IS NULL)
ORDER BY
- "search"."embedding" <= > $5 ASC
+ "search"."embedding" <= > $6 ASC
LIMIT
101
COMMIT
@@ -232,3 +238,37 @@ FROM
WHERE
res.distance <= $3
COMMIT
+
+-- SearchRepository.searchPlaces
+SELECT
+ "geoplaces"."id" AS "geoplaces_id",
+ "geoplaces"."name" AS "geoplaces_name",
+ "geoplaces"."longitude" AS "geoplaces_longitude",
+ "geoplaces"."latitude" AS "geoplaces_latitude",
+ "geoplaces"."countryCode" AS "geoplaces_countryCode",
+ "geoplaces"."admin1Code" AS "geoplaces_admin1Code",
+ "geoplaces"."admin2Code" AS "geoplaces_admin2Code",
+ "geoplaces"."admin1Name" AS "geoplaces_admin1Name",
+ "geoplaces"."admin2Name" AS "geoplaces_admin2Name",
+ "geoplaces"."alternateNames" AS "geoplaces_alternateNames",
+ "geoplaces"."modificationDate" AS "geoplaces_modificationDate"
+FROM
+ "geodata_places" "geoplaces"
+WHERE
+ f_unaccent (name) %>> f_unaccent ($1)
+ OR f_unaccent ("admin2Name") %>> f_unaccent ($1)
+ OR f_unaccent ("admin1Name") %>> f_unaccent ($1)
+ OR f_unaccent ("alternateNames") %>> f_unaccent ($1)
+ORDER BY
+ COALESCE(f_unaccent (name) <->>> f_unaccent ($1), 0) + COALESCE(
+ f_unaccent ("admin2Name") <->>> f_unaccent ($1),
+ 0
+ ) + COALESCE(
+ f_unaccent ("admin1Name") <->>> f_unaccent ($1),
+ 0
+ ) + COALESCE(
+ f_unaccent ("alternateNames") <->>> f_unaccent ($1),
+ 0
+ ) ASC
+LIMIT
+ 20
diff --git a/server/test/repositories/search.repository.mock.ts b/server/test/repositories/search.repository.mock.ts
index e0bdab269a..06a2cb76d0 100644
--- a/server/test/repositories/search.repository.mock.ts
+++ b/server/test/repositories/search.repository.mock.ts
@@ -7,5 +7,6 @@ export const newSearchRepositoryMock = (): jest.Mocked => {
searchSmart: jest.fn(),
searchFaces: jest.fn(),
upsert: jest.fn(),
+ searchPlaces: jest.fn(),
};
};
diff --git a/web/package-lock.json b/web/package-lock.json
index 654c7154cb..78e5caf7c5 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "immich-web",
- "version": "1.1.0",
+ "version": "1.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-web",
- "version": "1.1.0",
+ "version": "1.1.1",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@immich/sdk": "file:../open-api/typescript-sdk",
@@ -32,7 +32,7 @@
"@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/enhanced-img": "^0.1.8",
- "@sveltejs/kit": "^2.5.0",
+ "@sveltejs/kit": "^2.5.1",
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/svelte": "^4.0.3",
@@ -1859,9 +1859,9 @@
}
},
"node_modules/@sveltejs/kit": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.0.tgz",
- "integrity": "sha512-1uyXvzC2Lu1FZa30T4y5jUAC21R309ZMRG0TPt+PPPbNUoDpy8zSmSNVWYaBWxYDqLGQ5oPNWvjvvF2IjJ1jmA==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.1.tgz",
+ "integrity": "sha512-TKj08o3mJCoQNLTdRdGkHPePTCPUGTgkew65RDqjVU3MtPVxljsofXQYfXndHfq0P7KoPRO/0/reF6HesU0Djw==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
@@ -8733,9 +8733,9 @@
}
},
"node_modules/vite": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.2.tgz",
- "integrity": "sha512-uwiFebQbTWRIGbCaTEBVAfKqgqKNKMJ2uPXsXeLIZxM8MVMjoS3j0cG8NrPxdDIadaWnPSjrkLWffLSC+uiP3Q==",
+ "version": "5.1.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.3.tgz",
+ "integrity": "sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==",
"dev": true,
"dependencies": {
"esbuild": "^0.19.3",
diff --git a/web/package.json b/web/package.json
index 361849eb73..2b53d06451 100644
--- a/web/package.json
+++ b/web/package.json
@@ -1,6 +1,6 @@
{
"name": "immich-web",
- "version": "1.1.0",
+ "version": "1.1.1",
"license": "GNU Affero General Public License version 3",
"scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3000",
@@ -27,7 +27,7 @@
"@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/enhanced-img": "^0.1.8",
- "@sveltejs/kit": "^2.5.0",
+ "@sveltejs/kit": "^2.5.1",
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/svelte": "^4.0.3",
diff --git a/web/src/hooks.client.ts b/web/src/hooks.client.ts
index 1e29371fa9..802e9a7122 100644
--- a/web/src/hooks.client.ts
+++ b/web/src/hooks.client.ts
@@ -1,34 +1,22 @@
+import { isHttpError } from '@immich/sdk';
import type { HandleClientError } from '@sveltejs/kit';
-import type { AxiosError, AxiosResponse } from 'axios';
const LOG_PREFIX = '[hooks.client.ts]';
const DEFAULT_MESSAGE = 'Hmm, not sure about that. Check the logs or open a ticket?';
const parseError = (error: unknown) => {
- const httpError = error as AxiosError;
- const request = httpError?.request as Request & { path: string };
- const response = httpError?.response as AxiosResponse<{
- message: string;
- statusCode: number;
- error: string;
- }>;
+ const httpError = isHttpError(error) ? error : undefined;
+ const statusCode = httpError?.status || httpError?.data?.statusCode || 500;
+ const message = httpError?.data?.message || (httpError?.data && String(httpError.data)) || httpError?.message;
- let code = response?.data?.statusCode || response?.status || httpError.code || '500';
- if (response) {
- code += ` - ${response.data?.error || response.statusText}`;
- }
-
- if (request && response) {
- console.log({
- status: response.status,
- url: `${request.method} ${request.path}`,
- response: response.data || 'No data',
- });
- }
+ console.log({
+ status: statusCode,
+ response: httpError?.data || 'No data',
+ });
return {
- message: response?.data?.message || httpError?.message || DEFAULT_MESSAGE,
- code,
+ message: message || DEFAULT_MESSAGE,
+ code: statusCode,
stack: httpError?.stack,
};
};
diff --git a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte
index cd73b77f44..f4bead5b39 100644
--- a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte
@@ -14,12 +14,14 @@
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
- import SettingAccordion from '../setting-accordion.svelte';
- import SettingButtonsRow from '../setting-buttons-row.svelte';
- import SettingCheckboxes from '../setting-checkboxes.svelte';
- import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
- import SettingSelect from '../setting-select.svelte';
- import SettingSwitch from '../setting-switch.svelte';
+ import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
+ import SettingInputField, {
+ SettingInputFieldType,
+ } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+ import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
+ import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
+ import SettingCheckboxes from '$lib/components/shared-components/settings/setting-checkboxes.svelte';
+ import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -90,7 +92,10 @@
]}
name="acodec"
isEdited={config.ffmpeg.targetAudioCodec !== savedConfig.ffmpeg.targetAudioCodec}
- on:select={() => (config.ffmpeg.acceptedAudioCodecs = [config.ffmpeg.targetAudioCodec])}
+ on:select={() =>
+ config.ffmpeg.acceptedAudioCodecs.includes(config.ffmpeg.targetAudioCodec)
+ ? null
+ : config.ffmpeg.acceptedAudioCodecs.push(config.ffmpeg.targetAudioCodec)}
/>
- import SettingButtonsRow from '$lib/components/admin-page/settings/setting-buttons-row.svelte';
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
- import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
+ import SettingInputField, {
+ SettingInputFieldType,
+ } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+ import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
diff --git a/web/src/lib/components/admin-page/settings/setting-input-field.svelte b/web/src/lib/components/admin-page/settings/setting-input-field.svelte
deleted file mode 100644
index 3f16cdd431..0000000000
--- a/web/src/lib/components/admin-page/settings/setting-input-field.svelte
+++ /dev/null
@@ -1,75 +0,0 @@
-
-
-
-
-
-
-
- {#if required}
-
*
- {/if}
-
- {#if isEdited}
-
- Unsaved change
-
- {/if}
-
-
- {#if desc}
-
- {desc}
-
- {:else}
-
- {/if}
-
-
-
diff --git a/web/src/lib/components/admin-page/settings/setting-switch.svelte b/web/src/lib/components/admin-page/settings/setting-switch.svelte
deleted file mode 100644
index 6797423a55..0000000000
--- a/web/src/lib/components/admin-page/settings/setting-switch.svelte
+++ /dev/null
@@ -1,97 +0,0 @@
-
-
-
-
-
-
- {#if isEdited}
-
- Unsaved change
-
- {/if}
-
-
-
{subtitle}
-
-
-
-
-
-
diff --git a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte
index 475c3a65cb..11e07d0029 100644
--- a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte
@@ -13,11 +13,13 @@
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
- import SettingButtonsRow from '../setting-buttons-row.svelte';
- import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
- import SettingSwitch from '../setting-switch.svelte';
import SupportedDatetimePanel from './supported-datetime-panel.svelte';
import SupportedVariablesPanel from './supported-variables-panel.svelte';
+ import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
+ import SettingInputField, {
+ SettingInputFieldType,
+ } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+ import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -82,7 +84,26 @@
};
-
+
+
{#await getTemplateOptions() then}
+ import { locale } from '$lib/stores/preferences.store';
import type { SystemConfigTemplateStorageOptionDto } from '@immich/sdk';
- import * as luxon from 'luxon';
+ import { DateTime } from 'luxon';
export let options: SystemConfigTemplateStorageOptionDto;
const getLuxonExample = (format: string) => {
- return luxon.DateTime.fromISO(new Date('2022-09-04T20:03:05.250').toISOString()).toFormat(format);
+ return DateTime.fromISO('2022-09-04T20:03:05.250Z', { locale: $locale }).toFormat(format);
};
diff --git a/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte b/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte
index bb1f6351be..10c52c1361 100644
--- a/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte
@@ -4,8 +4,8 @@
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
- import SettingButtonsRow from '../setting-buttons-row.svelte';
- import SettingTextarea from '../setting-textarea.svelte';
+ import SettingTextarea from '$lib/components/shared-components/settings/setting-textarea.svelte';
+ import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
diff --git a/web/src/lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte b/web/src/lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte
index 4c63380fda..8e2936b556 100644
--- a/web/src/lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte
@@ -1,13 +1,16 @@
+
+
($isShowDetail = false)}
on:closeViewer={handleCloseViewer}
- on:descriptionFocusIn={disableKeyDownEvent}
- on:descriptionFocusOut={enableKeyDownEvent}
/>
{/if}
diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte
index 15d46da80b..38f65e3df7 100644
--- a/web/src/lib/components/asset-viewer/detail-panel.svelte
+++ b/web/src/lib/components/asset-viewer/detail-panel.svelte
@@ -40,6 +40,7 @@
import ChangeLocation from '../shared-components/change-location.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
+ import { NotificationType, notificationController } from '../shared-components/notification/notification';
export let asset: AssetResponseDto;
export let albums: AlbumResponseDto[] = [];
@@ -101,9 +102,6 @@
const dispatch = createEventDispatcher<{
close: void;
- descriptionFocusIn: void;
- descriptionFocusOut: void;
- click: AlbumResponseDto;
closeViewer: void;
}>();
@@ -139,19 +137,18 @@
showEditFaces = false;
};
- const handleFocusIn = () => {
- dispatch('descriptionFocusIn');
- };
-
const handleFocusOut = async () => {
textArea.blur();
if (description === originalDescription) {
return;
}
originalDescription = description;
- dispatch('descriptionFocusOut');
try {
await updateAsset({ id: asset.id, updateAssetDto: { description } });
+ notificationController.show({
+ type: NotificationType.Info,
+ message: 'Asset description has been updated',
+ });
} catch (error) {
handleError(error, 'Cannot update the description');
}
@@ -220,7 +217,6 @@
class="max-h-[500px]
w-full resize-none overflow-hidden border-b border-gray-500 bg-transparent text-base text-black outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:text-white dark:focus:border-immich-dark-primary"
placeholder={isOwner ? 'Add a description' : ''}
- on:focusin={handleFocusIn}
on:focusout={handleFocusOut}
on:input={() => autoGrowHeight(textArea)}
bind:value={description}
@@ -447,6 +443,7 @@
{@const assetDateTimeOriginal = asset.exifInfo?.dateTimeOriginal
? DateTime.fromISO(asset.exifInfo.dateTimeOriginal, {
zone: asset.exifInfo.timeZone ?? undefined,
+ locale: $locale,
})
: DateTime.now()}
APPEARS IN
{#each albums as album}
-
- dispatch('click', album)}
- on:keydown={() => dispatch('click', album)}
- >
+
![{album.albumName}]()
- import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
- import ProgressBar, { ProgressBarStatus } from '../shared-components/progress-bar/progress-bar.svelte';
+ import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
+ import ProgressBar, { ProgressBarStatus } from '$lib/components/shared-components/progress-bar/progress-bar.svelte';
+ import SlideshowSettings from '$lib/components/slideshow-settings.svelte';
import { slideshowStore } from '$lib/stores/slideshow.store';
+ import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiCog, mdiPause, mdiPlay } from '@mdi/js';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
- import {
- mdiChevronLeft,
- mdiChevronRight,
- mdiClose,
- mdiPause,
- mdiPlay,
- mdiShuffle,
- mdiShuffleDisabled,
- } from '@mdi/js';
- const { slideshowShuffle } = slideshowStore;
- const { restartProgress, stopProgress } = slideshowStore;
+ const { restartProgress, stopProgress, slideshowDelay, showProgressBar } = slideshowStore;
let progressBarStatus: ProgressBarStatus;
let progressBar: ProgressBar;
+ let showSettings = false;
let unsubscribeRestart: () => void;
let unsubscribeStop: () => void;
@@ -54,25 +47,27 @@
- dispatch('close')} title="Exit Slideshow" />
- {#if $slideshowShuffle}
- ($slideshowShuffle = false)} title="Shuffle" />
- {:else}
- ($slideshowShuffle = true)} title="No shuffle" />
- {/if}
+ dispatch('close')} title="Exit Slideshow" />
(progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())}
title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'}
/>
- dispatch('prev')} title="Previous" />
- dispatch('next')} title="Next" />
+ dispatch('prev')} title="Previous" />
+ dispatch('next')} title="Next" />
+ (showSettings = !showSettings)} title="Next" />
+{#if showSettings}
+
(showSettings = false)} />
+{/if}
+
dispatch('next')}
- duration={5000}
/>
diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte
index ec511d4192..8ee042a1a6 100644
--- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte
+++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte
@@ -3,7 +3,7 @@
import Icon from '$lib/components/elements/icon.svelte';
import { ProjectionType } from '$lib/constants';
import { getAssetFileUrl, getAssetThumbnailUrl, isSharedLink } from '$lib/utils';
- import { timeToSeconds } from '$lib/utils/time-to-seconds';
+ import { timeToSeconds } from '$lib/utils/date-time';
import { AssetTypeEnum, ThumbnailFormat, type AssetResponseDto } from '@immich/sdk';
import {
mdiArchiveArrowDownOutline,
diff --git a/web/src/lib/components/elements/date-input.svelte b/web/src/lib/components/elements/date-input.svelte
new file mode 100644
index 0000000000..e4ec4bcab8
--- /dev/null
+++ b/web/src/lib/components/elements/date-input.svelte
@@ -0,0 +1,24 @@
+
+
+ {
+ updatedValue = e.currentTarget.value;
+
+ // Only update when value is not empty to prevent resetting the input
+ if (updatedValue !== '') {
+ value = updatedValue;
+ }
+ }}
+ on:blur={() => (value = updatedValue)}
+/>
diff --git a/web/src/lib/components/faces-page/search-bar.svelte b/web/src/lib/components/elements/search-bar.svelte
similarity index 75%
rename from web/src/lib/components/faces-page/search-bar.svelte
rename to web/src/lib/components/elements/search-bar.svelte
index e1f999dbca..898601d0ad 100644
--- a/web/src/lib/components/faces-page/search-bar.svelte
+++ b/web/src/lib/components/elements/search-bar.svelte
@@ -1,12 +1,14 @@
-
+