Compare commits

..

4 Commits

Author SHA1 Message Date
shenlong-tanwen e2735c0462 chore: do not optimize on cleanup 2026-06-16 03:25:49 +05:30
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
13 changed files with 208 additions and 95 deletions
+1 -2
View File
@@ -17,8 +17,7 @@ const Map<String, Locale> locales = {
'Dutch (nl)': Locale('nl'),
'English (United Kingdom) (en_GB)': Locale('en', 'GB'),
'Estonian (et)': Locale('et'),
'Filipino (fil)': Locale('fil'),
'Filipino (Tagalog) (tl)': Locale('tl'),
'Filipino (tl)': Locale('tl'),
'Finnish (fi)': Locale('fi'),
'French (fr)': Locale('fr'),
'Galician (gl)': Locale('gl'),
@@ -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)),
@@ -104,6 +104,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
Future<void> onAndroidUpload(int? maxMinutes) async {
final hashTimeout = Duration(minutes: _isBackupEnabled ? 3 : 6);
final backupTimeout = maxMinutes != null ? Duration(minutes: maxMinutes - 1) : null;
await _optimizeDB();
return _backgroundLoop(
hashTimeout: hashTimeout,
backupTimeout: backupTimeout,
@@ -123,6 +124,11 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
return;
}
// Only for Background Processing tasks
if (maxSeconds == null) {
await _optimizeDB();
}
// Run sync local, sync remote, hash and backup concurrently so the bg
// refresh task (20s budget) can make progress on all four instead of
// racing them sequentially. Phases are independent at the data layer:
@@ -193,6 +199,14 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
}
}
Future<void> _optimizeDB() async {
try {
await (_drift.optimize(allTables: true), _driftLogger.optimize()).wait;
} catch (error, stack) {
dPrint(() => "Error during background worker optimize: $error, $stack");
}
}
Future<void> _cleanup() async {
await runZonedGuarded(_handleCleanup, (error, stack) {
dPrint(() => "Error during background worker cleanup: $error, $stack");
@@ -221,7 +235,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
if (nativeSyncApi != null) nativeSyncApi.cancelHashing(),
]);
await workerManagerPatch.dispose().catchError((_) async {});
await Future.wait([LogService.I.dispose(), Store.dispose(), _drift.optimize(allTables: true)]);
await Future.wait([LogService.I.dispose(), Store.dispose()]);
await _drift.close();
await _driftLogger.close();
@@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
import 'package:drift_sqlite_async/drift_sqlite_async.dart';
import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.drift.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:sqlite_async/sqlite_async.dart';
@DriftDatabase(tables: [LogMessageEntity])
@@ -13,6 +14,14 @@ class DriftLogger extends $DriftLogger {
@override
int get schemaVersion => 1;
Future<void> optimize() async {
try {
await customStatement('PRAGMA optimize=0x10002');
} catch (error) {
dPrint(() => 'Failed to optimize logger database: $error');
}
}
@override
MigrationStrategy get migration => MigrationStrategy(
beforeOpen: (details) async {
@@ -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),
@@ -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) {
-1
View File
@@ -116,7 +116,6 @@ const nonIntlNames: Record<string, string> = {
kxm: 'Khmer Surin',
mfa: 'Malay (Pattani)',
swg: 'Schwäbisch',
tl: 'Tagalog',
};
const getLanguageName = (code: string) =>
@@ -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" />