Compare commits

..

6 Commits

Author SHA1 Message Date
timonrieger c2b4750986 no uuid on file name upload check 2026-06-16 14:07:30 +02:00
timonrieger 9ee9953e6d clients 2026-06-16 13:34:56 +02:00
timonrieger 2622e5d44b source 2026-06-16 13:32:49 +02:00
Timon cc8d3b4107 fix(server): do not merge metadata when multiple duplicates are kept (#29035)
* fix(server): do not merge metadata when multiple duplicates are kept

* Update server/src/services/duplicate.service.spec.ts

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2026-06-15 16:05:04 -04:00
Alex 622a330d82 chore: slideshow transition improvement (#29079)
* chore: better slideshow transition

* chore: tune

* simplify setup

* better default

* fix: correctly zoom alternatively

* lint
2026-06-15 10:10:05 -05:00
Timon 5e8744a568 fix: lock transcoding options (#29076) 2026-06-15 10:50:00 -04:00
37 changed files with 538 additions and 195 deletions
@@ -142,7 +142,6 @@ class AppConfig {
.cleanupCutoffDaysAgo => cleanup.cutoffDaysAgo,
.cleanupDefaultsInitialized => cleanup.defaultsInitialized,
.shareFileType => share.fileType,
.slideshowTransition => slideshow.transition,
.slideshowRepeat => slideshow.repeat,
.slideshowDuration => slideshow.duration,
.slideshowLook => slideshow.look,
@@ -196,7 +195,6 @@ class AppConfig {
.cleanupCutoffDaysAgo => copyWith(cleanup: cleanup.copyWith(cutoffDaysAgo: value as int)),
.cleanupDefaultsInitialized => copyWith(cleanup: cleanup.copyWith(defaultsInitialized: value as bool)),
.shareFileType => copyWith(share: share.copyWith(fileType: value as ShareAssetType)),
.slideshowTransition => copyWith(slideshow: slideshow.copyWith(transition: value as bool)),
.slideshowRepeat => copyWith(slideshow: slideshow.copyWith(repeat: value as bool)),
.slideshowDuration => copyWith(slideshow: slideshow.copyWith(duration: value as int)),
.slideshowLook => copyWith(slideshow: slideshow.copyWith(look: value as SlideshowLook)),
@@ -1,48 +1,38 @@
import 'package:immich_mobile/constants/enums.dart';
class SlideshowConfig {
final bool transition;
final bool repeat;
final int duration;
final SlideshowLook look;
final SlideshowDirection direction;
const SlideshowConfig({
this.transition = true,
this.repeat = true,
this.duration = 5,
this.look = SlideshowLook.contain,
this.look = SlideshowLook.blurredBackground,
this.direction = SlideshowDirection.forward,
});
SlideshowConfig copyWith({
bool? transition,
bool? repeat,
int? duration,
SlideshowLook? look,
SlideshowDirection? direction,
}) => SlideshowConfig(
transition: transition ?? this.transition,
repeat: repeat ?? this.repeat,
duration: duration ?? this.duration,
look: look ?? this.look,
direction: direction ?? this.direction,
);
SlideshowConfig copyWith({bool? repeat, int? duration, SlideshowLook? look, SlideshowDirection? direction}) =>
SlideshowConfig(
repeat: repeat ?? this.repeat,
duration: duration ?? this.duration,
look: look ?? this.look,
direction: direction ?? this.direction,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is SlideshowConfig &&
other.transition == transition &&
other.repeat == repeat &&
other.duration == duration &&
other.look == look &&
other.direction == direction);
@override
int get hashCode => Object.hash(transition, repeat, duration, look, direction);
int get hashCode => Object.hash(repeat, duration, look, direction);
@override
String toString() =>
'SlideshowConfig(transition: $transition, repeat: $repeat, duration: $duration, look: $look, direction: $direction)';
String toString() => 'SlideshowConfig(repeat: $repeat, duration: $duration, look: $look, direction: $direction)';
}
@@ -70,7 +70,6 @@ enum SettingsKey<T> {
shareFileType<ShareAssetType>(codec: _EnumCodec(ShareAssetType.values)),
// Slideshow
slideshowTransition<bool>(),
slideshowRepeat<bool>(),
slideshowDuration<int>(),
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
@@ -33,7 +33,9 @@ class DriftSlideshowPage extends ConsumerStatefulWidget {
ConsumerState<DriftSlideshowPage> createState() => _DriftSlideshowPageState();
}
class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> {
class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with SingleTickerProviderStateMixin {
static const double _kenBurnsZoom = 0.1;
late SlideshowConfig _config;
late final PageController _pageController;
late final Stopwatch _stopwatch;
@@ -43,6 +45,12 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> {
bool _paused = false;
bool _showAppBar = false;
late final AnimationController _crossfadeController;
late final Animation<double> _crossfadeOpacity;
int? _crossfadeFromIndex;
int? _crossfadeToIndex;
int _zoomCycle = 0;
@override
initState() {
super.initState();
@@ -50,6 +58,8 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> {
final asset = ref.read(assetViewerProvider).currentAsset;
_index = asset == null ? 0 : widget.timeline.getIndex(asset.heroTag) ?? 0;
_pageController = PageController(initialPage: _index);
_crossfadeController = AnimationController(vsync: this, duration: Durations.extralong2);
_crossfadeOpacity = Tween<double>(begin: 1.0, end: 0.0).animate(_crossfadeController);
_stopwatch = Stopwatch();
_createTimer();
_updateNextIndex();
@@ -64,6 +74,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> {
_timer.cancel();
_stopwatch.stop();
_pageController.dispose();
_crossfadeController.dispose();
unawaited(WakelockPlus.disable());
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
@@ -150,11 +161,64 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> {
await widget.timeline.preloadAssets(_nextIndex);
}
if (_config.direction == SlideshowDirection.shuffle || !_config.transition) {
_pageController.jumpToPage(_nextIndex);
_crossFadeToPage(_nextIndex);
}
void _crossFadeToPage(int page) {
final previousIndex = _index;
_pageController.jumpToPage(page);
setState(() {
_crossfadeFromIndex = previousIndex;
_crossfadeToIndex = page;
});
_crossfadeController.forward(from: 0.0).whenComplete(() {
if (mounted) {
setState(() {
_crossfadeFromIndex = null;
_crossfadeToIndex = null;
});
}
});
}
Widget _getCrossfadeLayer(BuildContext context, int index, {required bool isIncoming}) {
final asset = widget.timeline.getAssetSafe(index);
final Widget child;
if (isIncoming && asset?.isImage == true) {
child = _getPhotoView(context, index);
} else {
unawaited(_pageController.animateToPage(_nextIndex, duration: Durations.long2, curve: Curves.easeIn));
final zoomOut = isIncoming ? _zoomCycle.isOdd : _zoomCycle.isEven;
final zoom = isIncoming ? (zoomOut ? 1.0 : 0.0) : (zoomOut ? 0.0 : 1.0);
child = _getCrossfadeChild(context, index, zoom);
}
return Stack(
fit: StackFit.expand,
children: [if (_config.look == SlideshowLook.blurredBackground) _getBlur(context, index), child],
);
}
Widget _getCrossfadeChild(BuildContext context, int index, double zoom) {
final asset = widget.timeline.getAssetSafe(index);
if (asset == null) {
return const SizedBox.shrink();
}
final scale = _config.look == SlideshowLook.cover
? PhotoViewComputedScale.covered
: PhotoViewComputedScale.contained;
return PhotoView(
imageProvider: getFullImageProvider(asset, size: context.sizeData),
index: index,
disableScaleGestures: true,
gaplessPlayback: true,
filterQuality: FilterQuality.high,
initialScale: scale * (1.0 + zoom * _kenBurnsZoom),
controller: PhotoViewController(),
);
}
void _createTimer() {
@@ -172,6 +236,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> {
setState(() {
_index = page;
_zoomCycle++;
if (!asset.isImage) {
_paused = false;
@@ -268,7 +333,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> {
final imageProvider = getFullImageProvider(asset, size: context.sizeData);
if (asset.isImage) {
final zoomOut = index % 2 == 1;
final zoomOut = _zoomCycle.isOdd;
final elapsed = _stopwatch.elapsedMilliseconds;
final duration = _config.duration * 1000;
final progress = zoomOut ? 1.0 - elapsed / duration.toDouble() : elapsed / duration.toDouble();
@@ -289,7 +354,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> {
disableScaleGestures: true,
gaplessPlayback: true,
filterQuality: FilterQuality.high,
initialScale: scale * (1.0 + value / 10.0),
initialScale: scale * (1.0 + value * _kenBurnsZoom),
controller: PhotoViewController(),
onTapUp: (_, _, _) => _onTapUp(),
),
@@ -356,20 +421,43 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> {
extendBody: true,
extendBodyBehindAppBar: true,
backgroundColor: Colors.black,
body: PhotoViewGestureDetectorScope(
axis: Axis.horizontal,
child: PageView.builder(
controller: _pageController,
physics: const FastClampingScrollPhysics(),
itemCount: widget.timeline.totalAssets,
onPageChanged: _pageChanged,
itemBuilder: (context, index) => Stack(
children: [
if (_config.look == SlideshowLook.blurredBackground) _getBlur(context, index),
_getPhotoView(context, index),
],
body: Stack(
children: [
PhotoViewGestureDetectorScope(
axis: Axis.horizontal,
child: PageView.builder(
controller: _pageController,
physics: const FastClampingScrollPhysics(),
itemCount: widget.timeline.totalAssets,
onPageChanged: _pageChanged,
itemBuilder: (context, index) => Stack(
children: [
if (_config.look == SlideshowLook.blurredBackground) _getBlur(context, index),
_getPhotoView(context, index),
],
),
),
),
),
if (_crossfadeFromIndex != null && _crossfadeToIndex != null)
Positioned.fill(
child: IgnorePointer(
child: Stack(
fit: StackFit.expand,
children: [
const ColoredBox(color: Colors.black),
FadeTransition(
opacity: _crossfadeController,
child: _getCrossfadeLayer(context, _crossfadeToIndex!, isIncoming: true),
),
FadeTransition(
opacity: _crossfadeOpacity,
child: _getCrossfadeLayer(context, _crossfadeFromIndex!, isIncoming: false),
),
],
),
),
),
],
),
);
}
@@ -16,15 +16,11 @@ class SlideshowSettings extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final slideshow = ref.read(appConfigProvider).slideshow;
final useTransition = useState(slideshow.transition);
final useRepeat = useState(slideshow.repeat);
final useDuration = useState(slideshow.duration);
final useLook = useState(slideshow.look);
final useDirection = useState(slideshow.direction);
useValueChanged<bool, void>(useTransition.value, (_, __) {
ref.read(settingsProvider).write(.slideshowTransition, useTransition.value);
});
useValueChanged<bool, void>(useRepeat.value, (_, __) {
ref.read(settingsProvider).write(.slideshowRepeat, useRepeat.value);
});
@@ -45,11 +41,6 @@ class SlideshowSettings extends HookConsumerWidget {
title: 'slideshow'.t(context: context),
icon: Icons.slideshow_outlined,
),
SettingsSwitchListTile(
valueNotifier: useTransition,
title: "show_slideshow_transition".t(context: context),
enabled: useDirection.value != SlideshowDirection.shuffle,
),
SettingsSwitchListTile(
valueNotifier: useRepeat,
title: "slideshow_repeat".t(context: context),
+182
View File
@@ -16508,7 +16508,9 @@
},
"albumThumbnailAssetId": {
"description": "Thumbnail asset ID",
"format": "uuid",
"nullable": true,
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"albumUsers": {
@@ -16551,6 +16553,8 @@
},
"id": {
"description": "Album ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"isActivityEnabled": {
@@ -16793,6 +16797,8 @@
},
"id": {
"description": "API key ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"name": {
@@ -17001,6 +17007,8 @@
},
"id": {
"description": "Asset ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"isTrashed": {
@@ -17379,6 +17387,8 @@
"properties": {
"assetId": {
"description": "Asset ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"error": {
@@ -17494,6 +17504,8 @@
"properties": {
"id": {
"description": "Asset media ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"status": {
@@ -17562,6 +17574,8 @@
"properties": {
"assetId": {
"description": "Asset ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"key": {
@@ -17809,7 +17823,9 @@
},
"duplicateId": {
"description": "Duplicate group ID",
"format": "uuid",
"nullable": true,
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"duration": {
@@ -17845,6 +17861,8 @@
},
"id": {
"description": "Asset ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"isArchived": {
@@ -17923,6 +17941,8 @@
},
"ownerId": {
"description": "Owner user ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"people": {
@@ -18020,10 +18040,14 @@
},
"id": {
"description": "Stack ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"primaryAssetId": {
"description": "Primary asset ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -18159,6 +18183,8 @@
},
"id": {
"description": "ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"success": {
@@ -18337,6 +18363,8 @@
},
"userId": {
"description": "User ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -18441,6 +18469,8 @@
},
"userId": {
"description": "User ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -18601,6 +18631,8 @@
"assetIds": {
"description": "Asset IDs in this archive",
"items": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"type": "array"
@@ -18786,6 +18818,8 @@
},
"duplicateId": {
"description": "Duplicate group ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"suggestedKeepAssetIds": {
@@ -19102,6 +19136,8 @@
"properties": {
"id": {
"description": "Integrity report item id",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"path": {
@@ -19276,6 +19312,8 @@
},
"id": {
"description": "Library ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"importPaths": {
@@ -19291,6 +19329,8 @@
},
"ownerId": {
"description": "Owner user ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"refreshedAt": {
@@ -19445,6 +19485,8 @@
},
"userId": {
"description": "User ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -19635,6 +19677,8 @@
},
"id": {
"description": "Asset ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"lat": {
@@ -19835,6 +19879,8 @@
},
"id": {
"description": "Memory ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"isSaved": {
@@ -19850,6 +19896,8 @@
},
"ownerId": {
"description": "Owner user ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"seenAt": {
@@ -20311,6 +20359,8 @@
},
"id": {
"description": "Notification ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"level": {
@@ -20755,6 +20805,8 @@
},
"id": {
"description": "Person ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"isFavorite": {
@@ -20990,6 +21042,8 @@
},
"id": {
"description": "Person ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"isFavorite": {
@@ -21245,6 +21299,8 @@
},
"id": {
"description": "Plugin ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"methods": {
@@ -22640,6 +22696,8 @@
},
"id": {
"description": "Version history entry ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"version": {
@@ -22744,6 +22802,8 @@
},
"id": {
"description": "Session ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"isPendingSyncReset": {
@@ -22801,6 +22861,8 @@
},
"id": {
"description": "Session ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"isPendingSyncReset": {
@@ -23023,6 +23085,8 @@
},
"id": {
"description": "Shared link ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"key": {
@@ -23048,6 +23112,8 @@
},
"userId": {
"description": "Owner user ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -23389,10 +23455,14 @@
},
"id": {
"description": "Stack ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"primaryAssetId": {
"description": "Primary asset ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -23666,6 +23736,8 @@
"properties": {
"albumId": {
"description": "Album ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -23678,10 +23750,14 @@
"properties": {
"albumId": {
"description": "Album ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"assetId": {
"description": "Asset ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -23695,10 +23771,14 @@
"properties": {
"albumId": {
"description": "Album ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"assetId": {
"description": "Asset ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -23712,10 +23792,14 @@
"properties": {
"albumId": {
"description": "Album ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"userId": {
"description": "User ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -23729,6 +23813,8 @@
"properties": {
"albumId": {
"description": "Album ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"role": {
@@ -23736,6 +23822,8 @@
},
"userId": {
"description": "User ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -23761,6 +23849,8 @@
},
"id": {
"description": "Album ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"isActivityEnabled": {
@@ -23776,6 +23866,8 @@
},
"ownerId": {
"description": "Owner ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"thumbnailAssetId": {
@@ -23819,6 +23911,8 @@
},
"id": {
"description": "Album ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"isActivityEnabled": {
@@ -23861,6 +23955,8 @@
"properties": {
"assetId": {
"description": "Asset ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -23873,6 +23969,8 @@
"properties": {
"editId": {
"description": "Edit ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -23888,10 +23986,14 @@
},
"assetId": {
"description": "Asset ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"id": {
"description": "Edit ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"parameters": {
@@ -23919,6 +24021,8 @@
"properties": {
"assetId": {
"description": "Asset ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"city": {
@@ -24096,6 +24200,8 @@
"properties": {
"assetFaceId": {
"description": "Asset face ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -24108,6 +24214,8 @@
"properties": {
"assetId": {
"description": "Asset ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"boundingBoxX1": {
@@ -24136,6 +24244,8 @@
},
"id": {
"description": "Asset face ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"imageHeight": {
@@ -24178,6 +24288,8 @@
"properties": {
"assetId": {
"description": "Asset ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"boundingBoxX1": {
@@ -24214,6 +24326,8 @@
},
"id": {
"description": "Asset face ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"imageHeight": {
@@ -24262,6 +24376,8 @@
"properties": {
"assetId": {
"description": "Asset ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"key": {
@@ -24279,6 +24395,8 @@
"properties": {
"assetId": {
"description": "Asset ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"key": {
@@ -24327,6 +24445,8 @@
"properties": {
"assetId": {
"description": "Asset ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"boxScore": {
@@ -24336,6 +24456,8 @@
},
"id": {
"description": "OCR entry ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"isVisible": {
@@ -24462,6 +24584,8 @@
},
"id": {
"description": "Asset ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"isEdited": {
@@ -24496,6 +24620,8 @@
},
"ownerId": {
"description": "Owner ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"stackId": {
@@ -24600,6 +24726,8 @@
},
"id": {
"description": "Asset ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"isEdited": {
@@ -24634,6 +24762,8 @@
},
"ownerId": {
"description": "Owner ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"stackId": {
@@ -24712,6 +24842,8 @@
},
"id": {
"description": "User ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"isAdmin": {
@@ -24846,10 +24978,14 @@
"properties": {
"assetId": {
"description": "Asset ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"memoryId": {
"description": "Memory ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -24863,10 +24999,14 @@
"properties": {
"assetId": {
"description": "Asset ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"memoryId": {
"description": "Memory ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -24880,6 +25020,8 @@
"properties": {
"memoryId": {
"description": "Memory ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -24920,6 +25062,8 @@
},
"id": {
"description": "Memory ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"isSaved": {
@@ -24935,6 +25079,8 @@
},
"ownerId": {
"description": "Owner ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"seenAt": {
@@ -24984,10 +25130,14 @@
"properties": {
"sharedById": {
"description": "Shared by ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"sharedWithId": {
"description": "Shared with ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -25005,10 +25155,14 @@
},
"sharedById": {
"description": "Shared by ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"sharedWithId": {
"description": "Shared with ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -25023,6 +25177,8 @@
"properties": {
"personId": {
"description": "Person ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -25060,6 +25216,8 @@
},
"id": {
"description": "Person ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"isFavorite": {
@@ -25076,6 +25234,8 @@
},
"ownerId": {
"description": "Owner ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"updatedAt": {
@@ -25141,6 +25301,8 @@
"properties": {
"stackId": {
"description": "Stack ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -25160,14 +25322,20 @@
},
"id": {
"description": "Stack ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"ownerId": {
"description": "Owner ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"primaryAssetId": {
"description": "Primary asset ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"updatedAt": {
@@ -25210,6 +25378,8 @@
"properties": {
"userId": {
"description": "User ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -25225,6 +25395,8 @@
},
"userId": {
"description": "User ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -25241,6 +25413,8 @@
},
"userId": {
"description": "User ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"value": {
@@ -25284,6 +25458,8 @@
},
"id": {
"description": "User ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"name": {
@@ -26470,6 +26646,8 @@
},
"id": {
"description": "Tag ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"name": {
@@ -26993,6 +27171,8 @@
},
"userId": {
"description": "User ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"userName": {
@@ -27635,6 +27815,8 @@
},
"id": {
"description": "Workflow ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"name": {
+3 -3
View File
@@ -100,21 +100,21 @@ const AlbumUserResponseSchema = z
const ContributorCountResponseSchema = z
.object({
userId: z.string().describe('User ID'),
userId: z.uuidv4().describe('User ID'),
assetCount: z.int().min(0).describe('Number of assets contributed'),
})
.meta({ id: 'ContributorCountResponseDto' });
export const AlbumResponseSchema = z
.object({
id: z.string().describe('Album ID'),
id: z.uuidv4().describe('Album ID'),
albumName: z.string().describe('Album name'),
description: z.string().describe('Album description'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
createdAt: z.string().meta({ format: 'date-time' }).describe('Creation date'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
updatedAt: z.string().meta({ format: 'date-time' }).describe('Last update date'),
albumThumbnailAssetId: z.string().nullable().describe('Thumbnail asset ID'),
albumThumbnailAssetId: z.uuidv4().nullable().describe('Thumbnail asset ID'),
shared: z.boolean().describe('Is shared album'),
albumUsers: z
.array(AlbumUserResponseSchema)
+1 -1
View File
@@ -21,7 +21,7 @@ const ApiKeyUpdateSchema = z
const ApiKeyResponseSchema = z
.object({
id: z.string().describe('API key ID'),
id: z.uuidv4().describe('API key ID'),
name: z.string().describe('API key name'),
createdAt: isoDatetimeToDate.describe('Creation date'),
updatedAt: isoDatetimeToDate.describe('Last update date'),
+2 -2
View File
@@ -16,7 +16,7 @@ const AssetIdErrorReasonSchema = z
/** @deprecated Use `BulkIdResponseDto` instead */
const AssetIdsResponseSchema = z
.object({
assetId: z.string().describe('Asset ID'),
assetId: z.uuidv4().describe('Asset ID'),
success: z.boolean().describe('Whether operation succeeded'),
error: AssetIdErrorReasonSchema.optional(),
})
@@ -43,7 +43,7 @@ export const BulkIdsSchema = z
const BulkIdResponseSchema = z
.object({
id: z.string().describe('ID'),
id: z.uuidv4().describe('ID'),
success: z.boolean().describe('Whether operation succeeded'),
error: BulkIdErrorReasonSchema.optional(),
errorMessage: z.string().optional(),
+2 -2
View File
@@ -11,7 +11,7 @@ const AssetMediaStatusSchema = z.enum(AssetMediaStatus).describe('Upload status'
const AssetMediaResponseSchema = z
.object({
status: AssetMediaStatusSchema,
id: z.string().describe('Asset media ID'),
id: z.uuidv4().describe('Asset media ID'),
})
.meta({ id: 'AssetMediaResponseDto' });
@@ -34,7 +34,7 @@ const AssetRejectReasonSchema = z
const AssetBulkUploadCheckResultSchema = z
.object({
id: z.string().describe('Asset ID'),
id: z.uuidv4().describe('Asset ID'),
action: AssetUploadActionSchema,
reason: AssetRejectReasonSchema.optional(),
assetId: z.string().optional().describe('Existing asset ID if duplicate'),
+5 -5
View File
@@ -24,7 +24,7 @@ import z from 'zod';
const SanitizedAssetResponseSchema = z
.object({
id: z.string().describe('Asset ID'),
id: z.uuidv4().describe('Asset ID'),
type: AssetTypeSchema,
thumbhash: z
.string()
@@ -52,8 +52,8 @@ export class SanitizedAssetResponseDto extends createZodDto(SanitizedAssetRespon
const AssetStackResponseSchema = z
.object({
id: z.string().describe('Stack ID'),
primaryAssetId: z.string().describe('Primary asset ID'),
id: z.uuidv4().describe('Stack ID'),
primaryAssetId: z.uuidv4().describe('Primary asset ID'),
assetCount: z.int().min(0).describe('Number of assets in stack'),
})
.meta({ id: 'AssetStackResponseDto' });
@@ -65,7 +65,7 @@ export const AssetResponseSchema = SanitizedAssetResponseSchema.extend(
.string()
.meta({ format: 'date-time' })
.describe('The UTC timestamp when the asset was originally uploaded to Immich.'),
ownerId: z.string().describe('Owner user ID'),
ownerId: z.uuidv4().describe('Owner user ID'),
owner: UserResponseSchema.optional(),
libraryId: z
.uuidv4()
@@ -103,7 +103,7 @@ export const AssetResponseSchema = SanitizedAssetResponseSchema.extend(
people: z.array(PersonResponseSchema).optional(),
checksum: z.string().describe('Base64 encoded SHA1 hash'),
stack: AssetStackResponseSchema.nullish(),
duplicateId: z.string().nullish().describe('Duplicate group ID'),
duplicateId: z.uuidv4().nullish().describe('Duplicate group ID'),
resized: z
.boolean()
.optional()
+1 -1
View File
@@ -148,7 +148,7 @@ const AssetMetadataResponseSchema = z
.meta({ id: 'AssetMetadataResponseDto' });
const AssetMetadataBulkResponseSchema = AssetMetadataResponseSchema.extend({
assetId: z.string().describe('Asset ID'),
assetId: z.uuidv4().describe('Asset ID'),
}).meta({ id: 'AssetMetadataBulkResponseDto' });
const AssetCopySchema = z
+1 -1
View File
@@ -29,7 +29,7 @@ const LoginCredentialSchema = z
const LoginResponseSchema = z
.object({
accessToken: z.string().describe('Access token'),
userId: z.string().describe('User ID'),
userId: z.uuidv4().describe('User ID'),
userEmail: toEmail.describe('User email'),
name: z.string().describe('User name'),
profileImagePath: z.string().describe('Profile image path'),
+1 -1
View File
@@ -14,7 +14,7 @@ const DownloadInfoSchema = z
const DownloadArchiveInfoSchema = z
.object({
size: z.int().describe('Archive size in bytes'),
assetIds: z.array(z.string()).describe('Asset IDs in this archive'),
assetIds: z.array(z.uuidv4()).describe('Asset IDs in this archive'),
})
.meta({ id: 'DownloadArchiveInfo' });
+1 -1
View File
@@ -4,7 +4,7 @@ import z from 'zod';
const DuplicateResponseSchema = z
.object({
duplicateId: z.string().describe('Duplicate group ID'),
duplicateId: z.uuidv4().describe('Duplicate group ID'),
assets: z.array(AssetResponseSchema).describe('Duplicate assets'),
suggestedKeepAssetIds: z.array(z.uuidv4()).describe('Suggested asset IDs to keep based on file size and EXIF data'),
})
+1 -1
View File
@@ -27,7 +27,7 @@ const IntegrityDeleteReportSchema = z.object({ type: IntegrityReport }).meta({ i
export class IntegrityDeleteReportDto extends createZodDto(IntegrityDeleteReportSchema) {}
const IntegrityReportResponseItemSchema = z.object({
id: z.string().describe('Integrity report item id'),
id: z.uuidv4().describe('Integrity report item id'),
type: IntegrityReportSchema,
path: z.string().describe('Integrity report item path'),
});
+2 -2
View File
@@ -62,8 +62,8 @@ const ValidateLibraryResponseSchema = z
const LibraryResponseSchema = z
.object({
id: z.string().describe('Library ID'),
ownerId: z.string().describe('Owner user ID'),
id: z.uuidv4().describe('Library ID'),
ownerId: z.uuidv4().describe('Owner user ID'),
name: z.string().describe('Library name'),
assetCount: z.int().describe('Number of assets'),
importPaths: z.array(z.string()).describe('Import paths'),
+1 -1
View File
@@ -30,7 +30,7 @@ const MapMarkerSchema = z
const MapMarkerResponseSchema = z
.object({
id: z.string().describe('Asset ID'),
id: z.uuidv4().describe('Asset ID'),
lat: z.number().meta({ format: 'double' }).describe('Latitude'),
lon: z.number().meta({ format: 'double' }).describe('Longitude'),
city: z.string().nullable().describe('City name'),
+2 -2
View File
@@ -59,7 +59,7 @@ const MemoryStatisticsResponseSchema = z
const MemoryResponseSchema = z
.object({
id: z.string().describe('Memory ID'),
id: z.uuidv4().describe('Memory ID'),
createdAt: isoDatetimeToDate.describe('Creation date'),
updatedAt: isoDatetimeToDate.describe('Last update date'),
deletedAt: isoDatetimeToDate.optional().describe('Deletion date'),
@@ -67,7 +67,7 @@ const MemoryResponseSchema = z
seenAt: isoDatetimeToDate.optional().describe('Date when memory was seen'),
showAt: isoDatetimeToDate.optional().describe('Date when memory should be shown'),
hideAt: isoDatetimeToDate.optional().describe('Date when memory should be hidden'),
ownerId: z.string().describe('Owner user ID'),
ownerId: z.uuidv4().describe('Owner user ID'),
type: MemoryTypeSchema,
data: OnThisDaySchema,
isSaved: z.boolean().describe('Is memory saved'),
+1 -1
View File
@@ -24,7 +24,7 @@ const TemplateSchema = z
const NotificationSchema = z
.object({
id: z.string().describe('Notification ID'),
id: z.uuidv4().describe('Notification ID'),
createdAt: isoDatetimeToDate.describe('Creation date'),
level: NotificationLevelSchema,
type: NotificationTypeSchema,
+2 -2
View File
@@ -33,7 +33,7 @@ const PersonUpdateSchema = PersonCreateSchema.extend({
}).meta({ id: 'PersonUpdateDto' });
const PeopleUpdateItemSchema = PersonUpdateSchema.extend({
id: z.string().describe('Person ID'),
id: z.uuidv4().describe('Person ID'),
}).meta({ id: 'PeopleUpdateItem' });
const PeopleUpdateSchema = z
@@ -60,7 +60,7 @@ const PersonSearchSchema = z
export const PersonResponseSchema = z
.object({
id: z.string().describe('Person ID'),
id: z.uuidv4().describe('Person ID'),
name: z.string().describe('Person name'),
// TODO: use `isoDateToDate` when using `ZodSerializerDto` on the controllers.
birthDate: z.string().meta({ format: 'date' }).describe('Person date of birth').nullable(),
+1 -1
View File
@@ -32,7 +32,7 @@ const PluginMethodResponseSchema = z
const PluginResponseSchema = z
.object({
id: z.string().describe('Plugin ID'),
id: z.uuidv4().describe('Plugin ID'),
name: z.string().describe('Plugin name'),
title: z.string().describe('Plugin title'),
description: z.string().describe('Plugin description'),
+2 -2
View File
@@ -73,7 +73,7 @@ const ServerVersionResponseSchema = z
const ServerVersionHistoryResponseSchema = z
.object({
id: z.string().describe('Version history entry ID'),
id: z.uuidv4().describe('Version history entry ID'),
createdAt: isoDatetimeToDate.describe('When this version was first seen'),
version: z.string().describe('Version string'),
})
@@ -81,7 +81,7 @@ const ServerVersionHistoryResponseSchema = z
const UsageByUserSchema = z
.object({
userId: z.string().describe('User ID'),
userId: z.uuidv4().describe('User ID'),
userName: z.string().describe('User name'),
photos: z.int().describe('Number of photos'),
videos: z.int().describe('Number of videos'),
+1 -1
View File
@@ -18,7 +18,7 @@ const SessionUpdateSchema = z
const SessionResponseSchema = z
.object({
id: z.string().describe('Session ID'),
id: z.uuidv4().describe('Session ID'),
createdAt: z.string().describe('Creation date'),
updatedAt: z.string().describe('Last update date'),
expiresAt: z.string().optional().describe('Expiration date'),
+2 -2
View File
@@ -53,10 +53,10 @@ const SharedLinkLoginSchema = z
const SharedLinkResponseSchema = z
.object({
id: z.string().describe('Shared link ID'),
id: z.uuidv4().describe('Shared link ID'),
description: z.string().nullable().describe('Link description'),
password: z.string().nullable().describe('Has password'),
userId: z.string().describe('Owner user ID'),
userId: z.uuidv4().describe('Owner user ID'),
key: z.string().describe('Encryption key (base64url)'),
type: SharedLinkTypeSchema,
createdAt: isoDatetimeToDate.describe('Creation date'),
+2 -2
View File
@@ -24,8 +24,8 @@ const StackUpdateSchema = z
const StackResponseSchema = z
.object({
id: z.string().describe('Stack ID'),
primaryAssetId: z.string().describe('Primary asset ID'),
id: z.uuidv4().describe('Stack ID'),
primaryAssetId: z.uuidv4().describe('Primary asset ID'),
assets: z.array(AssetResponseSchema),
})
.describe('Stack response')
+50 -50
View File
@@ -19,7 +19,7 @@ import z from 'zod';
const SyncUserV1Schema = z
.object({
id: z.string().describe('User ID'),
id: z.uuidv4().describe('User ID'),
name: z.string().describe('User name'),
email: z.string().describe('User email'),
avatarColor: UserAvatarColorSchema.nullish(),
@@ -40,27 +40,27 @@ const SyncAuthUserV1Schema = SyncUserV1Schema.merge(
}),
).meta({ id: 'SyncAuthUserV1' });
const SyncUserDeleteV1Schema = z.object({ userId: z.string().describe('User ID') }).meta({ id: 'SyncUserDeleteV1' });
const SyncUserDeleteV1Schema = z.object({ userId: z.uuidv4().describe('User ID') }).meta({ id: 'SyncUserDeleteV1' });
const SyncPartnerV1Schema = z
.object({
sharedById: z.string().describe('Shared by ID'),
sharedWithId: z.string().describe('Shared with ID'),
sharedById: z.uuidv4().describe('Shared by ID'),
sharedWithId: z.uuidv4().describe('Shared with ID'),
inTimeline: z.boolean().describe('In timeline'),
})
.meta({ id: 'SyncPartnerV1' });
const SyncPartnerDeleteV1Schema = z
.object({
sharedById: z.string().describe('Shared by ID'),
sharedWithId: z.string().describe('Shared with ID'),
sharedById: z.uuidv4().describe('Shared by ID'),
sharedWithId: z.uuidv4().describe('Shared with ID'),
})
.meta({ id: 'SyncPartnerDeleteV1' });
const SyncAssetV1Schema = z
.object({
id: z.string().describe('Asset ID'),
ownerId: z.string().describe('Owner ID'),
id: z.uuidv4().describe('Asset ID'),
ownerId: z.uuidv4().describe('Owner ID'),
originalFileName: z.string().describe('Original file name'),
thumbhash: z.string().nullable().describe('Thumbhash'),
checksum: z.string().describe('Checksum'),
@@ -84,8 +84,8 @@ const SyncAssetV1Schema = z
const SyncAssetV2Schema = z
.object({
id: z.string().describe('Asset ID'),
ownerId: z.string().describe('Owner ID'),
id: z.uuidv4().describe('Asset ID'),
ownerId: z.uuidv4().describe('Owner ID'),
originalFileName: z.string().describe('Original file name'),
thumbhash: z.string().nullable().describe('Thumbhash'),
checksum: z.string().describe('Checksum'),
@@ -123,12 +123,12 @@ export class SyncAssetV1 extends createZodDto(SyncAssetV1Schema) {}
export class SyncAssetV2 extends createZodDto(SyncAssetV2Schema) {}
const SyncAssetDeleteV1Schema = z
.object({ assetId: z.string().describe('Asset ID') })
.object({ assetId: z.uuidv4().describe('Asset ID') })
.meta({ id: 'SyncAssetDeleteV1' });
const SyncAssetExifV1Schema = z
.object({
assetId: z.string().describe('Asset ID'),
assetId: z.uuidv4().describe('Asset ID'),
description: z.string().nullable().describe('Description'),
exifImageWidth: z.int().nullable().describe('Exif image width'),
exifImageHeight: z.int().nullable().describe('Exif image height'),
@@ -158,7 +158,7 @@ const SyncAssetExifV1Schema = z
const SyncAssetMetadataV1Schema = z
.object({
assetId: z.string().describe('Asset ID'),
assetId: z.uuidv4().describe('Asset ID'),
key: z.string().describe('Key'),
value: z.record(z.string(), z.unknown()).describe('Value'),
})
@@ -166,15 +166,15 @@ const SyncAssetMetadataV1Schema = z
const SyncAssetMetadataDeleteV1Schema = z
.object({
assetId: z.string().describe('Asset ID'),
assetId: z.uuidv4().describe('Asset ID'),
key: z.string().describe('Key'),
})
.meta({ id: 'SyncAssetMetadataDeleteV1' });
const SyncAssetEditV1Schema = z
.object({
id: z.string().describe('Edit ID'),
assetId: z.string().describe('Asset ID'),
id: z.uuidv4().describe('Edit ID'),
assetId: z.uuidv4().describe('Asset ID'),
action: AssetEditActionSchema,
parameters: z.record(z.string(), z.unknown()).describe('Edit parameters'),
sequence: z.int().describe('Edit sequence'),
@@ -182,7 +182,7 @@ const SyncAssetEditV1Schema = z
.meta({ id: 'SyncAssetEditV1' });
const SyncAssetEditDeleteV1Schema = z
.object({ editId: z.string().describe('Edit ID') })
.object({ editId: z.uuidv4().describe('Edit ID') })
.meta({ id: 'SyncAssetEditDeleteV1' });
@ExtraModel()
@@ -199,28 +199,28 @@ export class SyncAssetEditV1 extends createZodDto(SyncAssetEditV1Schema) {}
class SyncAssetEditDeleteV1 extends createZodDto(SyncAssetEditDeleteV1Schema) {}
const SyncAlbumDeleteV1Schema = z
.object({ albumId: z.string().describe('Album ID') })
.object({ albumId: z.uuidv4().describe('Album ID') })
.meta({ id: 'SyncAlbumDeleteV1' });
const SyncAlbumUserDeleteV1Schema = z
.object({
albumId: z.string().describe('Album ID'),
userId: z.string().describe('User ID'),
albumId: z.uuidv4().describe('Album ID'),
userId: z.uuidv4().describe('User ID'),
})
.meta({ id: 'SyncAlbumUserDeleteV1' });
const SyncAlbumUserV1Schema = z
.object({
albumId: z.string().describe('Album ID'),
userId: z.string().describe('User ID'),
albumId: z.uuidv4().describe('Album ID'),
userId: z.uuidv4().describe('User ID'),
role: AlbumUserRoleSchema,
})
.meta({ id: 'SyncAlbumUserV1' });
const SyncAlbumV1Schema = z
.object({
id: z.string().describe('Album ID'),
ownerId: z.string().describe('Owner ID'),
id: z.uuidv4().describe('Album ID'),
ownerId: z.uuidv4().describe('Owner ID'),
name: z.string().describe('Album name'),
description: z.string().describe('Album description'),
createdAt: isoDatetimeToDate.describe('Created at'),
@@ -233,7 +233,7 @@ const SyncAlbumV1Schema = z
const SyncAlbumV2Schema = z
.object({
id: z.string().describe('Album ID'),
id: z.uuidv4().describe('Album ID'),
name: z.string().describe('Album name'),
description: z.string().describe('Album description'),
createdAt: isoDatetimeToDate.describe('Created at'),
@@ -246,15 +246,15 @@ const SyncAlbumV2Schema = z
const SyncAlbumToAssetV1Schema = z
.object({
albumId: z.string().describe('Album ID'),
assetId: z.string().describe('Asset ID'),
albumId: z.uuidv4().describe('Album ID'),
assetId: z.uuidv4().describe('Asset ID'),
})
.meta({ id: 'SyncAlbumToAssetV1' });
const SyncAlbumToAssetDeleteV1Schema = z
.object({
albumId: z.string().describe('Album ID'),
assetId: z.string().describe('Asset ID'),
albumId: z.uuidv4().describe('Album ID'),
assetId: z.uuidv4().describe('Asset ID'),
})
.meta({ id: 'SyncAlbumToAssetDeleteV1' });
@@ -284,11 +284,11 @@ export function syncAlbumV2ToV1(
const SyncMemoryV1Schema = z
.object({
id: z.string().describe('Memory ID'),
id: z.uuidv4().describe('Memory ID'),
createdAt: isoDatetimeToDate.describe('Created at'),
updatedAt: isoDatetimeToDate.describe('Updated at'),
deletedAt: isoDatetimeToDate.nullable().describe('Deleted at'),
ownerId: z.string().describe('Owner ID'),
ownerId: z.uuidv4().describe('Owner ID'),
type: MemoryTypeSchema,
data: z.record(z.string(), z.unknown()).describe('Data'),
isSaved: z.boolean().describe('Is saved'),
@@ -300,43 +300,43 @@ const SyncMemoryV1Schema = z
.meta({ id: 'SyncMemoryV1' });
const SyncMemoryDeleteV1Schema = z
.object({ memoryId: z.string().describe('Memory ID') })
.object({ memoryId: z.uuidv4().describe('Memory ID') })
.meta({ id: 'SyncMemoryDeleteV1' });
const SyncMemoryAssetV1Schema = z
.object({
memoryId: z.string().describe('Memory ID'),
assetId: z.string().describe('Asset ID'),
memoryId: z.uuidv4().describe('Memory ID'),
assetId: z.uuidv4().describe('Asset ID'),
})
.meta({ id: 'SyncMemoryAssetV1' });
const SyncMemoryAssetDeleteV1Schema = z
.object({
memoryId: z.string().describe('Memory ID'),
assetId: z.string().describe('Asset ID'),
memoryId: z.uuidv4().describe('Memory ID'),
assetId: z.uuidv4().describe('Asset ID'),
})
.meta({ id: 'SyncMemoryAssetDeleteV1' });
const SyncStackV1Schema = z
.object({
id: z.string().describe('Stack ID'),
id: z.uuidv4().describe('Stack ID'),
createdAt: isoDatetimeToDate.describe('Created at'),
updatedAt: isoDatetimeToDate.describe('Updated at'),
primaryAssetId: z.string().describe('Primary asset ID'),
ownerId: z.string().describe('Owner ID'),
primaryAssetId: z.uuidv4().describe('Primary asset ID'),
ownerId: z.uuidv4().describe('Owner ID'),
})
.meta({ id: 'SyncStackV1' });
const SyncStackDeleteV1Schema = z
.object({ stackId: z.string().describe('Stack ID') })
.object({ stackId: z.uuidv4().describe('Stack ID') })
.meta({ id: 'SyncStackDeleteV1' });
const SyncPersonV1Schema = z
.object({
id: z.string().describe('Person ID'),
id: z.uuidv4().describe('Person ID'),
createdAt: isoDatetimeToDate.describe('Created at'),
updatedAt: isoDatetimeToDate.describe('Updated at'),
ownerId: z.string().describe('Owner ID'),
ownerId: z.uuidv4().describe('Owner ID'),
name: z.string().describe('Person name'),
birthDate: isoDatetimeToDate.nullable().describe('Birth date'),
isHidden: z.boolean().describe('Is hidden'),
@@ -347,13 +347,13 @@ const SyncPersonV1Schema = z
.meta({ id: 'SyncPersonV1' });
const SyncPersonDeleteV1Schema = z
.object({ personId: z.string().describe('Person ID') })
.object({ personId: z.uuidv4().describe('Person ID') })
.meta({ id: 'SyncPersonDeleteV1' });
const SyncAssetFaceV1Schema = z
.object({
id: z.string().describe('Asset face ID'),
assetId: z.string().describe('Asset ID'),
id: z.uuidv4().describe('Asset face ID'),
assetId: z.uuidv4().describe('Asset ID'),
personId: z.string().nullable().describe('Person ID'),
imageWidth: z.int().describe('Image width'),
imageHeight: z.int().describe('Image height'),
@@ -371,12 +371,12 @@ const SyncAssetFaceV2Schema = SyncAssetFaceV1Schema.extend({
}).meta({ id: 'SyncAssetFaceV2' });
const SyncAssetFaceDeleteV1Schema = z
.object({ assetFaceId: z.string().describe('Asset face ID') })
.object({ assetFaceId: z.uuidv4().describe('Asset face ID') })
.meta({ id: 'SyncAssetFaceDeleteV1' });
const SyncUserMetadataV1Schema = z
.object({
userId: z.string().describe('User ID'),
userId: z.uuidv4().describe('User ID'),
key: UserMetadataKeySchema,
value: z.record(z.string(), z.unknown()).describe('User metadata value'),
})
@@ -384,7 +384,7 @@ const SyncUserMetadataV1Schema = z
const SyncUserMetadataDeleteV1Schema = z
.object({
userId: z.string().describe('User ID'),
userId: z.uuidv4().describe('User ID'),
key: UserMetadataKeySchema,
})
.meta({ id: 'SyncUserMetadataDeleteV1' });
@@ -404,8 +404,8 @@ class SyncMemoryAssetDeleteV1 extends createZodDto(SyncMemoryAssetDeleteV1Schema
const SyncAssetOcrV1Schema = z
.object({
id: z.string().describe('OCR entry ID'),
assetId: z.string().describe('Asset ID'),
id: z.uuidv4().describe('OCR entry ID'),
assetId: z.uuidv4().describe('Asset ID'),
x1: z.number().meta({ format: 'double' }).describe('Top-left X coordinate (normalized 01)'),
y1: z.number().meta({ format: 'double' }).describe('Top-left Y coordinate (normalized 01)'),
+1 -1
View File
@@ -40,7 +40,7 @@ const TagBulkAssetsResponseSchema = z
export const TagResponseSchema = z
.object({
id: z.string().describe('Tag ID'),
id: z.uuidv4().describe('Tag ID'),
parentId: z.string().optional().describe('Parent tag ID'),
name: z.string().describe('Tag name'),
value: z.string().describe('Tag value (full path)'),
+1 -1
View File
@@ -11,7 +11,7 @@ export class CreateProfileImageDto {
const CreateProfileImageResponseSchema = z
.object({
userId: z.string().describe('User ID'),
userId: z.uuidv4().describe('User ID'),
profileChangedAt: isoDatetimeToDate.describe('Profile image change date'),
profileImagePath: z.string().describe('Profile image file path'),
})
+1 -1
View File
@@ -58,7 +58,7 @@ const WorkflowUpdateSchema = z
const WorkflowResponseSchema = z
.object({
id: z.string().describe('Workflow ID'),
id: z.uuidv4().describe('Workflow ID'),
trigger: WorkflowTriggerSchema.describe('Workflow trigger type'),
name: z.string().nullable().describe('Workflow name'),
description: z.string().nullable().describe('Workflow description'),
@@ -369,6 +369,26 @@ describe(DuplicateService.name, () => {
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SidecarWrite, data: { id: asset1.id } }]);
});
it('should not merge metadata when multiple assets are kept', async () => {
const asset1 = AssetFactory.create({ isFavorite: true });
const asset2 = AssetFactory.create();
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
mocks.duplicateRepository.get.mockResolvedValue({
duplicateId: 'group-1',
assets: [asset1 as unknown as MapAsset, asset2 as unknown as MapAsset],
});
const result = await sut.resolve(authStub.admin, {
groups: [{ duplicateId: 'group-1', keepAssetIds: [asset1.id, asset2.id], trashAssetIds: [] }],
});
expect(result[0].success).toBe(true);
expect(mocks.album.addAssetIdsToAlbums).not.toHaveBeenCalled();
expect(mocks.tag.replaceAssetTags).not.toHaveBeenCalled();
expect(mocks.asset.updateAllExif).not.toHaveBeenCalled();
expect(mocks.asset.updateAll).toHaveBeenCalledWith([asset1.id, asset2.id], { duplicateId: null });
});
// NOTE: The following integration-style tests are covered by E2E tests instead
// to avoid complex mock setup. The validation and error-handling logic above
// is thoroughly unit tested.
+37 -35
View File
@@ -156,51 +156,51 @@ export class DuplicateService extends BaseService {
}
}
const assetAlbumMap = await this.albumRepository.getByAssetIds(auth.user.id, [...groupAssetIds]);
// Only merge metadata into the keeper when exactly one asset can absorb trashed duplicates.
if (idsToKeep.length === 1 && idsToTrash.length > 0) {
const assetAlbumMap = await this.albumRepository.getByAssetIds(auth.user.id, [...groupAssetIds]);
const { assetUpdate, exifUpdate, mergedAlbumIds, mergedTagIds, mergedTagValues } = this.getSyncMergeResult(
duplicateGroup.assets,
assetAlbumMap,
);
const { assetUpdate, exifUpdate, mergedAlbumIds, mergedTagIds, mergedTagValues } = this.getSyncMergeResult(
duplicateGroup.assets,
assetAlbumMap,
);
if (mergedAlbumIds.length > 0) {
const allowedAlbumIds = await this.checkAccess({
auth,
permission: Permission.AlbumAssetCreate,
ids: mergedAlbumIds,
});
if (mergedAlbumIds.length > 0) {
const allowedAlbumIds = await this.checkAccess({
auth,
permission: Permission.AlbumAssetCreate,
ids: mergedAlbumIds,
});
const allowedShareIds = await this.checkAccess({
auth,
permission: Permission.AssetShare,
ids: idsToKeep,
});
const allowedShareIds = await this.checkAccess({
auth,
permission: Permission.AssetShare,
ids: idsToKeep,
});
if (allowedAlbumIds.size > 0 && allowedShareIds.size > 0) {
await this.albumRepository.addAssetIdsToAlbums(
[...allowedAlbumIds].flatMap((albumId) => [...allowedShareIds].map((assetId) => ({ albumId, assetId }))),
);
if (allowedAlbumIds.size > 0 && allowedShareIds.size > 0) {
await this.albumRepository.addAssetIdsToAlbums(
[...allowedAlbumIds].flatMap((albumId) => [...allowedShareIds].map((assetId) => ({ albumId, assetId }))),
);
}
}
}
if (mergedTagIds.length > 0) {
const allowedTagIds = await this.checkAccess({
auth,
permission: Permission.TagAsset,
ids: mergedTagIds,
});
if (mergedTagIds.length > 0) {
const allowedTagIds = await this.checkAccess({
auth,
permission: Permission.TagAsset,
ids: mergedTagIds,
});
if (allowedTagIds.size > 0) {
// Replace tags for each keeper asset to ensure all merged tags are applied
await Promise.all(idsToKeep.map((assetId) => this.tagRepository.replaceAssetTags(assetId, [...allowedTagIds])));
if (allowedTagIds.size > 0) {
await Promise.all(
idsToKeep.map((assetId) => this.tagRepository.replaceAssetTags(assetId, [...allowedTagIds])),
);
// Update asset_exif.tags so the subsequent SidecarWrite + MetadataExtraction
// cycle preserves the merged tags (updateAllExif locks the property automatically)
await this.assetRepository.updateAllExif(idsToKeep, { tags: mergedTagValues });
await this.assetRepository.updateAllExif(idsToKeep, { tags: mergedTagValues });
}
}
}
if (idsToKeep.length > 0) {
const hasExifUpdate = Object.keys(exifUpdate).length > 0;
const hasTagUpdate = mergedTagIds.length > 0;
@@ -213,6 +213,8 @@ export class DuplicateService extends BaseService {
}
await this.assetRepository.updateAll(idsToKeep, { duplicateId: null, ...assetUpdate });
} else if (idsToKeep.length > 0) {
await this.assetRepository.updateAll(idsToKeep, { duplicateId: null });
}
if (idsToTrash.length > 0) {
+32
View File
@@ -13,6 +13,38 @@ export interface PlacesGroup {
places: AssetResponseDto[];
}
export interface PlacesGroupOptionMetadata {
id: PlacesGroupBy;
isDisabled: () => boolean;
}
export const groupOptionsMetadata: PlacesGroupOptionMetadata[] = [
{
id: PlacesGroupBy.None,
isDisabled: () => false,
},
{
id: PlacesGroupBy.Country,
isDisabled: () => false,
},
];
export const findGroupOptionMetadata = (groupBy: string) => {
// Default is no grouping
const defaultGroupOption = groupOptionsMetadata[0];
return groupOptionsMetadata.find(({ id }) => groupBy === id) ?? defaultGroupOption;
};
export const getSelectedPlacesGroupOption = (settings: PlacesViewSettings) => {
const defaultGroupOption = PlacesGroupBy.None;
const albumGroupOption = settings.groupBy ?? defaultGroupOption;
if (findGroupOptionMetadata(albumGroupOption).isDisabled()) {
return defaultGroupOption;
}
return albumGroupOption;
};
/**
* ----------------------------
* Places Groups Collapse/Expand
@@ -1,11 +1,24 @@
<script lang="ts">
import Dropdown from '$lib/elements/Dropdown.svelte';
import SearchBar from '$lib/elements/SearchBar.svelte';
import { PlacesGroupBy, placesViewSettings } from '$lib/stores/preferences.store';
import { collapseAllPlacesGroups, expandAllPlacesGroups } from '$lib/utils/places-utils';
import { IconButton, Select } from '@immich/ui';
import { mdiUnfoldLessHorizontal, mdiUnfoldMoreHorizontal } from '@mdi/js';
import {
type PlacesGroupOptionMetadata,
collapseAllPlacesGroups,
expandAllPlacesGroups,
findGroupOptionMetadata,
getSelectedPlacesGroupOption,
groupOptionsMetadata,
} from '$lib/utils/places-utils';
import { IconButton } from '@immich/ui';
import {
mdiFolderArrowUpOutline,
mdiFolderRemoveOutline,
mdiUnfoldLessHorizontal,
mdiUnfoldMoreHorizontal,
} from '@mdi/js';
import { t } from 'svelte-i18n';
import { slide } from 'svelte/transition';
import { fly } from 'svelte/transition';
interface Props {
placesGroups: string[];
@@ -14,26 +27,48 @@
let { placesGroups, searchQuery = $bindable() }: Props = $props();
let options = $derived([
{ value: PlacesGroupBy.None, label: $t('group_no') },
{ value: PlacesGroupBy.Country, label: $t('group_country') },
]);
const handleChangeGroupBy = ({ id }: PlacesGroupOptionMetadata) => {
$placesViewSettings.groupBy = id;
};
let groupIcon = $derived.by(() => {
return selectedGroupOption.id === PlacesGroupBy.None ? mdiFolderRemoveOutline : mdiFolderArrowUpOutline; // OR mdiFolderArrowDownOutline
});
let selectedGroupOption = $derived(findGroupOptionMetadata($placesViewSettings.groupBy));
let placesGroupByNames: Record<PlacesGroupBy, string> = $derived({
[PlacesGroupBy.None]: $t('group_no'),
[PlacesGroupBy.Country]: $t('group_country'),
});
</script>
<!-- Search Places -->
<div class="hidden h-10 md:block xl:w-60 2xl:w-80">
<SearchBar placeholder={$t('search_places')} bind:name={searchQuery} showLoadingSpinner={false} />
</div>
<div title={$t('group_places_by')}>
<Select {options} bind:value={$placesViewSettings.groupBy} class="w-fit min-w-50" />
</div>
<!-- Group Places -->
<Dropdown
position="bottom-right"
title={$t('group_places_by')}
options={Object.values(groupOptionsMetadata)}
selectedOption={selectedGroupOption}
onSelect={handleChangeGroupBy}
render={({ id, isDisabled }) => ({
title: placesGroupByNames[id],
icon: groupIcon,
disabled: isDisabled(),
})}
/>
{#if $placesViewSettings.groupBy !== PlacesGroupBy.None}
<span transition:slide={{ axis: 'x', duration: 250 }}>
{#if getSelectedPlacesGroupOption($placesViewSettings) !== PlacesGroupBy.None}
<span in:fly={{ x: -50, duration: 250 }}>
<!-- Expand Countries Groups -->
<div class="hidden gap-0 xl:flex">
<div class="block">
<IconButton
title={$t('expand_all')}
onclick={() => expandAllPlacesGroups()}
variant="ghost"
color="secondary"
@@ -46,6 +81,7 @@
<!-- Collapse Countries Groups -->
<div class="block">
<IconButton
title={$t('collapse_all')}
onclick={() => collapseAllPlacesGroups(placesGroups)}
variant="ghost"
color="secondary"
@@ -6,7 +6,7 @@
import { groupBy } from 'lodash-es';
import PlacesCardGroup from './PlacesCardGroup.svelte';
import { type PlacesGroup } from '$lib/utils/places-utils';
import { type PlacesGroup, getSelectedPlacesGroupOption } from '$lib/utils/places-utils';
import { Icon } from '@immich/ui';
import { t } from 'svelte-i18n';
@@ -78,8 +78,9 @@
: places;
});
const groupingFunction = $derived(groupOptions[userSettings.groupBy] ?? groupOptions[PlacesGroupBy.None]);
const groupedPlaces = $derived(groupingFunction(filteredPlaces));
const placesGroupOption: string = $derived(getSelectedPlacesGroupOption(userSettings));
const groupingFunction = $derived(groupOptions[placesGroupOption] ?? groupOptions[PlacesGroupBy.None]);
const groupedPlaces: PlacesGroup[] = $derived(groupingFunction(filteredPlaces));
$effect(() => {
searchResultCount = filteredPlaces.length;
@@ -92,7 +93,7 @@
{#if places.length > 0}
<!-- Album Cards -->
{#if userSettings.groupBy === PlacesGroupBy.None}
{#if placesGroupOption === PlacesGroupBy.None}
<PlacesCardGroup places={groupedPlaces[0].places} />
{:else}
{#each groupedPlaces as placeGroup (placeGroup.id)}
@@ -88,6 +88,7 @@
desc={$t('admin.transcoding_accepted_video_codecs_description')}
bind:value={configToEdit.ffmpeg.acceptedVideoCodecs}
name="videoCodecs"
lockedOptions={[configToEdit.ffmpeg.targetVideoCodec]}
options={[
{ value: VideoCodec.H264, text: 'H.264' },
{ value: VideoCodec.Hevc, text: 'HEVC' },
@@ -106,6 +107,7 @@
desc={$t('admin.transcoding_accepted_audio_codecs_description')}
bind:value={configToEdit.ffmpeg.acceptedAudioCodecs}
name="audioCodecs"
lockedOptions={[configToEdit.ffmpeg.targetAudioCodec]}
options={[
{ value: AudioCodec.Aac, text: 'AAC' },
{ value: AudioCodec.Mp3, text: 'MP3' },
@@ -1,17 +1,18 @@
<script lang="ts">
<script lang="ts" generics="T extends string">
import { Checkbox, Label } from '@immich/ui';
import { t } from 'svelte-i18n';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
interface Props {
value: string[];
options: { value: string; text: string }[];
value: T[];
options: { value: T; text: string }[];
label?: string;
desc?: string;
name?: string;
isEdited?: boolean;
disabled?: boolean;
lockedOptions?: T[];
}
let {
@@ -22,9 +23,10 @@
name = '',
isEdited = false,
disabled = false,
lockedOptions = [],
}: Props = $props();
function handleCheckboxChange(option: string) {
function handleCheckboxChange(option: T) {
value = value.includes(option) ? value.filter((item) => item !== option) : [...value, option];
}
</script>
@@ -57,7 +59,7 @@
size="tiny"
id="{option.value}-checkbox"
checked={value.includes(option.value)}
{disabled}
disabled={disabled || lockedOptions.includes(option.value)}
onCheckedChange={() => handleCheckboxChange(option.value)}
/>
<Label label={option.text} for="{option.value}-checkbox" size="small" />