diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 72ad1b495b8f26..97937419dc273e 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -72,6 +72,7 @@ jobs: filters: | mobile: - 'mobile/**' + - 'open-api/**' force-filters: | - '.github/workflows/build-mobile.yml' force-events: 'workflow_call,workflow_dispatch' diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index c4545b9de3cdb3..9118ad42494421 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -34,6 +34,7 @@ jobs: filters: | mobile: - 'mobile/**' + - 'open-api/**' - 'i18n/en.json' force-filters: | - '.github/workflows/static_analysis.yml' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8367c490a2e739..14d10eda5ad350 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,6 +59,7 @@ jobs: - 'mise.toml' mobile: - 'mobile/**' + - 'open-api/**' - 'mise.toml' machine-learning: - 'machine-learning/**' diff --git a/docker/hwaccel.ml.yml b/docker/hwaccel.ml.yml index c95ac7ee4c9429..516385caecb79d 100644 --- a/docker/hwaccel.ml.yml +++ b/docker/hwaccel.ml.yml @@ -45,13 +45,9 @@ services: - 'c 189:* rmw' devices: - /dev/dri:/dev/dri - volumes: - - /dev/bus/usb:/dev/bus/usb openvino-wsl: devices: - - /dev/dri:/dev/dri - /dev/dxg:/dev/dxg volumes: - - /dev/bus/usb:/dev/bus/usb - /usr/lib/wsl:/usr/lib/wsl diff --git a/docs/docs/FAQ.mdx b/docs/docs/FAQ.mdx index 869e84e377014c..21e620335ca083 100644 --- a/docs/docs/FAQ.mdx +++ b/docs/docs/FAQ.mdx @@ -543,4 +543,22 @@ The damaged database dump can be used to manually recover any changes made since The causes of possible corruption are many, but can include unexpected poweroffs or unmounts, use of a network share for Postgres data, or a poor storage medium such an SD card or failing HDD/SSD. +### Should I upgrade the database to a newer major version? + +We recommend using the same version of PostgreSQL as in our default docker-compose.yml, however newer versions are also supported. Do note that the major upgrade process is not as simple as just changing the version of the container image. See [the PostgreSQL documentation](https://www.postgresql.org/docs/current/upgrading.html) for more detail. + +## Other + +### Will Immich save me money? + +Probably not. At first glance, it seems like it might: compared to cloud-hosted commercial services, Immich has a one time purchase for the [product key](https://buy.immich.app/), but there are no monthly fees to run Immich on your own server. However, there are many other costs like hardware, storage for backups, and your own time. There are many good reasons for self-hosting, but saving money is rarely one of them. + +### I really want a particular feature. Can I sponsor development of it? + +No, we have no feature sponsorship scheme. The Immich team [works on Immich full-time](https://immich.app/blog/immich-joins-futo), which is supported by users paying for [a product key](https://buy.immich.app/). We'd rather not let money influence the roadmap. If features could be bought, we'd end up building Immich for the highest bidder instead of for all of our users. + +A common alternative suggestion is to offer bounties that can be collected by anyone who submits a pull request, but we don't want those either; the Zig project has [explained the reason well](https://ziglang.org/news/bounties-damage-open-source-projects/). + +If you really want to pay someone to build a feature you like, you're welcome to hire somebody directly and pay them for their time, but please note that we provide no guarantee that we'll merge the resulting work so we recommend that you check in with us first. + [huggingface]: https://huggingface.co/immich-app diff --git a/mobile/lib/infrastructure/repositories/memory.repository.dart b/mobile/lib/infrastructure/repositories/memory.repository.dart index 09f753db3fb58a..f9755e7f290d3f 100644 --- a/mobile/lib/infrastructure/repositories/memory.repository.dart +++ b/mobile/lib/infrastructure/repositories/memory.repository.dart @@ -45,22 +45,17 @@ class MemoryRepository extends DatabaseAccessor with $MemoryRepositoryMix return const []; } - final Map memoriesMap = {}; + final memories = assets})>{}; for (final row in rows) { final memory = row.readTable(_db.memoryEntity); final asset = row.readTable(_db.remoteAssetEntity); - final existingMemory = memoriesMap[memory.id]; - if (existingMemory != null) { - existingMemory.assets.add(asset.toDto()); - } else { - final assets = [asset.toDto()]; - memoriesMap[memory.id] = memory.toDto().copyWith(assets: assets); - } + final entry = memories.putIfAbsent(memory.id, () => (memory: memory, assets: [])); + entry.assets.add(asset.toDto()); } - return memoriesMap.values.toList(growable: false); + return memories.values.map((e) => e.memory.toDto().copyWith(assets: e.assets)).toList(growable: false); } Future get(String memoryId) async { diff --git a/mobile/lib/infrastructure/utils/exif.converter.dart b/mobile/lib/infrastructure/utils/exif.converter.dart index d47c9cc97ad75c..1dda910de3c81d 100644 --- a/mobile/lib/infrastructure/utils/exif.converter.dart +++ b/mobile/lib/infrastructure/utils/exif.converter.dart @@ -11,16 +11,16 @@ abstract final class ExifDtoConverter { timeZone: dto.timeZone.orElse(null), dateTimeOriginal: dto.dateTimeOriginal.orElse(null), isFlipped: isOrientationFlipped(dto.orientation.orElse(null)), - latitude: dto.latitude.orElse(null)?.toDouble(), - longitude: dto.longitude.orElse(null)?.toDouble(), + latitude: dto.latitude.orElse(null), + longitude: dto.longitude.orElse(null), city: dto.city.orElse(null), state: dto.state.orElse(null), country: dto.country.orElse(null), make: dto.make.orElse(null), model: dto.model.orElse(null), lens: dto.lensModel.orElse(null), - f: dto.fNumber.orElse(null)?.toDouble(), - mm: dto.focalLength.orElse(null)?.toDouble(), + f: dto.fNumber.orElse(null), + mm: dto.focalLength.orElse(null), iso: dto.iso.orElse(null), exposureSeconds: exposureTimeToSeconds(dto.exposureTime.orElse(null)), ); diff --git a/mobile/lib/presentation/pages/edit/editor.provider.dart b/mobile/lib/presentation/pages/edit/editor.provider.dart index 8f3c22f3dfa59b..adb2f341250988 100644 --- a/mobile/lib/presentation/pages/edit/editor.provider.dart +++ b/mobile/lib/presentation/pages/edit/editor.provider.dart @@ -43,7 +43,7 @@ class EditorProvider extends Notifier { flipVertical: transform.mirrorVertical, ); - _animateRotation(transform.rotation.toInt(), duration: Duration.zero); + _animateRotation(transform.rotation, duration: Duration.zero); } void _animateRotation(int angle, {Duration duration = const Duration(milliseconds: 300)}) { diff --git a/mobile/lib/presentation/pages/search/search.page.dart b/mobile/lib/presentation/pages/search/search.page.dart index 9a4e567f0c73fb..a5c2423af81275 100644 --- a/mobile/lib/presentation/pages/search/search.page.dart +++ b/mobile/lib/presentation/pages/search/search.page.dart @@ -155,7 +155,7 @@ class SearchPage extends HookConsumerWidget { expanded: true, onSearch: handleApply, onClear: handleClear, - child: PeoplePicker(onSelect: handleOnSelect, filter: filter.value.people), + child: PeoplePicker(onSelect: handleOnSelect, initialSelection: filter.value.people), ), ), ), @@ -192,7 +192,10 @@ class SearchPage extends HookConsumerWidget { expanded: true, onSearch: handleApply, onClear: handleClear, - child: TagPicker(onSelectExistingTag: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()), + child: TagPicker( + onSelectExistingTag: handleOnSelect, + initialSelection: (filter.value.tagIds ?? []).toSet(), + ), ), ), ), diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index 32bbffa95e321a..13abd664f35f3d 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -106,7 +106,7 @@ class _FixedSegmentRow extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing)); + final recommendDeferredLoading = ref.watch(timelineStateProvider.select((s) => s.recommendDeferredLoading)); final timelineService = ref.watch(timelineServiceProvider); final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3); @@ -119,7 +119,7 @@ class _FixedSegmentRow extends ConsumerWidget { ); } - if (isScrubbing) { + if (recommendDeferredLoading) { return _buildPlaceholder(context); } diff --git a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart index 635e35875533f4..8eca9a3ab27655 100644 --- a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart @@ -12,7 +12,6 @@ import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; -import 'package:immich_mobile/utils/debounce.dart'; import 'package:intl/intl.dart' hide TextDirection; part 'scrubber.widget.freezed.dart'; @@ -91,8 +90,6 @@ class ScrubberState extends ConsumerState with TickerProviderStateMixi bool _isDragging = false; List<_Segment> _segments = []; int _monthCount = 0; - DateTime? _currentScrubberDate; - Debouncer? _scrubberDebouncer; late AnimationController _thumbAnimationController; Timer? _fadeOutTimer; @@ -147,7 +144,6 @@ class ScrubberState extends ConsumerState with TickerProviderStateMixi _thumbAnimationController.dispose(); _labelAnimationController.dispose(); _fadeOutTimer?.cancel(); - _scrubberDebouncer?.dispose(); super.dispose(); } @@ -191,24 +187,6 @@ class ScrubberState extends ConsumerState with TickerProviderStateMixi return false; } - void _onScrubberDateChanged(DateTime date) { - if (_currentScrubberDate != date) { - // Date changed, immediately set scrubbing to true - _currentScrubberDate = date; - ref.read(timelineStateProvider.notifier).setScrubbing(true); - - // Initialize debouncer if needed - _scrubberDebouncer ??= Debouncer(interval: const Duration(milliseconds: 50)); - - // Debounce setting scrubbing to false - _scrubberDebouncer!.run(() { - if (_currentScrubberDate == date) { - ref.read(timelineStateProvider.notifier).setScrubbing(false); - } - }); - } - } - void _onDragStart(DragStartDetails _) { setState(() { _isDragging = true; @@ -239,11 +217,6 @@ class ScrubberState extends ConsumerState with TickerProviderStateMixi if (_lastLabel != label) { ref.read(hapticFeedbackProvider.notifier).selectionClick(); _lastLabel = label; - - // Notify timeline state of the new scrubber date position - if (_monthCount >= kMinMonthsToEnableScrubberSnap) { - _onScrubberDateChanged(nearestMonthSegment.date); - } } } @@ -351,13 +324,6 @@ class ScrubberState extends ConsumerState with TickerProviderStateMixi _isDragging = false; }); - ref.read(timelineStateProvider.notifier).setScrubbing(false); - - // Reset scrubber tracking when drag ends - _currentScrubberDate = null; - _scrubberDebouncer?.dispose(); - _scrubberDebouncer = null; - _resetThumbTimer(); } diff --git a/mobile/lib/presentation/widgets/timeline/timeline.state.dart b/mobile/lib/presentation/widgets/timeline/timeline.state.dart index adad06b987bad2..6727464a7763fe 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.state.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.state.dart @@ -25,26 +25,44 @@ abstract class TimelineArgs with _$TimelineArgs { }) = _TimelineArgs; } -@freezed -abstract class TimelineState with _$TimelineState { - const TimelineState._(); +class TimelineState { + final bool isScrolling; - const factory TimelineState({@Default(false) bool isScrubbing, @Default(false) bool isScrolling}) = _TimelineState; + /// Indicates whether the timeline is scrolling beyond some configured "high" speed, + /// such as when programmatically scrolling to the top or a really fast user fling + final bool recommendDeferredLoading; - bool get isInteracting => isScrubbing || isScrolling; -} + const TimelineState({this.isScrolling = false, this.recommendDeferredLoading = false}); -class TimelineStateNotifier extends Notifier { - void setScrubbing(bool isScrubbing) { - state = state.copyWith(isScrubbing: isScrubbing); + bool get isInteracting => isScrolling || recommendDeferredLoading; + + @override + bool operator ==(covariant TimelineState other) { + return isScrolling == other.isScrolling && recommendDeferredLoading == other.recommendDeferredLoading; } + @override + int get hashCode => isScrolling.hashCode ^ recommendDeferredLoading.hashCode; + + TimelineState copyWith({bool? isScrolling, bool? recommendDeferredLoading}) { + return TimelineState( + isScrolling: isScrolling ?? this.isScrolling, + recommendDeferredLoading: recommendDeferredLoading ?? this.recommendDeferredLoading, + ); + } +} + +class TimelineStateNotifier extends Notifier { void setScrolling(bool isScrolling) { state = state.copyWith(isScrolling: isScrolling); } + void setRecommendDeferredLoading(bool recommendDeferredLoading) { + state = state.copyWith(recommendDeferredLoading: recommendDeferredLoading); + } + @override - TimelineState build() => const TimelineState(isScrubbing: false, isScrolling: false); + TimelineState build() => const TimelineState(isScrolling: false, recommendDeferredLoading: false); } // This provider watches the buckets from the timeline service & args and serves the segments. diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 868edba64d5d99..d0ff5c60d743b2 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -27,6 +27,7 @@ import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/routing/app_navigation_observer.dart'; +import 'package:immich_mobile/utils/debounce.dart'; import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart'; @@ -157,6 +158,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi double _baseScaleFactor = 3.0; int? _restoreAssetIndex; + final Debouncer _fastScrollDebouncer = Debouncer(interval: const Duration(milliseconds: 100)); + @override void initState() { super.initState(); @@ -265,27 +268,39 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi @override void dispose() { WidgetsBinding.instance.removeObserver(this); + _fastScrollDebouncer.dispose(); _scrollController.dispose(); unawaited(_eventSubscription?.cancel()); super.dispose(); } + /// Track whether the timeline is moving fast enough to defer per-row asset loading + bool _onScrollVelocityNotification(ScrollNotification notification) { + // Only consider the primary timeline ScrollView (no nested views) and update events + if (notification.depth != 0 || notification is! ScrollUpdateNotification) { + return false; + } + + // Use Flutter's built in fast velocity tracking + if (_scrollController.position.recommendDeferredLoading(context)) { + ref.read(timelineStateProvider.notifier).setRecommendDeferredLoading(true); + + // We cannot rely on scroll end events, as the timeline scrubber jumps from position + // to position, resulting in large spikes in velocity followed by low velocity + _fastScrollDebouncer.run(() => ref.read(timelineStateProvider.notifier).setRecommendDeferredLoading(false)); + } + return false; + } + void _scrollToTop() { if (!_scrollController.hasClients) { return; } - final timelineState = ref.read(timelineStateProvider.notifier); - timelineState.setScrubbing(true); - unawaited( - _scrollController - .animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut) - .whenComplete(() => timelineState.setScrubbing(false)), - ); + _scrollController.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut); } void _scrollToDate(DateTime date) { - final timelineState = ref.read(timelineStateProvider.notifier); final asyncSegments = ref.read(timelineSegmentProvider); asyncSegments.whenData((segments) { // Find the segment that contains assets from the target date @@ -312,18 +327,11 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi if (fallbackSegment != null) { // Scroll to the segment with a small offset to show the header final targetOffset = fallbackSegment.startOffset - 50; - timelineState.setScrubbing(true); - unawaited( - _scrollController - .animateTo( - targetOffset.clamp(0.0, _scrollController.position.maxScrollExtent), - duration: const Duration(milliseconds: 500), - curve: Curves.easeInOut, - ) - .whenComplete(() => timelineState.setScrubbing(false)), + _scrollController.animateTo( + targetOffset.clamp(0.0, _scrollController.position.maxScrollExtent), + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, ); - } else { - timelineState.setScrubbing(false); } }); } @@ -526,7 +534,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi child: Stack( clipBehavior: Clip.none, children: [ - timeline, + NotificationListener( + onNotification: _onScrollVelocityNotification, + child: timeline, + ), if (isBottomWidgetVisible) Positioned( top: MediaQuery.paddingOf(context).top, diff --git a/mobile/lib/utils/editor.utils.dart b/mobile/lib/utils/editor.utils.dart index c3440cce1a9324..fc5cbf79a75a59 100644 --- a/mobile/lib/utils/editor.utils.dart +++ b/mobile/lib/utils/editor.utils.dart @@ -45,7 +45,7 @@ bool isCloseToZero(double value, [double epsilon = 1e-15]) { return value.abs() < epsilon; } -typedef NormalizedTransform = ({double rotation, bool mirrorHorizontal, bool mirrorVertical}); +typedef NormalizedTransform = ({int rotation, bool mirrorHorizontal, bool mirrorVertical}); NormalizedTransform normalizeTransformEdits(List edits) { final matrix = buildAffineFromEdits(edits); @@ -55,11 +55,10 @@ NormalizedTransform normalizeTransformEdits(List edits) { final double c = matrix.c; final double d = matrix.d; - final rotation = ((isCloseToZero(a) ? asin(c) : acos(a)) * 180) / pi; + final degrees = ((isCloseToZero(a) ? asin(c) : acos(a)) * 180) / pi; - return ( - rotation: rotation < 0 ? 360 + rotation : rotation, - mirrorHorizontal: false, - mirrorVertical: isCloseToZero(a) ? b == c : a == -d, - ); + // We only allow 90 degree increments + final quarterTurns = (degrees / 90).round() % 4; + + return (rotation: quarterTurns * 90, mirrorHorizontal: false, mirrorVertical: isCloseToZero(a) ? b == c : a == -d); } diff --git a/mobile/lib/widgets/common/tag_picker.dart b/mobile/lib/widgets/common/tag_picker.dart index 2b4f07536f6352..09701ede2ab860 100644 --- a/mobile/lib/widgets/common/tag_picker.dart +++ b/mobile/lib/widgets/common/tag_picker.dart @@ -61,7 +61,7 @@ class _TagPickerModal extends HookWidget { height: MediaQuery.of(context).size.height * 0.6, child: TagPicker( onSelectExistingTag: onSelectExistingTag, - filter: selectedTagIds.value, + initialSelection: selectedTagIds.value, onSelectNewTag: onSelectNewTag, ), ), @@ -70,9 +70,9 @@ class _TagPickerModal extends HookWidget { } class TagPicker extends HookConsumerWidget { - const TagPicker({super.key, required this.onSelectExistingTag, required this.filter, this.onSelectNewTag}); + const TagPicker({super.key, required this.onSelectExistingTag, required this.initialSelection, this.onSelectNewTag}); - final Set filter; + final Set initialSelection; /// Callback when existing tags are selected/deselected. final Function(Iterable) onSelectExistingTag; @@ -85,7 +85,7 @@ class TagPicker extends HookConsumerWidget { final formFocus = useFocusNode(); final searchQuery = useState(''); final tags = ref.watch(tagProvider); - final selectedTagIds = useState>(filter); + final selectedTagIds = useState>(initialSelection); const borderRadius = BorderRadius.all(Radius.circular(10)); final selectedNewTagValues = useState>({}); diff --git a/mobile/lib/widgets/search/search_filter/people_picker.dart b/mobile/lib/widgets/search/search_filter/people_picker.dart index ee738265b2449d..2e380bb236c0af 100644 --- a/mobile/lib/widgets/search/search_filter/people_picker.dart +++ b/mobile/lib/widgets/search/search_filter/people_picker.dart @@ -13,10 +13,10 @@ import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/widgets/common/search_field.dart'; class PeoplePicker extends HookConsumerWidget { - const PeoplePicker({super.key, required this.onSelect, this.filter}); + const PeoplePicker({super.key, required this.onSelect, this.initialSelection}); final Function(Set) onSelect; - final Set? filter; + final Set? initialSelection; @override Widget build(BuildContext context, WidgetRef ref) { @@ -24,7 +24,7 @@ class PeoplePicker extends HookConsumerWidget { const imageSize = 60.0; final searchQuery = useState(''); final people = ref.watch(getAllPeopleProvider); - final selectedPeople = useState>(filter ?? {}); + final selectedPeople = useState>({...?initialSelection}); return Column( children: [ @@ -87,14 +87,15 @@ class PeoplePicker extends HookConsumerWidget { ), ), onTap: () { - if (selectedPeople.value.contains(person)) { - selectedPeople.value.remove(person); + final newSelected = {...selectedPeople.value}; + if (isSelected) { + newSelected.remove(person); } else { - selectedPeople.value.add(person); + newSelected.add(person); } - selectedPeople.value = {...selectedPeople.value}; - onSelect(selectedPeople.value); + selectedPeople.value = newSelected; + onSelect(newSelected); }, selected: isSelected, selectedTileColor: context.primaryColor, diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index 96af89edb56a4c..0611cd59731424 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -465,7 +465,7 @@ select ) as "faces", ( select - coalesce(json_agg(agg), '[]') + to_json(obj) from ( select @@ -477,14 +477,18 @@ select "asset_file" where "asset_file"."assetId" = "asset"."id" - and "asset_file"."type" = $1 - ) as agg - ) as "files" + and "asset_file"."type" = 'preview' + order by + "asset_file"."isEdited" desc + limit + 1 + ) as obj + ) as "previewFile" from "asset" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where - "asset"."id" = $2 + "asset"."id" = $1 -- AssetJobRepository.getForOcr select diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index 5c7924a2086c55..78e1b0b3ad114d 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Kysely, sql } from 'kysely'; -import { jsonArrayFrom } from 'kysely/helpers/postgres'; +import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { columns } from 'src/database'; import { DummyValue, GenerateSql } from 'src/decorators'; @@ -236,7 +236,17 @@ export class AssetJobRepository { .select(['asset.id', 'asset.visibility']) .$call(withExifInner) .select((eb) => withFaces(eb, true, true)) - .select((eb) => withFiles(eb, AssetFileType.Preview)) + .select((eb) => + jsonObjectFrom( + eb + .selectFrom('asset_file') + .select(columns.assetFiles) + .whereRef('asset_file.assetId', '=', 'asset.id') + .where('asset_file.type', '=', sql.lit(AssetFileType.Preview)) + .orderBy('asset_file.isEdited', 'desc') + .limit(sql.lit(1)), + ).as('previewFile'), + ) .where('asset.id', '=', id) .executeTakeFirst(); } diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index dcb0c73beabfa8..0606eb70c8413c 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -320,8 +320,8 @@ export class PersonService extends BaseService { } const asset = await this.assetJobRepository.getForDetectFacesJob(id); - const previewFile = asset?.files[0]; - if (!asset || asset.files.length !== 1 || !previewFile) { + const previewFile = asset?.previewFile; + if (!asset || !previewFile) { return JobStatus.Failed; } diff --git a/server/test/mappers.ts b/server/test/mappers.ts index f2694f184f63fd..6937c55456c2ec 100644 --- a/server/test/mappers.ts +++ b/server/test/mappers.ts @@ -1,6 +1,7 @@ import { Selectable, ShallowDehydrateObject } from 'kysely'; import { MapAsset } from 'src/dtos/asset-response.dto'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; +import { AssetFileType } from 'src/enum'; import { FaceSearchResult } from 'src/repositories/search.repository'; import { ActivityTable } from 'src/schema/tables/activity.table'; import { AssetTable } from 'src/schema/tables/asset.table'; @@ -188,7 +189,10 @@ export const getForDetectedFaces = (asset: ReturnType) => visibility: asset.visibility, exifInfo: getDehydrated(asset.exifInfo), faces: asset.faces.map((face) => getDehydrated(face)), - files: asset.files.map((file) => getDehydrated(file)), + previewFile: asset.files + .filter((file) => file.type === AssetFileType.Preview) + .toSorted((a) => (a.isEdited ? -1 : 1)) + .map((file) => getDehydrated(file))[0], }); export const getForSidecarWrite = (asset: ReturnType) => ({ diff --git a/server/test/medium/specs/services/person.service.spec.ts b/server/test/medium/specs/services/person.service.spec.ts index 2adefe7e7a4b8b..377d8c5eca6254 100644 --- a/server/test/medium/specs/services/person.service.spec.ts +++ b/server/test/medium/specs/services/person.service.spec.ts @@ -2,14 +2,16 @@ import { Kysely } from 'kysely'; import { DateTime } from 'luxon'; import { AssetEditAction, MirrorAxis } from 'src/dtos/editing.dto'; import { AssetFaceCreateDto } from 'src/dtos/person.dto'; -import { JobName } from 'src/enum'; +import { AssetFileType, JobName } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; +import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; import { JobRepository } from 'src/repositories/job.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { MachineLearningRepository } from 'src/repositories/machine-learning.repository'; import { PersonRepository } from 'src/repositories/person.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; @@ -26,6 +28,7 @@ const setup = (db?: Kysely) => { database: db || defaultDatabase, real: [ AccessRepository, + AssetJobRepository, ConfigRepository, DatabaseRepository, PersonRepository, @@ -33,7 +36,7 @@ const setup = (db?: Kysely) => { AssetEditRepository, SystemMetadataRepository, ], - mock: [JobRepository, LoggingRepository, StorageRepository], + mock: [JobRepository, LoggingRepository, StorageRepository, MachineLearningRepository], }); }; @@ -99,6 +102,38 @@ describe(PersonService.name, () => { }); }); + describe('handleDetectFaces', () => { + it('should prefer an edited preview file', async () => { + const { sut, ctx } = setup(); + const config = await ctx.getConfig(); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, description: '' }); + await ctx.newAssetFile({ + assetId: asset.id, + type: AssetFileType.Preview, + isEdited: true, + path: 'edited_file.jpg', + }); + await ctx.newAssetFile({ + assetId: asset.id, + type: AssetFileType.Preview, + isEdited: false, + path: 'unedited_file.jpg', + }); + ctx + .getMock(MachineLearningRepository) + .detectFaces.mockResolvedValue({ imageHeight: 42, imageWidth: 69, faces: [] }); + + await sut.handleDetectFaces({ id: asset.id }); + + expect(ctx.getMock(MachineLearningRepository).detectFaces).toHaveBeenCalledWith( + 'edited_file.jpg', + config.machineLearning.facialRecognition, + ); + }); + }); + describe('handleQueueRecognizeFaces', () => { it('should delete all people and queue faces for recognition', async () => { const { sut, ctx } = setup(); diff --git a/web/src/lib/managers/timeline-manager/internal/websocket-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/websocket-support.svelte.ts index 0a004b1045a4f9..bc9b3a843fe796 100644 --- a/web/src/lib/managers/timeline-manager/internal/websocket-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/websocket-support.svelte.ts @@ -13,10 +13,10 @@ export class WebsocketSupport { #processPendingChanges = throttle(() => { const { add, update, remove } = this.#getPendingChangeBatches(); if (add.length > 0) { - this.#timelineManager.upsertAssets(add); + this.#timelineManager.upsertAssetsFromLiveEvent(add); } if (update.length > 0) { - this.#timelineManager.upsertAssets(update); + this.#timelineManager.upsertAssetsFromLiveEvent(update); } if (remove.length > 0) { this.#timelineManager.removeAssets(remove); diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts index f44f8a30464418..15b6ffb053c3bf 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts @@ -485,6 +485,85 @@ describe('TimelineManager', () => { }); }); + describe('live event asset insertion', () => { + let timelineManager: TimelineManager; + + beforeEach(async () => { + timelineManager = new TimelineManager(); + sdkMock.getTimeBuckets.mockResolvedValue([]); + + await timelineManager.updateViewport({ width: 1588, height: 1000 }); + }); + + afterEach(() => { + timelineManager.destroy(); + }); + + it('does not insert live event assets with a different owner into a user-scoped timeline', async () => { + await timelineManager.updateOptions({ userId: 'partner-id', visibility: AssetVisibility.Timeline }); + + const asset = deriveLocalDateTimeFromFileCreatedAt( + timelineAssetFactory.build({ + ownerId: 'current-user-id', + visibility: AssetVisibility.Timeline, + }), + ); + + timelineManager.upsertAssetsFromLiveEvent([asset]); + + expect(timelineManager.assetCount).toEqual(0); + }); + + it('inserts live event assets for the matching owner into a user-scoped timeline', async () => { + await timelineManager.updateOptions({ userId: 'partner-id', visibility: AssetVisibility.Timeline }); + + const asset = deriveLocalDateTimeFromFileCreatedAt( + timelineAssetFactory.build({ + ownerId: 'partner-id', + visibility: AssetVisibility.Timeline, + }), + ); + + timelineManager.upsertAssetsFromLiveEvent([asset]); + + expect(timelineManager.assetCount).toEqual(1); + }); + + it('does not insert unknown live event assets into album timelines', async () => { + await timelineManager.updateOptions({ albumId: 'album-id' }); + + const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build()); + + timelineManager.upsertAssetsFromLiveEvent([asset]); + + expect(timelineManager.assetCount).toEqual(0); + }); + + it('updates existing live event assets in scoped timelines', async () => { + await timelineManager.updateOptions({ albumId: 'album-id' }); + + const asset = deriveLocalDateTimeFromFileCreatedAt( + timelineAssetFactory.build({ + isFavorite: false, + }), + ); + + timelineManager.upsertAssets([asset]); + expect(timelineManager.assetCount).toEqual(1); + expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(false); + + timelineManager.upsertAssetsFromLiveEvent([ + { + ...asset, + isFavorite: true, + }, + ]); + + expect(timelineManager.assetCount).toEqual(1); + expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(true); + }); + }); + describe('removeAssets', () => { let timelineManager: TimelineManager; diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index 7016371f8e1407..dac5644c3a1a3b 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -381,6 +381,12 @@ export class TimelineManager extends VirtualScrollManager { this.addAssetsUpsertSegments([...notExcluded]); } + upsertAssetsFromLiveEvent(assets: TimelineAsset[]) { + const notUpdated = this.#updateAssets(assets); + const insertable = notUpdated.filter((asset) => this.canInsertAssetFromLiveEvent(asset)); + this.addAssetsUpsertSegments(insertable); + } + async findTimelineMonthForAsset(asset: AssetDescriptor | AssetResponseDto) { if (!this.isInitialized) { await this.initTask.waitUntilExecution(); @@ -620,6 +626,19 @@ export class TimelineManager extends VirtualScrollManager { ); } + canInsertAssetFromLiveEvent(asset: TimelineAsset) { + if (this.isExcluded(asset)) { + return false; + } + if (this.#options.albumId || this.#options.personId || this.#options.timelineAlbumId) { + return false; + } + if (this.#options.userId && !this.#options.withPartners && asset.ownerId !== this.#options.userId) { + return false; + } + return true; + } + getAssetOrder() { return this.#options.order ?? AssetOrder.Desc; }