diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index 65788f07e2..7c72c1e7f2 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -244,17 +244,21 @@ class DriftTimelineRepository extends DriftDatabaseRepository { final isAscending = albumData.order == AlbumAssetOrder.asc; - final query = _db.remoteAssetEntity.select().addColumns([_db.localAssetEntity.id]).join([ + // Correlated subquery picks the first matching local asset by checksum, + // avoiding fan-out when the same photo exists in multiple device albums (#23273). + final localId = subqueryExpression( + _db.localAssetEntity.selectOnly() + ..addColumns([_db.localAssetEntity.id]) + ..where(_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum)) + ..limit(1), + ); + + final query = _db.remoteAssetEntity.select().addColumns([localId]).join([ innerJoin( _db.remoteAlbumAssetEntity, _db.remoteAlbumAssetEntity.assetId.equalsExp(_db.remoteAssetEntity.id), useColumns: false, ), - leftOuterJoin( - _db.localAssetEntity, - _db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum), - useColumns: false, - ), ])..where(_db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAlbumAssetEntity.albumId.equals(albumId)); if (isAscending) { @@ -265,9 +269,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository { query.limit(count, offset: offset); - return query - .map((row) => row.readTable(_db.remoteAssetEntity).toDto(localId: row.read(_db.localAssetEntity.id))) - .get(); + return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto(localId: row.read(localId))).get(); } TimelineQuery fromAssets(List assets, TimelineOrigin origin) => ( diff --git a/mobile/test/medium/repositories/timeline_repository_test.dart b/mobile/test/medium/repositories/timeline_repository_test.dart new file mode 100644 index 0000000000..70e9a3080a --- /dev/null +++ b/mobile/test/medium/repositories/timeline_repository_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; + +import '../repository_context.dart'; + +void main() { + late MediumRepositoryContext ctx; + late DriftTimelineRepository sut; + + setUp(() { + ctx = MediumRepositoryContext(); + sut = DriftTimelineRepository(ctx.db); + }); + + tearDown(() async { + await ctx.dispose(); + }); + + group('remoteAlbum assets', () { + test('no duplicate assets when identical checksum appears in multiple local asset rows', () async { + // Regression check for #23273: a LEFT OUTER JOIN on checksum would fan out and create duplicates + // happens when same photo exists in multiple albums on device + final user = await ctx.newUser(); + final checksum = 'yolo'; + final album = await ctx.newRemoteAlbum(ownerId: user.id); + final remoteAsset = await ctx.newRemoteAsset(ownerId: user.id, checksum: checksum); + await ctx.insertRemoteAlbumAsset(albumId: album.id, assetId: remoteAsset.id); + + final localAsset1 = await ctx.newLocalAsset(checksum: checksum); + final localAsset2 = await ctx.newLocalAsset(checksum: checksum); + + final assets = await sut.remoteAlbum(album.id, GroupAssetsBy.day).assetSource(0, 10); + + expect(assets, hasLength(1)); + expect((assets.first as RemoteAsset).id, remoteAsset.id); + expect([localAsset1.id, localAsset2.id], contains((assets.first as RemoteAsset).localId)); + }); + }); +}