Compare commits

..

2 Commits

Author SHA1 Message Date
Daniel Dietzler
5e3f5f2b55 fix: unlock properties after successful sidecar write (#25168) 2026-01-12 14:01:38 +01:00
Jason Rasmussen
d4ad523eb3 refactor(web): user app settings (#25177) 2026-01-10 07:58:50 -05:00
12 changed files with 216 additions and 176 deletions

View File

@@ -171,8 +171,16 @@ class RemoteAlbumService {
Future<List<RemoteAlbum>> _sortByNewestAsset(List<RemoteAlbum> albums) async {
// map album IDs to their newest asset dates
final albumIds = albums.map((e) => e.id).toList();
final assetTimestamps = await _repository.getNewestAssetTimestampForAlbums(albumIds);
final Map<String, Future<DateTime?>> assetTimestampFutures = {};
for (final album in albums) {
assetTimestampFutures[album.id] = _repository.getNewestAssetTimestamp(album.id);
}
// await all database queries
final entries = await Future.wait(
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
);
final assetTimestamps = Map.fromEntries(entries);
final sorted = albums.sorted((a, b) {
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
@@ -185,8 +193,15 @@ class RemoteAlbumService {
Future<List<RemoteAlbum>> _sortByOldestAsset(List<RemoteAlbum> albums) async {
// map album IDs to their oldest asset dates
final albumIds = albums.map((e) => e.id).toList();
final assetTimestamps = await _repository.getOldestAssetTimestampForAlbums(albumIds);
final Map<String, Future<DateTime?>> assetTimestampFutures = {
for (final album in albums) album.id: _repository.getOldestAssetTimestamp(album.id),
};
// await all database queries
final entries = await Future.wait(
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
);
final assetTimestamps = Map.fromEntries(entries);
final sorted = albums.sorted((a, b) {
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);

View File

@@ -321,64 +321,26 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
}).watchSingleOrNull();
}
Future<Map<String, DateTime?>> getNewestAssetTimestampForAlbums(List<String> albumIds) async {
if (albumIds.isEmpty) {
return {};
}
Future<DateTime?> getNewestAssetTimestamp(String albumId) {
final query = _db.remoteAlbumAssetEntity.selectOnly()
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
..addColumns([_db.remoteAssetEntity.localDateTime.max()])
..join([
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
]);
final results = <String, DateTime?>{};
// Chunk calls to avoid SQLite limit (default 999 variables)
const chunkSize = 900;
for (var i = 0; i < albumIds.length; i += chunkSize) {
final end = (i + chunkSize < albumIds.length) ? i + chunkSize : albumIds.length;
final subList = albumIds.sublist(i, end);
final query = _db.remoteAlbumAssetEntity.selectOnly()
..where(_db.remoteAlbumAssetEntity.albumId.isIn(subList))
..addColumns([_db.remoteAlbumAssetEntity.albumId, _db.remoteAssetEntity.localDateTime.max()])
..join([
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
])
..groupBy([_db.remoteAlbumAssetEntity.albumId]);
final rows = await query.get();
for (final row in rows) {
results[row.read(_db.remoteAlbumAssetEntity.albumId)!] = row.read(_db.remoteAssetEntity.localDateTime.max());
}
}
return results;
return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.max())).getSingleOrNull();
}
Future<Map<String, DateTime?>> getOldestAssetTimestampForAlbums(List<String> albumIds) async {
if (albumIds.isEmpty) {
return {};
}
Future<DateTime?> getOldestAssetTimestamp(String albumId) {
final query = _db.remoteAlbumAssetEntity.selectOnly()
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
..addColumns([_db.remoteAssetEntity.localDateTime.min()])
..join([
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
]);
final results = <String, DateTime?>{};
// Chunk calls to avoid SQLite limit (default 999 variables)
const chunkSize = 900;
for (var i = 0; i < albumIds.length; i += chunkSize) {
final end = (i + chunkSize < albumIds.length) ? i + chunkSize : albumIds.length;
final subList = albumIds.sublist(i, end);
final query = _db.remoteAlbumAssetEntity.selectOnly()
..where(_db.remoteAlbumAssetEntity.albumId.isIn(subList))
..addColumns([_db.remoteAlbumAssetEntity.albumId, _db.remoteAssetEntity.localDateTime.min()])
..join([
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
])
..groupBy([_db.remoteAlbumAssetEntity.albumId]);
final rows = await query.get();
for (final row in rows) {
results[row.read(_db.remoteAlbumAssetEntity.albumId)!] = row.read(_db.remoteAssetEntity.localDateTime.min());
}
}
return results;
return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.min())).getSingleOrNull();
}
Future<int> getCount() {

View File

@@ -18,34 +18,30 @@ void main() {
mockAlbumApiRepo = MockDriftAlbumApiRepository();
sut = RemoteAlbumService(mockRemoteAlbumRepo, mockAlbumApiRepo);
when(() => mockRemoteAlbumRepo.getNewestAssetTimestampForAlbums(any())).thenAnswer((invocation) async {
final albumIds = invocation.positionalArguments[0] as List<String>;
final result = <String, DateTime?>{};
for (final id in albumIds) {
if (id == '1') {
result[id] = DateTime(2023, 1, 1);
} else if (id == '2') {
result[id] = DateTime(2023, 2, 1);
} else {
result[id] = DateTime.fromMillisecondsSinceEpoch(0);
}
when(() => mockRemoteAlbumRepo.getNewestAssetTimestamp(any())).thenAnswer((invocation) {
// Simulate a timestamp for the newest asset in the album
final albumID = invocation.positionalArguments[0] as String;
if (albumID == '1') {
return Future.value(DateTime(2023, 1, 1));
} else if (albumID == '2') {
return Future.value(DateTime(2023, 2, 1));
}
return result;
return Future.value(DateTime.fromMillisecondsSinceEpoch(0));
});
when(() => mockRemoteAlbumRepo.getOldestAssetTimestampForAlbums(any())).thenAnswer((invocation) async {
final albumIds = invocation.positionalArguments[0] as List<String>;
final result = <String, DateTime?>{};
for (final id in albumIds) {
if (id == '1') {
result[id] = DateTime(2019, 1, 1);
} else if (id == '2') {
result[id] = DateTime(2019, 2, 1);
} else {
result[id] = DateTime.fromMillisecondsSinceEpoch(0);
}
when(() => mockRemoteAlbumRepo.getOldestAssetTimestamp(any())).thenAnswer((invocation) {
// Simulate a timestamp for the oldest asset in the album
final albumID = invocation.positionalArguments[0] as String;
if (albumID == '1') {
return Future.value(DateTime(2019, 1, 1));
} else if (albumID == '2') {
return Future.value(DateTime(2019, 2, 1));
}
return result;
return Future.value(DateTime.fromMillisecondsSinceEpoch(0));
});
});

View File

@@ -49,6 +49,23 @@ returning
"dateTimeOriginal",
"timeZone"
-- AssetRepository.unlockProperties
update "asset_exif"
set
"lockedProperties" = nullif(
array(
select distinct
property
from
unnest("asset_exif"."lockedProperties") property
where
not property = any ($1)
),
'{}'
)
where
"assetId" = $2
-- AssetRepository.getMetadata
select
"key",

View File

@@ -223,6 +223,17 @@ export class AssetRepository {
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, ['description']] })
unlockProperties(assetId: string, properties: LockableProperty[]) {
return this.db
.updateTable('asset_exif')
.where('assetId', '=', assetId)
.set((eb) => ({
lockedProperties: sql`nullif(array(select distinct property from unnest(${eb.ref('asset_exif.lockedProperties')}) property where not property = any(${properties})), '{}')`,
}))
.execute();
}
async upsertJobStatus(...jobStatus: Insertable<AssetJobStatusTable>[]): Promise<void> {
if (jobStatus.length === 0) {
return;

View File

@@ -1758,6 +1758,12 @@ describe(MetadataService.name, () => {
GPSLatitude: gps,
GPSLongitude: gps,
});
expect(mocks.asset.unlockProperties).toHaveBeenCalledWith(asset.id, [
'description',
'latitude',
'longitude',
'dateTimeOriginal',
]);
});
});

View File

@@ -461,6 +461,8 @@ export class MetadataService extends BaseService {
await this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: sidecarPath });
}
await this.assetRepository.unlockProperties(asset.id, lockedProperties);
return JobStatus.Success;
}

View File

@@ -87,4 +87,64 @@ describe(AssetRepository.name, () => {
).resolves.toEqual({ lockedProperties: ['description', 'dateTimeOriginal'] });
});
});
describe('unlockProperties', () => {
it('should unlock one property', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({
assetId: asset.id,
dateTimeOriginal: '2023-11-19T18:11:00',
lockedProperties: ['dateTimeOriginal', 'description'],
});
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: ['dateTimeOriginal', 'description'] });
await sut.unlockProperties(asset.id, ['dateTimeOriginal']);
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: ['description'] });
});
it('should unlock all properties', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({
assetId: asset.id,
dateTimeOriginal: '2023-11-19T18:11:00',
lockedProperties: ['dateTimeOriginal', 'description'],
});
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: ['dateTimeOriginal', 'description'] });
await sut.unlockProperties(asset.id, ['description', 'dateTimeOriginal']);
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: null });
});
});
});

