Merge remote-tracking branch 'origin/main' into feature/gallery_app

This commit is contained in:
Peter Ombodi
2026-02-10 12:27:11 +02:00
65 changed files with 1773 additions and 1436 deletions
+1 -1
View File
@@ -131,7 +131,7 @@ jobs:
- device: rocm
suffixes: '-rocm'
platforms: linux/amd64
runner-mapping: '{"linux/amd64": "mich"}'
runner-mapping: '{"linux/amd64": "pokedex-giant"}'
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
permissions:
contents: read
+1 -1
View File
@@ -66,7 +66,7 @@ Now make sure that the local album is selected in the backup screen (steps 1-2 a
- **Keep on device:** You can choose to restrict removal to `Always keep` **All photos** or **All videos**, regardless of other settings. This setting can hamper freeing up space significantly — with 80 GB of videos and 40 GB photos, selecting `Always keep photos` retains thousands of photos on your device.
2. **Scan & Review:** Before any files are removed, you are presented with a review screen to verify which items will be deleted and how much storage is reclamable.
3. **Deletion:** Confirmed items are moved to your device's native Trash/Recycle Bin.
3. **Deletion:** Confirmed items are moved to your device's native Trash/Recycle Bin. For large queues, Immich processes deletion in batches for stability (`2000` assets per batch on Android, `10000` per batch on iOS).
:::info reclaim storage
To use the reclaimed space right away, you must empty the system/gallery trash manually outside of Immich.
+10
View File
@@ -26,6 +26,16 @@ docker image prune
[breaking]: https://github.com/immich-app/immich/discussions?discussions_q=label%3Achangelog%3Abreaking-change+sort%3Adate_created
[releases]: https://github.com/immich-app/immich/releases
## Versioning Policy
Immich follows [semantic versioning][semver], which tags releases in the format `<major>.<minor>.<patch>`. We intend for breaking changes to be limited to major version releases.
You can configure your Docker image to point to the current major version by using a metatag, such as `:v2`.
Currently, we have no plans to backport patches to earlier versions. We encourage all users to run the most recent release of Immich.
Switching back to an earlier version, even within the same minor release tag, is not supported.
[semver]: https://semver.org/
## Migrating to VectorChord
:::info
+5
View File
@@ -997,6 +997,11 @@
"editor_close_without_save_prompt": "The changes will not be saved",
"editor_close_without_save_title": "Close editor?",
"editor_confirm_reset_all_changes": "Are you sure you want to reset all changes?",
"editor_discard_edits_confirm": "Discard edits",
"editor_discard_edits_prompt": "You have unsaved edits. Are you sure you want to discard them?",
"editor_discard_edits_title": "Discard edits?",
"editor_edits_applied_error": "Failed to apply edits",
"editor_edits_applied_success": "Edits applied successfully",
"editor_flip_horizontal": "Flip horizontal",
"editor_flip_vertical": "Flip vertical",
"editor_orientation": "Orientation",
+1 -1
View File
@@ -54,7 +54,7 @@ RUN --mount=type=cache,target=/ccache \
--build_wheel \
--update \
--build \
--parallel 17 \
--parallel 48 \
--cmake_extra_defines \
ONNXRUNTIME_VERSION="${ONNXRUNTIME_VERSION}" \
CMAKE_HIP_ARCHITECTURES="gfx900;gfx906;gfx908;gfx90a;gfx940;gfx941;gfx942;gfx1030;gfx1100;gfx1101;gfx1102;gfx1200;gfx1201" \
+8 -1
View File
@@ -1,4 +1,11 @@
enum SortOrder { asc, desc }
enum SortOrder {
asc,
desc;
SortOrder reverse() {
return this == SortOrder.asc ? SortOrder.desc : SortOrder.asc;
}
}
enum TextSearchType { context, filename, description, ocr }
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
@@ -36,6 +37,7 @@ class RemoteAlbumService {
AlbumSortMode sortMode, {
bool isReverse = false,
}) async {
// list of albums sorted ascendingly according to the selected sort mode
final List<RemoteAlbum> sorted = switch (sortMode) {
AlbumSortMode.created => albums.sortedBy((album) => album.createdAt),
AlbumSortMode.title => albums.sortedBy((album) => album.name),
@@ -44,8 +46,9 @@ class RemoteAlbumService {
AlbumSortMode.mostRecent => await _sortByNewestAsset(albums),
AlbumSortMode.mostOldest => await _sortByOldestAsset(albums),
};
final effectiveOrder = isReverse ? sortMode.defaultOrder.reverse() : sortMode.defaultOrder;
return (isReverse ? sorted.reversed : sorted).toList();
return (effectiveOrder == SortOrder.asc ? sorted : sorted.reversed).toList();
}
List<RemoteAlbum> searchAlbums(
@@ -209,6 +212,6 @@ class RemoteAlbumService {
return aDate.compareTo(bDate);
});
return sorted.reversed.toList();
return sorted;
}
}
+2 -2
View File
@@ -78,7 +78,7 @@ Future<void> initApp() async {
await EasyLocalization.ensureInitialized();
await initializeDateFormatting();
if (kReleaseMode && Platform.isAndroid) {
if (Platform.isAndroid) {
try {
await FlutterDisplayMode.setHighRefreshRate();
dPrint(() => "Enabled high refresh mode");
@@ -117,7 +117,7 @@ Future<void> initApp() async {
await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false);
await FileDownloader().trackTasks();
unawaited(FileDownloader().trackTasks());
LicenseRegistry.addLicense(() async* {
for (final license in nonPubLicenses.entries) {
@@ -5,6 +5,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -281,6 +282,8 @@ class _SortButtonState extends ConsumerState<_SortButton> {
setState(() {
albumSortOption = sortMode;
isSorting = true;
// reset sort order to default state when switching option
albumSortIsReverse = false;
});
}
@@ -293,6 +296,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
@override
Widget build(BuildContext context) {
final effectiveOrder = albumSortOption.effectiveOrder(albumSortIsReverse);
return MenuAnchor(
controller: widget.controller,
style: MenuStyle(
@@ -307,7 +311,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
.map(
(sortMode) => MenuItemButton(
leadingIcon: albumSortOption == sortMode
? albumSortIsReverse
? effectiveOrder == SortOrder.desc
? Icon(
Icons.keyboard_arrow_down,
color: albumSortOption == sortMode
@@ -355,7 +359,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
children: [
Padding(
padding: const EdgeInsets.only(right: 5),
child: albumSortIsReverse
child: effectiveOrder == SortOrder.desc
? Icon(Icons.keyboard_arrow_down, color: context.colorScheme.onSurface)
: Icon(Icons.keyboard_arrow_up_rounded, color: context.colorScheme.onSurface),
),
@@ -62,6 +62,9 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
return;
}
yield image;
} catch (e) {
PaintingBinding.instance.imageCache.evict(this);
rethrow;
} finally {
this.request = null;
}
@@ -31,7 +31,7 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<Size>('Size', key.size),
],
onDispose: cancel,
onLastListenerRemoved: cancel,
);
}
@@ -76,7 +76,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<Size>('Size', key.size),
],
onDispose: cancel,
onLastListenerRemoved: cancel,
);
}
@@ -9,7 +9,10 @@ import 'package:flutter/painting.dart';
/// An ImageStreamCompleter with support for loading multiple images.
class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
void Function()? _onDispose;
void Function()? _onLastListenerRemoved;
int _listenerCount = 0;
// True once setImage() has been called at least once.
bool didProvideImage = false;
/// The constructor to create an OneFramePlaceholderImageStreamCompleter. The [images]
/// should be the primary images to display (typically asynchronously as they load).
@@ -19,14 +22,18 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
Stream<ImageInfo> images, {
ImageInfo? initialImage,
InformationCollector? informationCollector,
void Function()? onDispose,
void Function()? onLastListenerRemoved,
}) {
if (initialImage != null) {
didProvideImage = true;
setImage(initialImage);
}
_onDispose = onDispose;
_onLastListenerRemoved = onLastListenerRemoved;
images.listen(
setImage,
(image) {
didProvideImage = true;
setImage(image);
},
onError: (Object error, StackTrace stack) {
reportError(
context: ErrorDescription('resolving a single-frame image stream'),
@@ -40,12 +47,24 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
}
@override
void onDisposed() {
final onDispose = _onDispose;
if (onDispose != null) {
_onDispose = null;
onDispose();
void addListener(ImageStreamListener listener) {
super.addListener(listener);
_listenerCount = _listenerCount + 1;
}
@override
void removeListener(ImageStreamListener listener) {
super.removeListener(listener);
_listenerCount = _listenerCount - 1;
final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage;
final bool noListenersAfterImage = _listenerCount == 0 && didProvideImage;
final onLastListenerRemoved = _onLastListenerRemoved;
if (onLastListenerRemoved != null && (noListenersAfterImage || onlyCacheListenerLeft)) {
_onLastListenerRemoved = null;
onLastListenerRemoved();
}
super.onDisposed();
}
}
@@ -32,7 +32,7 @@ class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('URL', key.url),
],
onDispose: cancel,
onLastListenerRemoved: cancel,
);
}
@@ -76,7 +76,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
],
onDispose: cancel,
onLastListenerRemoved: cancel,
);
}
@@ -17,7 +17,7 @@ class ThumbHashProvider extends CancellableImageProvider<ThumbHashProvider>
@override
ImageStreamCompleter loadImage(ThumbHashProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(_loadCodec(key, decode), onDispose: cancel);
return OneFramePlaceholderImageStreamCompleter(_loadCodec(key, decode), onLastListenerRemoved: cancel);
}
Stream<ImageInfo> _loadCodec(ThumbHashProvider key, ImageDecoderCallback decode) {
@@ -233,16 +233,6 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
@override
void dispose() {
final imageProvider = widget.imageProvider;
if (imageProvider is CancellableImageProvider) {
imageProvider.cancel();
}
final thumbhashProvider = widget.thumbhashProvider;
if (thumbhashProvider is CancellableImageProvider) {
thumbhashProvider.cancel();
}
_fadeController.removeStatusListener(_onAnimationStatusChanged);
_fadeController.dispose();
_stopListeningToStream();
@@ -1,4 +1,5 @@
import 'package:collection/collection.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/entities/album.entity.dart';
@@ -73,18 +74,21 @@ class _AlbumSortHandlers {
// Store index allows us to re-arrange the values without affecting the saved prefs
enum AlbumSortMode {
title(1, "library_page_sort_title", _AlbumSortHandlers.title),
assetCount(4, "library_page_sort_asset_count", _AlbumSortHandlers.assetCount),
lastModified(3, "library_page_sort_last_modified", _AlbumSortHandlers.lastModified),
created(0, "library_page_sort_created", _AlbumSortHandlers.created),
mostRecent(2, "sort_recent", _AlbumSortHandlers.mostRecent),
mostOldest(5, "sort_oldest", _AlbumSortHandlers.mostOldest);
title(1, "library_page_sort_title", _AlbumSortHandlers.title, SortOrder.asc),
assetCount(4, "library_page_sort_asset_count", _AlbumSortHandlers.assetCount, SortOrder.desc),
lastModified(3, "library_page_sort_last_modified", _AlbumSortHandlers.lastModified, SortOrder.desc),
created(0, "library_page_sort_created", _AlbumSortHandlers.created, SortOrder.desc),
mostRecent(2, "sort_recent", _AlbumSortHandlers.mostRecent, SortOrder.desc),
mostOldest(5, "sort_oldest", _AlbumSortHandlers.mostOldest, SortOrder.asc);
final int storeIndex;
final String label;
final AlbumSortFn sortFn;
final SortOrder defaultOrder;
const AlbumSortMode(this.storeIndex, this.label, this.sortFn);
const AlbumSortMode(this.storeIndex, this.label, this.sortFn, this.defaultOrder);
SortOrder effectiveOrder(bool isReverse) => isReverse ? defaultOrder.reverse() : defaultOrder;
}
@riverpod
+15 -5
View File
@@ -1,5 +1,6 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
@@ -9,6 +10,8 @@ final cleanupServiceProvider = Provider<CleanupService>((ref) {
});
class CleanupService {
static final int _deleteBatchSize = CurrentPlatform.isAndroid ? 2000 : 10000;
final DriftLocalAssetRepository _localAssetRepository;
final AssetMediaRepository _assetMediaRepository;
@@ -35,13 +38,20 @@ class CleanupService {
return 0;
}
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
if (deletedIds.isNotEmpty) {
await _localAssetRepository.delete(deletedIds);
return deletedIds.length;
int deletedCount = 0;
for (int index = 0; index < localIds.length; index += _deleteBatchSize) {
final end = index + _deleteBatchSize < localIds.length ? index + _deleteBatchSize : localIds.length;
final batch = localIds.sublist(index, end);
final deletedIds = await _assetMediaRepository.deleteAll(batch);
if (deletedIds.isNotEmpty) {
await _localAssetRepository.delete(deletedIds);
deletedCount += deletedIds.length;
}
}
return 0;
return deletedCount;
}
/// Returns album IDs that should be kept by default (e.g., messaging app albums)
+4 -6
View File
@@ -27,19 +27,17 @@ import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
void configureFileDownloaderNotifications() {
final fileName = 'file_name'.t(args: {'file_name': '{filename}'});
FileDownloader().configureNotificationForGroup(
kDownloadGroupImage,
running: TaskNotification('downloading_media'.t(), fileName),
complete: TaskNotification('download_finished'.t(), fileName),
running: TaskNotification('downloading_media'.t(), '${'file_name_text'.t()}: {filename}'),
complete: TaskNotification('download_finished'.t(), '${'file_name_text'.t()}: {filename}'),
progressBar: true,
);
FileDownloader().configureNotificationForGroup(
kDownloadGroupVideo,
running: TaskNotification('downloading_media'.t(), fileName),
complete: TaskNotification('download_finished'.t(), fileName),
running: TaskNotification('downloading_media'.t(), '${'file_name_text'.t()}: {filename}'),
complete: TaskNotification('download_finished'.t(), '${'file_name_text'.t()}: {filename}'),
progressBar: true,
);
@@ -128,6 +128,9 @@ class _ProfileIndicator extends ConsumerWidget {
const widgetSize = 30.0;
// TODO: remove this when update Flutter version newer than 3.35.7
final isIpad = defaultTargetPlatform == TargetPlatform.iOS && !context.isMobile;
void toggleReadonlyMode() {
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
ref.read(readonlyModeProvider.notifier).toggleReadonlyMode();
@@ -144,7 +147,12 @@ class _ProfileIndicator extends ConsumerWidget {
}
return InkWell(
onTap: () => showDialog(context: context, useRootNavigator: false, builder: (ctx) => const ImmichAppBarDialog()),
onTap: () => showDialog(
context: context,
useRootNavigator: false,
barrierDismissible: !isIpad,
builder: (ctx) => const ImmichAppBarDialog(),
),
onLongPress: () => toggleReadonlyMode(),
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: Badge(
@@ -1,5 +1,3 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
@@ -20,7 +18,7 @@ class UserCircleAvatar extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final userAvatarColor = user.avatarColor.toColor();
final profileImageUrl =
'${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${Random().nextInt(1024)}';
'${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${user.profileChangedAt.millisecondsSinceEpoch}';
final textIcon = DefaultTextStyle(
style: TextStyle(
@@ -85,35 +85,47 @@ void main() {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, AlbumSortMode.created);
expect(result, [albumA, albumB]);
expect(result, [albumB, albumA]);
});
test('should sort correctly based on updatedAt', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, AlbumSortMode.lastModified);
expect(result, [albumA, albumB]);
expect(result, [albumB, albumA]);
});
test('should sort correctly based on assetCount', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, AlbumSortMode.assetCount);
expect(result, [albumA, albumB]);
expect(result, [albumB, albumA]);
});
test('should sort correctly based on newestAssetTimestamp', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, AlbumSortMode.mostRecent);
expect(result, [albumA, albumB]);
expect(result, [albumB, albumA]);
});
test('should sort correctly based on oldestAssetTimestamp', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, AlbumSortMode.mostOldest);
expect(result, [albumB, albumA]);
expect(result, [albumA, albumB]);
});
test('should flip order when isReverse is true for all modes', () async {
final albums = [albumB, albumA];
for (final mode in AlbumSortMode.values) {
final normal = await sut.sortAlbums(albums, mode, isReverse: false);
final reversed = await sut.sortAlbums(albums, mode, isReverse: true);
// reversed should be the exact inverse of normal
expect(reversed, normal.reversed.toList(), reason: 'Mode: $mode');
}
});
});
}
@@ -0,0 +1,73 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/services/cleanup.service.dart';
import 'package:mocktail/mocktail.dart';
import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart';
void main() {
late CleanupService sut;
late MockDriftLocalAssetRepository localAssetRepository;
late MockAssetMediaRepository assetMediaRepository;
setUp(() {
localAssetRepository = MockDriftLocalAssetRepository();
assetMediaRepository = MockAssetMediaRepository();
sut = CleanupService(localAssetRepository, assetMediaRepository);
});
group('CleanupService.deleteLocalAssets', () {
test('returns 0 and does nothing for empty input', () async {
final result = await sut.deleteLocalAssets([]);
expect(result, 0);
verifyNever(() => assetMediaRepository.deleteAll(any()));
verifyNever(() => localAssetRepository.delete(any()));
});
test('deletes in a single batch when under limit', () async {
final ids = List.generate(999, (i) => 'asset-$i');
when(() => assetMediaRepository.deleteAll(any())).thenAnswer((invocation) async {
return (invocation.positionalArguments.first as List<String>).toList();
});
when(() => localAssetRepository.delete(any())).thenAnswer((_) async {});
final result = await sut.deleteLocalAssets(ids);
expect(result, ids.length);
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
verify(() => localAssetRepository.delete(ids)).called(1);
});
test('deletes in platform-specific batches when over limit', () async {
final batchSize = CurrentPlatform.isAndroid ? 2000 : 10000;
final ids = List.generate(batchSize * 2 + 501, (i) => 'asset-$i');
final capturedBatches = <List<String>>[];
when(() => assetMediaRepository.deleteAll(any())).thenAnswer((invocation) async {
final batch = (invocation.positionalArguments.first as List<String>).toList();
capturedBatches.add(batch);
return batch;
});
when(() => localAssetRepository.delete(any())).thenAnswer((_) async {});
final result = await sut.deleteLocalAssets(ids);
expect(result, ids.length);
expect(capturedBatches.length, 3);
expect(capturedBatches[0].length, batchSize);
expect(capturedBatches[1].length, batchSize);
expect(capturedBatches[2].length, 501);
expect(capturedBatches[0].first, 'asset-0');
expect(capturedBatches[0].last, 'asset-${batchSize - 1}');
expect(capturedBatches[1].first, 'asset-$batchSize');
expect(capturedBatches[1].last, 'asset-${batchSize * 2 - 1}');
expect(capturedBatches[2].first, 'asset-${batchSize * 2}');
expect(capturedBatches[2].last, 'asset-${batchSize * 2 + 500}');
verify(() => localAssetRepository.delete(any())).called(3);
});
});
}
+5 -5
View File
@@ -741,8 +741,8 @@ importers:
specifier: file:../open-api/typescript-sdk
version: link:../open-api/typescript-sdk
'@immich/ui':
specifier: ^0.61.3
version: 0.61.3(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)
specifier: ^0.61.4
version: 0.61.4(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)
'@mapbox/mapbox-gl-rtl-text':
specifier: 0.2.3
version: 0.2.3(mapbox-gl@1.13.3)
@@ -3131,8 +3131,8 @@ packages:
peerDependencies:
svelte: ^5.0.0
'@immich/ui@0.61.3':
resolution: {integrity: sha512-9cz/7kc/CSmJ37gH5nIZNiHxw5OlBCGbdlSGkCOtaMJ458wmcdUFVmF5arjGioaOa4NMwseOVyln7rMhkNU7ww==}
'@immich/ui@0.61.4':
resolution: {integrity: sha512-32nrY7GW6BdBQ12ZI/E4VgrgY40Yn2K31vSO6GPiOvmNgt8h3s3TSKUXbh7pY4yJgfnr7f2QL2EYRV9KkjRybQ==}
peerDependencies:
svelte: ^5.0.0
@@ -15762,7 +15762,7 @@ snapshots:
node-emoji: 2.2.0
svelte: 5.48.0
'@immich/ui@0.61.3(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)':
'@immich/ui@0.61.4(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)':
dependencies:
'@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.48.0)
'@internationalized/date': 3.10.0
+8 -5
View File
@@ -1,15 +1,18 @@
import { mapAlbum } from 'src/dtos/album.dto';
import { albumStub } from 'test/fixtures/album.stub';
import { AlbumFactory } from 'test/factories/album.factory';
describe('mapAlbum', () => {
it('should set start and end dates', () => {
const dto = mapAlbum(albumStub.twoAssets, false);
expect(dto.startDate).toEqual(new Date('2020-12-31T23:59:00.000Z'));
expect(dto.endDate).toEqual(new Date('2025-01-01T01:02:03.456Z'));
const startDate = new Date('2023-02-22T05:06:29.716Z');
const endDate = new Date('2025-01-01T01:02:03.456Z');
const album = AlbumFactory.from().asset({ localDateTime: endDate }).asset({ localDateTime: startDate }).build();
const dto = mapAlbum(album, false);
expect(dto.startDate).toEqual(startDate);
expect(dto.endDate).toEqual(endDate);
});
it('should not set start and end dates for empty assets', () => {
const dto = mapAlbum(albumStub.empty, false);
const dto = mapAlbum(AlbumFactory.create(), false);
expect(dto.startDate).toBeUndefined();
expect(dto.endDate).toBeUndefined();
});
+3 -3
View File
@@ -58,7 +58,7 @@ select
from
(
select
*
"shared_link".*
from
"shared_link"
where
@@ -243,7 +243,7 @@ select
from
(
select
*
"shared_link".*
from
"shared_link"
where
@@ -316,7 +316,7 @@ select
from
(
select
*
"shared_link".*
from
"shared_link"
where
@@ -17,8 +17,6 @@ set
where
"userId" = $2
and "albumId" = $3
returning
*
-- AlbumUserRepository.delete
delete from "album_user"
@@ -25,14 +25,13 @@ export class AlbumUserRepository {
}
@GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }, { role: AlbumUserRole.Viewer }] })
update({ userId, albumId }: AlbumPermissionId, dto: Updateable<AlbumUserTable>) {
return this.db
async update({ userId, albumId }: AlbumPermissionId, dto: Updateable<AlbumUserTable>) {
await this.db
.updateTable('album_user')
.set(dto)
.where('userId', '=', userId)
.where('albumId', '=', albumId)
.returningAll()
.executeTakeFirstOrThrow();
.execute();
}
@GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }] })
+4 -4
View File
@@ -44,9 +44,9 @@ const withAlbumUsers = (eb: ExpressionBuilder<DB, 'album'>) => {
};
const withSharedLink = (eb: ExpressionBuilder<DB, 'album'>) => {
return jsonArrayFrom(eb.selectFrom('shared_link').selectAll().whereRef('shared_link.albumId', '=', 'album.id')).as(
'sharedLinks',
);
return jsonArrayFrom(
eb.selectFrom('shared_link').selectAll('shared_link').whereRef('shared_link.albumId', '=', 'album.id'),
).as('sharedLinks');
};
const withAssets = (eb: ExpressionBuilder<DB, 'album'>) => {
@@ -283,7 +283,7 @@ export class AlbumRepository {
return tx
.selectFrom('album')
.selectAll()
.selectAll('album')
.where('id', '=', newAlbum.id)
.select(withOwner)
.select(withAssets)
@@ -260,7 +260,7 @@ export class SharedLinkRepository {
.selectAll('asset')
.innerJoinLateral(
(eb) =>
eb.selectFrom('asset_exif').whereRef('asset_exif.assetId', '=', 'asset.id').selectAll().as('exif'),
eb.selectFrom('asset_exif').whereRef('asset_exif.assetId', '=', 'asset.id').selectAll().as('exifInfo'),
(join) => join.onTrue(),
)
.as('assets'),
File diff suppressed because it is too large Load Diff
+43 -53
View File
@@ -6,9 +6,10 @@ import { AssetEditAction } from 'src/dtos/editing.dto';
import { AssetMetadataKey, AssetStatus, AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
import { AssetStats } from 'src/repositories/asset.repository';
import { AssetService } from 'src/services/asset.service';
import { AssetFactory } from 'test/factories/asset.factory';
import { AuthFactory } from 'test/factories/auth.factory';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { userStub } from 'test/fixtures/user.stub';
import { factory } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
@@ -45,35 +46,33 @@ describe(AssetService.name, () => {
describe('getStatistics', () => {
it('should get the statistics for a user, excluding archived assets', async () => {
const auth = AuthFactory.create();
mocks.asset.getStatistics.mockResolvedValue(stats);
await expect(sut.getStatistics(authStub.admin, { visibility: AssetVisibility.Timeline })).resolves.toEqual(
statResponse,
);
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, {
visibility: AssetVisibility.Timeline,
});
await expect(sut.getStatistics(auth, { visibility: AssetVisibility.Timeline })).resolves.toEqual(statResponse);
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(auth.user.id, { visibility: AssetVisibility.Timeline });
});
it('should get the statistics for a user for archived assets', async () => {
const auth = AuthFactory.create();
mocks.asset.getStatistics.mockResolvedValue(stats);
await expect(sut.getStatistics(authStub.admin, { visibility: AssetVisibility.Archive })).resolves.toEqual(
statResponse,
);
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, {
await expect(sut.getStatistics(auth, { visibility: AssetVisibility.Archive })).resolves.toEqual(statResponse);
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(auth.user.id, {
visibility: AssetVisibility.Archive,
});
});
it('should get the statistics for a user for favorite assets', async () => {
const auth = AuthFactory.create();
mocks.asset.getStatistics.mockResolvedValue(stats);
await expect(sut.getStatistics(authStub.admin, { isFavorite: true })).resolves.toEqual(statResponse);
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isFavorite: true });
await expect(sut.getStatistics(auth, { isFavorite: true })).resolves.toEqual(statResponse);
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(auth.user.id, { isFavorite: true });
});
it('should get the statistics for a user for all assets', async () => {
const auth = AuthFactory.create();
mocks.asset.getStatistics.mockResolvedValue(stats);
await expect(sut.getStatistics(authStub.admin, {})).resolves.toEqual(statResponse);
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, {});
await expect(sut.getStatistics(auth, {})).resolves.toEqual(statResponse);
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(auth.user.id, {});
});
});
@@ -249,10 +248,11 @@ describe(AssetService.name, () => {
});
it('should fail linking a live video if the motion part could not be found', async () => {
const auth = AuthFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
await expect(
sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, {
sut.update(auth, assetStub.livePhotoStillAsset.id, {
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
}),
).rejects.toBeInstanceOf(BadRequestException);
@@ -267,11 +267,12 @@ describe(AssetService.name, () => {
});
expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', {
assetId: assetStub.livePhotoMotionAsset.id,
userId: userStub.admin.id,
userId: auth.user.id,
});
});
it('should fail linking a live video if the motion part is not a video', async () => {
const auth = AuthFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset);
@@ -291,16 +292,17 @@ describe(AssetService.name, () => {
});
expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', {
assetId: assetStub.livePhotoMotionAsset.id,
userId: userStub.admin.id,
userId: auth.user.id,
});
});
it('should fail linking a live video if the motion part has a different owner', async () => {
const auth = AuthFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset);
await expect(
sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, {
sut.update(auth, assetStub.livePhotoStillAsset.id, {
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
}),
).rejects.toBeInstanceOf(BadRequestException);
@@ -315,52 +317,41 @@ describe(AssetService.name, () => {
});
expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', {
assetId: assetStub.livePhotoMotionAsset.id,
userId: userStub.admin.id,
userId: auth.user.id,
});
});
it('should link a live video', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
mocks.asset.getById.mockResolvedValueOnce({
...assetStub.livePhotoMotionAsset,
ownerId: authStub.admin.user.id,
visibility: AssetVisibility.Timeline,
});
mocks.asset.getById.mockResolvedValueOnce(assetStub.image);
mocks.asset.update.mockResolvedValue(assetStub.image);
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Timeline });
const stillAsset = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([stillAsset.id]));
mocks.asset.getById.mockResolvedValueOnce(motionAsset);
mocks.asset.getById.mockResolvedValueOnce(stillAsset);
mocks.asset.update.mockResolvedValue(stillAsset);
const auth = AuthFactory.from(motionAsset.owner).build();
await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, {
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
});
await sut.update(auth, stillAsset.id, { livePhotoVideoId: motionAsset.id });
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.livePhotoMotionAsset.id,
visibility: AssetVisibility.Hidden,
});
expect(mocks.event.emit).toHaveBeenCalledWith('AssetHide', {
assetId: assetStub.livePhotoMotionAsset.id,
userId: userStub.admin.id,
});
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.livePhotoStillAsset.id,
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
});
expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, visibility: AssetVisibility.Hidden });
expect(mocks.event.emit).toHaveBeenCalledWith('AssetHide', { assetId: motionAsset.id, userId: auth.user.id });
expect(mocks.asset.update).toHaveBeenCalledWith({ id: stillAsset.id, livePhotoVideoId: motionAsset.id });
});
it('should throw an error if asset could not be found after update', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
await expect(sut.update(authStub.admin, 'asset-1', { isFavorite: true })).rejects.toBeInstanceOf(
await expect(sut.update(AuthFactory.create(), 'asset-1', { isFavorite: true })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('should unlink a live video', async () => {
const auth = AuthFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
mocks.asset.update.mockResolvedValueOnce(assetStub.image);
await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null });
await sut.update(auth, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null });
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.livePhotoStillAsset.id,
@@ -372,7 +363,7 @@ describe(AssetService.name, () => {
});
expect(mocks.event.emit).toHaveBeenCalledWith('AssetShow', {
assetId: assetStub.livePhotoMotionAsset.id,
userId: userStub.admin.id,
userId: auth.user.id,
});
});
@@ -392,17 +383,15 @@ describe(AssetService.name, () => {
describe('updateAll', () => {
it('should require asset write access for all ids', async () => {
await expect(
sut.updateAll(authStub.admin, {
ids: ['asset-1'],
}),
).rejects.toBeInstanceOf(BadRequestException);
const auth = AuthFactory.create();
await expect(sut.updateAll(auth, { ids: ['asset-1'] })).rejects.toBeInstanceOf(BadRequestException);
});
it('should update all assets', async () => {
const auth = AuthFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], visibility: AssetVisibility.Archive });
await sut.updateAll(auth, { ids: ['asset-1', 'asset-2'], visibility: AssetVisibility.Archive });
expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], {
visibility: AssetVisibility.Archive,
@@ -410,9 +399,10 @@ describe(AssetService.name, () => {
});
it('should not update Assets table if no relevant fields are provided', async () => {
const auth = AuthFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
await sut.updateAll(authStub.admin, {
await sut.updateAll(auth, {
ids: ['asset-1'],
latitude: 0,
longitude: 0,
+17 -16
View File
@@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common';
import { Readable } from 'node:stream';
import { DownloadResponseDto } from 'src/dtos/download.dto';
import { DownloadService } from 'src/services/download.service';
import { AssetFactory } from 'test/factories/asset.factory';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
@@ -60,22 +61,22 @@ describe(DownloadService.name, () => {
stream: new Readable(),
};
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
const asset1 = AssetFactory.create();
const asset2 = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id]));
mocks.storage.realpath.mockRejectedValue(new Error('Could not read file'));
mocks.asset.getByIds.mockResolvedValue([
{ ...assetStub.noResizePath, id: 'asset-1' },
{ ...assetStub.noWebpPath, id: 'asset-2' },
]);
mocks.asset.getByIds.mockResolvedValue([asset1, asset2]);
mocks.storage.createZipStream.mockReturnValue(archiveMock);
await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({
await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({
stream: archiveMock.stream,
});
expect(mocks.logger.warn).toHaveBeenCalledTimes(2);
expect(archiveMock.addFile).toHaveBeenCalledTimes(2);
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, '/data/library/IMG_123.jpg', 'IMG_123.jpg');
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, '/data/library/IMG_456.jpg', 'IMG_456.jpg');
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, asset1.originalPath, asset1.originalFileName);
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, asset2.originalPath, asset2.originalFileName);
});
it('should download an archive', async () => {
@@ -85,20 +86,20 @@ describe(DownloadService.name, () => {
stream: new Readable(),
};
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
mocks.asset.getByIds.mockResolvedValue([
{ ...assetStub.noResizePath, id: 'asset-1' },
{ ...assetStub.noWebpPath, id: 'asset-2' },
]);
const asset1 = AssetFactory.create();
const asset2 = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id]));
mocks.asset.getByIds.mockResolvedValue([asset1, asset2]);
mocks.storage.createZipStream.mockReturnValue(archiveMock);
await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({
await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({
stream: archiveMock.stream,
});
expect(archiveMock.addFile).toHaveBeenCalledTimes(2);
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, '/data/library/IMG_123.jpg', 'IMG_123.jpg');
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, '/data/library/IMG_456.jpg', 'IMG_456.jpg');
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, asset1.originalPath, asset1.originalFileName);
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, asset2.originalPath, asset2.originalFileName);
});
it('should handle duplicate file names', async () => {
+65 -54
View File
@@ -18,6 +18,7 @@ import {
} from 'src/enum';
import { MediaService } from 'src/services/media.service';
import { JobCounts, RawImageInfo } from 'src/types';
import { AssetFactory } from 'test/factories/asset.factory';
import { assetStub, previewFile } from 'test/fixtures/asset.stub';
import { faceStub } from 'test/fixtures/face.stub';
import { probeStub } from 'test/fixtures/media.stub';
@@ -139,33 +140,30 @@ describe(MediaService.name, () => {
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
});
it('should queue all assets with missing webp path', async () => {
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.noWebpPath]));
it('should queue all assets with missing preview', async () => {
const asset = AssetFactory.create();
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: false });
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.AssetGenerateThumbnails,
data: { id: assetStub.image.id },
},
{ name: JobName.AssetGenerateThumbnails, data: { id: asset.id } },
]);
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
});
it('should queue all assets with missing thumbhash', async () => {
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.noThumbhash]));
const asset = AssetFactory.from({ thumbhash: null })
.files([AssetFileType.Thumbnail, AssetFileType.Preview])
.build();
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: false });
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.AssetGenerateThumbnails,
data: { id: assetStub.image.id },
},
{ name: JobName.AssetGenerateThumbnails, data: { id: asset.id } },
]);
expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
@@ -1052,12 +1050,19 @@ describe(MediaService.name, () => {
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
// HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari.
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif);
const asset = AssetFactory.from({ originalFileName: 'image.hif' })
.exif({
fileSizeInByte: 5000,
profileDescription: 'Adobe RGB',
bitsPerSample: 14,
})
.build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
await sut.handleGenerateThumbnails({ id: asset.id });
expect(mocks.media.decodeImage).toHaveBeenCalledOnce();
expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageHif.originalPath, {
expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, {
colorspace: Colorspace.P3,
processInvalidImages: false,
});
@@ -1107,12 +1112,19 @@ describe(MediaService.name, () => {
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.media.copyTagGroup.mockResolvedValue(true);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.panoramaTif);
const asset = AssetFactory.from({ originalFileName: 'panorama.tif' })
.exif({
fileSizeInByte: 5000,
projectionType: 'EQUIRECTANGULAR',
})
.build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(mocks.media.decodeImage).toHaveBeenCalledOnce();
expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.panoramaTif.originalPath, {
expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, {
colorspace: Colorspace.Srgb,
orientation: undefined,
processInvalidImages: false,
@@ -1135,11 +1147,7 @@ describe(MediaService.name, () => {
);
expect(mocks.media.copyTagGroup).toHaveBeenCalledTimes(2);
expect(mocks.media.copyTagGroup).toHaveBeenCalledWith(
'XMP-GPano',
assetStub.panoramaTif.originalPath,
expect.any(String),
);
expect(mocks.media.copyTagGroup).toHaveBeenCalledWith('XMP-GPano', asset.originalPath, expect.any(String));
});
it('should respect encoding options when generating full-size preview', async () => {
@@ -1149,12 +1157,19 @@ describe(MediaService.name, () => {
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
// HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari.
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif);
const asset = AssetFactory.from({ originalFileName: 'image.hif' })
.exif({
fileSizeInByte: 5000,
profileDescription: 'Adobe RGB',
bitsPerSample: 14,
})
.build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(mocks.media.decodeImage).toHaveBeenCalledOnce();
expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageHif.originalPath, {
expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, {
colorspace: Colorspace.P3,
processInvalidImages: false,
});
@@ -1181,9 +1196,16 @@ describe(MediaService.name, () => {
});
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif);
const asset = AssetFactory.from({ originalFileName: 'image.hif' })
.exif({
fileSizeInByte: 5000,
profileDescription: 'Adobe RGB',
bitsPerSample: 14,
})
.build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
await sut.handleGenerateThumbnails({ id: asset.id });
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
@@ -1263,30 +1285,25 @@ describe(MediaService.name, () => {
});
it('should clean up edited files if an asset has no edits', async () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({
...assetStub.withoutEdits,
});
const asset = AssetFactory.from({ thumbhash: factory.buffer() })
.exif()
.files([
{ type: AssetFileType.Preview, path: 'edited1.jpg', isEdited: true },
{ type: AssetFileType.Thumbnail, path: 'edited2.jpg', isEdited: true },
{ type: AssetFileType.FullSize, path: 'edited3.jpg', isEdited: true },
])
.build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
const status = await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
const status = await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id });
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete,
data: {
files: expect.arrayContaining([
'/uploads/user-id/fullsize/path_edited.jpg',
'/uploads/user-id/preview/path_edited.jpg',
'/uploads/user-id/thumbnail/path_edited.jpg',
]),
files: expect.arrayContaining(['edited1.jpg', 'edited2.jpg', 'edited3.jpg']),
},
});
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ path: '/uploads/user-id/preview/path_edited.jpg' }),
expect.objectContaining({ path: '/uploads/user-id/thumbnail/path_edited.jpg' }),
expect.objectContaining({ path: '/uploads/user-id/fullsize/path_edited.jpg' }),
]),
);
expect(status).toBe(JobStatus.Success);
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
@@ -1320,11 +1337,9 @@ describe(MediaService.name, () => {
});
it('should generate the original thumbhash if no edits exist', async () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({
...assetStub.withoutEdits,
});
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
const asset = AssetFactory.from().exif().build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.media.generateThumbhash.mockResolvedValue(factory.buffer());
await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id, source: 'upload' });
@@ -1335,18 +1350,14 @@ describe(MediaService.name, () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({
...assetStub.withCropEdit,
});
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
const thumbhashBuffer = factory.buffer();
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
mocks.person.getFaces.mockResolvedValue([]);
mocks.ocr.getByAssetId.mockResolvedValue([]);
await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id });
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({
thumbhash: thumbhashBuffer,
}),
);
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ thumbhash: thumbhashBuffer }));
});
});
+1 -1
View File
@@ -185,7 +185,7 @@ export class MediaService extends BaseService {
const generated = await this.generateEditedThumbnails(asset, config);
await this.syncFiles(
asset.files.filter((asset) => asset.isEdited),
asset.files.filter((file) => file.isEdited),
generated?.files ?? [],
);
+137 -130
View File
@@ -16,6 +16,7 @@ import {
} from 'src/enum';
import { ImmichTags } from 'src/repositories/metadata.repository';
import { firstDateTime, MetadataService } from 'src/services/metadata.service';
import { AssetFactory } from 'test/factories/asset.factory';
import { assetStub } from 'test/fixtures/asset.stub';
import { fileStub } from 'test/fixtures/file.stub';
import { probeStub } from 'test/fixtures/media.stub';
@@ -24,13 +25,6 @@ import { tagStub } from 'test/fixtures/tag.stub';
import { factory } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
const removeNonSidecarFiles = (asset: any) => {
return {
...asset,
files: asset.files.filter((file: any) => file.type === AssetFileType.Sidecar),
};
};
const forSidecarJob = (
asset: {
id?: string;
@@ -182,17 +176,18 @@ describe(MetadataService.name, () => {
it('should handle a date in a sidecar file', async () => {
const originalDate = new Date('2023-11-21T16:13:17.517Z');
const sidecarDate = new Date('2022-01-01T00:00:00.000Z');
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.sidecar));
const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).build();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mockReadTags({ CreationDate: originalDate.toISOString() }, { CreationDate: sidecarDate.toISOString() });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.sidecar.id);
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate }), {
lockedPropertiesBehavior: 'skip',
});
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({
id: assetStub.image.id,
id: asset.id,
duration: null,
fileCreatedAt: sidecarDate,
localDateTime: sidecarDate,
@@ -203,7 +198,8 @@ describe(MetadataService.name, () => {
it('should take the file modification date when missing exif and earlier than creation date', async () => {
const fileCreatedAt = new Date('2022-01-01T00:00:00.000Z');
const fileModifiedAt = new Date('2021-01-01T00:00:00.000Z');
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.storage.stat.mockResolvedValue({
size: 123_456,
mtime: fileModifiedAt,
@@ -212,14 +208,14 @@ describe(MetadataService.name, () => {
} as Stats);
mockReadTags();
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ dateTimeOriginal: fileModifiedAt }),
{ lockedPropertiesBehavior: 'skip' },
);
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.image.id,
id: asset.id,
duration: null,
fileCreatedAt: fileModifiedAt,
fileModifiedAt,
@@ -232,7 +228,8 @@ describe(MetadataService.name, () => {
it('should take the file creation date when missing exif and earlier than modification date', async () => {
const fileCreatedAt = new Date('2021-01-01T00:00:00.000Z');
const fileModifiedAt = new Date('2022-01-01T00:00:00.000Z');
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.storage.stat.mockResolvedValue({
size: 123_456,
mtime: fileModifiedAt,
@@ -241,14 +238,14 @@ describe(MetadataService.name, () => {
} as Stats);
mockReadTags();
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ dateTimeOriginal: fileCreatedAt }),
{ lockedPropertiesBehavior: 'skip' },
);
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.image.id,
id: asset.id,
duration: null,
fileCreatedAt,
fileModifiedAt,
@@ -260,10 +257,11 @@ describe(MetadataService.name, () => {
it('should determine dateTimeOriginal regardless of the server time zone', async () => {
process.env.TZ = 'America/Los_Angeles';
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.sidecar));
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({
dateTimeOriginal: new Date('2022-01-01T00:00:00.000Z'),
@@ -279,16 +277,15 @@ describe(MetadataService.name, () => {
});
it('should handle lists of numbers', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.storage.stat.mockResolvedValue({
size: 123_456,
mtime: assetStub.image.fileModifiedAt,
mtimeMs: assetStub.image.fileModifiedAt.valueOf(),
birthtimeMs: assetStub.image.fileCreatedAt.valueOf(),
mtime: asset.fileModifiedAt,
mtimeMs: asset.fileModifiedAt.valueOf(),
birthtimeMs: asset.fileCreatedAt.valueOf(),
} as Stats);
mockReadTags({
ISO: [160],
});
mockReadTags({ ISO: [160] });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
@@ -296,11 +293,11 @@ describe(MetadataService.name, () => {
lockedPropertiesBehavior: 'skip',
});
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.image.id,
id: asset.id,
duration: null,
fileCreatedAt: assetStub.image.fileCreatedAt,
fileModifiedAt: assetStub.image.fileCreatedAt,
localDateTime: assetStub.image.fileCreatedAt,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileCreatedAt,
localDateTime: asset.fileCreatedAt,
width: null,
height: null,
});
@@ -308,77 +305,77 @@ describe(MetadataService.name, () => {
it('should not delete latituide and longitude without reverse geocode', async () => {
// regression test for issue 17511
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.withLocation);
const asset = AssetFactory.from().exif().build();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: false } });
mocks.storage.stat.mockResolvedValue({
size: 123_456,
mtime: assetStub.withLocation.fileModifiedAt,
mtimeMs: assetStub.withLocation.fileModifiedAt.valueOf(),
birthtimeMs: assetStub.withLocation.fileCreatedAt.valueOf(),
mtime: asset.fileModifiedAt,
mtimeMs: asset.fileModifiedAt.valueOf(),
birthtimeMs: asset.fileCreatedAt.valueOf(),
} as Stats);
mockReadTags({
GPSLatitude: assetStub.withLocation.exifInfo!.latitude!,
GPSLongitude: assetStub.withLocation.exifInfo!.longitude!,
GPSLatitude: asset.exifInfo.latitude!,
GPSLongitude: asset.exifInfo.longitude!,
});
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ city: null, state: null, country: null }),
{ lockedPropertiesBehavior: 'skip' },
);
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.withLocation.id,
id: asset.id,
duration: null,
fileCreatedAt: assetStub.withLocation.fileCreatedAt,
fileModifiedAt: assetStub.withLocation.fileModifiedAt,
localDateTime: new Date('2023-02-22T05:06:29.716Z'),
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
localDateTime: asset.localDateTime,
width: null,
height: null,
});
});
it('should apply reverse geocoding', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.withLocation));
const asset = AssetFactory.from().exif({ latitude: 10, longitude: 20 }).build();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: true } });
mocks.map.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' });
mocks.storage.stat.mockResolvedValue({
size: 123_456,
mtime: assetStub.withLocation.fileModifiedAt,
mtimeMs: assetStub.withLocation.fileModifiedAt.valueOf(),
birthtimeMs: assetStub.withLocation.fileCreatedAt.valueOf(),
mtime: asset.fileModifiedAt,
mtimeMs: asset.fileModifiedAt.valueOf(),
birthtimeMs: asset.fileCreatedAt.valueOf(),
} as Stats);
mockReadTags({
GPSLatitude: assetStub.withLocation.exifInfo!.latitude!,
GPSLongitude: assetStub.withLocation.exifInfo!.longitude!,
});
mockReadTags({ GPSLatitude: 10, GPSLongitude: 20 });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }),
{ lockedPropertiesBehavior: 'skip' },
);
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.withLocation.id,
id: asset.id,
duration: null,
fileCreatedAt: assetStub.withLocation.fileCreatedAt,
fileModifiedAt: assetStub.withLocation.fileModifiedAt,
localDateTime: new Date('2023-02-22T05:06:29.716Z'),
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
localDateTime: asset.localDateTime,
width: null,
height: null,
});
});
it('should discard latitude and longitude on null island', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.withLocation));
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mockReadTags({
GPSLatitude: 0,
GPSLongitude: 0,
});
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ latitude: null, longitude: null }),
{ lockedPropertiesBehavior: 'skip' },
@@ -386,19 +383,25 @@ describe(MetadataService.name, () => {
});
it('should extract tags from TagsList', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) });
const asset = AssetFactory.from()
.exif({ tags: ['Parent'] })
.build();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.asset.getById.mockResolvedValue(asset);
mockReadTags({ TagsList: ['Parent'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined });
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: 'Parent', parent: undefined });
});
it('should extract hierarchy from TagsList', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent/Child'] }) });
const asset = AssetFactory.from()
.exif({ tags: ['Parent/Child'] })
.build();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.asset.getById.mockResolvedValue(asset);
mockReadTags({ TagsList: ['Parent/Child'] });
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
@@ -406,135 +409,147 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, {
userId: 'user-id',
userId: asset.ownerId,
value: 'Parent',
parentId: undefined,
});
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, {
userId: 'user-id',
userId: asset.ownerId,
value: 'Parent/Child',
parentId: 'tag-parent',
});
});
it('should extract tags from Keywords as a string', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) });
const asset = AssetFactory.from()
.exif({ tags: ['Parent'] })
.build();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.asset.getById.mockResolvedValue(asset);
mockReadTags({ Keywords: 'Parent' });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined });
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: 'Parent', parent: undefined });
});
it('should extract tags from Keywords as a list', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) });
const asset = AssetFactory.from()
.exif({ tags: ['Parent'] })
.build();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.asset.getById.mockResolvedValue(asset);
mockReadTags({ Keywords: ['Parent'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined });
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: 'Parent', parent: undefined });
});
it('should extract tags from Keywords as a list with a number', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({
...factory.asset(),
exifInfo: factory.exif({ tags: ['Parent', '2024'] }),
});
const asset = AssetFactory.from()
.exif({ tags: ['Parent', '2024'] })
.build();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.asset.getById.mockResolvedValue(asset);
mockReadTags({ Keywords: ['Parent', 2024] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined });
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined });
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: 'Parent', parent: undefined });
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: '2024', parent: undefined });
});
it('should extract hierarchal tags from Keywords', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent/Child'] }) });
const asset = AssetFactory.from()
.exif({ tags: ['Parent/Child'] })
.build();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.asset.getById.mockResolvedValue(asset);
mockReadTags({ Keywords: 'Parent/Child' });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, {
userId: 'user-id',
userId: asset.ownerId,
value: 'Parent',
parentId: undefined,
});
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, {
userId: 'user-id',
userId: asset.ownerId,
value: 'Parent/Child',
parentId: 'tag-parent',
});
});
it('should ignore Keywords when TagsList is present', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({
...factory.asset(),
exifInfo: factory.exif({ tags: ['Parent/Child', 'Child'] }),
});
const asset = AssetFactory.from()
.exif({ tags: ['Parent/Child', 'Child'] })
.build();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.asset.getById.mockResolvedValue(asset);
mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, {
userId: 'user-id',
userId: asset.ownerId,
value: 'Parent',
parentId: undefined,
});
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, {
userId: 'user-id',
userId: asset.ownerId,
value: 'Parent/Child',
parentId: 'tag-parent',
});
});
it('should extract hierarchy from HierarchicalSubject', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({
...factory.asset(),
exifInfo: factory.exif({ tags: ['Parent/Child', 'TagA'] }),
});
const asset = AssetFactory.from()
.exif({ tags: ['Parent/Child', 'TagA'] })
.build();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.asset.getById.mockResolvedValue(asset);
mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] });
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, {
userId: 'user-id',
userId: asset.ownerId,
value: 'Parent',
parentId: undefined,
});
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, {
userId: 'user-id',
userId: asset.ownerId,
value: 'Parent/Child',
parentId: 'tag-parent',
});
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(3, { userId: 'user-id', value: 'TagA', parent: undefined });
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(3, {
userId: asset.ownerId,
value: 'TagA',
parent: undefined,
});
});
it('should extract tags from HierarchicalSubject as a list with a number', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.asset.getById.mockResolvedValue({
...factory.asset(),
exifInfo: factory.exif({ tags: ['Parent', '2024'] }),
});
const asset = AssetFactory.from()
.exif({ tags: ['Parent', '2024'] })
.build();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.asset.getById.mockResolvedValue(asset);
mockReadTags({ HierarchicalSubject: ['Parent', 2024] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined });
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined });
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: 'Parent', parent: undefined });
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: '2024', parent: undefined });
});
it('should extract ignore / characters in a HierarchicalSubject tag', async () => {
@@ -1646,31 +1661,23 @@ describe(MetadataService.name, () => {
describe('handleQueueSidecar', () => {
it('should queue assets with sidecar files', async () => {
mocks.assetJob.streamForSidecar.mockReturnValue(makeStream([assetStub.image]));
const asset = AssetFactory.create();
mocks.assetJob.streamForSidecar.mockReturnValue(makeStream([asset]));
await sut.handleQueueSidecar({ force: true });
expect(mocks.assetJob.streamForSidecar).toHaveBeenCalledWith(true);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.SidecarCheck,
data: { id: assetStub.sidecar.id },
},
]);
expect(mocks.assetJob.streamForSidecar).toHaveBeenCalledWith(true);
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SidecarCheck, data: { id: asset.id } }]);
});
it('should queue assets without sidecar files', async () => {
mocks.assetJob.streamForSidecar.mockReturnValue(makeStream([assetStub.image]));
const asset = AssetFactory.create();
mocks.assetJob.streamForSidecar.mockReturnValue(makeStream([asset]));
await sut.handleQueueSidecar({ force: false });
expect(mocks.assetJob.streamForSidecar).toHaveBeenCalledWith(false);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.SidecarCheck,
data: { id: assetStub.image.id },
},
]);
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SidecarCheck, data: { id: asset.id } }]);
});
});
+4 -3
View File
@@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common';
import { StackService } from 'src/services/stack.service';
import { assetStub, stackStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { newUuid } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
describe(StackService.name, () => {
@@ -204,9 +205,9 @@ describe(StackService.name, () => {
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
mocks.stack.getForAssetRemoval.mockResolvedValue({ id: null, primaryAssetId: null });
await expect(
sut.removeAsset(authStub.admin, { id: 'stack-id', assetId: assetStub.imageFrom2015.id }),
).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.removeAsset(authStub.admin, { id: 'stack-id', assetId: newUuid() })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(mocks.asset.update).not.toHaveBeenCalled();
expect(mocks.event.emit).not.toHaveBeenCalled();
@@ -0,0 +1,54 @@
import { Selectable } from 'kysely';
import { AlbumUserRole } from 'src/enum';
import { AlbumUserTable } from 'src/schema/tables/album-user.table';
import { AlbumFactory } from 'test/factories/album.factory';
import { build } from 'test/factories/builder.factory';
import { AlbumUserLike, FactoryBuilder, UserLike } from 'test/factories/types';
import { UserFactory } from 'test/factories/user.factory';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
export class AlbumUserFactory {
#user!: UserFactory;
private constructor(private readonly value: Selectable<AlbumUserTable>) {
value.userId ??= newUuid();
this.#user = UserFactory.from({ id: value.userId });
}
static create(dto: AlbumUserLike = {}) {
return AlbumUserFactory.from(dto).build();
}
static from(dto: AlbumUserLike = {}) {
return new AlbumUserFactory({
albumId: newUuid(),
userId: newUuid(),
role: AlbumUserRole.Editor,
createId: newUuidV7(),
createdAt: newDate(),
updateId: newUuidV7(),
updatedAt: newDate(),
...dto,
});
}
album(dto: AlbumUserLike = {}, builder?: FactoryBuilder<AlbumFactory>) {
const album = build(AlbumFactory.from(dto), builder);
this.value.albumId = album.build().id;
return this;
}
user(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
const user = build(UserFactory.from(dto), builder);
this.value.userId = user.build().id;
this.#user = user;
return this;
}
build() {
return {
...this.value,
user: this.#user.build(),
};
}
}
+87
View File
@@ -0,0 +1,87 @@
import { Selectable } from 'kysely';
import { AssetOrder } from 'src/enum';
import { AlbumTable } from 'src/schema/tables/album.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
import { AlbumUserFactory } from 'test/factories/album-user.factory';
import { AssetFactory } from 'test/factories/asset.factory';
import { build } from 'test/factories/builder.factory';
import { AlbumLike, AlbumUserLike, AssetLike, FactoryBuilder, UserLike } from 'test/factories/types';
import { UserFactory } from 'test/factories/user.factory';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
export class AlbumFactory {
#owner: UserFactory;
#sharedLinks: Selectable<SharedLinkTable>[] = [];
#albumUsers: AlbumUserFactory[] = [];
#assets: AssetFactory[] = [];
private constructor(private readonly value: Selectable<AlbumTable>) {
value.ownerId ??= newUuid();
this.#owner = UserFactory.from({ id: value.ownerId });
}
static create(dto: AlbumLike = {}) {
return AlbumFactory.from(dto).build();
}
static from(dto: AlbumLike = {}) {
return new AlbumFactory({
id: newUuid(),
ownerId: newUuid(),
albumName: 'My Album',
albumThumbnailAssetId: null,
createdAt: newDate(),
deletedAt: null,
description: 'Album description',
isActivityEnabled: false,
order: AssetOrder.Desc,
updatedAt: newDate(),
updateId: newUuidV7(),
...dto,
}).owner();
}
owner(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
this.#owner = build(UserFactory.from(dto), builder);
this.value.ownerId = this.#owner.build().id;
return this;
}
sharedLinks() {
this.#sharedLinks = [];
return this;
}
albumUser(dto: AlbumUserLike = {}, builder?: FactoryBuilder<AlbumUserFactory>) {
const albumUser = build(AlbumUserFactory.from(dto).album(this.value), builder);
this.#albumUsers.push(albumUser);
return this;
}
asset(dto: AssetLike = {}, builder?: FactoryBuilder<AssetFactory>) {
const asset = build(AssetFactory.from(dto), builder);
// use album owner by default
if (!dto.ownerId) {
asset.owner(this.#owner.build());
}
if (!this.#assets) {
this.#assets = [];
}
this.#assets.push(asset);
return this;
}
build() {
return {
...this.value,
owner: this.#owner.build(),
assets: this.#assets.map((asset) => asset.build()),
albumUsers: this.#albumUsers.map((albumUser) => albumUser.build()),
sharedLinks: this.#sharedLinks,
};
}
}
@@ -0,0 +1,38 @@
import { Selectable } from 'kysely';
import { AssetEditAction } from 'src/dtos/editing.dto';
import { AssetEditTable } from 'src/schema/tables/asset-edit.table';
import { AssetFactory } from 'test/factories/asset.factory';
import { build } from 'test/factories/builder.factory';
import { AssetEditLike, AssetLike, FactoryBuilder } from 'test/factories/types';
import { newUuid } from 'test/small.factory';
export class AssetEditFactory {
private constructor(private readonly value: Selectable<AssetEditTable>) {}
static create(dto: AssetEditLike = {}) {
return AssetEditFactory.from(dto).build();
}
static from(dto: AssetEditLike = {}) {
const id = dto.id ?? newUuid();
return new AssetEditFactory({
id,
assetId: newUuid(),
action: AssetEditAction.Crop,
parameters: { x: 5, y: 6, width: 200, height: 100 },
sequence: 1,
...dto,
});
}
asset(dto: AssetLike = {}, builder?: FactoryBuilder<AssetFactory>) {
const asset = build(AssetFactory.from(dto), builder);
this.value.assetId = asset.build().id;
return this;
}
build() {
return { ...this.value } as Selectable<AssetEditTable<AssetEditAction.Crop>>;
}
}
@@ -0,0 +1,55 @@
import { Selectable } from 'kysely';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetExifLike } from 'test/factories/types';
import { factory } from 'test/small.factory';
export class AssetExifFactory {
private constructor(private readonly value: Selectable<AssetExifTable>) {}
static create(dto: AssetExifLike = {}) {
return AssetExifFactory.from(dto).build();
}
static from(dto: AssetExifLike = {}) {
return new AssetExifFactory({
updatedAt: factory.date(),
updateId: factory.uuid(),
assetId: factory.uuid(),
autoStackId: null,
bitsPerSample: null,
city: 'Austin',
colorspace: null,
country: 'United States of America',
dateTimeOriginal: factory.date(),
description: '',
exifImageHeight: 420,
exifImageWidth: 42,
exposureTime: null,
fileSizeInByte: 69,
fNumber: 1.7,
focalLength: 4.38,
fps: null,
iso: 947,
latitude: 30.267_334_570_570_195,
longitude: -97.789_833_534_282_07,
lensModel: null,
livePhotoCID: null,
make: 'Google',
model: 'Pixel 7',
modifyDate: factory.date(),
orientation: '1',
profileDescription: null,
projectionType: null,
rating: 4,
lockedProperties: [],
state: 'Texas',
tags: ['parent/child'],
timeZone: 'UTC-6',
...dto,
});
}
build() {
return { ...this.value };
}
}
@@ -0,0 +1,43 @@
import { Selectable } from 'kysely';
import { AssetFileType } from 'src/enum';
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetFactory } from 'test/factories/asset.factory';
import { build } from 'test/factories/builder.factory';
import { AssetFileLike, AssetLike, FactoryBuilder } from 'test/factories/types';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
export class AssetFileFactory {
private constructor(private readonly value: Selectable<AssetFileTable>) {}
static create(dto: AssetFileLike = {}) {
return AssetFileFactory.from(dto).build();
}
static from(dto: AssetFileLike = {}) {
const id = dto.id ?? newUuid();
const isEdited = dto.isEdited ?? false;
return new AssetFileFactory({
id,
assetId: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
type: AssetFileType.Thumbnail,
path: `/data/12/34/thumbs/${id.slice(0, 2)}/${id.slice(2, 4)}/${id}${isEdited ? '_edited' : ''}.jpg`,
updateId: newUuidV7(),
isProgressive: false,
isEdited,
...dto,
});
}
asset(dto: AssetLike = {}, builder?: FactoryBuilder<AssetFactory>) {
const asset = build(AssetFactory.from(dto), builder);
this.value.assetId = asset.build().id;
return this;
}
build() {
return { ...this.value };
}
}
+126
View File
@@ -0,0 +1,126 @@
import { Selectable } from 'kysely';
import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { AssetEditFactory } from 'test/factories/asset-edit.factory';
import { AssetExifFactory } from 'test/factories/asset-exif.factory';
import { AssetFileFactory } from 'test/factories/asset-file.factory';
import { build } from 'test/factories/builder.factory';
import { AssetEditLike, AssetExifLike, AssetFileLike, AssetLike, FactoryBuilder, UserLike } from 'test/factories/types';
import { UserFactory } from 'test/factories/user.factory';
import { newDate, newSha1, newUuid, newUuidV7 } from 'test/small.factory';
export class AssetFactory {
#owner!: UserFactory;
#assetExif?: AssetExifFactory;
#files: AssetFileFactory[] = [];
#edits: AssetEditFactory[] = [];
private constructor(private readonly value: Selectable<AssetTable>) {
value.ownerId ??= newUuid();
this.#owner = UserFactory.from({ id: value.ownerId });
}
static create(dto: AssetLike = {}) {
return AssetFactory.from(dto).build();
}
static from(dto: AssetLike = {}) {
const id = dto.id ?? newUuid();
const originalFileName = dto.originalFileName ?? `IMG_${id}.jpg`;
return new AssetFactory({
id,
createdAt: newDate(),
updatedAt: newDate(),
deletedAt: null,
updateId: newUuidV7(),
status: AssetStatus.Active,
checksum: newSha1(),
deviceAssetId: '',
deviceId: '',
duplicateId: null,
duration: null,
encodedVideoPath: null,
fileCreatedAt: newDate(),
fileModifiedAt: newDate(),
isExternal: false,
isFavorite: false,
isOffline: false,
libraryId: null,
livePhotoVideoId: null,
localDateTime: newDate(),
originalFileName,
originalPath: `/data/library/${originalFileName}`,
ownerId: newUuid(),
stackId: null,
thumbhash: null,
type: AssetType.Image,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
isEdited: false,
...dto,
});
}
owner(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
this.#owner = build(UserFactory.from(dto), builder);
this.value.ownerId = this.#owner.build().id;
return this;
}
exif(dto: AssetExifLike = {}, builder?: FactoryBuilder<AssetExifFactory>) {
this.#assetExif = build(AssetExifFactory.from(dto), builder);
return this;
}
edit(dto: AssetEditLike = {}, builder?: FactoryBuilder<AssetEditFactory>) {
this.#edits.push(build(AssetEditFactory.from(dto).asset(this.value), builder));
this.value.isEdited = true;
return this;
}
file(dto: AssetFileLike = {}, builder?: FactoryBuilder<AssetFileFactory>) {
this.#files.push(build(AssetFileFactory.from(dto).asset(this.value), builder));
return this;
}
files(dto?: 'edits'): AssetFactory;
files(items: AssetFileLike[], builder?: FactoryBuilder<AssetFileFactory>): AssetFactory;
files(items: AssetFileType[], builder?: FactoryBuilder<AssetFileFactory>): AssetFactory;
files(dto?: 'edits' | AssetFileLike[] | AssetFileType[], builder?: FactoryBuilder<AssetFileFactory>): AssetFactory {
const items: AssetFileLike[] = [];
if (dto === undefined || dto === 'edits') {
items.push(...Object.values(AssetFileType).map((type) => ({ type })));
if (dto === 'edits') {
items.push(...Object.values(AssetFileType).map((type) => ({ type, isEdited: true })));
}
} else {
for (const item of dto) {
items.push(typeof item === 'string' ? { type: item as AssetFileType } : item);
}
}
for (const item of items) {
this.file(item, builder);
}
return this;
}
build() {
const exif = this.#assetExif?.build();
return {
...this.value,
owner: this.#owner.build(),
exifInfo: exif as NonNullable<typeof exif>,
files: this.#files.map((file) => file.build()),
edits: this.#edits.map((edit) => edit.build()),
faces: [] as Selectable<AssetFaceTable>[],
};
}
}
+48
View File
@@ -0,0 +1,48 @@
import { AuthDto } from 'src/dtos/auth.dto';
import { build } from 'test/factories/builder.factory';
import { SharedLinkFactory } from 'test/factories/shared-link.factory';
import { FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types';
import { UserFactory } from 'test/factories/user.factory';
export class AuthFactory {
#user: UserFactory;
#sharedLink?: SharedLinkFactory;
private constructor(user: UserFactory) {
this.#user = user;
}
static create(dto: UserLike = {}) {
return AuthFactory.from(dto).build();
}
static from(dto: UserLike = {}) {
return new AuthFactory(UserFactory.from(dto));
}
apiKey() {
// TODO
return this;
}
sharedLink(dto: SharedLinkLike = {}, builder?: FactoryBuilder<SharedLinkFactory>) {
this.#sharedLink = build(SharedLinkFactory.from(dto), builder);
return this;
}
build(): AuthDto {
const { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes } = this.#user.build();
return {
user: {
id,
isAdmin,
name,
email,
quotaUsageInBytes,
quotaSizeInBytes,
},
sharedLink: this.#sharedLink?.build(),
};
}
}
+5
View File
@@ -0,0 +1,5 @@
import { FactoryBuilder } from 'test/factories/types';
export const build = <T>(factory: T, builder?: FactoryBuilder<T>) => {
return builder ? builder(factory) : factory;
};
@@ -0,0 +1,63 @@
import { Selectable } from 'kysely';
import { SharedLinkType } from 'src/enum';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
import { AlbumFactory } from 'test/factories/album.factory';
import { build } from 'test/factories/builder.factory';
import { AlbumLike, FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types';
import { UserFactory } from 'test/factories/user.factory';
import { factory, newDate, newUuid } from 'test/small.factory';
export class SharedLinkFactory {
#owner: UserFactory;
#album?: AlbumFactory;
private constructor(private readonly value: Selectable<SharedLinkTable>) {
value.userId ??= newUuid();
this.#owner = UserFactory.from({ id: value.userId });
}
static create(dto: SharedLinkLike = {}) {
return SharedLinkFactory.from(dto).build();
}
static from(dto: SharedLinkLike = {}) {
const type = dto.type ?? SharedLinkType.Individual;
const albumId = (dto.albumId ?? type === SharedLinkType.Album) ? newUuid() : null;
return new SharedLinkFactory({
id: factory.uuid(),
description: 'Shared link description',
userId: newUuid(),
key: factory.buffer(),
type,
albumId,
createdAt: newDate(),
expiresAt: null,
allowUpload: true,
allowDownload: true,
showExif: true,
password: null,
slug: null,
...dto,
});
}
owner(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>): SharedLinkFactory {
this.#owner = build(UserFactory.from(dto), builder);
return this;
}
album(dto: AlbumLike = {}, builder?: FactoryBuilder<AlbumFactory>) {
this.#album = build(AlbumFactory.from(dto), builder);
return this;
}
build() {
return {
...this.value,
owner: this.#owner.build(),
album: this.#album?.build(),
assets: [],
};
}
}
+20
View File
@@ -0,0 +1,20 @@
import { Selectable } from 'kysely';
import { AlbumUserTable } from 'src/schema/tables/album-user.table';
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetEditTable } from 'src/schema/tables/asset-edit.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
import { UserTable } from 'src/schema/tables/user.table';
export type FactoryBuilder<T, R extends T = T> = (builder: T) => R;
export type AssetLike = Partial<Selectable<AssetTable>>;
export type AssetExifLike = Partial<Selectable<AssetExifTable>>;
export type AssetEditLike = Partial<Selectable<AssetEditTable>>;
export type AssetFileLike = Partial<Selectable<AssetFileTable>>;
export type AlbumLike = Partial<Selectable<AlbumTable>>;
export type AlbumUserLike = Partial<Selectable<AlbumUserTable>>;
export type SharedLinkLike = Partial<Selectable<SharedLinkTable>>;
export type UserLike = Partial<Selectable<UserTable>>;
+46
View File
@@ -0,0 +1,46 @@
import { Selectable } from 'kysely';
import { UserStatus } from 'src/enum';
import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
import { UserTable } from 'src/schema/tables/user.table';
import { UserLike } from 'test/factories/types';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
export class UserFactory {
private constructor(private value: Selectable<UserTable>) {}
static create(dto: UserLike = {}) {
return UserFactory.from(dto).build();
}
static from(dto: UserLike = {}) {
return new UserFactory({
id: newUuid(),
email: 'test@immich.cloud',
password: '',
pinCode: null,
createdAt: newDate(),
profileImagePath: '',
isAdmin: false,
shouldChangePassword: false,
avatarColor: null,
deletedAt: null,
oauthId: '',
updatedAt: newDate(),
storageLabel: null,
name: 'Test User',
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
status: UserStatus.Active,
profileChangedAt: newDate(),
updateId: newUuidV7(),
...dto,
});
}
build() {
return {
...this.value,
metadata: [] as UserMetadataTable[],
};
}
}
-68
View File
@@ -45,56 +45,6 @@ export const albumStub = {
order: AssetOrder.Desc,
updateId: '42',
}),
sharedWithMultiple: Object.freeze({
id: 'album-3',
albumName: 'Empty album shared with users',
description: '',
ownerId: authStub.admin.user.id,
owner: userStub.admin,
assets: [],
albumThumbnailAsset: null,
albumThumbnailAssetId: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
sharedLinks: [],
albumUsers: [
{
user: userStub.user1,
role: AlbumUserRole.Editor,
},
{
user: userStub.user2,
role: AlbumUserRole.Editor,
},
],
isActivityEnabled: true,
order: AssetOrder.Desc,
updateId: '42',
}),
sharedWithAdmin: Object.freeze({
id: 'album-3',
albumName: 'Empty album shared with admin',
description: '',
ownerId: authStub.user1.user.id,
owner: userStub.user1,
assets: [],
albumThumbnailAsset: null,
albumThumbnailAssetId: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
sharedLinks: [],
albumUsers: [
{
user: userStub.admin,
role: AlbumUserRole.Editor,
},
],
isActivityEnabled: true,
order: AssetOrder.Desc,
updateId: '42',
}),
oneAsset: Object.freeze({
id: 'album-4',
albumName: 'Album with one asset',
@@ -113,24 +63,6 @@ export const albumStub = {
order: AssetOrder.Desc,
updateId: '42',
}),
twoAssets: Object.freeze({
id: 'album-4a',
albumName: 'Album with two assets',
description: '',
ownerId: authStub.admin.user.id,
owner: userStub.admin,
assets: [assetStub.image, assetStub.withLocation],
albumThumbnailAsset: assetStub.image,
albumThumbnailAssetId: assetStub.image.id,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
sharedLinks: [],
albumUsers: [],
isActivityEnabled: true,
order: AssetOrder.Desc,
updateId: '42',
}),
emptyWithValidThumbnail: Object.freeze({
id: 'album-5',
albumName: 'Empty album with valid thumbnail',
-410
View File
@@ -20,45 +20,8 @@ const fullsizeFile = factory.assetFile({
path: '/uploads/user-id/fullsize/path.webp',
});
const sidecarFileWithExt = factory.assetFile({
type: AssetFileType.Sidecar,
path: '/original/path.ext.xmp',
});
const sidecarFileWithoutExt = factory.assetFile({
type: AssetFileType.Sidecar,
path: '/original/path.xmp',
});
const editedPreviewFile = factory.assetFile({
type: AssetFileType.Preview,
path: '/uploads/user-id/preview/path_edited.jpg',
isEdited: true,
});
const editedThumbnailFile = factory.assetFile({
type: AssetFileType.Thumbnail,
path: '/uploads/user-id/thumbnail/path_edited.jpg',
isEdited: true,
});
const editedFullsizeFile = factory.assetFile({
type: AssetFileType.FullSize,
path: '/uploads/user-id/fullsize/path_edited.jpg',
isEdited: true,
});
const files = [fullsizeFile, previewFile, thumbnailFile];
const editedFiles = [
fullsizeFile,
previewFile,
thumbnailFile,
editedFullsizeFile,
editedPreviewFile,
editedThumbnailFile,
];
export const stackStub = (stackId: string, assets: (MapAsset & { exifInfo: Exif })[]) => {
return {
id: stackId,
@@ -132,87 +95,6 @@ export const assetStub = {
isEdited: false,
}),
noWebpPath: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/data/library/IMG_456.jpg',
files: [previewFile],
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
duration: null,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
originalFileName: 'IMG_456.jpg',
faces: [],
isExternal: false,
exifInfo: {
fileSizeInByte: 123_000,
} as Exif,
deletedAt: null,
duplicateId: null,
isOffline: false,
libraryId: null,
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
}),
noThumbhash: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.ext',
files,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
thumbhash: null,
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
duration: null,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
deletedAt: null,
duplicateId: null,
isOffline: false,
libraryId: null,
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
}),
primaryImage: Object.freeze({
id: 'primary-asset-id',
status: AssetStatus.Active,
@@ -526,48 +408,6 @@ export const assetStub = {
isEdited: false,
}),
imageFrom2015: Object.freeze({
id: 'asset-id-2015',
status: AssetStatus.Active,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2015-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2015-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.ext',
files,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2015-02-23T05:06:29.716Z'),
updatedAt: new Date('2015-02-23T05:06:29.716Z'),
localDateTime: new Date('2015-02-23T05:06:29.716Z'),
isFavorite: true,
isExternal: false,
duration: null,
livePhotoVideo: null,
livePhotoVideoId: null,
updateId: 'foo',
libraryId: null,
stackId: null,
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
exifInfo: {
fileSizeInByte: 5000,
} as Exif,
deletedAt: null,
duplicateId: null,
isOffline: false,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
}),
video: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
@@ -736,81 +576,6 @@ export const assetStub = {
isEdited: false,
}),
sidecar: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.ext',
thumbhash: null,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
files: [previewFile, sidecarFileWithExt],
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isExternal: false,
duration: null,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
deletedAt: null,
duplicateId: null,
isOffline: false,
updateId: 'foo',
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
}),
sidecarWithoutExt: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.ext',
thumbhash: null,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
files: [previewFile, sidecarFileWithoutExt],
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isExternal: false,
duration: null,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
deletedAt: null,
duplicateId: null,
isOffline: false,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
}),
hasEncodedVideo: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
@@ -854,46 +619,6 @@ export const assetStub = {
isEdited: false,
}),
hasFileExtension: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/data/user1/photo.jpg',
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
files,
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isExternal: true,
duration: null,
livePhotoVideo: null,
livePhotoVideoId: null,
libraryId: 'library-id',
sharedLinks: [],
originalFileName: 'photo.jpg',
faces: [],
deletedAt: null,
exifInfo: {
fileSizeInByte: 5000,
} as Exif,
duplicateId: null,
isOffline: false,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
}),
imageDng: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
@@ -938,93 +663,6 @@ export const assetStub = {
isEdited: false,
}),
imageHif: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.hif',
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
files,
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
duration: null,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
originalFileName: 'asset-id.hif',
faces: [],
deletedAt: null,
exifInfo: {
fileSizeInByte: 5000,
profileDescription: 'Adobe RGB',
bitsPerSample: 14,
} as Exif,
duplicateId: null,
isOffline: false,
updateId: '42',
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
}),
panoramaTif: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.tif',
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
files,
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
duration: null,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
originalFileName: 'asset-id.tif',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
projectionType: 'EQUIRECTANGULAR',
} as Exif,
duplicateId: null,
isOffline: false,
updateId: '42',
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
withCropEdit: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
@@ -1082,52 +720,4 @@ export const assetStub = {
] as AssetEditActionItem[],
isEdited: true,
}),
withoutEdits: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.jpg',
files: editedFiles,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2025-01-01T01:02:03.456Z'),
isFavorite: true,
duration: null,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
updateId: 'foo',
libraryId: null,
stackId: null,
sharedLinks: [],
originalFileName: 'asset-id.jpg',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
exifImageHeight: 3840,
exifImageWidth: 2160,
} as Exif,
duplicateId: null,
isOffline: false,
stack: null,
orientation: '',
projectionType: null,
height: 3840,
width: 2160,
visibility: AssetVisibility.Timeline,
edits: [],
isEdited: false,
}),
};
-17
View File
@@ -38,21 +38,4 @@ export const userStub = {
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
},
user2: <UserAdmin>{
...authStub.user2.user,
status: UserStatus.Active,
profileChangedAt: new Date('2021-01-01'),
metadata: [],
name: 'immich_name',
storageLabel: null,
oauthId: '',
shouldChangePassword: false,
avatarColor: null,
profileImagePath: '',
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
},
};
+21
View File
@@ -1,5 +1,6 @@
import {
Activity,
Album,
ApiKey,
AssetFace,
AssetFile,
@@ -23,6 +24,7 @@ import { AssetEditAction, AssetEditActionItem, MirrorAxis } from 'src/dtos/editi
import { QueueStatisticsDto } from 'src/dtos/queue.dto';
import {
AssetFileType,
AssetOrder,
AssetStatus,
AssetType,
AssetVisibility,
@@ -506,6 +508,24 @@ const personFactory = (person?: Partial<Person>): Person => ({
...person,
});
const albumFactory = (album?: Partial<Omit<Album, 'assets'>>) => ({
albumName: 'My Album',
albumThumbnailAssetId: null,
albumUsers: [],
assets: [],
createdAt: newDate(),
deletedAt: null,
description: 'Album description',
id: newUuid(),
isActivityEnabled: false,
order: AssetOrder.Desc,
ownerId: newUuid(),
sharedLinks: [],
updatedAt: newDate(),
updateId: newUuidV7(),
...album,
});
export const factory = {
activity: activityFactory,
apiKey: apiKeyFactory,
@@ -532,6 +552,7 @@ export const factory = {
person: personFactory,
assetEdit: assetEditFactory,
tag: tagFactory,
album: albumFactory,
uuid: newUuid,
buffer: () => Buffer.from('this is a fake buffer'),
date: newDate,
+1 -1
View File
@@ -27,7 +27,7 @@
"@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.61.3",
"@immich/ui": "^0.61.4",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.0",
+2 -1
View File
@@ -75,6 +75,7 @@
show: false,
},
width: 2,
pxAlign: 0,
};
const options: uPlot.Options = {
@@ -91,7 +92,7 @@
width: 200,
height: 200,
ms: 1,
pxAlign: true,
pxAlign: 0,
scales: {
y: {
distr: 1,
@@ -105,72 +105,74 @@
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
<div
class="fixed top-0 z-1 flex h-16 w-full items-center justify-between border-b bg-white p-1 dark:border-immich-dark-gray dark:bg-black dark:text-immich-dark-fg md:p-8"
>
<div class="flex items-center">
<IconButton
shape="round"
color="secondary"
variant="ghost"
aria-label={$t('close')}
icon={mdiClose}
onclick={onClose}
/>
<div class="flex gap-2 items-center">
<p id={titleId} class="ms-2">{$t('show_and_hide_people')}</p>
<p class="text-sm text-gray-400 dark:text-gray-600">({totalPeopleCount.toLocaleString($locale)})</p>
</div>
</div>
<div class="flex items-center justify-end">
<div class="flex items-center md:me-4">
<div class="h-full overflow-y-auto">
<div
class="sticky top-0 z-1 flex h-16 w-full items-center justify-between border-b bg-white p-1 dark:border-immich-dark-gray dark:bg-black dark:text-immich-dark-fg md:p-8"
>
<div class="flex items-center">
<IconButton
shape="round"
color="secondary"
variant="ghost"
aria-label={$t('reset_people_visibility')}
icon={mdiRestart}
onclick={() => overrides.clear()}
/>
<IconButton
shape="round"
color="secondary"
variant="ghost"
aria-label={toggleButton.label}
icon={toggleButton.icon}
onclick={handleToggleVisibility}
aria-label={$t('close')}
icon={mdiClose}
onclick={onClose}
/>
<div class="flex gap-2 items-center">
<p id={titleId} class="ms-2">{$t('show_and_hide_people')}</p>
<p class="text-sm text-gray-400 dark:text-gray-600">({totalPeopleCount.toLocaleString($locale)})</p>
</div>
</div>
<Button loading={showLoadingSpinner} onclick={handleSaveVisibility} size="small">{$t('done')}</Button>
</div>
</div>
<div class="flex flex-wrap gap-1 p-2 pb-8 md:px-8 mt-16">
<PeopleInfiniteScroll {people} hasNextPage={true} {loadNextPage}>
{#snippet children({ person })}
{@const hidden = overrides.get(person.id) ?? person.isHidden}
<button
type="button"
class="group relative w-full h-full"
onclick={() => setHiddenOverride(person, !hidden)}
aria-pressed={hidden}
aria-label={person.name ? $t('hide_named_person', { values: { name: person.name } }) : $t('hide_person')}
>
<ImageThumbnail
{hidden}
shadow
url={getPeopleThumbnailUrl(person)}
altText={person.name}
widthStyle="100%"
hiddenIconClass="text-white group-hover:text-black transition-colors"
preload={false}
<div class="flex items-center justify-end">
<div class="flex items-center md:me-4">
<IconButton
shape="round"
color="secondary"
variant="ghost"
aria-label={$t('reset_people_visibility')}
icon={mdiRestart}
onclick={() => overrides.clear()}
/>
{#if person.name}
<span class="absolute bottom-2 start-0 w-full select-text px-1 text-center font-medium text-white">
{person.name}
</span>
{/if}
</button>
{/snippet}
</PeopleInfiniteScroll>
<IconButton
shape="round"
color="secondary"
variant="ghost"
aria-label={toggleButton.label}
icon={toggleButton.icon}
onclick={handleToggleVisibility}
/>
</div>
<Button loading={showLoadingSpinner} onclick={handleSaveVisibility} size="small">{$t('done')}</Button>
</div>
</div>
<div class="flex flex-wrap gap-1 p-2 pb-8 md:px-8">
<PeopleInfiniteScroll {people} hasNextPage={true} {loadNextPage}>
{#snippet children({ person })}
{@const hidden = overrides.get(person.id) ?? person.isHidden}
<button
type="button"
class="group relative w-full h-full"
onclick={() => setHiddenOverride(person, !hidden)}
aria-pressed={hidden}
aria-label={person.name ? $t('hide_named_person', { values: { name: person.name } }) : $t('hide_person')}
>
<ImageThumbnail
{hidden}
shadow
url={getPeopleThumbnailUrl(person)}
altText={person.name}
widthStyle="100%"
hiddenIconClass="text-white group-hover:text-black transition-colors"
preload={false}
/>
{#if person.name}
<span class="absolute bottom-2 start-0 w-full select-text px-1 text-center font-medium text-white">
{person.name}
</span>
{/if}
</button>
{/snippet}
</PeopleInfiniteScroll>
</div>
</div>
@@ -1,6 +1,7 @@
<script lang="ts">
import OnEvents from '$lib/components/OnEvents.svelte';
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
@@ -174,6 +175,8 @@
await deleteFace({ id: face.id, assetFaceDeleteDto: { force: false } });
eventManager.emit('PersonAssetDelete', { id: face.person.id, assetId });
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
await assetViewingStore.setAssetId(assetId);
@@ -1,6 +1,6 @@
<script lang="ts">
import { ByteUnit } from '$lib/utils/byte-units';
import { Code, Icon, Text } from '@immich/ui';
import { Icon, Text } from '@immich/ui';
interface Props {
icon: string;
@@ -26,10 +26,10 @@
<Text size="giant" fontWeight="medium">{title}</Text>
</div>
<div class="relative mx-auto font-mono text-2xl font-medium">
<div class="mx-auto font-mono text-2xl font-medium">
<span class="text-gray-300 dark:text-gray-600">{zeros()}</span><span>{value}</span>
{#if unit}
<Code color="muted" class="font-mono absolute -top-5 end-1 font-light p-0">{unit}</Code>
<code class="font-mono text-base font-normal">{unit}</code>
{/if}
</div>
</div>
@@ -47,30 +47,23 @@
const left = Math.max(8, Math.min(window.innerWidth - rect.width, x - directionWidth));
const top = Math.max(8, Math.min(window.innerHeight - menuHeight, y));
const maxHeight = window.innerHeight - top - 8;
return { left, top };
return { left, top, maxHeight };
});
// We need to bind clientHeight since the bounding box may return a height
// of zero when starting the 'slide' animation.
let height: number = $state(0);
let isTransitioned = $state(false);
</script>
<div
bind:clientHeight={height}
class="fixed min-w-50 w-max max-w-75 overflow-hidden rounded-lg shadow-lg z-1"
class="fixed min-w-50 w-max max-w-75 overflow-hidden rounded-lg shadow-lg z-1 immich-scrollbar"
style:left="{position.left}px"
style:top="{position.top}px"
transition:slide={{ duration: 250, easing: quintOut }}
use:clickOutside={{ onOutclick: onClose }}
onintroend={() => {
isTransitioned = true;
}}
onoutrostart={() => {
isTransitioned = false;
}}
>
<ul
{id}
@@ -78,11 +71,8 @@
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
bind:this={menuElement}
class="{isVisible
? 'max-h-dvh'
: 'max-h-0'} flex flex-col transition-all duration-250 ease-in-out outline-none {isTransitioned
? 'overflow-auto'
: ''}"
class="flex flex-col transition-all duration-250 ease-in-out outline-none overflow-auto immich-scrollbar"
style:max-height={isVisible ? `${position.maxHeight}px` : '0px'}
role="menu"
tabindex="-1"
>
@@ -29,7 +29,7 @@
<div class="flex place-items-center justify-between">
<div>
<div class="flex h-6.5 place-items-center gap-1">
<label class="font-medium text-primary text-sm" for={title}>
<label class="font-medium text-sm" for={title}>
{title}
</label>
{#if isEdited}
@@ -48,6 +48,7 @@
<div class="w-fit">
<Dropdown
{options}
position="bottom-right"
hideTextOnSmallScreen={false}
bind:selectedOption
render={(option) => {
@@ -2,6 +2,7 @@ import TransformTool from '$lib/components/asset-viewer/editor/transform-tool/tr
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { waitForWebsocketEvent } from '$lib/stores/websocket';
import { getFormatter } from '$lib/utils/i18n';
import { editAsset, removeAssetEdits, type AssetEditsDto, type AssetResponseDto } from '@immich/sdk';
import { ConfirmModal, modalManager, toastManager } from '@immich/ui';
import { mdiCropRotate } from '@mdi/js';
@@ -63,10 +64,12 @@ export class EditManager {
this.isShowingConfirmDialog = true;
const t = await getFormatter();
const confirmed = await modalManager.show(ConfirmModal, {
title: 'Discard Edits?',
prompt: 'You have unsaved edits. Are you sure you want to discard them?',
confirmText: 'Discard Edits',
title: t('editor_discard_edits_title'),
prompt: t('editor_discard_edits_prompt'),
confirmText: t('editor_discard_edits_confirm'),
});
this.isShowingConfirmDialog = false;
@@ -120,6 +123,7 @@ export class EditManager {
}
const assetId = this.currentAsset.id;
const t = await getFormatter();
try {
// Setup the websocket listener before sending the edit request
@@ -138,12 +142,12 @@ export class EditManager {
eventManager.emit('AssetEditsApplied', assetId);
toastManager.success('Edits applied successfully');
toastManager.success(t('editor_edits_applied_success'));
this.hasAppliedEdits = true;
return true;
} catch {
toastManager.danger('Failed to apply edits');
toastManager.danger(t('editor_edits_applied_error'));
return false;
} finally {
this.isApplyingEdits = false;
@@ -48,6 +48,7 @@ export type Events = {
PersonUpdate: [PersonResponseDto];
PersonThumbnailReady: [{ id: string }];
PersonAssetDelete: [{ id: string; assetId: string }];
BackupDeleteStatus: [{ filename: string; isDeleting: boolean }];
BackupDeleted: [{ filename: string }];
@@ -16,7 +16,6 @@
let { asset, onClose }: Props = $props();
let imgElement: HTMLDivElement | undefined = $state();
let cropContainer: HTMLDivElement | undefined = $state();
onMount(() => {
if (!imgElement) {
@@ -52,23 +51,16 @@
};
const onSubmit = async () => {
if (!cropContainer) {
if (!imgElement) {
return;
}
try {
// Get the container dimensions (which is always square due to aspect-square class)
const containerSize = cropContainer.offsetWidth;
// Capture the crop container which maintains 1:1 aspect ratio
// Override border-radius and border to avoid transparent corners from rounded-full
const blob = await domtoimage.toBlob(cropContainer, {
width: containerSize,
height: containerSize,
style: {
borderRadius: '0',
border: 'none',
},
const imgElementHeight = imgElement.offsetHeight;
const imgElementWidth = imgElement.offsetWidth;
const blob = await domtoimage.toBlob(imgElement, {
width: imgElementWidth,
height: imgElementHeight,
});
if (await hasTransparentPixels(blob)) {
@@ -91,7 +83,6 @@
<FormModal size="small" title={$t('set_profile_picture')} {onClose} {onSubmit}>
<div class="flex place-items-center items-center justify-center">
<div
bind:this={cropContainer}
class="relative flex aspect-square w-62.5 overflow-hidden rounded-full border-4 border-immich-primary bg-immich-dark-primary dark:border-immich-dark-primary dark:bg-immich-primary"
>
<PhotoViewer bind:element={imgElement} cursor={{ current: asset }} />
+2 -5
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { focusTrap } from '$lib/actions/focus-trap';
import { scrollMemory } from '$lib/actions/scroll-memory';
import { shortcut } from '$lib/actions/shortcut';
import ManagePeopleVisibility from '$lib/components/faces-page/manage-people-visibility.svelte';
@@ -381,12 +380,10 @@
{#if selectHidden}
<dialog
open
transition:fly={{ y: innerHeight, duration: 150, easing: quintOut, opacity: 0 }}
class="absolute start-0 top-0 h-full w-full bg-light"
aria-modal="true"
class="fixed inset-0 h-full w-full max-w-none max-h-none bg-light"
aria-labelledby="manage-visibility-title"
use:focusTrap
{@attach (dialog) => dialog.showModal()}
>
<ManagePeopleVisibility
{people}
@@ -297,6 +297,14 @@
person = response;
};
const handlePersonAssetDelete = async ({ id, assetId }: { id: string; assetId: string }) => {
if (id !== person.id) {
return;
}
timelineManager.removeAssets([assetId]);
await updateAssetCount();
};
const { SetDateOfBirth, Favorite, Unfavorite, HidePerson, ShowPerson } = $derived(getPersonActions($t, person));
const SelectFeaturePhoto: ActionItem = {
title: $t('select_featured_photo'),
@@ -315,7 +323,12 @@
};
</script>
<OnEvents {onPersonUpdate} onAssetsDelete={updateAssetCount} onAssetsArchive={updateAssetCount} />
<OnEvents
{onPersonUpdate}
onPersonAssetDelete={handlePersonAssetDelete}
onAssetsDelete={updateAssetCount}
onAssetsArchive={updateAssetCount}
/>
<main
class="relative z-0 h-dvh overflow-hidden px-2 md:px-6 md:pt-(--navbar-height-md) pt-(--navbar-height)"
+1 -1
View File
@@ -60,7 +60,7 @@
return new URL(page.url.pathname + page.url.search, 'https://my.immich.app');
};
toastManager.setOptions({ class: 'top-16' });
toastManager.setOptions({ class: 'top-16 fixed' });
onMount(() => {
const element = document.querySelector('#stencil');
@@ -53,7 +53,7 @@
<div class="flex gap-1 mb-4">
<Badge>{$t('active_count', { values: { count: queue.statistics.active } })}</Badge>
<Badge>{$t('waiting_count', { values: { count: queue.statistics.waiting } })}</Badge>
<Badge>{$t('waiting_count', { values: { count: queue.statistics.waiting + queue.statistics.paused } })}</Badge>
{#if queue.statistics.failed > 0}
<Badge color="danger">{$t('failed_count', { values: { count: queue.statistics.failed } })}</Badge>
{/if}