Browse Source

Merge branch 'main' of https://github.com/immich-app/immich into 4382-thumbnail-metadata

Jonathan Jogenfors 1 year ago
parent
commit
beb2a48339

+ 1 - 0
cli/.eslintrc.js

@@ -18,6 +18,7 @@ module.exports = {
     '@typescript-eslint/explicit-function-return-type': 'off',
     '@typescript-eslint/explicit-module-boundary-types': 'off',
     '@typescript-eslint/no-explicit-any': 'off',
+    '@typescript-eslint/no-floating-promises': 'error',
     'prettier/prettier': 0,
   },
 };

+ 8 - 8
cli/src/index.ts

@@ -19,9 +19,9 @@ program
   )
   .addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS'))
   .argument('[paths...]', 'One or more paths to assets to be uploaded')
-  .action((paths, options) => {
+  .action(async (paths, options) => {
     options.excludePatterns = options.ignore;
-    new Upload().run(paths, options);
+    await new Upload().run(paths, options);
   });
 
 program
@@ -37,18 +37,18 @@ program
   .addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS').default(false))
   .addOption(new Option('--no-read-only', 'Import files without read-only protection, allowing Immich to manage them'))
   .argument('[paths...]', 'One or more paths to assets to be imported')
-  .action((paths, options) => {
+  .action(async (paths, options) => {
     options.import = true;
     options.excludePatterns = options.ignore;
-    new Upload().run(paths, options);
+    await new Upload().run(paths, options);
   });
 
 program
   .command('server-info')
   .description('Display server information')
 
-  .action(() => {
-    new ServerInfo().run();
+  .action(async () => {
+    await new ServerInfo().run();
   });
 
 program
@@ -56,8 +56,8 @@ program
   .description('Login using an API key')
   .argument('[instanceUrl]')
   .argument('[apiKey]')
-  .action((paths, options) => {
-    new LoginKey().run(paths, options);
+  .action(async (paths, options) => {
+    await new LoginKey().run(paths, options);
   });
 
 program.parse(process.argv);

+ 1 - 1
cli/src/services/session.service.spec.ts

@@ -67,7 +67,7 @@ describe('SessionService', () => {
     });
   });
 
-  it('should create auth file when logged in', async () => {
+  it.skip('should create auth file when logged in', async () => {
     mockfs();
 
     await sessionService.keyLogin('https://test/api', 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg');

+ 8 - 1
cli/src/services/session.service.ts

@@ -53,7 +53,14 @@ export class SessionService {
 
     if (!fs.existsSync(this.configDir)) {
       // Create config folder if it doesn't exist
-      fs.mkdirSync(this.configDir, { recursive: true });
+      const created = await fs.promises.mkdir(this.configDir, { recursive: true });
+      if (!created) {
+        throw new Error(`Failed to create config folder ${this.configDir}`);
+      }
+    }
+
+    if (!fs.existsSync(this.configDir)) {
+      console.error('waah');
     }
 
     fs.writeFileSync(this.authPath, yaml.stringify({ instanceUrl, apiKey }));

+ 4 - 15
cli/src/services/upload.service.spec.ts

@@ -1,35 +1,24 @@
 import { UploadService } from './upload.service';
-import mockfs from 'mock-fs';
 import axios from 'axios';
-import mockAxios from 'jest-mock-axios';
 import FormData from 'form-data';
 import { ApiConfiguration } from '../cores/api-configuration';
 
+jest.mock('axios', () => jest.fn());
+
 describe('UploadService', () => {
   let uploadService: UploadService;
 
-  beforeAll(() => {
-    // Write a dummy output before mock-fs to prevent some annoying errors
-    console.log();
-  });
-
   beforeEach(() => {
     const apiConfiguration = new ApiConfiguration('https://example.com/api', 'key');
 
     uploadService = new UploadService(apiConfiguration);
   });
 
-  it('should upload a single file', async () => {
+  it('should call axios', async () => {
     const data = new FormData();
 
-    uploadService.upload(data);
+    await uploadService.upload(data);
 
-    mockAxios.mockResponse();
     expect(axios).toHaveBeenCalled();
   });
-
-  afterEach(() => {
-    mockfs.restore();
-    mockAxios.reset();
-  });
 });

+ 3 - 3
cli/src/services/upload.service.ts

@@ -42,21 +42,21 @@ export class UploadService {
     };
   }
 
-  public checkIfAssetAlreadyExists(path: string, checksum: string): Promise<any> {
+  public checkIfAssetAlreadyExists(path: string, checksum: string) {
     this.checkAssetExistenceConfig.data = JSON.stringify({ assets: [{ id: path, checksum: checksum }] });
 
     // TODO: retry on 500 errors?
     return axios(this.checkAssetExistenceConfig);
   }
 
-  public upload(data: FormData): Promise<any> {
+  public upload(data: FormData) {
     this.uploadConfig.data = data;
 
     // TODO: retry on 500 errors?
     return axios(this.uploadConfig);
   }
 
-  public import(data: any): Promise<any> {
+  public import(data: any) {
     this.importConfig.data = data;
 
     // TODO: retry on 500 errors?

+ 2 - 0
mobile/assets/i18n/en-US.json

@@ -173,6 +173,8 @@
   "library_page_sharing": "Sharing",
   "library_page_sort_created": "Most recently created",
   "library_page_sort_title": "Album title",
+  "library_page_sort_most_recent_photo": "Most recent photo",
+  "library_page_sort_last_modified": "Last modified",
   "login_disabled": "Login has been disabled",
   "login_form_api_exception": "API exception. Please check the server URL and try again.",
   "login_form_handshake_exception": "There was an Handshake Exception with the server. Enable self-signed certificate support in the settings if you are using a self-signed certificate.",

+ 1 - 1
mobile/ios/Podfile.lock

@@ -169,4 +169,4 @@ SPEC CHECKSUMS:
 
 PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382
 
-COCOAPODS: 1.11.3
+COCOAPODS: 1.12.1

+ 31 - 0
mobile/lib/modules/album/views/library_page.dart

@@ -47,6 +47,7 @@ class LibraryPage extends HookConsumerWidget {
         useState(settings.getSetting(AppSettingsEnum.selectedAlbumSortOrder));
 
     List<Album> sortedAlbums() {
+      // Created.
       if (selectedAlbumSortOrder.value == 0) {
         return albums
             .where((a) => a.isRemote)
@@ -54,6 +55,34 @@ class LibraryPage extends HookConsumerWidget {
             .reversed
             .toList();
       }
+      // Album title.
+      if (selectedAlbumSortOrder.value == 1) {
+        return albums.where((a) => a.isRemote).sortedBy((album) => album.name);
+      }
+      // Most recent photo, if unset (e.g. empty album, use modifiedAt / updatedAt).
+      if (selectedAlbumSortOrder.value == 2) {
+        return albums
+            .where((a) => a.isRemote)
+            .sorted(
+              (a, b) => a.lastModifiedAssetTimestamp != null &&
+                      b.lastModifiedAssetTimestamp != null
+                  ? a.lastModifiedAssetTimestamp!
+                      .compareTo(b.lastModifiedAssetTimestamp!)
+                  : a.modifiedAt.compareTo(b.modifiedAt),
+            )
+            .reversed
+            .toList();
+      }
+      // Last modified.
+      if (selectedAlbumSortOrder.value == 3) {
+        return albums
+            .where((a) => a.isRemote)
+            .sortedBy((album) => album.modifiedAt)
+            .reversed
+            .toList();
+      }
+
+      // Fallback: Album title.
       return albums.where((a) => a.isRemote).sortedBy((album) => album.name);
     }
 
@@ -61,6 +90,8 @@ class LibraryPage extends HookConsumerWidget {
       final options = [
         "library_page_sort_created".tr(),
         "library_page_sort_title".tr(),
+        "library_page_sort_most_recent_photo".tr(),
+        "library_page_sort_last_modified".tr(),
       ];
 
       return PopupMenuButton(

+ 12 - 1
mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart

@@ -364,7 +364,18 @@ class ExifBottomSheet extends HookConsumerWidget {
                   children: [
                     buildDragHeader(),
                     buildDate(),
-                    if (asset.isRemote) DescriptionInput(asset: asset),
+                    assetWithExif.when(
+                      data: (data) => DescriptionInput(asset: data),
+                      error: (error, stackTrace) => Icon(
+                        Icons.image_not_supported_outlined,
+                        color: Theme.of(context).primaryColor,
+                      ),
+                      loading: () => const SizedBox(
+                        width: 75,
+                        height: 75,
+                        child: CircularProgressIndicator.adaptive(),
+                      ),
+                    ),
                     const SizedBox(height: 8.0),
                     buildLocation(),
                     SizedBox(height: hasCoordinates(exifInfo) ? 16.0 : 0.0),

+ 7 - 7
mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart

@@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/providers/asset.provider.dart';
 
 class TopControlAppBar extends HookConsumerWidget {
   const TopControlAppBar({
@@ -14,7 +15,6 @@ class TopControlAppBar extends HookConsumerWidget {
     required this.isPlayingMotionVideo,
     required this.onFavorite,
     required this.onUploadPressed,
-    required this.isFavorite,
   }) : super(key: key);
 
   final Asset asset;
@@ -23,19 +23,19 @@ class TopControlAppBar extends HookConsumerWidget {
   final VoidCallback? onDownloadPressed;
   final VoidCallback onToggleMotionVideo;
   final VoidCallback onAddToAlbumPressed;
-  final VoidCallback? onFavorite;
+  final Function(Asset) onFavorite;
   final bool isPlayingMotionVideo;
-  final bool isFavorite;
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
     const double iconSize = 22.0;
+    final a = ref.watch(assetWatcher(asset)).value ?? asset;
 
-    Widget buildFavoriteButton() {
+    Widget buildFavoriteButton(a) {
       return IconButton(
-        onPressed: onFavorite,
+        onPressed: () => onFavorite(a),
         icon: Icon(
-          isFavorite ? Icons.favorite : Icons.favorite_border,
+          a.isFavorite ? Icons.favorite : Icons.favorite_border,
           color: Colors.grey[200],
         ),
       );
@@ -123,7 +123,7 @@ class TopControlAppBar extends HookConsumerWidget {
         size: iconSize,
       ),
       actions: [
-        if (asset.isRemote) buildFavoriteButton(),
+        if (asset.isRemote) buildFavoriteButton(a),
         if (asset.livePhotoVideoId != null) buildLivePhotoButton(),
         if (asset.isLocal && !asset.isRemote) buildUploadButton(),
         if (asset.isRemote && !asset.isLocal) buildDownloadButton(),

+ 1 - 3
mobile/lib/modules/asset_viewer/views/gallery_viewer.dart

@@ -297,10 +297,8 @@ class GalleryViewerPage extends HookConsumerWidget {
             child: TopControlAppBar(
               isPlayingMotionVideo: isPlayingMotionVideo.value,
               asset: asset(),
-              isFavorite: asset().isFavorite,
               onMoreInfoPressed: showInfo,
-              onFavorite:
-                  asset().isRemote ? () => toggleFavorite(asset()) : null,
+              onFavorite: toggleFavorite,
               onUploadPressed:
                   asset().isLocal ? () => handleUpload(asset()) : null,
               onDownloadPressed: asset().isLocal

+ 34 - 20
mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart

@@ -60,6 +60,39 @@ class ThumbnailImage extends StatelessWidget {
       }
     }
 
+    Widget buildVideoIcon() {
+      final minutes = asset.duration.inMinutes;
+      final durationString = asset.duration.toString();
+      return Positioned(
+        top: 5,
+        right: 5,
+        child: Row(
+          children: [
+            Text(
+              minutes > 59
+                  ? durationString.substring(0, 7) // h:mm:ss
+                  : minutes > 0
+                      ? durationString.substring(2, 7) // mm:ss
+                      : durationString.substring(3, 7), // m:ss
+              style: const TextStyle(
+                color: Colors.white,
+                fontSize: 10,
+                fontWeight: FontWeight.bold,
+              ),
+            ),
+            const SizedBox(
+              width: 3,
+            ),
+            const Icon(
+              Icons.play_circle_fill_rounded,
+              color: Colors.white,
+              size: 18,
+            ),
+          ],
+        ),
+      );
+    }
+
     Widget buildImage() {
       final image = SizedBox(
         width: 300,
@@ -162,26 +195,7 @@ class ThumbnailImage extends StatelessWidget {
                 size: 18,
               ),
             ),
-          if (!asset.isImage)
-            Positioned(
-              top: 5,
-              right: 5,
-              child: Row(
-                children: [
-                  Text(
-                    asset.duration.toString().substring(0, 7),
-                    style: const TextStyle(
-                      color: Colors.white,
-                      fontSize: 10,
-                    ),
-                  ),
-                  const Icon(
-                    Icons.play_circle_outline_rounded,
-                    color: Colors.white,
-                  ),
-                ],
-              ),
-            ),
+          if (!asset.isImage) buildVideoIcon(),
         ],
       ),
     );

+ 13 - 0
mobile/lib/shared/models/album.dart

@@ -18,6 +18,7 @@ class Album {
     required this.name,
     required this.createdAt,
     required this.modifiedAt,
+    this.lastModifiedAssetTimestamp,
     required this.shared,
   });
 
@@ -29,6 +30,7 @@ class Album {
   String name;
   DateTime createdAt;
   DateTime modifiedAt;
+  DateTime? lastModifiedAssetTimestamp;
   bool shared;
   final IsarLink<User> owner = IsarLink<User>();
   final IsarLink<Asset> thumbnail = IsarLink<Asset>();
@@ -83,12 +85,21 @@ class Album {
   @override
   bool operator ==(other) {
     if (other is! Album) return false;
+
+    final lastModifiedAssetTimestampIsSetAndEqual =
+        lastModifiedAssetTimestamp != null &&
+                other.lastModifiedAssetTimestamp != null
+            ? lastModifiedAssetTimestamp!
+                .isAtSameMomentAs(other.lastModifiedAssetTimestamp!)
+            : true;
+
     return id == other.id &&
         remoteId == other.remoteId &&
         localId == other.localId &&
         name == other.name &&
         createdAt.isAtSameMomentAs(other.createdAt) &&
         modifiedAt.isAtSameMomentAs(other.modifiedAt) &&
+        lastModifiedAssetTimestampIsSetAndEqual &&
         shared == other.shared &&
         owner.value == other.owner.value &&
         thumbnail.value == other.thumbnail.value &&
@@ -105,6 +116,7 @@ class Album {
       name.hashCode ^
       createdAt.hashCode ^
       modifiedAt.hashCode ^
+      lastModifiedAssetTimestamp.hashCode ^
       shared.hashCode ^
       owner.value.hashCode ^
       thumbnail.value.hashCode ^
@@ -130,6 +142,7 @@ class Album {
       name: dto.albumName,
       createdAt: dto.createdAt,
       modifiedAt: dto.updatedAt,
+      lastModifiedAssetTimestamp: dto.lastModifiedAssetTimestamp,
       shared: dto.shared,
     );
     a.owner.value = await db.users.getById(dto.ownerId);

+ 141 - 19
mobile/lib/shared/models/album.g.dart

@@ -22,28 +22,33 @@ const AlbumSchema = CollectionSchema(
       name: r'createdAt',
       type: IsarType.dateTime,
     ),
-    r'localId': PropertySchema(
+    r'lastModifiedAssetTimestamp': PropertySchema(
       id: 1,
+      name: r'lastModifiedAssetTimestamp',
+      type: IsarType.dateTime,
+    ),
+    r'localId': PropertySchema(
+      id: 2,
       name: r'localId',
       type: IsarType.string,
     ),
     r'modifiedAt': PropertySchema(
-      id: 2,
+      id: 3,
       name: r'modifiedAt',
       type: IsarType.dateTime,
     ),
     r'name': PropertySchema(
-      id: 3,
+      id: 4,
       name: r'name',
       type: IsarType.string,
     ),
     r'remoteId': PropertySchema(
-      id: 4,
+      id: 5,
       name: r'remoteId',
       type: IsarType.string,
     ),
     r'shared': PropertySchema(
-      id: 5,
+      id: 6,
       name: r'shared',
       type: IsarType.bool,
     )
@@ -143,11 +148,12 @@ void _albumSerialize(
   Map<Type, List<int>> allOffsets,
 ) {
   writer.writeDateTime(offsets[0], object.createdAt);
-  writer.writeString(offsets[1], object.localId);
-  writer.writeDateTime(offsets[2], object.modifiedAt);
-  writer.writeString(offsets[3], object.name);
-  writer.writeString(offsets[4], object.remoteId);
-  writer.writeBool(offsets[5], object.shared);
+  writer.writeDateTime(offsets[1], object.lastModifiedAssetTimestamp);
+  writer.writeString(offsets[2], object.localId);
+  writer.writeDateTime(offsets[3], object.modifiedAt);
+  writer.writeString(offsets[4], object.name);
+  writer.writeString(offsets[5], object.remoteId);
+  writer.writeBool(offsets[6], object.shared);
 }
 
 Album _albumDeserialize(
@@ -158,11 +164,12 @@ Album _albumDeserialize(
 ) {
   final object = Album(
     createdAt: reader.readDateTime(offsets[0]),
-    localId: reader.readStringOrNull(offsets[1]),
-    modifiedAt: reader.readDateTime(offsets[2]),
-    name: reader.readString(offsets[3]),
-    remoteId: reader.readStringOrNull(offsets[4]),
-    shared: reader.readBool(offsets[5]),
+    lastModifiedAssetTimestamp: reader.readDateTimeOrNull(offsets[1]),
+    localId: reader.readStringOrNull(offsets[2]),
+    modifiedAt: reader.readDateTime(offsets[3]),
+    name: reader.readString(offsets[4]),
+    remoteId: reader.readStringOrNull(offsets[5]),
+    shared: reader.readBool(offsets[6]),
   );
   object.id = id;
   return object;
@@ -178,14 +185,16 @@ P _albumDeserializeProp<P>(
     case 0:
       return (reader.readDateTime(offset)) as P;
     case 1:
-      return (reader.readStringOrNull(offset)) as P;
+      return (reader.readDateTimeOrNull(offset)) as P;
     case 2:
-      return (reader.readDateTime(offset)) as P;
+      return (reader.readStringOrNull(offset)) as P;
     case 3:
-      return (reader.readString(offset)) as P;
+      return (reader.readDateTime(offset)) as P;
     case 4:
-      return (reader.readStringOrNull(offset)) as P;
+      return (reader.readString(offset)) as P;
     case 5:
+      return (reader.readStringOrNull(offset)) as P;
+    case 6:
       return (reader.readBool(offset)) as P;
     default:
       throw IsarError('Unknown property with id $propertyId');
@@ -520,6 +529,80 @@ extension AlbumQueryFilter on QueryBuilder<Album, Album, QFilterCondition> {
     });
   }
 
+  QueryBuilder<Album, Album, QAfterFilterCondition>
+      lastModifiedAssetTimestampIsNull() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(const FilterCondition.isNull(
+        property: r'lastModifiedAssetTimestamp',
+      ));
+    });
+  }
+
+  QueryBuilder<Album, Album, QAfterFilterCondition>
+      lastModifiedAssetTimestampIsNotNull() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(const FilterCondition.isNotNull(
+        property: r'lastModifiedAssetTimestamp',
+      ));
+    });
+  }
+
+  QueryBuilder<Album, Album, QAfterFilterCondition>
+      lastModifiedAssetTimestampEqualTo(DateTime? value) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'lastModifiedAssetTimestamp',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<Album, Album, QAfterFilterCondition>
+      lastModifiedAssetTimestampGreaterThan(
+    DateTime? value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        include: include,
+        property: r'lastModifiedAssetTimestamp',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<Album, Album, QAfterFilterCondition>
+      lastModifiedAssetTimestampLessThan(
+    DateTime? value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.lessThan(
+        include: include,
+        property: r'lastModifiedAssetTimestamp',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<Album, Album, QAfterFilterCondition>
+      lastModifiedAssetTimestampBetween(
+    DateTime? lower,
+    DateTime? upper, {
+    bool includeLower = true,
+    bool includeUpper = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.between(
+        property: r'lastModifiedAssetTimestamp',
+        lower: lower,
+        includeLower: includeLower,
+        upper: upper,
+        includeUpper: includeUpper,
+      ));
+    });
+  }
+
   QueryBuilder<Album, Album, QAfterFilterCondition> localIdIsNull() {
     return QueryBuilder.apply(this, (query) {
       return query.addFilterCondition(const FilterCondition.isNull(
@@ -1158,6 +1241,19 @@ extension AlbumQuerySortBy on QueryBuilder<Album, Album, QSortBy> {
     });
   }
 
+  QueryBuilder<Album, Album, QAfterSortBy> sortByLastModifiedAssetTimestamp() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'lastModifiedAssetTimestamp', Sort.asc);
+    });
+  }
+
+  QueryBuilder<Album, Album, QAfterSortBy>
+      sortByLastModifiedAssetTimestampDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'lastModifiedAssetTimestamp', Sort.desc);
+    });
+  }
+
   QueryBuilder<Album, Album, QAfterSortBy> sortByLocalId() {
     return QueryBuilder.apply(this, (query) {
       return query.addSortBy(r'localId', Sort.asc);
@@ -1244,6 +1340,19 @@ extension AlbumQuerySortThenBy on QueryBuilder<Album, Album, QSortThenBy> {
     });
   }
 
+  QueryBuilder<Album, Album, QAfterSortBy> thenByLastModifiedAssetTimestamp() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'lastModifiedAssetTimestamp', Sort.asc);
+    });
+  }
+
+  QueryBuilder<Album, Album, QAfterSortBy>
+      thenByLastModifiedAssetTimestampDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'lastModifiedAssetTimestamp', Sort.desc);
+    });
+  }
+
   QueryBuilder<Album, Album, QAfterSortBy> thenByLocalId() {
     return QueryBuilder.apply(this, (query) {
       return query.addSortBy(r'localId', Sort.asc);
@@ -1312,6 +1421,12 @@ extension AlbumQueryWhereDistinct on QueryBuilder<Album, Album, QDistinct> {
     });
   }
 
+  QueryBuilder<Album, Album, QDistinct> distinctByLastModifiedAssetTimestamp() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addDistinctBy(r'lastModifiedAssetTimestamp');
+    });
+  }
+
   QueryBuilder<Album, Album, QDistinct> distinctByLocalId(
       {bool caseSensitive = true}) {
     return QueryBuilder.apply(this, (query) {
@@ -1359,6 +1474,13 @@ extension AlbumQueryProperty on QueryBuilder<Album, Album, QQueryProperty> {
     });
   }
 
+  QueryBuilder<Album, DateTime?, QQueryOperations>
+      lastModifiedAssetTimestampProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'lastModifiedAssetTimestamp');
+    });
+  }
+
   QueryBuilder<Album, String?, QQueryOperations> localIdProperty() {
     return QueryBuilder.apply(this, (query) {
       return query.addPropertyName(r'localId');

+ 6 - 0
mobile/lib/shared/providers/asset.provider.dart

@@ -200,6 +200,12 @@ final assetDetailProvider =
   }
 });
 
+final assetWatcher =
+    StreamProvider.autoDispose.family<Asset?, Asset>((ref, asset) {
+  final db = ref.watch(dbProvider);
+  return db.assets.watchObject(asset.id, fireImmediately: true);
+});
+
 final assetsProvider =
     StreamProvider.family<RenderList, int?>((ref, userId) async* {
   if (userId == null) return;

+ 13 - 1
mobile/lib/shared/services/sync.service.dart

@@ -282,6 +282,9 @@ class SyncService {
     if (!_hasAlbumResponseDtoChanged(dto, album)) {
       return false;
     }
+    // loadDetails (/api/album/:id) will not include lastModifiedAssetTimestamp,
+    // i.e. it will always be null. Save it here.
+    final originalDto = dto;
     dto = await loadDetails(dto);
     if (dto.assetCount != dto.assets.length) {
       return false;
@@ -321,6 +324,7 @@ class SyncService {
     album.name = dto.albumName;
     album.shared = dto.shared;
     album.modifiedAt = dto.updatedAt;
+    album.lastModifiedAssetTimestamp = originalDto.lastModifiedAssetTimestamp;
     if (album.thumbnail.value?.remoteId != dto.albumThumbnailAssetId) {
       album.thumbnail.value = await _db.assets
           .where()
@@ -808,5 +812,13 @@ bool _hasAlbumResponseDtoChanged(AlbumResponseDto dto, Album a) {
       dto.albumThumbnailAssetId != a.thumbnail.value?.remoteId ||
       dto.shared != a.shared ||
       dto.sharedUsers.length != a.sharedUsers.length ||
-      !dto.updatedAt.isAtSameMomentAs(a.modifiedAt);
+      !dto.updatedAt.isAtSameMomentAs(a.modifiedAt) ||
+      (dto.lastModifiedAssetTimestamp == null &&
+          a.lastModifiedAssetTimestamp != null) ||
+      (dto.lastModifiedAssetTimestamp != null &&
+          a.lastModifiedAssetTimestamp == null) ||
+      (dto.lastModifiedAssetTimestamp != null &&
+          a.lastModifiedAssetTimestamp != null &&
+          !dto.lastModifiedAssetTimestamp!
+              .isAtSameMomentAs(a.lastModifiedAssetTimestamp!));
 }

+ 1 - 0
server/.eslintrc.js

@@ -18,6 +18,7 @@ module.exports = {
     '@typescript-eslint/explicit-function-return-type': 'off',
     '@typescript-eslint/explicit-module-boundary-types': 'off',
     '@typescript-eslint/no-explicit-any': 'off',
+    '@typescript-eslint/no-floating-promises': 'error',
     'prettier/prettier': 0,
   },
 };

+ 1 - 1
server/src/domain/asset/asset.service.ts

@@ -272,7 +272,7 @@ export class AssetService {
       zip.addFile(originalPath, filename);
     }
 
-    zip.finalize();
+    void zip.finalize();
 
     return { stream: zip.stream };
   }

+ 6 - 6
server/src/domain/search/search.service.spec.ts

@@ -267,9 +267,9 @@ describe(SearchService.name, () => {
   });
 
   describe('handleIndexAlbums', () => {
-    it('should skip if search is disabled', () => {
+    it('should skip if search is disabled', async () => {
       sut['enabled'] = false;
-      sut.handleIndexAlbums();
+      await sut.handleIndexAlbums();
     });
 
     it('should index all the albums', async () => {
@@ -355,18 +355,18 @@ describe(SearchService.name, () => {
   });
 
   describe('handleIndexAsset', () => {
-    it('should skip if search is disabled', () => {
+    it('should skip if search is disabled', async () => {
       sut['enabled'] = false;
-      sut.handleIndexFace({ assetId: 'asset-1', personId: 'person-1' });
+      await sut.handleIndexFace({ assetId: 'asset-1', personId: 'person-1' });
 
       expect(searchMock.importFaces).not.toHaveBeenCalled();
       expect(personMock.getFacesByIds).not.toHaveBeenCalled();
     });
 
-    it('should index the face', () => {
+    it('should index the face', async () => {
       personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
 
-      sut.handleIndexFace({ assetId: 'asset-1', personId: 'person-1' });
+      await sut.handleIndexFace({ assetId: 'asset-1', personId: 'person-1' });
 
       expect(personMock.getFacesByIds).toHaveBeenCalledWith([{ assetId: 'asset-1', personId: 'person-1' }]);
     });

+ 2 - 2
server/src/immich/app.module.ts

@@ -75,7 +75,7 @@ export class AppModule implements OnModuleInit, OnModuleDestroy {
     await this.appService.init();
   }
 
-  onModuleDestroy() {
-    this.appService.destroy();
+  async onModuleDestroy() {
+    await this.appService.destroy();
   }
 }

+ 3 - 3
server/src/infra/repositories/communication.repository.ts

@@ -16,7 +16,7 @@ export class CommunicationRepository implements OnGatewayConnection, OnGatewayDi
       this.logger.log(`New websocket connection: ${client.id}`);
       const user = await this.authService.validate(client.request.headers, {});
       if (user) {
-        client.join(user.id);
+        await client.join(user.id);
         this.send(CommunicationEvent.SERVER_VERSION, user.id, serverVersion);
       } else {
         client.emit('error', 'unauthorized');
@@ -28,8 +28,8 @@ export class CommunicationRepository implements OnGatewayConnection, OnGatewayDi
     }
   }
 
-  handleDisconnect(client: Socket) {
-    client.leave(client.nsp.name);
+  async handleDisconnect(client: Socket) {
+    await client.leave(client.nsp.name);
     this.logger.log(`Client ${client.id} disconnected from Websocket`);
   }
 

+ 3 - 2
server/src/infra/repositories/person.repository.ts

@@ -51,7 +51,7 @@ export class PersonRepository implements IPersonRepository {
   }
 
   getAllFaces(): Promise<AssetFaceEntity[]> {
-    return this.assetFaceRepository.find({ relations: { asset: true } });
+    return this.assetFaceRepository.find({ relations: { asset: true }, withDeleted: true });
   }
 
   getAll(): Promise<PersonEntity[]> {
@@ -88,6 +88,7 @@ export class PersonRepository implements IPersonRepository {
       .leftJoin('person.faces', 'face')
       .having('COUNT(face.assetId) = 0')
       .groupBy('person.id')
+      .withDeleted()
       .getMany();
   }
 
@@ -142,7 +143,7 @@ export class PersonRepository implements IPersonRepository {
   }
 
   async getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]> {
-    return this.assetFaceRepository.find({ where: ids, relations: { asset: true } });
+    return this.assetFaceRepository.find({ where: ids, relations: { asset: true }, withDeleted: true });
   }
 
   async getRandomFace(personId: string): Promise<AssetFaceEntity | null> {

+ 1 - 1
server/src/main.ts

@@ -24,4 +24,4 @@ function bootstrap() {
       process.exit(1);
   }
 }
-bootstrap();
+void bootstrap();

+ 2 - 2
server/src/microservices/app.service.ts

@@ -90,14 +90,14 @@ export class AppService {
       [JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(),
     });
 
-    process.on('uncaughtException', (error: Error | any) => {
+    process.on('uncaughtException', async (error: Error | any) => {
       const isCsvError = error.code === 'CSV_RECORD_INCONSISTENT_FIELDS_LENGTH';
       if (!isCsvError) {
         throw error;
       }
 
       this.logger.warn('Geocoding csv parse error, trying again without cache...');
-      this.metadataService.init(true);
+      await this.metadataService.init(true);
     });
 
     await this.metadataService.init();

+ 2 - 1
web/src/lib/components/faces-page/people-card.svelte

@@ -8,6 +8,7 @@
   import MenuOption from '../shared-components/context-menu/menu-option.svelte';
   import Portal from '../shared-components/portal/portal.svelte';
   import { createEventDispatcher } from 'svelte';
+  import { AppRoute } from '$lib/constants';
 
   export let person: PersonResponseDto;
 
@@ -42,7 +43,7 @@
   on:mouseleave={() => (showVerticalDots = false)}
   role="group"
 >
-  <a href="/people/{person.id}" draggable="false">
+  <a href="/people/{person.id}?previousRoute={AppRoute.PEOPLE}" draggable="false">
     <div class="h-48 w-48 rounded-xl brightness-95 filter">
       <ImageThumbnail
         shadow

+ 1 - 1
web/src/routes/(user)/people/+page.svelte

@@ -245,7 +245,7 @@
   };
 
   const handleMergeFaces = (detail: PersonResponseDto) => {
-    goto(`${AppRoute.PEOPLE}/${detail.id}?action=merge`);
+    goto(`${AppRoute.PEOPLE}/${detail.id}?action=merge&previousRoute=${AppRoute.PEOPLE}`);
   };
 
   const submitNameChange = async () => {

+ 5 - 1
web/src/routes/(user)/people/[personId]/+page.svelte

@@ -132,6 +132,10 @@
 
   onMount(() => {
     const action = $page.url.searchParams.get('action');
+    const getPreviousRoute = $page.url.searchParams.get('previousRoute');
+    if (getPreviousRoute) {
+      previousRoute = getPreviousRoute;
+    }
     if (action == 'merge') {
       viewMode = ViewMode.MERGE_FACES;
     }
@@ -176,7 +180,7 @@
         type: NotificationType.Info,
       });
 
-      goto(AppRoute.EXPLORE, { replaceState: true });
+      goto(previousRoute, { replaceState: true });
     } catch (error) {
       handleError(error, 'Unable to hide person');
     }