View File

@@ -9,6 +9,7 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
upsertExif: vitest.fn(),
updateAllExif: vitest.fn(),
updateDateTimeOriginal: vitest.fn().mockResolvedValue([]),
unlockProperties: vitest.fn().mockResolvedValue([]),
upsertJobStatus: vitest.fn(),
getForCopy: vitest.fn(),
getByDayOfYear: vitest.fn(),

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
import { Label, Text } from '@immich/ui';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { quintOut } from 'svelte/easing';
@@ -28,25 +29,21 @@
}: Props = $props();
</script>
<div class="grid grid-cols-2">
<div>
<div class="flex h-6.5 place-items-center gap-1">
<label class="font-medium text-primary text-sm" for={title}>
{title}
</label>
{#if isEdited}
<div
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
class="rounded-full bg-orange-100 px-2 text-[10px] text-orange-900"
>
{$t('unsaved_change')}
</div>
{/if}
</div>
<p class="text-sm dark:text-immich-dark-fg">{subtitle}</p>
<div>
<div class="flex h-6.5 place-items-center gap-1">
<Label>{title}</Label>
{#if isEdited}
<div
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
class="rounded-full bg-orange-100 px-2 text-[10px] text-orange-900"
>
{$t('unsaved_change')}
</div>
{/if}
</div>
<div class="flex items-center">
<Text size="small" color="muted">{subtitle}</Text>
<div class="flex items-center mt-2 max-w-[300px]">
<Combobox label={title} hideLabel={true} {selectedOption} {options} placeholder={comboboxPlaceholder} {onSelect} />
{@render children?.()}
</div>

View File

@@ -4,6 +4,7 @@
import { defaultLang, langs } from '$lib/constants';
import { lang } from '$lib/stores/preferences.store';
import { getClosestAvailableLocale, langCodes } from '$lib/utils/i18n';
import { Label, Text } from '@immich/ui';
import { locale as i18nLocale, t } from 'svelte-i18n';
interface Props {
@@ -34,16 +35,14 @@
let closestLanguage = $derived(getClosestAvailableLocale([$lang], langCodes));
</script>
<div class={showSettingDescription ? 'grid grid-cols-2' : ''}>
<div class="max-w-[300px]">
{#if showSettingDescription}
<div>
<div class="flex h-6.5 place-items-center gap-1">
<label class="font-medium text-primary text-sm" for={$t('language')}>
{$t('language')}
</label>
<Label>{$t('language')}</Label>
</div>
<p class="text-sm dark:text-immich-dark-fg">{$t('language_setting_description')}</p>
<Text size="small" color="muted">{$t('language_setting_description')}</Text>
</div>
{/if}

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import type { ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
import SettingCombobox from '$lib/components/shared-components/settings/setting-combobox.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingsLanguageSelector from '$lib/components/shared-components/settings/settings-language-selector.svelte';
import { fallbackLocale, locales } from '$lib/constants';
import { themeManager } from '$lib/managers/theme-manager.svelte';
@@ -15,6 +14,7 @@
showDeleteModal,
} from '$lib/stores/preferences.store';
import { createDateFormatter, findLocale } from '$lib/utils';
import { Field, Switch, Text } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -59,81 +59,55 @@
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<div class="ms-4">
<SettingSwitch
title={$t('theme_selection')}
subtitle={$t('theme_selection_description')}
checked={themeManager.theme.system}
onToggle={(isChecked) => themeManager.setSystem(isChecked)}
/>
</div>
<div class="ms-8 mt-4 flex flex-col gap-4">
<Field label={$t('theme_selection')} description={$t('theme_selection_description')}>
<Switch checked={themeManager.theme.system} onCheckedChange={(checked) => themeManager.setSystem(checked)} />
</Field>
<div class="ms-4">
<SettingsLanguageSelector showSettingDescription />
</div>
<SettingsLanguageSelector showSettingDescription />
<Field label={$t('default_locale')} description={$t('default_locale_description')}>
<Switch checked={$locale == 'default'} onCheckedChange={handleToggleLocaleBrowser} />
<Text size="small" class="mt-2">{selectedDate}</Text>
</Field>
<div class="ms-4">
<SettingSwitch
title={$t('default_locale')}
subtitle={$t('default_locale_description')}
checked={$locale == 'default'}
onToggle={handleToggleLocaleBrowser}
>
<p class="mt-2 dark:text-gray-400">{selectedDate}</p>
</SettingSwitch>
</div>
{#if $locale !== 'default'}
<div class="ms-4">
<SettingCombobox
comboboxPlaceholder={$t('searching_locales')}
{selectedOption}
options={getAllLanguages()}
title={$t('custom_locale')}
subtitle={$t('custom_locale_description')}
onSelect={(combobox) => handleLocaleChange(combobox?.value)}
/>
</div>
<SettingCombobox
comboboxPlaceholder={$t('searching_locales')}
{selectedOption}
options={getAllLanguages()}
title={$t('custom_locale')}
subtitle={$t('custom_locale_description')}
onSelect={(combobox) => handleLocaleChange(combobox?.value)}
/>
{/if}
<div class="ms-4">
<SettingSwitch
title={$t('display_original_photos')}
subtitle={$t('display_original_photos_setting_description')}
bind:checked={$alwaysLoadOriginalFile}
/>
</div>
<div class="ms-4">
<SettingSwitch
title={$t('video_hover_setting')}
subtitle={$t('video_hover_setting_description')}
bind:checked={$playVideoThumbnailOnHover}
/>
</div>
<div class="ms-4">
<SettingSwitch
title={$t('setting_video_viewer_auto_play_title')}
subtitle={$t('setting_video_viewer_auto_play_subtitle')}
bind:checked={$autoPlayVideo}
/>
</div>
<div class="ms-4">
<SettingSwitch title={$t('loop_videos')} subtitle={$t('loop_videos_description')} bind:checked={$loopVideo} />
</div>
<div class="ms-4">
<SettingSwitch
title={$t('play_original_video')}
subtitle={$t('play_original_video_setting_description')}
bind:checked={$alwaysLoadOriginalVideo}
/>
</div>
<div class="ms-4">
<SettingSwitch
title={$t('permanent_deletion_warning')}
subtitle={$t('permanent_deletion_warning_setting_description')}
bind:checked={$showDeleteModal}
/>
</div>
<Field label={$t('display_original_photos')} description={$t('display_original_photos_setting_description')}>
<Switch bind:checked={$alwaysLoadOriginalFile} />
</Field>
<Field label={$t('video_hover_setting')} description={$t('video_hover_setting_description')}>
<Switch bind:checked={$playVideoThumbnailOnHover} />
</Field>
<Field
label={$t('setting_video_viewer_auto_play_title')}
description={$t('setting_video_viewer_auto_play_subtitle')}
>
<Switch bind:checked={$autoPlayVideo} />
</Field>
<Field label={$t('loop_videos')} description={$t('loop_videos_description')}>
<Switch bind:checked={$loopVideo} />
</Field>
<Field label={$t('play_original_video')} description={$t('play_original_video_setting_description')}>
<Switch bind:checked={$alwaysLoadOriginalVideo} />
</Field>
<Field label={$t('permanent_deletion_warning')} description={$t('permanent_deletion_warning_setting_description')}
><Switch bind:checked={$showDeleteModal} />
</Field>
</div>
</div>
</section>