Compare commits

..

4 Commits

Author SHA1 Message Date
shenlong-tanwen 3facfe8d50 debug: throttle timeline watch 2026-06-17 22:48:39 +05:30
shenlong-tanwen 430e1fed7d debug: full local sync 2026-06-17 22:48:25 +05:30
shenlong-tanwen 9c698d6694 debug: more logs
# Conflicts:
#	mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart
#	mobile/lib/presentation/widgets/timeline/timeline.widget.dart
2026-06-17 22:12:45 +05:30
shenlong-tanwen 2cc920ccdc debug: blank timeline rendering 2026-06-17 22:11:52 +05:30
25 changed files with 200 additions and 243 deletions
+14 -14
View File
@@ -5,38 +5,38 @@ version = "3.11.15"
backend = "core:python"
[tools.python."platforms.linux-arm64"]
checksum = "sha256:cbce0660e88cd9c56be7aaf2a2df92bea51f359388a521838b6b01817d728df0"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
checksum = "sha256:243f794278eff6adba96ed3677ec6877175df84c25f140e17f09f9be82d0f12a"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-arm64-musl"]
checksum = "sha256:a55ea44225ee3741d4157c383f3d5c3e8eee5f9665e2ea069233486b4275d928"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-aarch64-unknown-linux-musl-install_only_stripped.tar.gz"
checksum = "sha256:52b4c52094ff8b383a45c694acf4c5c0e883152be6d5229a35a8186ce907c6eb"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-aarch64-unknown-linux-musl-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64"]
checksum = "sha256:67a5b22f796e96f4d7fa628f95866d5fd1d524d0588f74e4601facd82b66792b"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
checksum = "sha256:171dffd8c0f66e8a0725364a7428015b22fc18dd298b24f541392e17dd0e561f"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64-musl"]
checksum = "sha256:5a8544aa4303da3ca4b7505c98dd8453b671157039d25cd551e55abea0f83a60"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-x86_64-unknown-linux-musl-install_only_stripped.tar.gz"
checksum = "sha256:2ac90fef8917ebd14826a6d667593a06cf0ae5f745ba9b1147dc086dd35f5284"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-unknown-linux-musl-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.macos-arm64"]
checksum = "sha256:8c56f1f59142e0f9f8861ad897bdfd97fd84403afa7b3d8b0f33b208ec471355"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-aarch64-apple-darwin-install_only_stripped.tar.gz"
checksum = "sha256:fdfc363b538662eb7441a14e06f72c4a992c56af7f401f5730ea5081f8f8ad6e"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-aarch64-apple-darwin-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.macos-x64"]
checksum = "sha256:8cd3878c656ba1698314cbcb65f78df4c37b7c8eabff958558115c6db11adb3d"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-x86_64-apple-darwin-install_only_stripped.tar.gz"
checksum = "sha256:5f1eb247cbca2c0ad5ccbf6d299a4f54b31b5c63b492d74c3531dc4344a42f88"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-apple-darwin-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.windows-x64"]
checksum = "sha256:f081a733b4e7ba0e5e5e12d533b3c795dbef3ecbebf92f0b4202e5329bf7c8ab"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
checksum = "sha256:756d7f148498b8822f6aedf44a020613576f09983161f346ad36dcef6238cdc3"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
provenance = "github-attestations"
[[tools.uv]]
@@ -174,10 +174,9 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2L
else -> 0L
}
// Date taken is in ms; date added/modified in seconds. No-EXIF (date taken <= 0)
// falls back to the earliest of modified/added to match the server + iOS.
// Date taken is milliseconds since epoch, Date added is seconds since epoch
val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000))
?: minOf(c.getLong(dateModifiedColumn), c.getLong(dateAddedColumn))
?: c.getLong(dateAddedColumn)
// Date modified is seconds since epoch
val modifiedAt = c.getLong(dateModifiedColumn)
val width = c.getInt(widthColumn).toLong()
@@ -138,7 +138,9 @@ class LocalSyncService {
final Stopwatch stopwatch = Stopwatch()..start();
final deviceAlbums = await _nativeSyncApi.getAlbums();
final getAlbumsTime = stopwatch.elapsedMilliseconds;
final dbAlbums = await _localAlbumRepository.getAll(sortBy: {SortLocalAlbumsBy.id});
final getAllTime = stopwatch.elapsedMilliseconds;
await diffSortedLists(
dbAlbums,
@@ -148,10 +150,15 @@ class LocalSyncService {
onlyFirst: removeAlbum,
onlySecond: addAlbum,
);
final diffTime = stopwatch.elapsedMilliseconds;
await _nativeSyncApi.checkpointSync();
stopwatch.stop();
_log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms");
_log.info(
"Full device sync took - ${stopwatch.elapsedMilliseconds}ms "
"(getAlbums=${getAlbumsTime}ms, getAll=${getAllTime - getAlbumsTime}ms, "
"diff=${diffTime - getAllTime}ms, checkpoint=${stopwatch.elapsedMilliseconds - diffTime}ms)",
);
} on PlatformException catch (e, s) {
if (e.code == _kSyncCancelledCode) {
_log.warning("Full device sync cancelled");
@@ -10,6 +10,8 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:logging/logging.dart';
import 'package:stream_transform/stream_transform.dart';
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count);
@@ -90,6 +92,7 @@ class TimelineFactory {
}
class TimelineService {
static final Logger _log = Logger('TimelineService');
final TimelineAssetSource _assetSource;
final TimelineBucketSource _bucketSource;
final TimelineOrigin origin;
@@ -105,37 +108,53 @@ class TimelineService {
: this._(assetSource: query.assetSource, bucketSource: query.bucketSource, origin: query.origin);
TimelineService._({required this._assetSource, required this._bucketSource, required this.origin}) {
_bucketSubscription = _bucketSource().listen((buckets) {
_mutex.run(() async {
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
_bucketSubscription = watchBuckets().listen(
(buckets) {
_mutex.run(() async {
try {
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
if (totalAssets == 0) {
_bufferOffset = 0;
_buffer = [];
} else {
final int offset;
final int count;
// When the buffer is empty or the old bufferOffset is greater than the new total assets,
// we need to reset the buffer and load the first batch of assets.
if (_bufferOffset >= totalAssets || _buffer.isEmpty) {
offset = 0;
count = kTimelineAssetLoadBatchSize;
} else {
offset = _bufferOffset;
count = math.min(_buffer.length, totalAssets - _bufferOffset);
_log.info(
'[$origin] bucket emission: ${buckets.length} buckets / $totalAssets assets '
'(current _totalAssets=$_totalAssets, _bufferOffset=$_bufferOffset, _buffer=${_buffer.length})',
);
if (totalAssets == 0) {
_bufferOffset = 0;
_buffer = [];
} else {
final int offset;
final int count;
// When the buffer is empty or the old bufferOffset is greater than the new total assets,
// we need to reset the buffer and load the first batch of assets.
if (_bufferOffset >= totalAssets || _buffer.isEmpty) {
offset = 0;
count = kTimelineAssetLoadBatchSize;
} else {
offset = _bufferOffset;
count = math.min(_buffer.length, totalAssets - _bufferOffset);
}
_buffer = await _assetSource(offset, count);
_bufferOffset = offset;
_log.info('[$origin] buffer reloaded: offset=$offset requested=$count got=${_buffer.length}');
}
_totalAssets = totalAssets;
EventStream.shared.emit(const TimelineReloadEvent());
} catch (error, stack) {
_log.severe('[$origin] bucket reload FAILED — _totalAssets stuck at $_totalAssets', error, stack);
rethrow;
}
_buffer = await _assetSource(offset, count);
_bufferOffset = offset;
}
// change the state's total assets count only after the buffer is reloaded
_totalAssets = totalAssets;
EventStream.shared.emit(const TimelineReloadEvent());
});
});
});
},
onError: (Object error, StackTrace stack) {
_log.severe('[$origin] bucket stream errored', error, stack);
},
);
}
Stream<List<Bucket>> Function() get watchBuckets => _bucketSource;
Stream<List<Bucket>> Function() get watchBuckets =>
() => _bucketSource().throttle(const Duration(milliseconds: 500), trailing: true);
Future<List<BaseAsset>> loadAssets(int index, int count) => _mutex.run(() => _loadAssets(index, count));
@@ -164,6 +183,13 @@ class TimelineService {
_buffer = await _assetSource(start, len);
_bufferOffset = start;
if (!hasRange(index, count)) {
_log.warning(
'[$origin] _loadAssets($index, $count): buffer loaded (offset=$start, got=${_buffer.length}) but still '
'out of range — _totalAssets=$_totalAssets. getAssets is about to throw RangeError.',
);
}
return getAssets(index, count);
}
@@ -23,6 +23,7 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:logging/logging.dart';
class FixedSegment extends Segment {
final double tileHeight;
@@ -90,6 +91,7 @@ class FixedSegment extends Segment {
}
class _FixedSegmentRow extends ConsumerWidget {
static final Logger _log = Logger('TimelineRow');
final int assetIndex;
final int assetCount;
final double tileHeight;
@@ -109,8 +111,20 @@ class _FixedSegmentRow extends ConsumerWidget {
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
final timelineService = ref.read(timelineServiceProvider);
final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3);
final inRange = timelineService.hasRange(assetIndex, assetCount);
if (timelineService.hasRange(assetIndex, assetCount)) {
if (assetIndex == 0) {
_log.info(
'row[0] inRange=$inRange isScrubbing=$isScrubbing totalAssets=${timelineService.totalAssets} '
'branch=${inRange
? "assets"
: isScrubbing
? "placeholder(scrubbing)"
: "future(load)"}',
);
}
if (inRange) {
return _buildAssetRow(
context,
timelineService.getAssets(assetIndex, assetCount),
@@ -129,6 +143,13 @@ class _FixedSegmentRow extends ConsumerWidget {
if (snapshot.connectionState != ConnectionState.done) {
return _buildPlaceholder(context);
}
if (snapshot.hasError) {
_log.warning(
'render row loadAssets($assetIndex, $assetCount) failed (totalAssets=${timelineService.totalAssets})',
snapshot.error,
snapshot.stackTrace,
);
}
return _buildAssetRow(context, snapshot.requireData, timelineService, isDynamicLayout);
},
);
@@ -13,6 +13,7 @@ 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;
import 'package:logging/logging.dart';
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
/// for quick navigation of the BoxScrollView.
@@ -84,6 +85,7 @@ List<_Segment> _buildSegments({required List<Segment> layoutSegments, required d
}
class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixin {
static final Logger _log = Logger('Scrubber');
String? _lastLabel;
double _thumbTopOffset = 0.0;
bool _isDragging = false;
@@ -114,6 +116,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
@override
void initState() {
super.initState();
_log.info('Scrubber initState');
_isDragging = false;
_segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight);
_thumbAnimationController = AnimationController(vsync: this, duration: kTimelineScrubberFadeInDuration);
@@ -134,7 +137,10 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
void didUpdateWidget(covariant Scrubber oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.layoutSegments.lastOrNull?.endOffset != widget.layoutSegments.lastOrNull?.endOffset) {
final oldEnd = oldWidget.layoutSegments.lastOrNull?.endOffset;
final newEnd = widget.layoutSegments.lastOrNull?.endOffset;
if (oldEnd != newEnd) {
_log.info('Scrubber layoutSegments endOffset $oldEnd -> $newEnd (isDragging=$_isDragging)');
_segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight);
_monthCount = getMonthCount();
}
@@ -142,6 +148,15 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
@override
void dispose() {
if (_isDragging || _currentScrubberDate != null || _scrubberDebouncer != null) {
_log.warning(
'Scrubber dispose mid-scrub '
'(isDragging=$_isDragging, pendingDate=$_currentScrubberDate, '
'debouncerPending=${_scrubberDebouncer != null}) — scrubbing reset may be orphaned',
);
} else {
_log.info('Scrubber dispose');
}
_thumbAnimationController.dispose();
_labelAnimationController.dispose();
_fadeOutTimer?.cancel();
@@ -208,6 +223,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
}
void _onDragStart(DragStartDetails _) {
_log.info('scrub dragStart');
setState(() {
_isDragging = true;
_labelAnimationController.forward();
@@ -222,9 +238,15 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
}
if (_scrubberHeight <= 0) {
_log.warning('drag ignored: scrubberHeight=$_scrubberHeight <= 0');
return;
}
final maxScrollExtent = _scrollController.hasClients ? _scrollController.position.maxScrollExtent : -1;
if (maxScrollExtent <= 0) {
_log.warning('drag ineffective: hasClients=${_scrollController.hasClients} maxScrollExtent=$maxScrollExtent');
}
if (_thumbAnimationController.status != AnimationStatus.forward) {
_thumbAnimationController.forward();
}
@@ -344,6 +366,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
}
void _onDragEnd(DragEndDetails _) {
_log.info('scrub dragEnd -> setScrubbing(false)');
_labelAnimationController.reverse();
setState(() {
_isDragging = false;
@@ -7,6 +7,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment_builde
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:logging/logging.dart';
class TimelineArgs {
final double maxWidth;
@@ -71,14 +72,27 @@ class TimelineState {
}
class TimelineStateNotifier extends Notifier<TimelineState> {
static final Logger _log = Logger('TimelineState');
void setScrubbing(bool isScrubbing) {
if (state.isScrubbing != isScrubbing) {
_log.info('isScrubbing ${state.isScrubbing} -> $isScrubbing (from ${_callSite()})');
}
state = state.copyWith(isScrubbing: isScrubbing);
}
void setScrolling(bool isScrolling) {
if (state.isScrolling != isScrolling) {
_log.info('isScrolling ${state.isScrolling} -> $isScrolling (from ${_callSite()})');
}
state = state.copyWith(isScrolling: isScrolling);
}
static String _callSite() {
final frames = StackTrace.current.toString().split('\n');
return frames.length > 2 ? frames[2].trim() : 'unknown';
}
@override
TimelineState build() => const TimelineState(isScrubbing: false, isScrolling: false);
}
@@ -96,6 +110,11 @@ final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>((ref)
final timelineService = ref.watch(timelineServiceProvider);
yield* timelineService.watchBuckets().map((buckets) {
final layoutTotal = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
Logger('TimelineService').info(
'[${timelineService.origin}] segment layout: '
'${buckets.length} buckets / $layoutTotal assets (service.totalAssets=${timelineService.totalAssets})',
);
return FixedSegmentBuilder(
buckets: buckets,
tileHeight: tileExtent,
@@ -28,6 +28,7 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.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';
import 'package:logging/logging.dart';
class Timeline extends StatelessWidget {
const Timeline({
@@ -136,6 +137,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
}
class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
static final Logger _log = Logger('Timeline');
late final ScrollController _scrollController;
StreamSubscription? _eventSubscription;
@@ -153,6 +155,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
@override
void initState() {
super.initState();
_log.info('SliverTimeline initState');
_scrollController = ScrollController(onAttach: _restoreAssetPosition);
_eventSubscription = EventStream.shared.listen(_onEvent);
@@ -179,6 +182,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
}
void _onEvent(Event event) {
_log.info('event ${event.runtimeType}');
switch (event) {
case ScrollToTopEvent():
{
@@ -186,7 +190,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
timelineState.setScrubbing(true);
_scrollController
.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut)
.whenComplete(() => timelineState.setScrubbing(false));
.whenComplete(() {
_log.info('ScrollToTop animation done -> setScrubbing(false)');
timelineState.setScrubbing(false);
});
}
case ScrollToDateEvent scrollToDateEvent:
@@ -246,6 +253,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
@override
void dispose() {
_log.info('SliverTimeline dispose');
_scrollController.dispose();
_eventSubscription?.cancel();
super.dispose();
@@ -286,8 +294,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
)
.whenComplete(() => timelineState.setScrubbing(false));
.whenComplete(() {
_log.info('ScrollToDate animation done -> setScrubbing(false)');
timelineState.setScrubbing(false);
});
} else {
_log.info('ScrollToDate: no matching segment for $date -> setScrubbing(false)');
timelineState.setScrubbing(false);
}
});
@@ -5,6 +5,9 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:logging/logging.dart';
final _log = Logger('TimelineProvider');
final timelineRepositoryProvider = Provider<DriftTimelineRepository>(
(ref) => DriftTimelineRepository(ref.watch(driftProvider)),
@@ -18,7 +21,11 @@ final timelineServiceProvider = Provider<TimelineService>(
(ref) {
final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? [];
final timelineService = ref.watch(timelineFactoryProvider).main(timelineUsers);
ref.onDispose(timelineService.dispose);
_log.info('main TimelineService built users=$timelineUsers');
ref.onDispose(() {
_log.info('main TimelineService disposed');
timelineService.dispose();
});
return timelineService;
},
// Empty dependencies to inform the framework that this provider
@@ -36,8 +43,12 @@ final timelineFactoryProvider = Provider<TimelineFactory>(
final timelineUsersProvider = StreamProvider<List<String>>((ref) {
final currentUserId = ref.watch(currentUserProvider.select((u) => u?.id));
if (currentUserId == null) {
_log.info('timelineUsers: currentUserId=null -> []');
return Stream.value([]);
}
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId);
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId).map((users) {
_log.info('timelineUsers emission: $users');
return users;
});
});
+1 -25
View File
@@ -12,14 +12,13 @@ import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
const int targetVersion = 27;
const int targetVersion = 26;
Future<void> migrateDatabaseIfNeeded(Drift drift) async {
final int version = Store.get(StoreKey.version, targetVersion);
@@ -32,33 +31,10 @@ Future<void> migrateDatabaseIfNeeded(Drift drift) async {
await _migrateTo26(drift);
}
if (version < 27) {
if (!await _migrateTo27(drift)) {
return;
}
}
await Store.put(StoreKey.version, targetVersion);
return;
}
Future<bool> _migrateTo27(Drift drift) async {
// Android-only: no-EXIF photos got a wrong createdAt (DATE_ADDED copy-time instead
// of the real DATE_MODIFIED). Those rows can't self-heal -- the local sync only
// updates an asset when its updatedAt (DATE_MODIFIED) changes, which it never does
// here. A createdAt later than updatedAt is the copy-time signature, so clamp it
// back to updatedAt (the real date, == the new minOf(DATE_MODIFIED, DATE_ADDED)).
if (!CurrentPlatform.isAndroid) {
return true;
}
try {
await drift.customStatement('UPDATE local_asset_entity SET created_at = updated_at WHERE created_at > updated_at');
return true;
} catch (_) {
return false;
}
}
Future<void> _migrateTo25() async {
final accessToken = Store.tryGet(StoreKey.accessToken);
if (accessToken == null || accessToken.isEmpty) {
+3 -11
View File
@@ -982,9 +982,7 @@ class AssetsApi {
/// * [String] key:
///
/// * [String] slug:
///
/// * [num] xImmichHlsPos:
Future<Response> getMediaPlaylistWithHttpInfo(String id, String sessionId, int variantIndex, { String? key, String? slug, num? xImmichHlsPos, Future<void>? abortTrigger, }) async {
Future<Response> getMediaPlaylistWithHttpInfo(String id, String sessionId, int variantIndex, { String? key, String? slug, Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/video/stream/{sessionId}/{variantIndex}/playlist.m3u8'
.replaceAll('{id}', id)
@@ -1005,10 +1003,6 @@ class AssetsApi {
queryParams.addAll(_queryParams('', 'slug', slug));
}
if (xImmichHlsPos != null) {
headerParams[r'x-immich-hls-pos'] = parameterToString(xImmichHlsPos);
}
const contentTypes = <String>[];
@@ -1039,10 +1033,8 @@ class AssetsApi {
/// * [String] key:
///
/// * [String] slug:
///
/// * [num] xImmichHlsPos:
Future<String?> getMediaPlaylist(String id, String sessionId, int variantIndex, { String? key, String? slug, num? xImmichHlsPos, Future<void>? abortTrigger, }) async {
final response = await getMediaPlaylistWithHttpInfo(id, sessionId, variantIndex, key: key, slug: slug, xImmichHlsPos: xImmichHlsPos, abortTrigger: abortTrigger,);
Future<String?> getMediaPlaylist(String id, String sessionId, int variantIndex, { String? key, String? slug, Future<void>? abortTrigger, }) async {
final response = await getMediaPlaylistWithHttpInfo(id, sessionId, variantIndex, key: key, slug: slug, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
-9
View File
@@ -4924,15 +4924,6 @@
"maximum": 9007199254740991,
"type": "integer"
}
},
{
"name": "x-immich-hls-pos",
"required": false,
"in": "header",
"schema": {
"minimum": 0,
"type": "number"
}
}
],
"responses": {
+2 -6
View File
@@ -4452,13 +4452,12 @@ export function endSession({ id, key, sessionId, slug }: {
/**
* Get HLS media playlist
*/
export function getMediaPlaylist({ id, key, sessionId, slug, variantIndex, xImmichHlsPos }: {
export function getMediaPlaylist({ id, key, sessionId, slug, variantIndex }: {
id: string;
key?: string;
sessionId: string;
slug?: string;
variantIndex: number;
xImmichHlsPos?: number;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchBlob<{
status: 200;
@@ -4467,10 +4466,7 @@ export function getMediaPlaylist({ id, key, sessionId, slug, variantIndex, xImmi
key,
slug
}))}`, {
...opts,
headers: oazapfts.mergeHeaders(opts?.headers, {
"x-immich-hls-pos": xImmichHlsPos
})
...opts
}));
}
/**
-6
View File
@@ -223,12 +223,6 @@ export const SUPPORTED_HWA_CODECS: Record<TranscodeHardwareAcceleration, VideoCo
export const HLS_BACKPRESSURE_PAUSE_SEGMENTS = 30;
export const HLS_BACKPRESSURE_RESUME_SEGMENTS = 15;
export const HLS_CLEANUP_INTERVAL_MS = 60 * 1000;
export const HLS_CRF: Record<VideoCodec, number> = {
[VideoCodec.H264]: 23,
[VideoCodec.Hevc]: 28,
[VideoCodec.Vp9]: 31,
[VideoCodec.Av1]: 35,
};
export const HLS_INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
export const HLS_LEASE_DURATION_MS = 30 * 60 * 1000;
export const HLS_PLAYLIST_CONTENT_TYPE = 'application/vnd.apple.mpegurl';
@@ -6,7 +6,6 @@ import { HLS_PLAYLIST_CONTENT_TYPE } from 'src/constants';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import {
HlsPlaylistHeaderDto,
HlsSegmentHeaderDto,
HlsSegmentParamDto,
HlsSessionParamDto,
@@ -51,17 +50,8 @@ export class VideoStreamController {
description: 'Returns an HLS media playlist for one variant of the streaming session.',
history: new HistoryBuilder().added('v3').alpha('v3'),
})
getMediaPlaylist(
@Auth() auth: AuthDto,
@Param() { id, sessionId, variantIndex }: HlsVariantParamDto,
@Headers() headers: HlsPlaylistHeaderDto,
) {
try {
headers = HlsPlaylistHeaderDto.create(headers);
} catch (error) {
throw new ZodValidationException(error);
}
return this.service.getMediaPlaylist(auth, id, sessionId, variantIndex, headers[ImmichHeader.HlsPosition]);
getMediaPlaylist(@Auth() auth: AuthDto, @Param() { id, sessionId }: HlsVariantParamDto) {
return this.service.getMediaPlaylist(auth, id, sessionId);
}
@Get(':id/video/stream/:sessionId/:variantIndex/:filename')
-8
View File
@@ -32,11 +32,3 @@ const HlsSegmentHeaderSchema = z.object({
});
export class HlsSegmentHeaderDto extends createZodDto(HlsSegmentHeaderSchema) {}
const HlsPlaylistHeaderSchema = z.object({
// Lets the client hint at which segment will be loaded after the playlist.
// A position rather than a segment index since indices aren't comparable across variants.
[ImmichHeader.HlsPosition]: z.coerce.number().min(0).optional(),
});
export class HlsPlaylistHeaderDto extends createZodDto(HlsPlaylistHeaderSchema) {}
-1
View File
@@ -25,7 +25,6 @@ export enum ImmichHeader {
Checksum = 'x-immich-checksum',
CorrelationId = 'X-Correlation-ID',
HlsInitSegment = 'x-immich-hls-msn',
HlsPosition = 'x-immich-hls-pos',
}
export enum ImmichQuery {
+7 -59
View File
@@ -8,7 +8,6 @@ import { newTestService, ServiceMocks } from 'test/utils';
// EXTINF values come from FFmpeg's playlist to enforce an exact match
const eiffelExpectedMediaPlaylist = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-TARGETDURATION:2
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
@@ -42,7 +41,6 @@ seg_11.m4s
const waterfallExpectedMediaPlaylist = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-TARGETDURATION:2
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
@@ -64,7 +62,6 @@ seg_5.m4s
const trainExpectedMediaPlaylist = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-TARGETDURATION:2
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
@@ -98,7 +95,6 @@ const sessionId = '00000000-0000-0000-0000-000000000000';
const eiffelExpectedMasterDisabled = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/0/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
@@ -121,7 +117,6 @@ ${sessionId}/8/playlist.m3u8
const eiffelExpectedMasterRkmpp = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/1/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
@@ -138,7 +133,6 @@ ${sessionId}/8/playlist.m3u8
const waterfallExpectedMasterDisabled = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/0/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
@@ -224,58 +218,12 @@ describe(HlsService.name, () => {
it.each(fixtures)('matches FFmpeg for $data.originalPath', async ({ data, playlist }) => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
mocks.videoStream.getForMediaPlaylist.mockResolvedValue(data);
await expect(sut.getMediaPlaylist(auth, assetId, sessionId, 0)).resolves.toBe(playlist);
await expect(sut.getMediaPlaylist(auth, assetId, sessionId)).resolves.toBe(playlist);
});
it('throws NotFoundException when the session/asset cannot be loaded', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
await expect(sut.getMediaPlaylist(auth, assetId, sessionId, 0)).rejects.toBeInstanceOf(NotFoundException);
});
it('prewarms transcoding at the segment containing the hinted position', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
mocks.videoStream.getForMediaPlaylist.mockResolvedValue(eiffelTower);
await sut.getMediaPlaylist(auth, assetId, sessionId, 1, 10.5);
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsSegmentRequest', {
sessionId,
assetId,
variantIndex: 1,
segmentIndex: 5,
});
});
it('prewarms from the last requested segment when no hint is given', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
mocks.videoStream.getSession.mockResolvedValue({ id: sessionId, assetId } as never);
mocks.storage.checkFileExists.mockResolvedValue(true);
await sut.getSegment(auth, assetId, sessionId, 0, 'seg_5.m4s');
mocks.videoStream.getForMediaPlaylist.mockResolvedValue(eiffelTower);
await sut.getMediaPlaylist(auth, assetId, sessionId, 1);
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsSegmentRequest', {
sessionId,
assetId,
variantIndex: 1,
segmentIndex: 6,
});
});
it('does not prewarm without a hint or prior segment traffic', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
mocks.videoStream.getForMediaPlaylist.mockResolvedValue(eiffelTower);
await sut.getMediaPlaylist(auth, assetId, sessionId, 1);
expect(mocks.websocket.serverSend).not.toHaveBeenCalled();
});
it('does not prewarm the variant the session is already playing', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
mocks.videoStream.getSession.mockResolvedValue({ id: sessionId, assetId } as never);
mocks.storage.checkFileExists.mockResolvedValue(true);
await sut.getSegment(auth, assetId, sessionId, 1, 'seg_5.m4s');
mocks.videoStream.getForMediaPlaylist.mockResolvedValue(eiffelTower);
await sut.getMediaPlaylist(auth, assetId, sessionId, 1, 12.5);
expect(mocks.websocket.serverSend).not.toHaveBeenCalledWith('HlsSegmentRequest', expect.anything());
await expect(sut.getMediaPlaylist(auth, assetId, sessionId)).rejects.toBeInstanceOf(NotFoundException);
});
});
@@ -366,7 +314,7 @@ describe(HlsService.name, () => {
);
});
it('uses the initSegment hint for init.mp4', async () => {
it('uses the target segment for init.mp4 when provided', async () => {
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4', 7);
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
sessionId,
@@ -375,18 +323,18 @@ describe(HlsService.name, () => {
});
});
it('prefers the initSegment hint over the lastRequested + 1 fallback', async () => {
it('prefers the target segment over the lastRequested + 1 fallback', async () => {
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'seg_5.m4s'); // fallback would be 6
mocks.websocket.serverSend.mockClear();
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4', 10);
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4', 12);
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
sessionId,
variantIndex,
segmentIndex: 10,
segmentIndex: 12,
});
});
it('ignores the initSegment hint for media segment requests (the filename wins)', async () => {
it('ignores the target segment for media segment requests (the filename wins)', async () => {
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'seg_5.m4s', 99);
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
sessionId,
+6 -33
View File
@@ -21,7 +21,6 @@ import { ImmichFileResponse } from 'src/utils/file';
import { getOutputSize } from 'src/utils/media';
type AssetWithStreamInfo = { videoStream: VideoStreamInfo & { timeBase: number }; packets: VideoPacketInfo };
type Segmentation = { fps: number; framesPerSegment: number; segmentCount: number; segmentDuration: number };
type ApiSession = { lastRequestedSegment: number | null; lastVariantIndex: number | null };
@Injectable()
@@ -72,7 +71,7 @@ export class HlsService extends BaseService {
return this.generateMainPlaylist(sessionId, ffmpeg, asset);
}
async getMediaPlaylist(auth: AuthDto, assetId: string, sessionId: string, variantIndex: number, position?: number) {
async getMediaPlaylist(auth: AuthDto, assetId: string, sessionId: string) {
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [assetId] });
const asset = await this.videoStreamRepository.getForMediaPlaylist(assetId, sessionId);
@@ -80,11 +79,7 @@ export class HlsService extends BaseService {
throw new NotFoundException('Asset not found or metadata not yet ready for streaming');
}
const segmentation = this.getSegmentation(asset);
const hintedSegment = position === undefined ? undefined : this.positionToSegmentIndex(segmentation, position);
this.prewarmVariant(assetId, sessionId, variantIndex, hintedSegment);
return this.generateMediaPlaylist(asset, segmentation);
return this.generateMediaPlaylist(asset);
}
async getSegment(
@@ -134,7 +129,7 @@ export class HlsService extends BaseService {
const fps = ((asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration).toFixed(3);
const sourceResolution = Math.min(asset.videoStream.height, asset.videoStream.width);
const targetResolution = Math.max(sourceResolution, HLS_VARIANTS[0].resolution);
const lines = ['#EXTM3U', `#EXT-X-VERSION:${HLS_VERSION}`, '#EXT-X-INDEPENDENT-SEGMENTS'];
const lines = ['#EXTM3U', `#EXT-X-VERSION:${HLS_VERSION}`];
for (let i = 0; i < HLS_VARIANTS.length; i++) {
const { resolution, bitrate, codec, codecString } = HLS_VARIANTS[i];
if (resolution > targetResolution || !SUPPORTED_HWA_CODECS[ffmpeg.accel].includes(codec)) {
@@ -148,33 +143,24 @@ export class HlsService extends BaseService {
}
lines.push('');
if (lines.length === 4) {
if (lines.length === 3) {
throw new NotFoundException('No supported variants for this video');
}
return lines.join('\n');
}
private getSegmentation({ videoStream, packets }: AssetWithStreamInfo): Segmentation {
private generateMediaPlaylist({ videoStream, packets }: AssetWithStreamInfo) {
const fps = (packets.packetCount * videoStream.timeBase) / packets.totalDuration;
const framesPerSegment = Math.ceil(HLS_SEGMENT_DURATION * fps);
const fullSegmentDuration = framesPerSegment / fps;
const segmentCount = Math.ceil(packets.outputFrames / framesPerSegment);
return { fps, framesPerSegment, segmentCount, segmentDuration: framesPerSegment / fps };
}
private positionToSegmentIndex({ segmentDuration, segmentCount }: Segmentation, position: number) {
return Math.min(Math.max(Math.floor(position / segmentDuration), 0), segmentCount - 1);
}
private generateMediaPlaylist({ packets }: AssetWithStreamInfo, segmentation: Segmentation) {
const { fps, framesPerSegment, segmentCount, segmentDuration: fullSegmentDuration } = segmentation;
const lastSegmentFrames = packets.outputFrames - framesPerSegment * (segmentCount - 1);
const lastSegmentDuration = lastSegmentFrames / fps;
const lines = [
'#EXTM3U',
`#EXT-X-VERSION:${HLS_VERSION}`,
'#EXT-X-INDEPENDENT-SEGMENTS',
`#EXT-X-TARGETDURATION:${HLS_SEGMENT_DURATION}`,
'#EXT-X-MEDIA-SEQUENCE:0',
'#EXT-X-PLAYLIST-TYPE:VOD',
@@ -189,19 +175,6 @@ export class HlsService extends BaseService {
return lines.join('\n');
}
private prewarmVariant(assetId: string, sessionId: string, variantIndex: number, hintedSegment?: number) {
const session = this.sessions.get(sessionId);
if (session?.lastVariantIndex === variantIndex) {
return;
}
const nextSegment = session && session.lastRequestedSegment !== null ? session.lastRequestedSegment + 1 : undefined;
const segmentIndex = hintedSegment ?? nextSegment;
if (segmentIndex !== undefined) {
this.websocketRepository.serverSend('HlsSegmentRequest', { sessionId, assetId, variantIndex, segmentIndex });
}
}
private getSegmentKey({ sessionId, variantIndex, segmentIndex }: ArgOf<'HlsSegmentResult'>) {
return `${sessionId}:${variantIndex}:${segmentIndex}`;
}
@@ -381,6 +381,8 @@ describe(TranscodingService.name, () => {
'50',
'-keyint_min',
'50',
'-crf',
'23',
'-copyts',
'-r',
'50130000/2012441',
@@ -415,8 +417,6 @@ describe(TranscodingService.name, () => {
'aac',
'-preset',
'12',
'-crf',
'35',
'-svtav1-params',
'hierarchical-levels=3:lookahead=0:enable-tf=0:mbr=4000k',
'-hls_segment_filename',
@@ -436,8 +436,6 @@ describe(TranscodingService.name, () => {
'hvc1',
'-preset',
'ultrafast',
'-crf',
'28',
'-maxrate',
'2500k',
'-bufsize',
@@ -461,8 +459,6 @@ describe(TranscodingService.name, () => {
'aac',
'-preset',
'ultrafast',
'-crf',
'23',
'-maxrate',
'2500k',
'-bufsize',
@@ -5,7 +5,6 @@ import {
HLS_BACKPRESSURE_PAUSE_SEGMENTS,
HLS_BACKPRESSURE_RESUME_SEGMENTS,
HLS_CLEANUP_INTERVAL_MS,
HLS_CRF,
HLS_INACTIVITY_TIMEOUT_MS,
HLS_LEASE_DURATION_MS,
HLS_SEGMENT_DURATION,
@@ -222,7 +221,6 @@ export class TranscodingService extends BaseService {
targetResolution: String(variant.resolution),
maxBitrate: `${Math.round(variant.bitrate / 1000)}k`,
gopSize: gop,
crf: HLS_CRF[variant.codec],
},
this.videoInterfaces,
{ strictGop: true, lowLatency: true },
@@ -44,7 +44,7 @@
brokenAssetClass?: ClassValue;
dimmed?: boolean;
albumUsers?: UserResponseDto[];
onClick?: (asset: TimelineAsset, event?: MouseEvent) => void;
onClick?: (asset: TimelineAsset) => void;
onPreview?: (asset: TimelineAsset) => void;
onSelect?: (asset: TimelineAsset) => void;
onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void;
@@ -93,12 +93,12 @@
}
};
const callClickHandlers = (e?: MouseEvent) => {
const callClickHandlers = () => {
if (selected) {
onIconClickedHandler(e);
onIconClickedHandler();
return;
}
onClick?.($state.snapshot(asset), e);
onClick?.($state.snapshot(asset));
};
const handleClick = (e: MouseEvent) => {
@@ -109,7 +109,7 @@
e.stopPropagation();
e.preventDefault();
callClickHandlers(e);
callClickHandlers();
};
const onMouseEnter = () => {
@@ -59,7 +59,6 @@
groupTitle: string,
asset: TimelineAsset,
) => void,
event?: MouseEvent,
) => void;
}
@@ -686,9 +685,9 @@
{asset}
{albumUsers}
{groupIndex}
onClick={(asset, event) => {
onClick={(asset) => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, timelineDay, _onClick, event);
onThumbnailClick(asset, timelineManager, timelineDay, _onClick);
} else {
_onClick(timelineManager, timelineDay.getAssets(), timelineDay.groupTitle, asset);
}
@@ -118,12 +118,7 @@
groupTitle: string,
asset: TimelineAsset,
) => void,
event?: MouseEvent,
) => {
if (event?.shiftKey) {
onClick(timelineManager, timelineDay.getAssets(), timelineDay.groupTitle, asset);
return;
}
if (hasGps(asset)) {
locationUpdated = true;
setTimeout(() => {
+1 -1
View File
@@ -16,7 +16,7 @@ const config = {
preprocess: vitePreprocess(),
kit: {
version: {
name: process.env.IMMICH_BUILD || process.env.npm_package_version || 'local',
name: process.env.IMMICH_BUILD || Date.now().toString(),
},
paths: {
relative: false,