diff --git a/e2e/src/api/specs/timeline.e2e-spec.ts b/e2e/src/api/specs/timeline.e2e-spec.ts
index 93ba8b6527..79bf748e9a 100644
--- a/e2e/src/api/specs/timeline.e2e-spec.ts
+++ b/e2e/src/api/specs/timeline.e2e-spec.ts
@@ -1,4 +1,4 @@
-import { AssetMediaResponseDto, AssetVisibility, LoginResponseDto, SharedLinkType, TimeBucketSize } from '@immich/sdk';
+import { AssetMediaResponseDto, AssetVisibility, LoginResponseDto, SharedLinkType } from '@immich/sdk';
 import { DateTime } from 'luxon';
 import { createUserDto } from 'src/fixtures';
 import { errorDto } from 'src/responses';
@@ -52,7 +52,7 @@ describe('/timeline', () => {
 
   describe('GET /timeline/buckets', () => {
     it('should require authentication', async () => {
-      const { status, body } = await request(app).get('/timeline/buckets').query({ size: TimeBucketSize.Month });
+      const { status, body } = await request(app).get('/timeline/buckets');
       expect(status).toBe(401);
       expect(body).toEqual(errorDto.unauthorized);
     });
@@ -60,8 +60,7 @@ describe('/timeline', () => {
     it('should get time buckets by month', async () => {
       const { status, body } = await request(app)
         .get('/timeline/buckets')
-        .set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
-        .query({ size: TimeBucketSize.Month });
+        .set('Authorization', `Bearer ${timeBucketUser.accessToken}`);
 
       expect(status).toBe(200);
       expect(body).toEqual(
@@ -78,33 +77,17 @@ describe('/timeline', () => {
         assetIds: userAssets.map(({ id }) => id),
       });
 
-      const { status, body } = await request(app)
-        .get('/timeline/buckets')
-        .query({ key: sharedLink.key, size: TimeBucketSize.Month });
+      const { status, body } = await request(app).get('/timeline/buckets').query({ key: sharedLink.key });
 
       expect(status).toBe(400);
       expect(body).toEqual(errorDto.noPermission);
     });
 
-    it('should get time buckets by day', async () => {
-      const { status, body } = await request(app)
-        .get('/timeline/buckets')
-        .set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
-        .query({ size: TimeBucketSize.Day });
-
-      expect(status).toBe(200);
-      expect(body).toEqual([
-        { count: 2, timeBucket: '1970-02-11T00:00:00.000Z' },
-        { count: 1, timeBucket: '1970-02-10T00:00:00.000Z' },
-        { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' },
-      ]);
-    });
-
     it('should return error if time bucket is requested with partners asset and archived', async () => {
       const req1 = await request(app)
         .get('/timeline/buckets')
         .set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
-        .query({ size: TimeBucketSize.Month, withPartners: true, visibility: AssetVisibility.Archive });
+        .query({ withPartners: true, visibility: AssetVisibility.Archive });
 
       expect(req1.status).toBe(400);
       expect(req1.body).toEqual(errorDto.badRequest());
@@ -112,7 +95,7 @@ describe('/timeline', () => {
       const req2 = await request(app)
         .get('/timeline/buckets')
         .set('Authorization', `Bearer ${user.accessToken}`)
-        .query({ size: TimeBucketSize.Month, withPartners: true, visibility: undefined });
+        .query({ withPartners: true, visibility: undefined });
 
       expect(req2.status).toBe(400);
       expect(req2.body).toEqual(errorDto.badRequest());
@@ -122,7 +105,7 @@ describe('/timeline', () => {
       const req1 = await request(app)
         .get('/timeline/buckets')
         .set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
-        .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: true });
+        .query({ withPartners: true, isFavorite: true });
 
       expect(req1.status).toBe(400);
       expect(req1.body).toEqual(errorDto.badRequest());
@@ -130,7 +113,7 @@ describe('/timeline', () => {
       const req2 = await request(app)
         .get('/timeline/buckets')
         .set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
-        .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: false });
+        .query({ withPartners: true, isFavorite: false });
 
       expect(req2.status).toBe(400);
       expect(req2.body).toEqual(errorDto.badRequest());
@@ -140,7 +123,7 @@ describe('/timeline', () => {
       const req = await request(app)
         .get('/timeline/buckets')
         .set('Authorization', `Bearer ${user.accessToken}`)
-        .query({ size: TimeBucketSize.Month, withPartners: true, isTrashed: true });
+        .query({ withPartners: true, isTrashed: true });
 
       expect(req.status).toBe(400);
       expect(req.body).toEqual(errorDto.badRequest());
@@ -150,7 +133,6 @@ describe('/timeline', () => {
   describe('GET /timeline/bucket', () => {
     it('should require authentication', async () => {
       const { status, body } = await request(app).get('/timeline/bucket').query({
-        size: TimeBucketSize.Month,
         timeBucket: '1900-01-01',
       });
 
@@ -161,11 +143,27 @@ describe('/timeline', () => {
     it('should handle 5 digit years', async () => {
       const { status, body } = await request(app)
         .get('/timeline/bucket')
-        .query({ size: TimeBucketSize.Month, timeBucket: '012345-01-01' })
+        .query({ timeBucket: '012345-01-01' })
         .set('Authorization', `Bearer ${timeBucketUser.accessToken}`);
 
       expect(status).toBe(200);
-      expect(body).toEqual([]);
+      expect(body).toEqual({
+        city: [],
+        country: [],
+        duration: [],
+        id: [],
+        visibility: [],
+        isFavorite: [],
+        isImage: [],
+        isTrashed: [],
+        livePhotoVideoId: [],
+        localDateTime: [],
+        ownerId: [],
+        projectionType: [],
+        ratio: [],
+        status: [],
+        thumbhash: [],
+      });
     });
 
     // TODO enable date string validation while still accepting 5 digit years
@@ -173,7 +171,7 @@ describe('/timeline', () => {
     //   const { status, body } = await request(app)
     //     .get('/timeline/bucket')
     //     .set('Authorization', `Bearer ${user.accessToken}`)
-    //     .query({ size: TimeBucketSize.Month, timeBucket: 'foo' });
+    //     .query({  timeBucket: 'foo' });
 
     //   expect(status).toBe(400);
     //   expect(body).toEqual(errorDto.badRequest);
@@ -183,10 +181,26 @@ describe('/timeline', () => {
       const { status, body } = await request(app)
         .get('/timeline/bucket')
         .set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
-        .query({ size: TimeBucketSize.Month, timeBucket: '1970-02-10' });
+        .query({ timeBucket: '1970-02-10' });
 
       expect(status).toBe(200);
-      expect(body).toEqual([]);
+      expect(body).toEqual({
+        city: [],
+        country: [],
+        duration: [],
+        id: [],
+        visibility: [],
+        isFavorite: [],
+        isImage: [],
+        isTrashed: [],
+        livePhotoVideoId: [],
+        localDateTime: [],
+        ownerId: [],
+        projectionType: [],
+        ratio: [],
+        status: [],
+        thumbhash: [],
+      });
     });
   });
 });
diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index 620fc97664..2c5dea7f19 100644
--- a/mobile/openapi/README.md
+++ b/mobile/openapi/README.md
@@ -494,8 +494,8 @@ Class | Method | HTTP request | Description
  - [TemplateDto](doc//TemplateDto.md)
  - [TemplateResponseDto](doc//TemplateResponseDto.md)
  - [TestEmailResponseDto](doc//TestEmailResponseDto.md)
- - [TimeBucketResponseDto](doc//TimeBucketResponseDto.md)
- - [TimeBucketSize](doc//TimeBucketSize.md)
+ - [TimeBucketAssetResponseDto](doc//TimeBucketAssetResponseDto.md)
+ - [TimeBucketsResponseDto](doc//TimeBucketsResponseDto.md)
  - [ToneMapping](doc//ToneMapping.md)
  - [TranscodeHWAccel](doc//TranscodeHWAccel.md)
  - [TranscodePolicy](doc//TranscodePolicy.md)
diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index 8710298d7d..541614ca55 100644
--- a/mobile/openapi/lib/api.dart
+++ b/mobile/openapi/lib/api.dart
@@ -289,8 +289,8 @@ part 'model/tags_update.dart';
 part 'model/template_dto.dart';
 part 'model/template_response_dto.dart';
 part 'model/test_email_response_dto.dart';
-part 'model/time_bucket_response_dto.dart';
-part 'model/time_bucket_size.dart';
+part 'model/time_bucket_asset_response_dto.dart';
+part 'model/time_buckets_response_dto.dart';
 part 'model/tone_mapping.dart';
 part 'model/transcode_hw_accel.dart';
 part 'model/transcode_policy.dart';
diff --git a/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart
index 1d25a379e8..399e7bde86 100644
--- a/mobile/openapi/lib/api/timeline_api.dart
+++ b/mobile/openapi/lib/api/timeline_api.dart
@@ -19,8 +19,6 @@ class TimelineApi {
   /// Performs an HTTP 'GET /timeline/bucket' operation and returns the [Response].
   /// Parameters:
   ///
-  /// * [TimeBucketSize] size (required):
-  ///
   /// * [String] timeBucket (required):
   ///
   /// * [String] albumId:
@@ -33,6 +31,10 @@ class TimelineApi {
   ///
   /// * [AssetOrder] order:
   ///
+  /// * [num] page:
+  ///
+  /// * [num] pageSize:
+  ///
   /// * [String] personId:
   ///
   /// * [String] tagId:
@@ -44,7 +46,7 @@ class TimelineApi {
   /// * [bool] withPartners:
   ///
   /// * [bool] withStacked:
-  Future<Response> getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
+  Future<Response> getTimeBucketWithHttpInfo(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, num? page, num? pageSize, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
     // ignore: prefer_const_declarations
     final apiPath = r'/timeline/bucket';
 
@@ -70,10 +72,15 @@ class TimelineApi {
     if (order != null) {
       queryParams.addAll(_queryParams('', 'order', order));
     }
+    if (page != null) {
+      queryParams.addAll(_queryParams('', 'page', page));
+    }
+    if (pageSize != null) {
+      queryParams.addAll(_queryParams('', 'pageSize', pageSize));
+    }
     if (personId != null) {
       queryParams.addAll(_queryParams('', 'personId', personId));
     }
-      queryParams.addAll(_queryParams('', 'size', size));
     if (tagId != null) {
       queryParams.addAll(_queryParams('', 'tagId', tagId));
     }
@@ -107,8 +114,6 @@ class TimelineApi {
 
   /// Parameters:
   ///
-  /// * [TimeBucketSize] size (required):
-  ///
   /// * [String] timeBucket (required):
   ///
   /// * [String] albumId:
@@ -121,6 +126,10 @@ class TimelineApi {
   ///
   /// * [AssetOrder] order:
   ///
+  /// * [num] page:
+  ///
+  /// * [num] pageSize:
+  ///
   /// * [String] personId:
   ///
   /// * [String] tagId:
@@ -132,8 +141,8 @@ class TimelineApi {
   /// * [bool] withPartners:
   ///
   /// * [bool] withStacked:
-  Future<List<AssetResponseDto>?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
-    final response = await getTimeBucketWithHttpInfo(size, timeBucket,  albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, visibility: visibility, withPartners: withPartners, withStacked: withStacked, );
+  Future<TimeBucketAssetResponseDto?> getTimeBucket(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, num? page, num? pageSize, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
+    final response = await getTimeBucketWithHttpInfo(timeBucket,  albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, page: page, pageSize: pageSize, personId: personId, tagId: tagId, userId: userId, visibility: visibility, withPartners: withPartners, withStacked: withStacked, );
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }
@@ -141,11 +150,8 @@ class TimelineApi {
     // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
     // FormatException when trying to decode an empty string.
     if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
-      final responseBody = await _decodeBodyBytes(response);
-      return (await apiClient.deserializeAsync(responseBody, 'List<AssetResponseDto>') as List)
-        .cast<AssetResponseDto>()
-        .toList(growable: false);
-
+      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TimeBucketAssetResponseDto',) as TimeBucketAssetResponseDto;
+    
     }
     return null;
   }
@@ -153,8 +159,6 @@ class TimelineApi {
   /// Performs an HTTP 'GET /timeline/buckets' operation and returns the [Response].
   /// Parameters:
   ///
-  /// * [TimeBucketSize] size (required):
-  ///
   /// * [String] albumId:
   ///
   /// * [bool] isFavorite:
@@ -176,7 +180,7 @@ class TimelineApi {
   /// * [bool] withPartners:
   ///
   /// * [bool] withStacked:
-  Future<Response> getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
+  Future<Response> getTimeBucketsWithHttpInfo({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
     // ignore: prefer_const_declarations
     final apiPath = r'/timeline/buckets';
 
@@ -205,7 +209,6 @@ class TimelineApi {
     if (personId != null) {
       queryParams.addAll(_queryParams('', 'personId', personId));
     }
-      queryParams.addAll(_queryParams('', 'size', size));
     if (tagId != null) {
       queryParams.addAll(_queryParams('', 'tagId', tagId));
     }
@@ -238,8 +241,6 @@ class TimelineApi {
 
   /// Parameters:
   ///
-  /// * [TimeBucketSize] size (required):
-  ///
   /// * [String] albumId:
   ///
   /// * [bool] isFavorite:
@@ -261,8 +262,8 @@ class TimelineApi {
   /// * [bool] withPartners:
   ///
   /// * [bool] withStacked:
-  Future<List<TimeBucketResponseDto>?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
-    final response = await getTimeBucketsWithHttpInfo(size,  albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, visibility: visibility, withPartners: withPartners, withStacked: withStacked, );
+  Future<List<TimeBucketsResponseDto>?> getTimeBuckets({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
+    final response = await getTimeBucketsWithHttpInfo( albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, visibility: visibility, withPartners: withPartners, withStacked: withStacked, );
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }
@@ -271,8 +272,8 @@ class TimelineApi {
     // FormatException when trying to decode an empty string.
     if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
       final responseBody = await _decodeBodyBytes(response);
-      return (await apiClient.deserializeAsync(responseBody, 'List<TimeBucketResponseDto>') as List)
-        .cast<TimeBucketResponseDto>()
+      return (await apiClient.deserializeAsync(responseBody, 'List<TimeBucketsResponseDto>') as List)
+        .cast<TimeBucketsResponseDto>()
         .toList(growable: false);
 
     }
diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart
index a3b1c41ca6..540dc11300 100644
--- a/mobile/openapi/lib/api_client.dart
+++ b/mobile/openapi/lib/api_client.dart
@@ -634,10 +634,10 @@ class ApiClient {
           return TemplateResponseDto.fromJson(value);
         case 'TestEmailResponseDto':
           return TestEmailResponseDto.fromJson(value);
-        case 'TimeBucketResponseDto':
-          return TimeBucketResponseDto.fromJson(value);
-        case 'TimeBucketSize':
-          return TimeBucketSizeTypeTransformer().decode(value);
+        case 'TimeBucketAssetResponseDto':
+          return TimeBucketAssetResponseDto.fromJson(value);
+        case 'TimeBucketsResponseDto':
+          return TimeBucketsResponseDto.fromJson(value);
         case 'ToneMapping':
           return ToneMappingTypeTransformer().decode(value);
         case 'TranscodeHWAccel':
diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart
index 4928adf767..1618f4a670 100644
--- a/mobile/openapi/lib/api_helper.dart
+++ b/mobile/openapi/lib/api_helper.dart
@@ -139,9 +139,6 @@ String parameterToString(dynamic value) {
   if (value is SyncRequestType) {
     return SyncRequestTypeTypeTransformer().encode(value).toString();
   }
-  if (value is TimeBucketSize) {
-    return TimeBucketSizeTypeTransformer().encode(value).toString();
-  }
   if (value is ToneMapping) {
     return ToneMappingTypeTransformer().encode(value).toString();
   }
diff --git a/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart b/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart
new file mode 100644
index 0000000000..3f1406c019
--- /dev/null
+++ b/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart
@@ -0,0 +1,241 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class TimeBucketAssetResponseDto {
+  /// Returns a new [TimeBucketAssetResponseDto] instance.
+  TimeBucketAssetResponseDto({
+    this.city = const [],
+    this.country = const [],
+    this.duration = const [],
+    this.id = const [],
+    this.isFavorite = const [],
+    this.isImage = const [],
+    this.isTrashed = const [],
+    this.livePhotoVideoId = const [],
+    this.localDateTime = const [],
+    this.ownerId = const [],
+    this.projectionType = const [],
+    this.ratio = const [],
+    this.stack = const [],
+    this.thumbhash = const [],
+    this.visibility = const [],
+  });
+
+  List<String?> city;
+
+  List<String?> country;
+
+  List<String?> duration;
+
+  List<String> id;
+
+  List<bool> isFavorite;
+
+  List<bool> isImage;
+
+  List<bool> isTrashed;
+
+  List<String?> livePhotoVideoId;
+
+  List<String> localDateTime;
+
+  List<String> ownerId;
+
+  List<String?> projectionType;
+
+  List<num> ratio;
+
+  /// (stack ID, stack asset count) tuple
+  List<List<String>?> stack;
+
+  List<String?> thumbhash;
+
+  List<AssetVisibility> visibility;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is TimeBucketAssetResponseDto &&
+    _deepEquality.equals(other.city, city) &&
+    _deepEquality.equals(other.country, country) &&
+    _deepEquality.equals(other.duration, duration) &&
+    _deepEquality.equals(other.id, id) &&
+    _deepEquality.equals(other.isFavorite, isFavorite) &&
+    _deepEquality.equals(other.isImage, isImage) &&
+    _deepEquality.equals(other.isTrashed, isTrashed) &&
+    _deepEquality.equals(other.livePhotoVideoId, livePhotoVideoId) &&
+    _deepEquality.equals(other.localDateTime, localDateTime) &&
+    _deepEquality.equals(other.ownerId, ownerId) &&
+    _deepEquality.equals(other.projectionType, projectionType) &&
+    _deepEquality.equals(other.ratio, ratio) &&
+    _deepEquality.equals(other.stack, stack) &&
+    _deepEquality.equals(other.thumbhash, thumbhash) &&
+    _deepEquality.equals(other.visibility, visibility);
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (city.hashCode) +
+    (country.hashCode) +
+    (duration.hashCode) +
+    (id.hashCode) +
+    (isFavorite.hashCode) +
+    (isImage.hashCode) +
+    (isTrashed.hashCode) +
+    (livePhotoVideoId.hashCode) +
+    (localDateTime.hashCode) +
+    (ownerId.hashCode) +
+    (projectionType.hashCode) +
+    (ratio.hashCode) +
+    (stack.hashCode) +
+    (thumbhash.hashCode) +
+    (visibility.hashCode);
+
+  @override
+  String toString() => 'TimeBucketAssetResponseDto[city=$city, country=$country, duration=$duration, id=$id, isFavorite=$isFavorite, isImage=$isImage, isTrashed=$isTrashed, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, ownerId=$ownerId, projectionType=$projectionType, ratio=$ratio, stack=$stack, thumbhash=$thumbhash, visibility=$visibility]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'city'] = this.city;
+      json[r'country'] = this.country;
+      json[r'duration'] = this.duration;
+      json[r'id'] = this.id;
+      json[r'isFavorite'] = this.isFavorite;
+      json[r'isImage'] = this.isImage;
+      json[r'isTrashed'] = this.isTrashed;
+      json[r'livePhotoVideoId'] = this.livePhotoVideoId;
+      json[r'localDateTime'] = this.localDateTime;
+      json[r'ownerId'] = this.ownerId;
+      json[r'projectionType'] = this.projectionType;
+      json[r'ratio'] = this.ratio;
+      json[r'stack'] = this.stack;
+      json[r'thumbhash'] = this.thumbhash;
+      json[r'visibility'] = this.visibility;
+    return json;
+  }
+
+  /// Returns a new [TimeBucketAssetResponseDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static TimeBucketAssetResponseDto? fromJson(dynamic value) {
+    upgradeDto(value, "TimeBucketAssetResponseDto");
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return TimeBucketAssetResponseDto(
+        city: json[r'city'] is Iterable
+            ? (json[r'city'] as Iterable).cast<String>().toList(growable: false)
+            : const [],
+        country: json[r'country'] is Iterable
+            ? (json[r'country'] as Iterable).cast<String>().toList(growable: false)
+            : const [],
+        duration: json[r'duration'] is Iterable
+            ? (json[r'duration'] as Iterable).cast<String>().toList(growable: false)
+            : const [],
+        id: json[r'id'] is Iterable
+            ? (json[r'id'] as Iterable).cast<String>().toList(growable: false)
+            : const [],
+        isFavorite: json[r'isFavorite'] is Iterable
+            ? (json[r'isFavorite'] as Iterable).cast<bool>().toList(growable: false)
+            : const [],
+        isImage: json[r'isImage'] is Iterable
+            ? (json[r'isImage'] as Iterable).cast<bool>().toList(growable: false)
+            : const [],
+        isTrashed: json[r'isTrashed'] is Iterable
+            ? (json[r'isTrashed'] as Iterable).cast<bool>().toList(growable: false)
+            : const [],
+        livePhotoVideoId: json[r'livePhotoVideoId'] is Iterable
+            ? (json[r'livePhotoVideoId'] as Iterable).cast<String>().toList(growable: false)
+            : const [],
+        localDateTime: json[r'localDateTime'] is Iterable
+            ? (json[r'localDateTime'] as Iterable).cast<String>().toList(growable: false)
+            : const [],
+        ownerId: json[r'ownerId'] is Iterable
+            ? (json[r'ownerId'] as Iterable).cast<String>().toList(growable: false)
+            : const [],
+        projectionType: json[r'projectionType'] is Iterable
+            ? (json[r'projectionType'] as Iterable).cast<String>().toList(growable: false)
+            : const [],
+        ratio: json[r'ratio'] is Iterable
+            ? (json[r'ratio'] as Iterable).cast<num>().toList(growable: false)
+            : const [],
+        stack: json[r'stack'] is List
+          ? (json[r'stack'] as List).map((e) =>
+              e == null ? null : (e as List).cast<String>()
+            ).toList()
+          :  const [],
+        thumbhash: json[r'thumbhash'] is Iterable
+            ? (json[r'thumbhash'] as Iterable).cast<String>().toList(growable: false)
+            : const [],
+        visibility: AssetVisibility.listFromJson(json[r'visibility']),
+      );
+    }
+    return null;
+  }
+
+  static List<TimeBucketAssetResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <TimeBucketAssetResponseDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = TimeBucketAssetResponseDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, TimeBucketAssetResponseDto> mapFromJson(dynamic json) {
+    final map = <String, TimeBucketAssetResponseDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = TimeBucketAssetResponseDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of TimeBucketAssetResponseDto-objects as value to a dart map
+  static Map<String, List<TimeBucketAssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<TimeBucketAssetResponseDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = TimeBucketAssetResponseDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'city',
+    'country',
+    'duration',
+    'id',
+    'isFavorite',
+    'isImage',
+    'isTrashed',
+    'livePhotoVideoId',
+    'localDateTime',
+    'ownerId',
+    'projectionType',
+    'ratio',
+    'thumbhash',
+    'visibility',
+  };
+}
+
diff --git a/mobile/openapi/lib/model/time_bucket_size.dart b/mobile/openapi/lib/model/time_bucket_size.dart
deleted file mode 100644
index e843b43f43..0000000000
--- a/mobile/openapi/lib/model/time_bucket_size.dart
+++ /dev/null
@@ -1,85 +0,0 @@
-//
-// AUTO-GENERATED FILE, DO NOT MODIFY!
-//
-// @dart=2.18
-
-// ignore_for_file: unused_element, unused_import
-// ignore_for_file: always_put_required_named_parameters_first
-// ignore_for_file: constant_identifier_names
-// ignore_for_file: lines_longer_than_80_chars
-
-part of openapi.api;
-
-
-class TimeBucketSize {
-  /// Instantiate a new enum with the provided [value].
-  const TimeBucketSize._(this.value);
-
-  /// The underlying value of this enum member.
-  final String value;
-
-  @override
-  String toString() => value;
-
-  String toJson() => value;
-
-  static const DAY = TimeBucketSize._(r'DAY');
-  static const MONTH = TimeBucketSize._(r'MONTH');
-
-  /// List of all possible values in this [enum][TimeBucketSize].
-  static const values = <TimeBucketSize>[
-    DAY,
-    MONTH,
-  ];
-
-  static TimeBucketSize? fromJson(dynamic value) => TimeBucketSizeTypeTransformer().decode(value);
-
-  static List<TimeBucketSize> listFromJson(dynamic json, {bool growable = false,}) {
-    final result = <TimeBucketSize>[];
-    if (json is List && json.isNotEmpty) {
-      for (final row in json) {
-        final value = TimeBucketSize.fromJson(row);
-        if (value != null) {
-          result.add(value);
-        }
-      }
-    }
-    return result.toList(growable: growable);
-  }
-}
-
-/// Transformation class that can [encode] an instance of [TimeBucketSize] to String,
-/// and [decode] dynamic data back to [TimeBucketSize].
-class TimeBucketSizeTypeTransformer {
-  factory TimeBucketSizeTypeTransformer() => _instance ??= const TimeBucketSizeTypeTransformer._();
-
-  const TimeBucketSizeTypeTransformer._();
-
-  String encode(TimeBucketSize data) => data.value;
-
-  /// Decodes a [dynamic value][data] to a TimeBucketSize.
-  ///
-  /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
-  /// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
-  /// cannot be decoded successfully, then an [UnimplementedError] is thrown.
-  ///
-  /// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
-  /// and users are still using an old app with the old code.
-  TimeBucketSize? decode(dynamic data, {bool allowNull = true}) {
-    if (data != null) {
-      switch (data) {
-        case r'DAY': return TimeBucketSize.DAY;
-        case r'MONTH': return TimeBucketSize.MONTH;
-        default:
-          if (!allowNull) {
-            throw ArgumentError('Unknown enum value to decode: $data');
-          }
-      }
-    }
-    return null;
-  }
-
-  /// Singleton [TimeBucketSizeTypeTransformer] instance.
-  static TimeBucketSizeTypeTransformer? _instance;
-}
-
diff --git a/mobile/openapi/lib/model/time_bucket_response_dto.dart b/mobile/openapi/lib/model/time_buckets_response_dto.dart
similarity index 62%
rename from mobile/openapi/lib/model/time_bucket_response_dto.dart
rename to mobile/openapi/lib/model/time_buckets_response_dto.dart
index 56044b27a8..8c9f8dab61 100644
--- a/mobile/openapi/lib/model/time_bucket_response_dto.dart
+++ b/mobile/openapi/lib/model/time_buckets_response_dto.dart
@@ -10,9 +10,9 @@
 
 part of openapi.api;
 
-class TimeBucketResponseDto {
-  /// Returns a new [TimeBucketResponseDto] instance.
-  TimeBucketResponseDto({
+class TimeBucketsResponseDto {
+  /// Returns a new [TimeBucketsResponseDto] instance.
+  TimeBucketsResponseDto({
     required this.count,
     required this.timeBucket,
   });
@@ -22,7 +22,7 @@ class TimeBucketResponseDto {
   String timeBucket;
 
   @override
-  bool operator ==(Object other) => identical(this, other) || other is TimeBucketResponseDto &&
+  bool operator ==(Object other) => identical(this, other) || other is TimeBucketsResponseDto &&
     other.count == count &&
     other.timeBucket == timeBucket;
 
@@ -33,7 +33,7 @@ class TimeBucketResponseDto {
     (timeBucket.hashCode);
 
   @override
-  String toString() => 'TimeBucketResponseDto[count=$count, timeBucket=$timeBucket]';
+  String toString() => 'TimeBucketsResponseDto[count=$count, timeBucket=$timeBucket]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -42,15 +42,15 @@ class TimeBucketResponseDto {
     return json;
   }
 
-  /// Returns a new [TimeBucketResponseDto] instance and imports its values from
+  /// Returns a new [TimeBucketsResponseDto] instance and imports its values from
   /// [value] if it's a [Map], null otherwise.
   // ignore: prefer_constructors_over_static_methods
-  static TimeBucketResponseDto? fromJson(dynamic value) {
-    upgradeDto(value, "TimeBucketResponseDto");
+  static TimeBucketsResponseDto? fromJson(dynamic value) {
+    upgradeDto(value, "TimeBucketsResponseDto");
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      return TimeBucketResponseDto(
+      return TimeBucketsResponseDto(
         count: mapValueOfType<int>(json, r'count')!,
         timeBucket: mapValueOfType<String>(json, r'timeBucket')!,
       );
@@ -58,11 +58,11 @@ class TimeBucketResponseDto {
     return null;
   }
 
-  static List<TimeBucketResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
-    final result = <TimeBucketResponseDto>[];
+  static List<TimeBucketsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <TimeBucketsResponseDto>[];
     if (json is List && json.isNotEmpty) {
       for (final row in json) {
-        final value = TimeBucketResponseDto.fromJson(row);
+        final value = TimeBucketsResponseDto.fromJson(row);
         if (value != null) {
           result.add(value);
         }
@@ -71,12 +71,12 @@ class TimeBucketResponseDto {
     return result.toList(growable: growable);
   }
 
-  static Map<String, TimeBucketResponseDto> mapFromJson(dynamic json) {
-    final map = <String, TimeBucketResponseDto>{};
+  static Map<String, TimeBucketsResponseDto> mapFromJson(dynamic json) {
+    final map = <String, TimeBucketsResponseDto>{};
     if (json is Map && json.isNotEmpty) {
       json = json.cast<String, dynamic>(); // ignore: parameter_assignments
       for (final entry in json.entries) {
-        final value = TimeBucketResponseDto.fromJson(entry.value);
+        final value = TimeBucketsResponseDto.fromJson(entry.value);
         if (value != null) {
           map[entry.key] = value;
         }
@@ -85,14 +85,14 @@ class TimeBucketResponseDto {
     return map;
   }
 
-  // maps a json object with a list of TimeBucketResponseDto-objects as value to a dart map
-  static Map<String, List<TimeBucketResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
-    final map = <String, List<TimeBucketResponseDto>>{};
+  // maps a json object with a list of TimeBucketsResponseDto-objects as value to a dart map
+  static Map<String, List<TimeBucketsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<TimeBucketsResponseDto>>{};
     if (json is Map && json.isNotEmpty) {
       // ignore: parameter_assignments
       json = json.cast<String, dynamic>();
       for (final entry in json.entries) {
-        map[entry.key] = TimeBucketResponseDto.listFromJson(entry.value, growable: growable,);
+        map[entry.key] = TimeBucketsResponseDto.listFromJson(entry.value, growable: growable,);
       }
     }
     return map;
diff --git a/open-api/bin/generate-open-api.sh b/open-api/bin/generate-open-api.sh
index e2badc6dff..d6f1333489 100755
--- a/open-api/bin/generate-open-api.sh
+++ b/open-api/bin/generate-open-api.sh
@@ -1,5 +1,5 @@
 #!/usr/bin/env bash
-OPENAPI_GENERATOR_VERSION=v7.8.0
+OPENAPI_GENERATOR_VERSION=v7.12.0
 
 # usage: ./bin/generate-open-api.sh
 
@@ -8,6 +8,7 @@ function dart {
   cd ./templates/mobile/serialization/native
   wget -O native_class.mustache https://raw.githubusercontent.com/OpenAPITools/openapi-generator/$OPENAPI_GENERATOR_VERSION/modules/openapi-generator/src/main/resources/dart2/serialization/native/native_class.mustache
   patch --no-backup-if-mismatch -u native_class.mustache <native_class.mustache.patch
+  patch --no-backup-if-mismatch -u native_class.mustache <native_class_nullable_items_in_arrays.patch
 
   cd ../../
   wget -O api.mustache https://raw.githubusercontent.com/OpenAPITools/openapi-generator/$OPENAPI_GENERATOR_VERSION/modules/openapi-generator/src/main/resources/dart2/api.mustache
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index 5de3987367..8d21c3ef90 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -7284,6 +7284,24 @@
               "$ref": "#/components/schemas/AssetOrder"
             }
           },
+          {
+            "name": "page",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "minimum": 1,
+              "type": "number"
+            }
+          },
+          {
+            "name": "pageSize",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "minimum": 1,
+              "type": "number"
+            }
+          },
           {
             "name": "personId",
             "required": false,
@@ -7293,14 +7311,6 @@
               "type": "string"
             }
           },
-          {
-            "name": "size",
-            "required": true,
-            "in": "query",
-            "schema": {
-              "$ref": "#/components/schemas/TimeBucketSize"
-            }
-          },
           {
             "name": "tagId",
             "required": false,
@@ -7357,10 +7367,7 @@
             "content": {
               "application/json": {
                 "schema": {
-                  "items": {
-                    "$ref": "#/components/schemas/AssetResponseDto"
-                  },
-                  "type": "array"
+                  "$ref": "#/components/schemas/TimeBucketAssetResponseDto"
                 }
               }
             },
@@ -7437,14 +7444,6 @@
               "type": "string"
             }
           },
-          {
-            "name": "size",
-            "required": true,
-            "in": "query",
-            "schema": {
-              "$ref": "#/components/schemas/TimeBucketSize"
-            }
-          },
           {
             "name": "tagId",
             "required": false,
@@ -7494,7 +7493,7 @@
               "application/json": {
                 "schema": {
                   "items": {
-                    "$ref": "#/components/schemas/TimeBucketResponseDto"
+                    "$ref": "#/components/schemas/TimeBucketsResponseDto"
                   },
                   "type": "array"
                 }
@@ -14069,7 +14068,131 @@
         ],
         "type": "object"
       },
-      "TimeBucketResponseDto": {
+      "TimeBucketAssetResponseDto": {
+        "properties": {
+          "city": {
+            "items": {
+              "nullable": true,
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "country": {
+            "items": {
+              "nullable": true,
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "duration": {
+            "items": {
+              "nullable": true,
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "id": {
+            "items": {
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "isFavorite": {
+            "items": {
+              "type": "boolean"
+            },
+            "type": "array"
+          },
+          "isImage": {
+            "items": {
+              "type": "boolean"
+            },
+            "type": "array"
+          },
+          "isTrashed": {
+            "items": {
+              "type": "boolean"
+            },
+            "type": "array"
+          },
+          "livePhotoVideoId": {
+            "items": {
+              "nullable": true,
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "localDateTime": {
+            "items": {
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "ownerId": {
+            "items": {
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "projectionType": {
+            "items": {
+              "nullable": true,
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "ratio": {
+            "items": {
+              "type": "number"
+            },
+            "type": "array"
+          },
+          "stack": {
+            "description": "(stack ID, stack asset count) tuple",
+            "items": {
+              "items": {
+                "type": "string"
+              },
+              "maxItems": 2,
+              "minItems": 2,
+              "nullable": true,
+              "type": "array"
+            },
+            "type": "array"
+          },
+          "thumbhash": {
+            "items": {
+              "nullable": true,
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "visibility": {
+            "items": {
+              "$ref": "#/components/schemas/AssetVisibility"
+            },
+            "type": "array"
+          }
+        },
+        "required": [
+          "city",
+          "country",
+          "duration",
+          "id",
+          "isFavorite",
+          "isImage",
+          "isTrashed",
+          "livePhotoVideoId",
+          "localDateTime",
+          "ownerId",
+          "projectionType",
+          "ratio",
+          "thumbhash",
+          "visibility"
+        ],
+        "type": "object"
+      },
+      "TimeBucketsResponseDto": {
         "properties": {
           "count": {
             "type": "integer"
@@ -14084,13 +14207,6 @@
         ],
         "type": "object"
       },
-      "TimeBucketSize": {
-        "enum": [
-          "DAY",
-          "MONTH"
-        ],
-        "type": "string"
-      },
       "ToneMapping": {
         "enum": [
           "hable",
diff --git a/open-api/templates/mobile/serialization/native/native_class.mustache b/open-api/templates/mobile/serialization/native/native_class.mustache
index 9a7b1439b1..9f40d5b0bf 100644
--- a/open-api/templates/mobile/serialization/native/native_class.mustache
+++ b/open-api/templates/mobile/serialization/native/native_class.mustache
@@ -32,7 +32,7 @@ class {{{classname}}} {
       {{/required}}
     {{/isNullable}}
   {{/isEnum}}
-  {{{datatypeWithEnum}}}{{#isNullable}}?{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isNullable}} {{{name}}};
+  {{#isArray}}{{#uniqueItems}}Set{{/uniqueItems}}{{^uniqueItems}}List{{/uniqueItems}}<{{{items.dataType}}}{{#items.isNullable}}?{{/items.isNullable}}>{{/isArray}}{{^isArray}}{{{datatypeWithEnum}}}{{/isArray}}{{#isNullable}}?{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isNullable}} {{{name}}};
 
 {{/vars}}
   @override
diff --git a/open-api/templates/mobile/serialization/native/native_class_nullable_items_in_arrays.patch b/open-api/templates/mobile/serialization/native/native_class_nullable_items_in_arrays.patch
new file mode 100644
index 0000000000..a59e300913
--- /dev/null
+++ b/open-api/templates/mobile/serialization/native/native_class_nullable_items_in_arrays.patch
@@ -0,0 +1,13 @@
+diff --git a/open-api/templates/mobile/serialization/native/native_class.mustache b/open-api/templates/mobile/serialization/native/native_class.mustache
+index 9a7b1439b..9f40d5b0b 100644
+--- a/open-api/templates/mobile/serialization/native/native_class.mustache
++++ b/open-api/templates/mobile/serialization/native/native_class.mustache
+@@ -32,7 +32,7 @@ class {{{classname}}} {
+       {{/required}}
+     {{/isNullable}}
+   {{/isEnum}}
+-  {{{datatypeWithEnum}}}{{#isNullable}}?{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isNullable}} {{{name}}};
++  {{#isArray}}{{#uniqueItems}}Set{{/uniqueItems}}{{^uniqueItems}}List{{/uniqueItems}}<{{{items.dataType}}}{{#items.isNullable}}?{{/items.isNullable}}>{{/isArray}}{{^isArray}}{{{datatypeWithEnum}}}{{/isArray}}{{#isNullable}}?{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isNullable}} {{{name}}};
+ 
+ {{/vars}}
+   @override
diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts
index c293b2aa6c..5358cdfec9 100644
--- a/open-api/typescript-sdk/src/fetch-client.ts
+++ b/open-api/typescript-sdk/src/fetch-client.ts
@@ -1420,7 +1420,25 @@ export type TagBulkAssetsResponseDto = {
 export type TagUpdateDto = {
     color?: string | null;
 };
-export type TimeBucketResponseDto = {
+export type TimeBucketAssetResponseDto = {
+    city: (string | null)[];
+    country: (string | null)[];
+    duration: (string | null)[];
+    id: string[];
+    isFavorite: boolean[];
+    isImage: boolean[];
+    isTrashed: boolean[];
+    livePhotoVideoId: (string | null)[];
+    localDateTime: string[];
+    ownerId: string[];
+    projectionType: (string | null)[];
+    ratio: number[];
+    /** (stack ID, stack asset count) tuple */
+    stack?: (string[] | null)[];
+    thumbhash: (string | null)[];
+    visibility: AssetVisibility[];
+};
+export type TimeBucketsResponseDto = {
     count: number;
     timeBucket: string;
 };
@@ -3367,14 +3385,15 @@ export function tagAssets({ id, bulkIdsDto }: {
         body: bulkIdsDto
     })));
 }
-export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, personId, size, tagId, timeBucket, userId, visibility, withPartners, withStacked }: {
+export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, page, pageSize, personId, tagId, timeBucket, userId, visibility, withPartners, withStacked }: {
     albumId?: string;
     isFavorite?: boolean;
     isTrashed?: boolean;
     key?: string;
     order?: AssetOrder;
+    page?: number;
+    pageSize?: number;
     personId?: string;
-    size: TimeBucketSize;
     tagId?: string;
     timeBucket: string;
     userId?: string;
@@ -3384,15 +3403,16 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers
 }, opts?: Oazapfts.RequestOpts) {
     return oazapfts.ok(oazapfts.fetchJson<{
         status: 200;
-        data: AssetResponseDto[];
+        data: TimeBucketAssetResponseDto;
     }>(`/timeline/bucket${QS.query(QS.explode({
         albumId,
         isFavorite,
         isTrashed,
         key,
         order,
+        page,
+        pageSize,
         personId,
-        size,
         tagId,
         timeBucket,
         userId,
@@ -3403,14 +3423,13 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers
         ...opts
     }));
 }
-export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, size, tagId, userId, visibility, withPartners, withStacked }: {
+export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, tagId, userId, visibility, withPartners, withStacked }: {
     albumId?: string;
     isFavorite?: boolean;
     isTrashed?: boolean;
     key?: string;
     order?: AssetOrder;
     personId?: string;
-    size: TimeBucketSize;
     tagId?: string;
     userId?: string;
     visibility?: AssetVisibility;
@@ -3419,7 +3438,7 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per
 }, opts?: Oazapfts.RequestOpts) {
     return oazapfts.ok(oazapfts.fetchJson<{
         status: 200;
-        data: TimeBucketResponseDto[];
+        data: TimeBucketsResponseDto[];
     }>(`/timeline/buckets${QS.query(QS.explode({
         albumId,
         isFavorite,
@@ -3427,7 +3446,6 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per
         key,
         order,
         personId,
-        size,
         tagId,
         userId,
         visibility,
@@ -3921,7 +3939,3 @@ export enum OAuthTokenEndpointAuthMethod {
     ClientSecretPost = "client_secret_post",
     ClientSecretBasic = "client_secret_basic"
 }
-export enum TimeBucketSize {
-    Day = "DAY",
-    Month = "MONTH"
-}
diff --git a/server/src/bin/sync-sql.ts b/server/src/bin/sync-sql.ts
index b791358a90..a114830e09 100644
--- a/server/src/bin/sync-sql.ts
+++ b/server/src/bin/sync-sql.ts
@@ -72,7 +72,9 @@ class SqlGenerator {
     await rm(this.options.targetDir, { force: true, recursive: true });
     await mkdir(this.options.targetDir);
 
-    process.env.DB_HOSTNAME = 'localhost';
+    if (!process.env.DB_HOSTNAME) {
+      process.env.DB_HOSTNAME = 'localhost';
+    }
     const { database, cls, otel } = new ConfigRepository().getEnv();
 
     const moduleFixture = await Test.createTestingModule({
diff --git a/server/src/controllers/timeline.controller.ts b/server/src/controllers/timeline.controller.ts
index 92de84d346..b4ee042625 100644
--- a/server/src/controllers/timeline.controller.ts
+++ b/server/src/controllers/timeline.controller.ts
@@ -1,8 +1,7 @@
-import { Controller, Get, Query } from '@nestjs/common';
-import { ApiTags } from '@nestjs/swagger';
-import { AssetResponseDto } from 'src/dtos/asset-response.dto';
+import { Controller, Get, Header, Query } from '@nestjs/common';
+import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
 import { AuthDto } from 'src/dtos/auth.dto';
-import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto';
+import { TimeBucketAssetDto, TimeBucketAssetResponseDto, TimeBucketDto } from 'src/dtos/time-bucket.dto';
 import { Permission } from 'src/enum';
 import { Auth, Authenticated } from 'src/middleware/auth.guard';
 import { TimelineService } from 'src/services/timeline.service';
@@ -14,13 +13,15 @@ export class TimelineController {
 
   @Get('buckets')
   @Authenticated({ permission: Permission.ASSET_READ, sharedLink: true })
-  getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto): Promise<TimeBucketResponseDto[]> {
+  getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto) {
     return this.service.getTimeBuckets(auth, dto);
   }
 
   @Get('bucket')
   @Authenticated({ permission: Permission.ASSET_READ, sharedLink: true })
-  getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> {
-    return this.service.getTimeBucket(auth, dto) as Promise<AssetResponseDto[]>;
+  @ApiOkResponse({ type: TimeBucketAssetResponseDto })
+  @Header('Content-Type', 'application/json')
+  getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto) {
+    return this.service.getTimeBucket(auth, dto);
   }
 }
diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts
index 2a44a34b58..4c1f2571e8 100644
--- a/server/src/dtos/asset-response.dto.ts
+++ b/server/src/dtos/asset-response.dto.ts
@@ -13,6 +13,7 @@ import {
 import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
 import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
 import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
+import { hexOrBufferToBase64 } from 'src/utils/bytes';
 import { mimeTypes } from 'src/utils/mime-types';
 
 export class SanitizedAssetResponseDto {
@@ -140,15 +141,6 @@ const mapStack = (entity: { stack?: Stack | null }) => {
   };
 };
 
-// if an asset is jsonified in the DB before being returned, its buffer fields will be hex-encoded strings
-export const hexOrBufferToBase64 = (encoded: string | Buffer) => {
-  if (typeof encoded === 'string') {
-    return Buffer.from(encoded.slice(2), 'hex').toString('base64');
-  }
-
-  return encoded.toString('base64');
-};
-
 export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): AssetResponseDto {
   const { stripMetadata = false, withStack = false } = options;
 
@@ -192,7 +184,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
     tags: entity.tags?.map((tag) => mapTag(tag)),
     people: peopleWithFaces(entity.faces),
     unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)),
-    checksum: hexOrBufferToBase64(entity.checksum),
+    checksum: hexOrBufferToBase64(entity.checksum)!,
     stack: withStack ? mapStack(entity) : undefined,
     isOffline: entity.isOffline,
     hasMetadata: true,
diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts
index 51d46871ae..f68ce93075 100644
--- a/server/src/dtos/time-bucket.dto.ts
+++ b/server/src/dtos/time-bucket.dto.ts
@@ -1,15 +1,10 @@
 import { ApiProperty } from '@nestjs/swagger';
-import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
+
+import { IsEnum, IsInt, IsString, Min } from 'class-validator';
 import { AssetOrder, AssetVisibility } from 'src/enum';
-import { TimeBucketSize } from 'src/repositories/asset.repository';
 import { Optional, ValidateAssetVisibility, ValidateBoolean, ValidateUUID } from 'src/validation';
 
 export class TimeBucketDto {
-  @IsNotEmpty()
-  @IsEnum(TimeBucketSize)
-  @ApiProperty({ enum: TimeBucketSize, enumName: 'TimeBucketSize' })
-  size!: TimeBucketSize;
-
   @ValidateUUID({ optional: true })
   userId?: string;
 
@@ -46,9 +41,75 @@ export class TimeBucketDto {
 export class TimeBucketAssetDto extends TimeBucketDto {
   @IsString()
   timeBucket!: string;
+
+  @IsInt()
+  @Min(1)
+  @Optional()
+  page?: number;
+
+  @IsInt()
+  @Min(1)
+  @Optional()
+  pageSize?: number;
 }
 
-export class TimeBucketResponseDto {
+export class TimelineStackResponseDto {
+  id!: string;
+  primaryAssetId!: string;
+  assetCount!: number;
+}
+
+export class TimeBucketAssetResponseDto {
+  id!: string[];
+
+  ownerId!: string[];
+
+  ratio!: number[];
+
+  isFavorite!: boolean[];
+
+  @ApiProperty({ enum: AssetVisibility, enumName: 'AssetVisibility', isArray: true })
+  visibility!: AssetVisibility[];
+
+  isTrashed!: boolean[];
+
+  isImage!: boolean[];
+
+  @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
+  thumbhash!: (string | null)[];
+
+  localDateTime!: string[];
+
+  @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
+  duration!: (string | null)[];
+
+  @ApiProperty({
+    type: 'array',
+    items: {
+      type: 'array',
+      items: { type: 'string' },
+      minItems: 2,
+      maxItems: 2,
+      nullable: true,
+    },
+    description: '(stack ID, stack asset count) tuple',
+  })
+  stack?: ([string, string] | null)[];
+
+  @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
+  projectionType!: (string | null)[];
+
+  @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
+  livePhotoVideoId!: (string | null)[];
+
+  @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
+  city!: (string | null)[];
+
+  @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
+  country!: (string | null)[];
+}
+
+export class TimeBucketsResponseDto {
   @ApiProperty({ type: 'string' })
   timeBucket!: string;
 
diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql
index f4f13c4d2b..8f25cbbd4a 100644
--- a/server/src/queries/asset.repository.sql
+++ b/server/src/queries/asset.repository.sql
@@ -235,14 +235,14 @@ limit
 with
   "assets" as (
     select
-      date_trunc($1, "localDateTime" at time zone 'UTC') at time zone 'UTC' as "timeBucket"
+      date_trunc('MONTH', "localDateTime" at time zone 'UTC') at time zone 'UTC' as "timeBucket"
     from
       "assets"
     where
       "assets"."deletedAt" is null
       and (
-        "assets"."visibility" = $2
-        or "assets"."visibility" = $3
+        "assets"."visibility" = $1
+        or "assets"."visibility" = $2
       )
   )
 select
@@ -256,40 +256,101 @@ order by
   "timeBucket" desc
 
 -- AssetRepository.getTimeBucket
-select
-  "assets".*,
-  to_json("exif") as "exifInfo",
-  to_json("stacked_assets") as "stack"
-from
-  "assets"
-  left join "exif" on "assets"."id" = "exif"."assetId"
-  left join "asset_stack" on "asset_stack"."id" = "assets"."stackId"
-  left join lateral (
+with
+  "cte" as (
     select
-      "asset_stack".*,
-      count("stacked") as "assetCount"
+      "assets"."duration",
+      "assets"."id",
+      "assets"."visibility",
+      "assets"."isFavorite",
+      assets.type = 'IMAGE' as "isImage",
+      assets."deletedAt" is null as "isTrashed",
+      "assets"."livePhotoVideoId",
+      "assets"."localDateTime",
+      "assets"."ownerId",
+      "assets"."status",
+      encode("assets"."thumbhash", 'base64') as "thumbhash",
+      "exif"."city",
+      "exif"."country",
+      "exif"."projectionType",
+      coalesce(
+        case
+          when exif."exifImageHeight" = 0
+          or exif."exifImageWidth" = 0 then 1
+          when "exif"."orientation" in ('5', '6', '7', '8', '-90', '90') then round(
+            exif."exifImageHeight"::numeric / exif."exifImageWidth"::numeric,
+            3
+          )
+          else round(
+            exif."exifImageWidth"::numeric / exif."exifImageHeight"::numeric,
+            3
+          )
+        end,
+        1
+      ) as "ratio",
+      "stack"
     from
-      "assets" as "stacked"
+      "assets"
+      inner join "exif" on "assets"."id" = "exif"."assetId"
+      left join lateral (
+        select
+          array[stacked."stackId"::text, count('stacked')::text] as "stack"
+        from
+          "assets" as "stacked"
+        where
+          "stacked"."stackId" = "assets"."stackId"
+          and "stacked"."deletedAt" is null
+          and "stacked"."visibility" != $1
+        group by
+          "stacked"."stackId"
+      ) as "stacked_assets" on true
     where
-      "stacked"."stackId" = "asset_stack"."id"
-      and "stacked"."deletedAt" is null
-      and "stacked"."visibility" != $1
-    group by
-      "asset_stack"."id"
-  ) as "stacked_assets" on "asset_stack"."id" is not null
-where
-  (
-    "asset_stack"."primaryAssetId" = "assets"."id"
-    or "assets"."stackId" is null
+      "assets"."deletedAt" is null
+      and (
+        "assets"."visibility" = $2
+        or "assets"."visibility" = $3
+      )
+      and date_trunc('MONTH', "localDateTime" at time zone 'UTC') at time zone 'UTC' = $4
+      and (
+        "assets"."visibility" = $5
+        or "assets"."visibility" = $6
+      )
+      and not exists (
+        select
+        from
+          "asset_stack"
+        where
+          "asset_stack"."id" = "assets"."stackId"
+          and "asset_stack"."primaryAssetId" != "assets"."id"
+      )
+    order by
+      "assets"."localDateTime" desc
+  ),
+  "agg" as (
+    select
+      coalesce(array_agg("city"), '{}') as "city",
+      coalesce(array_agg("country"), '{}') as "country",
+      coalesce(array_agg("duration"), '{}') as "duration",
+      coalesce(array_agg("id"), '{}') as "id",
+      coalesce(array_agg("visibility"), '{}') as "visibility",
+      coalesce(array_agg("isFavorite"), '{}') as "isFavorite",
+      coalesce(array_agg("isImage"), '{}') as "isImage",
+      coalesce(array_agg("isTrashed"), '{}') as "isTrashed",
+      coalesce(array_agg("livePhotoVideoId"), '{}') as "livePhotoVideoId",
+      coalesce(array_agg("localDateTime"), '{}') as "localDateTime",
+      coalesce(array_agg("ownerId"), '{}') as "ownerId",
+      coalesce(array_agg("projectionType"), '{}') as "projectionType",
+      coalesce(array_agg("ratio"), '{}') as "ratio",
+      coalesce(array_agg("status"), '{}') as "status",
+      coalesce(array_agg("thumbhash"), '{}') as "thumbhash",
+      coalesce(json_agg("stack"), '[]') as "stack"
+    from
+      "cte"
   )
-  and "assets"."deletedAt" is null
-  and (
-    "assets"."visibility" = $2
-    or "assets"."visibility" = $3
-  )
-  and date_trunc($4, "localDateTime" at time zone 'UTC') at time zone 'UTC' = $5
-order by
-  "assets"."localDateTime" desc
+select
+  to_json(agg)::text as "assets"
+from
+  "agg"
 
 -- AssetRepository.getDuplicates
 with
diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts
index e118bf39ad..f2f323f71e 100644
--- a/server/src/repositories/asset.repository.ts
+++ b/server/src/repositories/asset.repository.ts
@@ -68,7 +68,6 @@ export interface AssetBuilderOptions {
 }
 
 export interface TimeBucketOptions extends AssetBuilderOptions {
-  size: TimeBucketSize;
   order?: AssetOrder;
 }
 
@@ -539,7 +538,7 @@ export class AssetRepository {
         .with('assets', (qb) =>
           qb
             .selectFrom('assets')
-            .select(truncatedDate<Date>(options.size).as('timeBucket'))
+            .select(truncatedDate<Date>(TimeBucketSize.MONTH).as('timeBucket'))
             .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
             .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null)
             .$if(options.visibility === undefined, withDefaultVisibility)
@@ -581,53 +580,126 @@ export class AssetRepository {
     );
   }
 
-  @GenerateSql({ params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH, withStacked: true }] })
-  async getTimeBucket(timeBucket: string, options: TimeBucketOptions) {
-    return this.db
-      .selectFrom('assets')
-      .selectAll('assets')
-      .$call(withExif)
-      .$if(!!options.albumId, (qb) =>
+  @GenerateSql({
+    params: [DummyValue.TIME_BUCKET, { withStacked: true }],
+  })
+  getTimeBucket(timeBucket: string, options: TimeBucketOptions) {
+    const query = this.db
+      .with('cte', (qb) =>
         qb
-          .innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id')
-          .where('albums_assets_assets.albumsId', '=', options.albumId!),
+          .selectFrom('assets')
+          .innerJoin('exif', 'assets.id', 'exif.assetId')
+          .select((eb) => [
+            'assets.duration',
+            'assets.id',
+            'assets.visibility',
+            'assets.isFavorite',
+            sql`assets.type = 'IMAGE'`.as('isImage'),
+            sql`assets."deletedAt" is null`.as('isTrashed'),
+            'assets.livePhotoVideoId',
+            'assets.localDateTime',
+            'assets.ownerId',
+            'assets.status',
+            eb.fn('encode', ['assets.thumbhash', sql.lit('base64')]).as('thumbhash'),
+            'exif.city',
+            'exif.country',
+            'exif.projectionType',
+            eb.fn
+              .coalesce(
+                eb
+                  .case()
+                  .when(sql`exif."exifImageHeight" = 0 or exif."exifImageWidth" = 0`)
+                  .then(eb.lit(1))
+                  .when('exif.orientation', 'in', sql<string>`('5', '6', '7', '8', '-90', '90')`)
+                  .then(sql`round(exif."exifImageHeight"::numeric / exif."exifImageWidth"::numeric, 3)`)
+                  .else(sql`round(exif."exifImageWidth"::numeric / exif."exifImageHeight"::numeric, 3)`)
+                  .end(),
+                eb.lit(1),
+              )
+              .as('ratio'),
+          ])
+          .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null)
+          .$if(options.visibility == undefined, withDefaultVisibility)
+          .$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!))
+          .where(truncatedDate(TimeBucketSize.MONTH), '=', timeBucket.replace(/^[+-]/, ''))
+          .$if(!!options.albumId, (qb) =>
+            qb.where((eb) =>
+              eb.exists(
+                eb
+                  .selectFrom('albums_assets_assets')
+                  .whereRef('albums_assets_assets.assetsId', '=', 'assets.id')
+                  .where('albums_assets_assets.albumsId', '=', asUuid(options.albumId!)),
+              ),
+            ),
+          )
+          .$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
+          .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!)))
+          .$if(options.visibility == undefined, withDefaultVisibility)
+          .$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!))
+          .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!))
+          .$if(!!options.withStacked, (qb) =>
+            qb
+              .where((eb) =>
+                eb.not(
+                  eb.exists(
+                    eb
+                      .selectFrom('asset_stack')
+                      .whereRef('asset_stack.id', '=', 'assets.stackId')
+                      .whereRef('asset_stack.primaryAssetId', '!=', 'assets.id'),
+                  ),
+                ),
+              )
+              .leftJoinLateral(
+                (eb) =>
+                  eb
+                    .selectFrom('assets as stacked')
+                    .select(sql`array[stacked."stackId"::text, count('stacked')::text]`.as('stack'))
+                    .whereRef('stacked.stackId', '=', 'assets.stackId')
+                    .where('stacked.deletedAt', 'is', null)
+                    .where('stacked.visibility', '!=', AssetVisibility.ARCHIVE)
+                    .groupBy('stacked.stackId')
+                    .as('stacked_assets'),
+                (join) => join.onTrue(),
+              )
+              .select('stack'),
+          )
+          .$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!))
+          .$if(options.isDuplicate !== undefined, (qb) =>
+            qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null),
+          )
+          .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
+          .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
+          .orderBy('assets.localDateTime', options.order ?? 'desc'),
       )
-      .$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
-      .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!)))
-      .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!))
-      .$if(!!options.withStacked, (qb) =>
+      .with('agg', (qb) =>
         qb
-          .leftJoin('asset_stack', 'asset_stack.id', 'assets.stackId')
-          .where((eb) =>
-            eb.or([eb('asset_stack.primaryAssetId', '=', eb.ref('assets.id')), eb('assets.stackId', 'is', null)]),
-          )
-          .leftJoinLateral(
-            (eb) =>
-              eb
-                .selectFrom('assets as stacked')
-                .selectAll('asset_stack')
-                .select((eb) => eb.fn.count(eb.table('stacked')).as('assetCount'))
-                .whereRef('stacked.stackId', '=', 'asset_stack.id')
-                .where('stacked.deletedAt', 'is', null)
-                .where('stacked.visibility', '!=', AssetVisibility.ARCHIVE)
-                .groupBy('asset_stack.id')
-                .as('stacked_assets'),
-            (join) => join.on('asset_stack.id', 'is not', null),
-          )
-          .select((eb) => eb.fn.toJson(eb.table('stacked_assets').$castTo<Stack | null>()).as('stack')),
+          .selectFrom('cte')
+          .select((eb) => [
+            eb.fn.coalesce(eb.fn('array_agg', ['city']), sql.lit('{}')).as('city'),
+            eb.fn.coalesce(eb.fn('array_agg', ['country']), sql.lit('{}')).as('country'),
+            eb.fn.coalesce(eb.fn('array_agg', ['duration']), sql.lit('{}')).as('duration'),
+            eb.fn.coalesce(eb.fn('array_agg', ['id']), sql.lit('{}')).as('id'),
+            eb.fn.coalesce(eb.fn('array_agg', ['visibility']), sql.lit('{}')).as('visibility'),
+            eb.fn.coalesce(eb.fn('array_agg', ['isFavorite']), sql.lit('{}')).as('isFavorite'),
+            eb.fn.coalesce(eb.fn('array_agg', ['isImage']), sql.lit('{}')).as('isImage'),
+            // TODO: isTrashed is redundant as it will always be all true or false depending on the options
+            eb.fn.coalesce(eb.fn('array_agg', ['isTrashed']), sql.lit('{}')).as('isTrashed'),
+            eb.fn.coalesce(eb.fn('array_agg', ['livePhotoVideoId']), sql.lit('{}')).as('livePhotoVideoId'),
+            eb.fn.coalesce(eb.fn('array_agg', ['localDateTime']), sql.lit('{}')).as('localDateTime'),
+            eb.fn.coalesce(eb.fn('array_agg', ['ownerId']), sql.lit('{}')).as('ownerId'),
+            eb.fn.coalesce(eb.fn('array_agg', ['projectionType']), sql.lit('{}')).as('projectionType'),
+            eb.fn.coalesce(eb.fn('array_agg', ['ratio']), sql.lit('{}')).as('ratio'),
+            eb.fn.coalesce(eb.fn('array_agg', ['status']), sql.lit('{}')).as('status'),
+            eb.fn.coalesce(eb.fn('array_agg', ['thumbhash']), sql.lit('{}')).as('thumbhash'),
+          ])
+          .$if(!!options.withStacked, (qb) =>
+            qb.select((eb) => eb.fn.coalesce(eb.fn('json_agg', ['stack']), sql.lit('[]')).as('stack')),
+          ),
       )
-      .$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!))
-      .$if(options.isDuplicate !== undefined, (qb) =>
-        qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null),
-      )
-      .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
-      .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
-      .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null)
-      .$if(options.visibility == undefined, withDefaultVisibility)
-      .$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!))
-      .where(truncatedDate(options.size), '=', timeBucket.replace(/^[+-]/, ''))
-      .orderBy('assets.localDateTime', options.order ?? 'desc')
-      .execute();
+      .selectFrom('agg')
+      .select(sql<string>`to_json(agg)::text`.as('assets'));
+
+    return query.executeTakeFirstOrThrow();
   }
 
   @GenerateSql({ params: [DummyValue.UUID] })
diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts
index 6ad488c48d..bd3c09098f 100644
--- a/server/src/services/sync.service.ts
+++ b/server/src/services/sync.service.ts
@@ -4,7 +4,7 @@ import { DateTime } from 'luxon';
 import { Writable } from 'node:stream';
 import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
 import { SessionSyncCheckpoints } from 'src/db';
-import { AssetResponseDto, hexOrBufferToBase64, mapAsset } from 'src/dtos/asset-response.dto';
+import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
 import { AuthDto } from 'src/dtos/auth.dto';
 import {
   AssetDeltaSyncDto,
@@ -18,6 +18,7 @@ import { AssetVisibility, DatabaseAction, EntityType, Permission, SyncEntityType
 import { BaseService } from 'src/services/base.service';
 import { SyncAck } from 'src/types';
 import { getMyPartnerIds } from 'src/utils/asset.util';
+import { hexOrBufferToBase64 } from 'src/utils/bytes';
 import { setIsEqual } from 'src/utils/set';
 import { fromAck, serialize } from 'src/utils/sync';
 
diff --git a/server/src/services/timeline.service.spec.ts b/server/src/services/timeline.service.spec.ts
index 1447594d4e..1669b1eac7 100644
--- a/server/src/services/timeline.service.spec.ts
+++ b/server/src/services/timeline.service.spec.ts
@@ -1,10 +1,7 @@
 import { BadRequestException } from '@nestjs/common';
 import { AssetVisibility } from 'src/enum';
-import { TimeBucketSize } from 'src/repositories/asset.repository';
 import { TimelineService } from 'src/services/timeline.service';
-import { assetStub } from 'test/fixtures/asset.stub';
 import { authStub } from 'test/fixtures/auth.stub';
-import { factory } from 'test/small.factory';
 import { newTestService, ServiceMocks } from 'test/utils';
 
 describe(TimelineService.name, () => {
@@ -19,13 +16,10 @@ describe(TimelineService.name, () => {
     it("should return buckets if userId and albumId aren't set", async () => {
       mocks.asset.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]);
 
-      await expect(
-        sut.getTimeBuckets(authStub.admin, {
-          size: TimeBucketSize.DAY,
-        }),
-      ).resolves.toEqual(expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }]));
+      await expect(sut.getTimeBuckets(authStub.admin, {})).resolves.toEqual(
+        expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }]),
+      );
       expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith({
-        size: TimeBucketSize.DAY,
         userIds: [authStub.admin.user.id],
       });
     });
@@ -34,35 +28,34 @@ describe(TimelineService.name, () => {
   describe('getTimeBucket', () => {
     it('should return the assets for a album time bucket if user has album.read', async () => {
       mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
-      mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]);
+      const json = `[{ id: ['asset-id'] }]`;
+      mocks.asset.getTimeBucket.mockResolvedValue({ assets: json });
 
-      await expect(
-        sut.getTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id' }),
-      ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
+      await expect(sut.getTimeBucket(authStub.admin, { timeBucket: 'bucket', albumId: 'album-id' })).resolves.toEqual(
+        json,
+      );
 
       expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id']));
       expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {
-        size: TimeBucketSize.DAY,
         timeBucket: 'bucket',
         albumId: 'album-id',
       });
     });
 
     it('should return the assets for a archive time bucket if user has archive.read', async () => {
-      mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]);
+      const json = `[{ id: ['asset-id'] }]`;
+      mocks.asset.getTimeBucket.mockResolvedValue({ assets: json });
 
       await expect(
         sut.getTimeBucket(authStub.admin, {
-          size: TimeBucketSize.DAY,
           timeBucket: 'bucket',
           visibility: AssetVisibility.ARCHIVE,
           userId: authStub.admin.user.id,
         }),
-      ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
+      ).resolves.toEqual(json);
       expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith(
         'bucket',
         expect.objectContaining({
-          size: TimeBucketSize.DAY,
           timeBucket: 'bucket',
           visibility: AssetVisibility.ARCHIVE,
           userIds: [authStub.admin.user.id],
@@ -71,20 +64,19 @@ describe(TimelineService.name, () => {
     });
 
     it('should include partner shared assets', async () => {
-      mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]);
+      const json = `[{ id: ['asset-id'] }]`;
+      mocks.asset.getTimeBucket.mockResolvedValue({ assets: json });
       mocks.partner.getAll.mockResolvedValue([]);
 
       await expect(
         sut.getTimeBucket(authStub.admin, {
-          size: TimeBucketSize.DAY,
           timeBucket: 'bucket',
           visibility: AssetVisibility.TIMELINE,
           userId: authStub.admin.user.id,
           withPartners: true,
         }),
-      ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
+      ).resolves.toEqual(json);
       expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {
-        size: TimeBucketSize.DAY,
         timeBucket: 'bucket',
         visibility: AssetVisibility.TIMELINE,
         withPartners: true,
@@ -93,62 +85,37 @@ describe(TimelineService.name, () => {
     });
 
     it('should check permissions to read tag', async () => {
-      mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]);
+      const json = `[{ id: ['asset-id'] }]`;
+      mocks.asset.getTimeBucket.mockResolvedValue({ assets: json });
       mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-123']));
 
       await expect(
         sut.getTimeBucket(authStub.admin, {
-          size: TimeBucketSize.DAY,
           timeBucket: 'bucket',
           userId: authStub.admin.user.id,
           tagId: 'tag-123',
         }),
-      ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
+      ).resolves.toEqual(json);
       expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {
-        size: TimeBucketSize.DAY,
         tagId: 'tag-123',
         timeBucket: 'bucket',
         userIds: [authStub.admin.user.id],
       });
     });
 
-    it('should strip metadata if showExif is disabled', async () => {
-      mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-id']));
-      mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]);
-
-      const auth = factory.auth({ sharedLink: { showExif: false } });
-
-      const buckets = await sut.getTimeBucket(auth, {
-        size: TimeBucketSize.DAY,
-        timeBucket: 'bucket',
-        visibility: AssetVisibility.ARCHIVE,
-        albumId: 'album-id',
-      });
-
-      expect(buckets).toEqual([expect.objectContaining({ id: 'asset-id' })]);
-      expect(buckets[0]).not.toHaveProperty('exif');
-      expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {
-        size: TimeBucketSize.DAY,
-        timeBucket: 'bucket',
-        visibility: AssetVisibility.ARCHIVE,
-        albumId: 'album-id',
-      });
-    });
-
     it('should return the assets for a library time bucket if user has library.read', async () => {
-      mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]);
+      const json = `[{ id: ['asset-id'] }]`;
+      mocks.asset.getTimeBucket.mockResolvedValue({ assets: json });
 
       await expect(
         sut.getTimeBucket(authStub.admin, {
-          size: TimeBucketSize.DAY,
           timeBucket: 'bucket',
           userId: authStub.admin.user.id,
         }),
-      ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
+      ).resolves.toEqual(json);
       expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith(
         'bucket',
         expect.objectContaining({
-          size: TimeBucketSize.DAY,
           timeBucket: 'bucket',
           userIds: [authStub.admin.user.id],
         }),
@@ -158,7 +125,6 @@ describe(TimelineService.name, () => {
     it('should throw an error if withParners is true and visibility true or undefined', async () => {
       await expect(
         sut.getTimeBucket(authStub.admin, {
-          size: TimeBucketSize.DAY,
           timeBucket: 'bucket',
           visibility: AssetVisibility.ARCHIVE,
           withPartners: true,
@@ -168,7 +134,6 @@ describe(TimelineService.name, () => {
 
       await expect(
         sut.getTimeBucket(authStub.admin, {
-          size: TimeBucketSize.DAY,
           timeBucket: 'bucket',
           visibility: undefined,
           withPartners: true,
@@ -180,7 +145,6 @@ describe(TimelineService.name, () => {
     it('should throw an error if withParners is true and isFavorite is either true or false', async () => {
       await expect(
         sut.getTimeBucket(authStub.admin, {
-          size: TimeBucketSize.DAY,
           timeBucket: 'bucket',
           isFavorite: true,
           withPartners: true,
@@ -190,7 +154,6 @@ describe(TimelineService.name, () => {
 
       await expect(
         sut.getTimeBucket(authStub.admin, {
-          size: TimeBucketSize.DAY,
           timeBucket: 'bucket',
           isFavorite: false,
           withPartners: true,
@@ -202,7 +165,6 @@ describe(TimelineService.name, () => {
     it('should throw an error if withParners is true and isTrash is true', async () => {
       await expect(
         sut.getTimeBucket(authStub.admin, {
-          size: TimeBucketSize.DAY,
           timeBucket: 'bucket',
           isTrashed: true,
           withPartners: true,
diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts
index c0cd4786a8..f3ebcc2cd7 100644
--- a/server/src/services/timeline.service.ts
+++ b/server/src/services/timeline.service.ts
@@ -1,7 +1,6 @@
 import { BadRequestException, Injectable } from '@nestjs/common';
-import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
 import { AuthDto } from 'src/dtos/auth.dto';
-import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto';
+import { TimeBucketAssetDto, TimeBucketDto, TimeBucketsResponseDto } from 'src/dtos/time-bucket.dto';
 import { AssetVisibility, Permission } from 'src/enum';
 import { TimeBucketOptions } from 'src/repositories/asset.repository';
 import { BaseService } from 'src/services/base.service';
@@ -9,22 +8,20 @@ import { getMyPartnerIds } from 'src/utils/asset.util';
 
 @Injectable()
 export class TimelineService extends BaseService {
-  async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketResponseDto[]> {
+  async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketsResponseDto[]> {
     await this.timeBucketChecks(auth, dto);
     const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto);
-    return this.assetRepository.getTimeBuckets(timeBucketOptions);
+    return await this.assetRepository.getTimeBuckets(timeBucketOptions);
   }
 
-  async getTimeBucket(
-    auth: AuthDto,
-    dto: TimeBucketAssetDto,
-  ): Promise<AssetResponseDto[] | SanitizedAssetResponseDto[]> {
+  // pre-jsonified response
+  async getTimeBucket(auth: AuthDto, dto: TimeBucketAssetDto): Promise<string> {
     await this.timeBucketChecks(auth, dto);
-    const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto);
-    const assets = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions);
-    return !auth.sharedLink || auth.sharedLink?.showExif
-      ? assets.map((asset) => mapAsset(asset, { withStack: true, auth }))
-      : assets.map((asset) => mapAsset(asset, { stripMetadata: true, auth }));
+    const timeBucketOptions = await this.buildTimeBucketOptions(auth, { ...dto });
+
+    // TODO: use id cursor for pagination
+    const bucket = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions);
+    return bucket.assets;
   }
 
   private async buildTimeBucketOptions(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketOptions> {
diff --git a/server/src/utils/bytes.ts b/server/src/utils/bytes.ts
index e837c81b9e..5e476f4dea 100644
--- a/server/src/utils/bytes.ts
+++ b/server/src/utils/bytes.ts
@@ -22,3 +22,12 @@ export function asHumanReadable(bytes: number, precision = 1): string {
 
   return `${remainder.toFixed(magnitude == 0 ? 0 : precision)} ${units[magnitude]}`;
 }
+
+// if an asset is jsonified in the DB before being returned, its buffer fields will be hex-encoded strings
+export const hexOrBufferToBase64 = (encoded: string | Buffer) => {
+  if (typeof encoded === 'string') {
+    return Buffer.from(encoded.slice(2), 'hex').toString('base64');
+  }
+
+  return encoded.toString('base64');
+};
diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts
index bacdf06d67..e0e7af49a4 100644
--- a/server/src/utils/database.ts
+++ b/server/src/utils/database.ts
@@ -271,7 +271,7 @@ export function withTags(eb: ExpressionBuilder<DB, 'assets'>) {
 }
 
 export function truncatedDate<O>(size: TimeBucketSize) {
-  return sql<O>`date_trunc(${size}, "localDateTime" at time zone 'UTC') at time zone 'UTC'`;
+  return sql<O>`date_trunc(${sql.lit(size)}, "localDateTime" at time zone 'UTC') at time zone 'UTC'`;
 }
 
 export function withTagId<O>(qb: SelectQueryBuilder<DB, 'assets', O>, tagId: string) {
@@ -285,6 +285,7 @@ export function withTagId<O>(qb: SelectQueryBuilder<DB, 'assets', O>, tagId: str
     ),
   );
 }
+
 const joinDeduplicationPlugin = new DeduplicateJoinsPlugin();
 /** TODO: This should only be used for search-related queries, not as a general purpose query builder */
 
diff --git a/server/src/workers/api.ts b/server/src/workers/api.ts
index 4248b23d30..ce1520c475 100644
--- a/server/src/workers/api.ts
+++ b/server/src/workers/api.ts
@@ -14,7 +14,6 @@ import { LoggingRepository } from 'src/repositories/logging.repository';
 import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
 import { ApiService } from 'src/services/api.service';
 import { isStartUpError, useSwagger } from 'src/utils/misc';
-
 async function bootstrap() {
   process.title = 'immich-api';
 
diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts
index a64194361a..454be00844 100644
--- a/server/test/fixtures/asset.stub.ts
+++ b/server/test/fixtures/asset.stub.ts
@@ -251,6 +251,10 @@ export const assetStub = {
     duplicateId: null,
     isOffline: false,
     stack: null,
+    orientation: '',
+    projectionType: null,
+    height: 3840,
+    width: 2160,
     visibility: AssetVisibility.TIMELINE,
   }),
 
diff --git a/typescript-open-api/typescript-sdk/package-lock.json b/typescript-open-api/typescript-sdk/package-lock.json
new file mode 100644
index 0000000000..ca6fc5e1de
--- /dev/null
+++ b/typescript-open-api/typescript-sdk/package-lock.json
@@ -0,0 +1,6 @@
+{
+  "name": "typescript-sdk",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {}
+}
diff --git a/web/src/lib/components/asset-viewer/actions/keep-this-delete-others.svelte b/web/src/lib/components/asset-viewer/actions/keep-this-delete-others.svelte
index 80dfb35067..be5e8f7827 100644
--- a/web/src/lib/components/asset-viewer/actions/keep-this-delete-others.svelte
+++ b/web/src/lib/components/asset-viewer/actions/keep-this-delete-others.svelte
@@ -1,5 +1,6 @@
 <script lang="ts">
   import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
+
   import { AssetAction } from '$lib/constants';
   import { modalManager } from '$lib/managers/modal-manager.svelte';
   import { keepThisDeleteOthers } from '$lib/utils/asset-utils';
diff --git a/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte b/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte
index d133010af7..91db84b172 100644
--- a/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte
+++ b/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte
@@ -5,7 +5,7 @@
   import { modalManager } from '$lib/managers/modal-manager.svelte';
   import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
   import { handleError } from '$lib/utils/handle-error';
-  import { AssetVisibility, updateAssets, Visibility } from '@immich/sdk';
+  import { AssetVisibility, updateAssets } from '@immich/sdk';
   import { mdiEyeOffOutline, mdiFolderMoveOutline } from '@mdi/js';
   import { t } from 'svelte-i18n';
   import type { OnAction, PreAction } from './action';
@@ -17,7 +17,7 @@
   }
 
   let { asset, onAction, preAction }: Props = $props();
-  const isLocked = asset.visibility === Visibility.Locked;
+  const isLocked = asset.visibility === AssetVisibility.Locked;
 
   const toggleLockedVisibility = async () => {
     const isConfirmed = await modalManager.showDialog({
diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte
index c3df91623b..eb8b15c4b5 100644
--- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte
+++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte
@@ -5,7 +5,7 @@
   import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
   import { timeToSeconds } from '$lib/utils/date-time';
   import { getAltText } from '$lib/utils/thumbnail-util';
-  import { AssetMediaSize, Visibility } from '@immich/sdk';
+  import { AssetMediaSize, AssetVisibility } from '@immich/sdk';
   import {
     mdiArchiveArrowDownOutline,
     mdiCameraBurst,
@@ -291,7 +291,7 @@
           </div>
         {/if}
 
-        {#if !authManager.key && showArchiveIcon && asset.visibility === Visibility.Archive}
+        {#if !authManager.key && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
           <div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
             <Icon path={mdiArchiveArrowDownOutline} size="24" class="text-white" />
           </div>
diff --git a/web/src/lib/components/photos-page/actions/archive-action.svelte b/web/src/lib/components/photos-page/actions/archive-action.svelte
index f42496fab4..8e8311a2ed 100644
--- a/web/src/lib/components/photos-page/actions/archive-action.svelte
+++ b/web/src/lib/components/photos-page/actions/archive-action.svelte
@@ -2,7 +2,7 @@
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
   import type { OnArchive } from '$lib/utils/actions';
   import { archiveAssets } from '$lib/utils/asset-utils';
-  import { AssetVisibility, Visibility } from '@immich/sdk';
+  import { AssetVisibility } from '@immich/sdk';
   import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline, mdiTimerSand } from '@mdi/js';
   import { t } from 'svelte-i18n';
   import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
@@ -24,12 +24,12 @@
   const { clearSelect, getOwnedAssets } = getAssetControlContext();
 
   const handleArchive = async () => {
-    const isArchived = unarchive ? Visibility.Timeline : Visibility.Archive;
+    const isArchived = unarchive ? AssetVisibility.Timeline : AssetVisibility.Archive;
     const assets = [...getOwnedAssets()].filter((asset) => asset.visibility !== isArchived);
     loading = true;
-    const ids = await archiveAssets(assets, isArchived as unknown as AssetVisibility);
+    const ids = await archiveAssets(assets, isArchived as AssetVisibility);
     if (ids) {
-      onArchive?.(ids, isArchived);
+      onArchive?.(ids, isArchived ? AssetVisibility.Archive : AssetVisibility.Timeline);
       clearSelect();
     }
     loading = false;
diff --git a/web/src/lib/stores/asset-interaction.svelte.spec.ts b/web/src/lib/stores/asset-interaction.svelte.spec.ts
index 2469c39b55..86e859f57e 100644
--- a/web/src/lib/stores/asset-interaction.svelte.spec.ts
+++ b/web/src/lib/stores/asset-interaction.svelte.spec.ts
@@ -1,6 +1,6 @@
 import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
 import { resetSavedUser, user } from '$lib/stores/user.store';
-import { Visibility } from '@immich/sdk';
+import { AssetVisibility } from '@immich/sdk';
 import { timelineAssetFactory } from '@test-data/factories/asset-factory';
 import { userAdminFactory } from '@test-data/factories/user-factory';
 
@@ -13,10 +13,10 @@ describe('AssetInteraction', () => {
 
   it('calculates derived values from selection', () => {
     assetInteraction.selectAsset(
-      timelineAssetFactory.build({ isFavorite: true, visibility: Visibility.Archive, isTrashed: true }),
+      timelineAssetFactory.build({ isFavorite: true, visibility: AssetVisibility.Archive, isTrashed: true }),
     );
     assetInteraction.selectAsset(
-      timelineAssetFactory.build({ isFavorite: true, visibility: Visibility.Timeline, isTrashed: false }),
+      timelineAssetFactory.build({ isFavorite: true, visibility: AssetVisibility.Timeline, isTrashed: false }),
     );
 
     expect(assetInteraction.selectionActive).toBe(true);
diff --git a/web/src/lib/stores/asset-interaction.svelte.ts b/web/src/lib/stores/asset-interaction.svelte.ts
index 48dc958893..41ce8c8d27 100644
--- a/web/src/lib/stores/asset-interaction.svelte.ts
+++ b/web/src/lib/stores/asset-interaction.svelte.ts
@@ -1,6 +1,6 @@
 import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
 import { user } from '$lib/stores/user.store';
-import { Visibility, type UserAdminResponseDto } from '@immich/sdk';
+import { AssetVisibility, type UserAdminResponseDto } from '@immich/sdk';
 import { SvelteSet } from 'svelte/reactivity';
 import { fromStore } from 'svelte/store';
 
@@ -21,7 +21,7 @@ export class AssetInteraction {
   private userId = $derived(this.user.current?.id);
 
   isAllTrashed = $derived(this.selectedAssets.every((asset) => asset.isTrashed));
-  isAllArchived = $derived(this.selectedAssets.every((asset) => asset.visibility === Visibility.Archive));
+  isAllArchived = $derived(this.selectedAssets.every((asset) => asset.visibility === AssetVisibility.Archive));
   isAllFavorite = $derived(this.selectedAssets.every((asset) => asset.isFavorite));
   isAllUserOwned = $derived(this.selectedAssets.every((asset) => asset.ownerId === this.userId));
 
diff --git a/web/src/lib/stores/assets-store.spec.ts b/web/src/lib/stores/assets-store.spec.ts
index e510feeb6c..7864c9618b 100644
--- a/web/src/lib/stores/assets-store.spec.ts
+++ b/web/src/lib/stores/assets-store.spec.ts
@@ -1,8 +1,8 @@
 import { sdkMock } from '$lib/__mocks__/sdk.mock';
 import { AbortError } from '$lib/utils';
-import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk';
-import { assetFactory, timelineAssetFactory } from '@test-data/factories/asset-factory';
-import { AssetStore } from './assets-store.svelte';
+import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
+import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
+import { AssetStore, type TimelineAsset } from './assets-store.svelte';
 
 describe('AssetStore', () => {
   beforeEach(() => {
@@ -11,18 +11,22 @@ describe('AssetStore', () => {
 
   describe('init', () => {
     let assetStore: AssetStore;
-    const bucketAssets: Record<string, AssetResponseDto[]> = {
-      '2024-03-01T00:00:00.000Z': assetFactory
+    const bucketAssets: Record<string, TimelineAsset[]> = {
+      '2024-03-01T00:00:00.000Z': timelineAssetFactory
         .buildList(1)
         .map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
-      '2024-02-01T00:00:00.000Z': assetFactory
+      '2024-02-01T00:00:00.000Z': timelineAssetFactory
         .buildList(100)
         .map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })),
-      '2024-01-01T00:00:00.000Z': assetFactory
+      '2024-01-01T00:00:00.000Z': timelineAssetFactory
         .buildList(3)
         .map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
     };
 
+    const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
+      Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
+    );
+
     beforeEach(async () => {
       assetStore = new AssetStore();
       sdkMock.getTimeBuckets.mockResolvedValue([
@@ -30,13 +34,14 @@ describe('AssetStore', () => {
         { count: 100, timeBucket: '2024-02-01T00:00:00.000Z' },
         { count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
       ]);
-      sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));
+
+      sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
       await assetStore.updateViewport({ width: 1588, height: 1000 });
     });
 
     it('should load buckets in viewport', () => {
       expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
-      expect(sdkMock.getTimeBuckets).toBeCalledWith({ size: TimeBucketSize.Month });
+
       expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
     });
 
@@ -48,29 +53,31 @@ describe('AssetStore', () => {
 
       expect(plainBuckets).toEqual(
         expect.arrayContaining([
-          expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 303 }),
-          expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 4514.333_333_333_333 }),
+          expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 185.5 }),
+          expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 12_016 }),
           expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }),
         ]),
       );
     });
 
     it('calculates timeline height', () => {
-      expect(assetStore.timelineHeight).toBe(5103.333_333_333_333);
+      expect(assetStore.timelineHeight).toBe(12_487.5);
     });
   });
 
   describe('loadBucket', () => {
     let assetStore: AssetStore;
-    const bucketAssets: Record<string, AssetResponseDto[]> = {
-      '2024-01-03T00:00:00.000Z': assetFactory
+    const bucketAssets: Record<string, TimelineAsset[]> = {
+      '2024-01-03T00:00:00.000Z': timelineAssetFactory
         .buildList(1)
         .map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
-      '2024-01-01T00:00:00.000Z': assetFactory
+      '2024-01-01T00:00:00.000Z': timelineAssetFactory
         .buildList(3)
         .map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
     };
-
+    const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
+      Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
+    );
     beforeEach(async () => {
       assetStore = new AssetStore();
       sdkMock.getTimeBuckets.mockResolvedValue([
@@ -82,7 +89,7 @@ describe('AssetStore', () => {
         if (signal?.aborted) {
           throw new AbortError();
         }
-        return bucketAssets[timeBucket];
+        return bucketAssetsResponse[timeBucket];
       });
       await assetStore.updateViewport({ width: 1588, height: 0 });
     });
@@ -296,7 +303,9 @@ describe('AssetStore', () => {
     });
 
     it('removes asset from bucket', () => {
-      const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' });
+      const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, {
+        localDateTime: '2024-01-20T12:00:00.000Z',
+      });
       assetStore.addAssets([assetOne, assetTwo]);
       assetStore.removeAssets([assetOne.id]);
 
@@ -342,17 +351,20 @@ describe('AssetStore', () => {
 
   describe('getPreviousAsset', () => {
     let assetStore: AssetStore;
-    const bucketAssets: Record<string, AssetResponseDto[]> = {
-      '2024-03-01T00:00:00.000Z': assetFactory
+    const bucketAssets: Record<string, TimelineAsset[]> = {
+      '2024-03-01T00:00:00.000Z': timelineAssetFactory
         .buildList(1)
         .map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
-      '2024-02-01T00:00:00.000Z': assetFactory
+      '2024-02-01T00:00:00.000Z': timelineAssetFactory
         .buildList(6)
         .map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })),
-      '2024-01-01T00:00:00.000Z': assetFactory
+      '2024-01-01T00:00:00.000Z': timelineAssetFactory
         .buildList(3)
         .map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
     };
+    const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
+      Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
+    );
 
     beforeEach(async () => {
       assetStore = new AssetStore();
@@ -361,8 +373,7 @@ describe('AssetStore', () => {
         { count: 6, timeBucket: '2024-02-01T00:00:00.000Z' },
         { count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
       ]);
-      sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));
-
+      sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
       await assetStore.updateViewport({ width: 1588, height: 1000 });
     });
 
diff --git a/web/src/lib/stores/assets-store.svelte.ts b/web/src/lib/stores/assets-store.svelte.ts
index 8093c4b583..10bb52a1ab 100644
--- a/web/src/lib/stores/assets-store.svelte.ts
+++ b/web/src/lib/stores/assets-store.svelte.ts
@@ -1,4 +1,5 @@
 import { authManager } from '$lib/managers/auth-manager.svelte';
+
 import { locale } from '$lib/stores/preferences.store';
 import { CancellableTask } from '$lib/utils/cancellable-task';
 import {
@@ -15,10 +16,8 @@ import {
   getAssetInfo,
   getTimeBucket,
   getTimeBuckets,
-  TimeBucketSize,
-  Visibility,
-  type AssetResponseDto,
   type AssetStackResponseDto,
+  type TimeBucketAssetResponseDto,
 } from '@immich/sdk';
 import { clamp, debounce, isEqual, throttle } from 'lodash-es';
 import { DateTime } from 'luxon';
@@ -32,6 +31,7 @@ const {
 } = TUNABLES;
 
 type AssetApiGetTimeBucketsRequest = Parameters<typeof getTimeBuckets>[0];
+
 export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & {
   timelineAlbumId?: string;
   deferInit?: boolean;
@@ -75,7 +75,7 @@ export type TimelineAsset = {
   ratio: number;
   thumbhash: string | null;
   localDateTime: string;
-  visibility: Visibility;
+  visibility: AssetVisibility;
   isFavorite: boolean;
   isTrashed: boolean;
   isVideo: boolean;
@@ -84,12 +84,11 @@ export type TimelineAsset = {
   duration: string | null;
   projectionType: string | null;
   livePhotoVideoId: string | null;
-  text: {
-    city: string | null;
-    country: string | null;
-    people: string[];
-  };
+  city: string | null;
+  country: string | null;
+  people: string[];
 };
+
 class IntersectingAsset {
   // --- public ---
   readonly #group: AssetDateGroup;
@@ -113,7 +112,7 @@ class IntersectingAsset {
   });
 
   position: CommonPosition | undefined = $state();
-  asset: TimelineAsset | undefined = $state();
+  asset: TimelineAsset = <TimelineAsset>$state();
   id: string | undefined = $derived(this.asset?.id);
 
   constructor(group: AssetDateGroup, asset: TimelineAsset) {
@@ -121,9 +120,11 @@ class IntersectingAsset {
     this.asset = asset;
   }
 }
+
 type AssetOperation = (asset: TimelineAsset) => { remove: boolean };
 
 type MoveAsset = { asset: TimelineAsset; year: number; month: number };
+
 export class AssetDateGroup {
   // --- public
   readonly bucket: AssetBucket;
@@ -166,6 +167,7 @@ export class AssetDateGroup {
   getFirstAsset() {
     return this.intersetingAssets[0]?.asset;
   }
+
   getRandomAsset() {
     const random = Math.floor(Math.random() * this.intersetingAssets.length);
     return this.intersetingAssets[random];
@@ -243,6 +245,7 @@ export interface Viewport {
   width: number;
   height: number;
 }
+
 export type ViewportXY = Viewport & {
   x: number;
   y: number;
@@ -250,11 +253,46 @@ export type ViewportXY = Viewport & {
 
 class AddContext {
   lookupCache: {
-    [dayOfMonth: number]: AssetDateGroup;
+    [year: number]: { [month: number]: { [day: number]: AssetDateGroup } };
   } = {};
   unprocessedAssets: TimelineAsset[] = [];
   changedDateGroups = new Set<AssetDateGroup>();
   newDateGroups = new Set<AssetDateGroup>();
+
+  getDateGroup(year: number, month: number, day: number): AssetDateGroup | undefined {
+    return this.lookupCache[year]?.[month]?.[day];
+  }
+
+  setDateGroup(dateGroup: AssetDateGroup, year: number, month: number, day: number) {
+    if (!this.lookupCache[year]) {
+      this.lookupCache[year] = {};
+    }
+    if (!this.lookupCache[year][month]) {
+      this.lookupCache[year][month] = {};
+    }
+    this.lookupCache[year][month][day] = dateGroup;
+  }
+
+  get existingDateGroups() {
+    return this.changedDateGroups.difference(this.newDateGroups);
+  }
+
+  get updatedBuckets() {
+    const updated = new Set<AssetBucket>();
+    for (const group of this.changedDateGroups) {
+      updated.add(group.bucket);
+    }
+    return updated;
+  }
+
+  get bucketsWithNewDateGroups() {
+    const updated = new Set<AssetBucket>();
+    for (const group of this.newDateGroups) {
+      updated.add(group.bucket);
+    }
+    return updated;
+  }
+
   sort(bucket: AssetBucket, sortOrder: AssetOrder = AssetOrder.Desc) {
     for (const group of this.changedDateGroups) {
       group.sortAssets(sortOrder);
@@ -267,6 +305,7 @@ class AddContext {
     }
   }
 }
+
 export class AssetBucket {
   // --- public ---
   #intersecting: boolean = $state(false);
@@ -331,6 +370,7 @@ export class AssetBucket {
       this.handleLoadError,
     );
   }
+
   set intersecting(newValue: boolean) {
     const old = this.#intersecting;
     if (old !== newValue) {
@@ -422,52 +462,74 @@ export class AssetBucket {
     };
   }
 
-  // note - if the assets are not part of this bucket, they will not be added
-  addAssets(bucketResponse: AssetResponseDto[]) {
+  addAssets(bucketAssets: TimeBucketAssetResponseDto) {
     const addContext = new AddContext();
-    for (const asset of bucketResponse) {
-      const timelineAsset = toTimelineAsset(asset);
+    const people: string[] = [];
+    for (let i = 0; i < bucketAssets.id.length; i++) {
+      const timelineAsset: TimelineAsset = {
+        city: bucketAssets.city[i],
+        country: bucketAssets.country[i],
+        duration: bucketAssets.duration[i],
+        id: bucketAssets.id[i],
+        visibility: bucketAssets.visibility[i],
+        isFavorite: bucketAssets.isFavorite[i],
+        isImage: bucketAssets.isImage[i],
+        isTrashed: bucketAssets.isTrashed[i],
+        isVideo: !bucketAssets.isImage[i],
+        livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
+        localDateTime: bucketAssets.localDateTime[i],
+        ownerId: bucketAssets.ownerId[i],
+        people,
+        projectionType: bucketAssets.projectionType[i],
+        ratio: bucketAssets.ratio[i],
+        stack: bucketAssets.stack?.[i]
+          ? {
+              id: bucketAssets.stack[i]![0],
+              primaryAssetId: bucketAssets.id[i],
+              assetCount: Number.parseInt(bucketAssets.stack[i]![1]),
+            }
+          : null,
+        thumbhash: bucketAssets.thumbhash[i],
+      };
       this.addTimelineAsset(timelineAsset, addContext);
     }
 
+    for (const group of addContext.existingDateGroups) {
+      group.sortAssets(this.#sortOrder);
+    }
+
+    if (addContext.newDateGroups.size > 0) {
+      this.sortDateGroups();
+    }
+
     addContext.sort(this, this.#sortOrder);
+
     return addContext.unprocessedAssets;
   }
 
   addTimelineAsset(timelineAsset: TimelineAsset, addContext: AddContext) {
-    const { id, localDateTime } = timelineAsset;
+    const { localDateTime } = timelineAsset;
     const date = DateTime.fromISO(localDateTime).toUTC();
-
     const month = date.get('month');
     const year = date.get('year');
 
-    // If the timeline asset does not belong to the current bucket, mark it as unprocessed
     if (this.month !== month || this.year !== year) {
       addContext.unprocessedAssets.push(timelineAsset);
       return;
     }
 
     const day = date.get('day');
-    let dateGroup: AssetDateGroup | undefined = addContext.lookupCache[day] || this.findDateGroupByDay(day);
+    let dateGroup = addContext.getDateGroup(year, month, day) || this.findDateGroupByDay(day);
 
     if (dateGroup) {
-      // Cache the found date group for future lookups
-      addContext.lookupCache[day] = dateGroup;
+      addContext.setDateGroup(dateGroup, year, month, day);
     } else {
-      // Create a new date group if none exists for the given day
       dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day);
       this.dateGroups.push(dateGroup);
-      addContext.lookupCache[day] = dateGroup;
+      addContext.setDateGroup(dateGroup, year, month, day);
       addContext.newDateGroups.add(dateGroup);
     }
 
-    // Check for duplicate assets in the date group
-    if (dateGroup.intersetingAssets.some((a) => a.id === id)) {
-      console.error(`Ignoring attempt to add duplicate asset ${id} to ${dateGroup.groupTitle}`);
-      return;
-    }
-
-    // Add the timeline asset to the date group
     const intersectingAsset = new IntersectingAsset(dateGroup, timelineAsset);
     dateGroup.intersetingAssets.push(intersectingAsset);
     addContext.changedDateGroups.add(dateGroup);
@@ -521,6 +583,7 @@ export class AssetBucket {
       }
     }
   }
+
   get bucketHeight() {
     return this.#bucketHeight;
   }
@@ -909,7 +972,6 @@ export class AssetStore {
   async #initialiazeTimeBuckets() {
     const timebuckets = await getTimeBuckets({
       ...this.#options,
-      size: TimeBucketSize.Month,
       key: authManager.key,
     });
 
@@ -1016,6 +1078,7 @@ export class AssetStore {
       rowWidth: Math.floor(viewportWidth),
     };
   }
+
   #updateGeometry(bucket: AssetBucket, invalidateHeight: boolean) {
     if (invalidateHeight) {
       bucket.isBucketHeightActual = false;
@@ -1117,7 +1180,7 @@ export class AssetStore {
         {
           ...this.#options,
           timeBucket: bucketDate,
-          size: TimeBucketSize.Month,
+
           key: authManager.key,
         },
         { signal },
@@ -1128,12 +1191,11 @@ export class AssetStore {
             {
               albumId: this.#options.timelineAlbumId,
               timeBucket: bucketDate,
-              size: TimeBucketSize.Month,
               key: authManager.key,
             },
             { signal },
           );
-          for (const { id } of albumAssets) {
+          for (const id of albumAssets.id) {
             this.albumAssets.add(id);
           }
         }
@@ -1169,9 +1231,10 @@ export class AssetStore {
     if (assets.length === 0) {
       return;
     }
-    const updatedBuckets = new Set<AssetBucket>();
-    const updatedDateGroups = new Set<AssetDateGroup>();
 
+    const addContext = new AddContext();
+    const updatedBuckets = new Set<AssetBucket>();
+    const bucketCount = this.buckets.length;
     for (const asset of assets) {
       const utc = DateTime.fromISO(asset.localDateTime).toUTC().startOf('month');
       const year = utc.get('year');
@@ -1182,20 +1245,26 @@ export class AssetStore {
         bucket = new AssetBucket(this, utc, 1, this.#options.order);
         this.buckets.push(bucket);
       }
-      const addContext = new AddContext();
+
       bucket.addTimelineAsset(asset, addContext);
-      addContext.sort(bucket, this.#options.order);
       updatedBuckets.add(bucket);
     }
 
-    this.buckets.sort((a, b) => {
-      return a.year === b.year ? b.month - a.month : b.year - a.year;
-    });
-
-    for (const dateGroup of updatedDateGroups) {
-      dateGroup.sortAssets(this.#options.order);
+    if (this.buckets.length !== bucketCount) {
+      this.buckets.sort((a, b) => {
+        return a.year === b.year ? b.month - a.month : b.year - a.year;
+      });
     }
-    for (const bucket of updatedBuckets) {
+
+    for (const group of addContext.existingDateGroups) {
+      group.sortAssets(this.#options.order);
+    }
+
+    for (const bucket of addContext.bucketsWithNewDateGroups) {
+      bucket.sortDateGroups();
+    }
+
+    for (const bucket of addContext.updatedBuckets) {
       bucket.sortDateGroups();
       this.#updateGeometry(bucket, true);
     }
@@ -1421,7 +1490,7 @@ export class AssetStore {
 
   isExcluded(asset: TimelineAsset) {
     return (
-      isMismatched(this.#options.visibility, asset.visibility as unknown as AssetVisibility) ||
+      isMismatched(this.#options.visibility, asset.visibility) ||
       isMismatched(this.#options.isFavorite, asset.isFavorite) ||
       isMismatched(this.#options.isTrashed, asset.isTrashed)
     );
diff --git a/web/src/lib/utils/actions.ts b/web/src/lib/utils/actions.ts
index 57672d0450..08a22de3f9 100644
--- a/web/src/lib/utils/actions.ts
+++ b/web/src/lib/utils/actions.ts
@@ -1,7 +1,7 @@
 import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
 import type { AssetStore, TimelineAsset } from '$lib/stores/assets-store.svelte';
 import type { StackResponse } from '$lib/utils/asset-utils';
-import { deleteAssets as deleteBulk, Visibility } from '@immich/sdk';
+import { AssetVisibility, deleteAssets as deleteBulk } from '@immich/sdk';
 import { t } from 'svelte-i18n';
 import { get } from 'svelte/store';
 import { handleError } from './handle-error';
@@ -11,7 +11,7 @@ export type OnRestore = (ids: string[]) => void;
 export type OnLink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void;
 export type OnUnlink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void;
 export type OnAddToAlbum = (ids: string[], albumId: string) => void;
-export type OnArchive = (ids: string[], visibility: Visibility) => void;
+export type OnArchive = (ids: string[], visibility: AssetVisibility) => void;
 export type OnFavorite = (ids: string[], favorite: boolean) => void;
 export type OnStack = (result: StackResponse) => void;
 export type OnUnstack = (assets: TimelineAsset[]) => void;
diff --git a/web/src/lib/utils/thumbnail-util.spec.ts b/web/src/lib/utils/thumbnail-util.spec.ts
index ad0c00a50e..f3f9d51fad 100644
--- a/web/src/lib/utils/thumbnail-util.spec.ts
+++ b/web/src/lib/utils/thumbnail-util.spec.ts
@@ -1,6 +1,6 @@
 import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
 import { getAltText } from '$lib/utils/thumbnail-util';
-import { Visibility } from '@immich/sdk';
+import { AssetVisibility } from '@immich/sdk';
 import { init, register, waitLocale } from 'svelte-i18n';
 
 interface Person {
@@ -62,7 +62,7 @@ describe('getAltText', () => {
         ratio: 1,
         thumbhash: null,
         localDateTime: '2024-01-01T12:00:00.000Z',
-        visibility: Visibility.Timeline,
+        visibility: AssetVisibility.Timeline,
         isFavorite: false,
         isTrashed: false,
         isVideo,
@@ -71,11 +71,9 @@ describe('getAltText', () => {
         duration: null,
         projectionType: null,
         livePhotoVideoId: null,
-        text: {
-          city: city ?? null,
-          country: country ?? null,
-          people: people?.map((person: Person) => person.name) ?? [],
-        },
+        city: city ?? null,
+        country: country ?? null,
+        people: people?.map((person: Person) => person.name) ?? [],
       };
 
       getAltText.subscribe((fn) => {
diff --git a/web/src/lib/utils/thumbnail-util.ts b/web/src/lib/utils/thumbnail-util.ts
index a6fee0a71d..954cfa3314 100644
--- a/web/src/lib/utils/thumbnail-util.ts
+++ b/web/src/lib/utils/thumbnail-util.ts
@@ -41,19 +41,18 @@ export function getThumbnailSize(assetCount: number, viewWidth: number): number
 export const getAltText = derived(t, ($t) => {
   return (asset: TimelineAsset) => {
     const date = fromLocalDateTime(asset.localDateTime).toLocaleString({ dateStyle: 'long' }, { locale: get(locale) });
-    const { city, country, people: names } = asset.text;
-    const hasPlace = city && country;
+    const hasPlace = asset.city && asset.country;
 
-    const peopleCount = names.length;
+    const peopleCount = asset.people.length;
     const isVideo = asset.isVideo;
 
     const values = {
       date,
-      city,
-      country,
-      person1: names[0],
-      person2: names[1],
-      person3: names[2],
+      city: asset.city,
+      country: asset.country,
+      person1: asset.people[0],
+      person2: asset.people[1],
+      person3: asset.people[2],
       isVideo,
       additionalCount: peopleCount > 3 ? peopleCount - 2 : 0,
     };
diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts
index 8c5f4d285a..66436940d5 100644
--- a/web/src/lib/utils/timeline-util.ts
+++ b/web/src/lib/utils/timeline-util.ts
@@ -2,7 +2,8 @@ import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
 import { locale } from '$lib/stores/preferences.store';
 import { getAssetRatio } from '$lib/utils/asset-utils';
 
-import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
+import { AssetTypeEnum, AssetVisibility, type AssetResponseDto } from '@immich/sdk';
+
 import { memoize } from 'lodash-es';
 import { DateTime, type LocaleOptions } from 'luxon';
 import { get } from 'svelte/store';
@@ -65,17 +66,12 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
   if (isTimelineAsset(unknownAsset)) {
     return unknownAsset;
   }
-  const assetResponse = unknownAsset as AssetResponseDto;
+  const assetResponse = unknownAsset;
   const { width, height } = getAssetRatio(assetResponse);
   const ratio = width / height;
   const city = assetResponse.exifInfo?.city;
   const country = assetResponse.exifInfo?.country;
   const people = assetResponse.people?.map((person) => person.name) || [];
-  const text = {
-    city: city || null,
-    country: country || null,
-    people,
-  };
   return {
     id: assetResponse.id,
     ownerId: assetResponse.ownerId,
@@ -83,7 +79,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
     thumbhash: assetResponse.thumbhash,
     localDateTime: assetResponse.localDateTime,
     isFavorite: assetResponse.isFavorite,
-    visibility: assetResponse.visibility,
+    visibility: assetResponse.isArchived ? AssetVisibility.Archive : AssetVisibility.Timeline,
     isTrashed: assetResponse.isTrashed,
     isVideo: assetResponse.type == AssetTypeEnum.Video,
     isImage: assetResponse.type == AssetTypeEnum.Image,
@@ -91,8 +87,10 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
     duration: assetResponse.duration || null,
     projectionType: assetResponse.exifInfo?.projectionType || null,
     livePhotoVideoId: assetResponse.livePhotoVideoId || null,
-    text,
+    city: city || null,
+    country: country || null,
+    people,
   };
 };
-export const isTimelineAsset = (asset: AssetResponseDto | TimelineAsset): asset is TimelineAsset =>
-  (asset as TimelineAsset).ratio !== undefined;
+export const isTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): unknownAsset is TimelineAsset =>
+  (unknownAsset as TimelineAsset).ratio !== undefined;
diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts
index bdffecc8bc..e36bec6c4e 100644
--- a/web/src/test-data/factories/asset-factory.ts
+++ b/web/src/test-data/factories/asset-factory.ts
@@ -1,6 +1,12 @@
 import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
 import { faker } from '@faker-js/faker';
-import { AssetTypeEnum, Visibility, type AssetResponseDto } from '@immich/sdk';
+import {
+  AssetTypeEnum,
+  AssetVisibility,
+  Visibility,
+  type AssetResponseDto,
+  type TimeBucketAssetResponseDto,
+} from '@immich/sdk';
 import { Sync } from 'factory.ts';
 
 export const assetFactory = Sync.makeFactory<AssetResponseDto>({
@@ -35,7 +41,7 @@ export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({
   thumbhash: Sync.each(() => faker.string.alphanumeric(28)),
   localDateTime: Sync.each(() => faker.date.past().toISOString()),
   isFavorite: Sync.each(() => faker.datatype.boolean()),
-  visibility: Visibility.Timeline,
+  visibility: AssetVisibility.Timeline,
   isTrashed: false,
   isImage: true,
   isVideo: false,
@@ -43,9 +49,46 @@ export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({
   stack: null,
   projectionType: null,
   livePhotoVideoId: Sync.each(() => faker.string.uuid()),
-  text: Sync.each(() => ({
-    city: faker.location.city(),
-    country: faker.location.country(),
-    people: [faker.person.fullName()],
-  })),
+  city: faker.location.city(),
+  country: faker.location.country(),
+  people: [faker.person.fullName()],
 });
+
+export const toResponseDto = (...timelineAsset: TimelineAsset[]) => {
+  const bucketAssets: TimeBucketAssetResponseDto = {
+    city: [],
+    country: [],
+    duration: [],
+    id: [],
+    visibility: [],
+    isFavorite: [],
+    isImage: [],
+    isTrashed: [],
+    livePhotoVideoId: [],
+    localDateTime: [],
+    ownerId: [],
+    projectionType: [],
+    ratio: [],
+    stack: [],
+    thumbhash: [],
+  };
+  for (const asset of timelineAsset) {
+    bucketAssets.city.push(asset.city);
+    bucketAssets.country.push(asset.country);
+    bucketAssets.duration.push(asset.duration!);
+    bucketAssets.id.push(asset.id);
+    bucketAssets.visibility.push(asset.visibility);
+    bucketAssets.isFavorite.push(asset.isFavorite);
+    bucketAssets.isImage.push(asset.isImage);
+    bucketAssets.isTrashed.push(asset.isTrashed);
+    bucketAssets.livePhotoVideoId.push(asset.livePhotoVideoId!);
+    bucketAssets.localDateTime.push(asset.localDateTime);
+    bucketAssets.ownerId.push(asset.ownerId);
+    bucketAssets.projectionType.push(asset.projectionType!);
+    bucketAssets.ratio.push(asset.ratio);
+    bucketAssets.stack?.push(asset.stack ? [asset.stack.id, asset.stack.assetCount.toString()] : null);
+    bucketAssets.thumbhash.push(asset.thumbhash!);
+  }
+
+  return bucketAssets;
+};