Compare commits

..

12 Commits

Author SHA1 Message Date
Alex 3ddea30a9c merge main 2026-07-07 11:51:41 -05:00
Daniel Dietzler 704b287bba fix: integrity checks checksum pagination (#29516) 2026-07-07 18:42:14 +02:00
Ben Beckford c7b4a798cb fix: incorrect grouping of recently added assets (#29666)
* fix: incorrect grouping of recently added assets

* chore(web): lint

* chore(web): refactor timeline utc to local dates

* chore(mobile): index remote asset upload dates

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-07-07 11:21:58 -05:00
Daniel Dietzler f8e78792b8 fix: oauth account linking (#29624) 2026-07-07 11:21:27 -05:00
shenlong 0ebb723b39 fix: wrap migrations in transaction (#29664)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-07 10:54:24 -05:00
Adam Gastineau 99a8354145 fix(mobile): match iOS scroll to top and tabbar scroll (#28469) (#29705)
Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-07-07 10:53:35 -05:00
shenlong 7ee2f3644f fix(mobile): mark Live Photo motion part hidden on upload (#29699)
* fix(mobile): mark Live Photo motion part hidden on upload

* review suggestion

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-07 09:27:39 -05:00
bo0tzz d2a46ade78 chore: webhook headers ordering (#29695) 2026-07-07 12:09:57 +00:00
Mert 68b04e8e8f chore: simplify hls description (#29680)
simplify hls description
2026-07-07 07:45:40 +00:00
Santo Shakil a1039261be chore(mobile): error logs when the timeline goes blank 2026-07-07 03:07:05 +06:00
Santo Shakil fbc3a8c7ec chore(mobile): temporary blank timeline diagnostics 2026-07-07 00:08:58 +06:00
Santo Shakil 2f87ce154b fix(mobile): update timeline args on layout constraint changes 2026-07-07 00:00:37 +06:00
29 changed files with 14946 additions and 289 deletions
+1 -1
View File
@@ -432,7 +432,7 @@
"transcoding_realtime_enabled": "Enable real-time transcoding",
"transcoding_realtime_enabled_description": "If disabled, the server will refuse to start new real-time transcoding sessions.",
"transcoding_realtime_resolutions": "Resolutions",
"transcoding_realtime_resolutions_description": "The resolutions offered for real-time transcoding. A variant is only offered when its resolution is no larger than the source. Higher resolutions may cause playback issues if the server cannot transcode them quickly enough.",
"transcoding_realtime_resolutions_description": "The resolutions offered for real-time transcoding. Higher resolutions may cause playback issues if the server cannot transcode them quickly enough.",
"transcoding_realtime_video_codecs": "Video codecs",
"transcoding_realtime_video_codecs_description": "The video codecs offered for real-time transcoding. Clients will choose the best option they support during playback. AV1 is more efficient than HEVC, which is more efficient than H.264. When using hardware acceleration, only select the codecs the accelerator can encode. When using software transcoding, note that H.264 is faster than AV1, which is faster than HEVC.",
"transcoding_reference_frames": "Reference frames",
+27
View File
@@ -184,6 +184,33 @@ checksum = "sha256:b5e1d2a1ad3c03229ddc89823848f4a1c11f9c6402a51fa26f0aaa5f1d7a2
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-windows.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288925833"
[[tools.java]]
version = "21.0.2"
backend = "core:java"
[tools.java.options]
shorthand_vendor = "openjdk"
[tools.java."platforms.linux-arm64"]
checksum = "sha256:08db1392a48d4eb5ea5315cf8f18b89dbaf36cda663ba882cf03c704c9257ec2"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-aarch64_bin.tar.gz"
[tools.java."platforms.linux-x64"]
checksum = "sha256:a2def047a73941e01a73739f92755f86b895811afb1f91243db214cff5bdac3f"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz"
[tools.java."platforms.macos-arm64"]
checksum = "sha256:b3d588e16ec1e0ef9805d8a696591bd518a5cea62567da8f53b5ce32d11d22e4"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-aarch64_bin.tar.gz"
[tools.java."platforms.macos-x64"]
checksum = "sha256:8fd09e15dc406387a0aba70bf5d99692874e999bf9cd9208b452b5d76ac922d3"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-x64_bin.tar.gz"
[tools.java."platforms.windows-x64"]
checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
[[tools.node]]
version = "24.15.0"
backend = "core:node"
+1 -1
View File
@@ -23,7 +23,7 @@ opentofu = "1.11.6"
"github:extism/cli" = "1.6.3"
"github:webassembly/binaryen" = "version_124"
"github:extism/js-pdk" = "1.6.0"
java = "21.0.11+10.0.LTS"
java = "21.0.2"
[tools."github:jellyfin/jellyfin-ffmpeg"]
version = "7.1.3-6"
File diff suppressed because it is too large Load Diff
@@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:logging/logging.dart';
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count);
@@ -89,6 +90,8 @@ class TimelineFactory {
TimelineService(_timelineRepository.map(userIds, options, groupBy));
}
const _kFirstEmissionAlarmDelay = Duration(seconds: 10);
class TimelineService {
final TimelineAssetSource _assetSource;
final TimelineBucketSource _bucketSource;
@@ -97,6 +100,8 @@ class TimelineService {
int _bufferOffset = 0;
List<BaseAsset> _buffer = [];
StreamSubscription? _bucketSubscription;
Timer? _firstEmissionAlarm;
final DateTime _builtAt = DateTime.now();
int _totalAssets = 0;
int get totalAssets => _totalAssets;
@@ -105,9 +110,29 @@ class TimelineService {
: this._(assetSource: query.assetSource, bucketSource: query.bucketSource, origin: query.origin);
TimelineService._({required this._assetSource, required this._bucketSource, required this.origin}) {
_firstEmissionAlarm = Timer(_kFirstEmissionAlarmDelay, () {
Logger('TimelineProbe').severe(
'[${origin.name}] no bucket emission ${_kFirstEmissionAlarmDelay.inSeconds}s after service build, '
'first fetch looks stuck',
);
});
_bucketSubscription = _bucketSource().listen((buckets) {
final alarm = _firstEmissionAlarm;
if (alarm != null) {
_firstEmissionAlarm = null;
if (alarm.isActive) {
alarm.cancel();
} else {
Logger(
'TimelineProbe',
).severe('[${origin.name}] first bucket emission after ${DateTime.now().difference(_builtAt).inSeconds}s');
}
}
_mutex.run(() async {
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
Logger(
'TimelineProbe',
).fine('[${origin.name}] bucket emission: ${buckets.length} buckets / $totalAssets assets');
if (totalAssets == 0) {
_bufferOffset = 0;
@@ -234,6 +259,8 @@ class TimelineService {
}
Future<void> dispose() async {
_firstEmissionAlarm?.cancel();
_firstEmissionAlarm = null;
await _bucketSubscription?.cancel();
_bucketSubscription = null;
_buffer = [];
@@ -21,6 +21,7 @@ WHERE (library_id IS NOT NULL);
CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created
ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)
''')
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_remote_asset_uploaded ON remote_asset_entity (uploaded_at)')
class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
const RemoteAssetEntity();
@@ -1779,3 +1779,7 @@ i0.Index get idxRemoteAssetOwnerVisibilityDeletedCreated => i0.Index(
'idx_remote_asset_owner_visibility_deleted_created',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
);
i0.Index get idxRemoteAssetUploaded => i0.Index(
'idx_remote_asset_uploaded',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_uploaded ON remote_asset_entity (uploaded_at)',
);
@@ -120,7 +120,7 @@ class Drift extends $Drift {
}
@override
int get schemaVersion => 30;
int get schemaVersion => 31;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -128,199 +128,209 @@ class Drift extends $Drift {
// Run migration steps without foreign keys and re-enable them later
await customStatement('PRAGMA foreign_keys = OFF');
await m.runMigrationSteps(
from: from,
to: to,
steps: migrationSteps(
from1To2: (m, v2) async {
for (final entity in v2.entities) {
await m.drop(entity);
await m.create(entity);
}
},
from2To3: (m, v3) async {
// Removed foreign key constraint on stack.primaryAssetId
await m.alterTable(TableMigration(v3.stackEntity));
},
from3To4: (m, v4) async {
// Thumbnail path column got removed from person_entity
await m.alterTable(TableMigration(v4.personEntity));
// asset_face_entity is added
await m.create(v4.assetFaceEntity);
},
from4To5: (m, v5) async {
await m.alterTable(
TableMigration(
v5.userEntity,
newColumns: [v5.userEntity.hasProfileImage, v5.userEntity.profileChangedAt],
columnTransformer: {v5.userEntity.profileChangedAt: currentDateAndTime},
),
);
},
from5To6: (m, v6) async {
// Drops the (checksum, ownerId) and adds it back as (ownerId, checksum)
await customStatement('DROP INDEX IF EXISTS UQ_remote_asset_owner_checksum');
await m.drop(v6.idxRemoteAssetOwnerChecksum);
await m.create(v6.idxRemoteAssetOwnerChecksum);
// Adds libraryId to remote_asset_entity
await m.addColumn(v6.remoteAssetEntity, v6.remoteAssetEntity.libraryId);
await m.drop(v6.uQRemoteAssetsOwnerChecksum);
await m.create(v6.uQRemoteAssetsOwnerChecksum);
await m.drop(v6.uQRemoteAssetsOwnerLibraryChecksum);
await m.create(v6.uQRemoteAssetsOwnerLibraryChecksum);
},
from6To7: (m, v7) async {
await m.createIndex(v7.idxLatLng);
},
from7To8: (m, v8) async {
await m.create(v8.storeEntity);
},
from8To9: (m, v9) async {
await m.addColumn(v9.localAlbumEntity, v9.localAlbumEntity.linkedRemoteAlbumId);
},
from9To10: (m, v10) async {
await m.createTable(v10.authUserEntity);
await m.addColumn(v10.userEntity, v10.userEntity.avatarColor);
await m.alterTable(TableMigration(v10.userEntity));
},
from10To11: (m, v11) async {
await m.addColumn(v11.localAlbumAssetEntity, v11.localAlbumAssetEntity.marker_);
},
from11To12: (m, v12) async {
final localToUTCMapping = {
v12.localAssetEntity: [v12.localAssetEntity.createdAt, v12.localAssetEntity.updatedAt],
v12.localAlbumEntity: [v12.localAlbumEntity.updatedAt],
};
try {
await transaction(
() => m.runMigrationSteps(
from: from,
to: to,
steps: migrationSteps(
from1To2: (m, v2) async {
for (final entity in v2.entities) {
await m.drop(entity);
await m.create(entity);
}
},
from2To3: (m, v3) async {
// Removed foreign key constraint on stack.primaryAssetId
await m.alterTable(TableMigration(v3.stackEntity));
},
from3To4: (m, v4) async {
// Thumbnail path column got removed from person_entity
await m.alterTable(TableMigration(v4.personEntity));
// asset_face_entity is added
await m.create(v4.assetFaceEntity);
},
from4To5: (m, v5) async {
await m.alterTable(
TableMigration(
v5.userEntity,
newColumns: [v5.userEntity.hasProfileImage, v5.userEntity.profileChangedAt],
columnTransformer: {v5.userEntity.profileChangedAt: currentDateAndTime},
),
);
},
from5To6: (m, v6) async {
// Drops the (checksum, ownerId) and adds it back as (ownerId, checksum)
await customStatement('DROP INDEX IF EXISTS UQ_remote_asset_owner_checksum');
await m.drop(v6.idxRemoteAssetOwnerChecksum);
await m.create(v6.idxRemoteAssetOwnerChecksum);
// Adds libraryId to remote_asset_entity
await m.addColumn(v6.remoteAssetEntity, v6.remoteAssetEntity.libraryId);
await m.drop(v6.uQRemoteAssetsOwnerChecksum);
await m.create(v6.uQRemoteAssetsOwnerChecksum);
await m.drop(v6.uQRemoteAssetsOwnerLibraryChecksum);
await m.create(v6.uQRemoteAssetsOwnerLibraryChecksum);
},
from6To7: (m, v7) async {
await m.createIndex(v7.idxLatLng);
},
from7To8: (m, v8) async {
await m.create(v8.storeEntity);
},
from8To9: (m, v9) async {
await m.addColumn(v9.localAlbumEntity, v9.localAlbumEntity.linkedRemoteAlbumId);
},
from9To10: (m, v10) async {
await m.createTable(v10.authUserEntity);
await m.addColumn(v10.userEntity, v10.userEntity.avatarColor);
await m.alterTable(TableMigration(v10.userEntity));
},
from10To11: (m, v11) async {
await m.addColumn(v11.localAlbumAssetEntity, v11.localAlbumAssetEntity.marker_);
},
from11To12: (m, v12) async {
final localToUTCMapping = {
v12.localAssetEntity: [v12.localAssetEntity.createdAt, v12.localAssetEntity.updatedAt],
v12.localAlbumEntity: [v12.localAlbumEntity.updatedAt],
};
for (final entry in localToUTCMapping.entries) {
final table = entry.key;
await m.alterTable(
TableMigration(
table,
columnTransformer: {
for (final column in entry.value)
column: column.modify(const DateTimeModifier.utc()).strftime('%Y-%m-%dT%H:%M:%fZ'),
},
),
);
}
},
from12To13: (m, v13) async {
await m.create(v13.trashedLocalAssetEntity);
await m.createIndex(v13.idxTrashedLocalAssetChecksum);
await m.createIndex(v13.idxTrashedLocalAssetAlbum);
},
from13To14: (m, v14) async {
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.adjustmentTime);
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.latitude);
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.longitude);
},
from14To15: (m, v15) async {
await m.alterTable(
TableMigration(
v15.trashedLocalAssetEntity,
columnTransformer: {v15.trashedLocalAssetEntity.source: Constant(TrashOrigin.localSync.index)},
newColumns: [v15.trashedLocalAssetEntity.source],
),
);
},
from15To16: (m, v16) async {
// Add i_cloud_id to local and remote asset tables
await m.addColumn(v16.localAssetEntity, v16.localAssetEntity.iCloudId);
await m.createIndex(v16.idxLocalAssetCloudId);
await m.createTable(v16.remoteAssetCloudIdEntity);
},
from16To17: (m, v17) async {
await m.addColumn(v17.remoteAssetEntity, v17.remoteAssetEntity.isEdited);
},
from17To18: (m, v18) async {
await m.createIndex(v18.idxRemoteAssetCloudId);
},
from18To19: (m, v19) async {
await m.createIndex(v19.idxAssetFacePersonId);
await m.createIndex(v19.idxAssetFaceAssetId);
await m.createIndex(v19.idxLocalAlbumAssetAlbumAsset);
await m.createIndex(v19.idxPartnerSharedWithId);
await m.createIndex(v19.idxPersonOwnerId);
await m.createIndex(v19.idxRemoteAlbumOwnerId);
await m.createIndex(v19.idxRemoteAlbumAssetAlbumAsset);
await m.createIndex(v19.idxRemoteAssetStackId);
await m.createIndex(v19.idxRemoteAssetLocalDateTimeDay);
await m.createIndex(v19.idxRemoteAssetLocalDateTimeMonth);
await m.createIndex(v19.idxStackPrimaryAssetId);
},
from19To20: (m, v20) async {
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.isVisible);
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.deletedAt);
},
from20To21: (m, v21) async {
await m.addColumn(v21.localAssetEntity, v21.localAssetEntity.playbackStyle);
await m.addColumn(v21.trashedLocalAssetEntity, v21.trashedLocalAssetEntity.playbackStyle);
},
from21To22: (m, v22) async {
await m.createTable(v22.assetEditEntity);
await m.createIndex(v22.idxAssetEditAssetId);
},
from22To23: (m, v23) async {
await m.renameColumn(v23.localAssetEntity, 'duration_in_seconds', v23.localAssetEntity.durationMs);
await m.renameColumn(v23.remoteAssetEntity, 'duration_in_seconds', v23.remoteAssetEntity.durationMs);
await m.renameColumn(
v23.trashedLocalAssetEntity,
'duration_in_seconds',
v23.trashedLocalAssetEntity.durationMs,
);
for (final entry in localToUTCMapping.entries) {
final table = entry.key;
await m.alterTable(
TableMigration(
table,
columnTransformer: {
for (final column in entry.value)
column: column.modify(const DateTimeModifier.utc()).strftime('%Y-%m-%dT%H:%M:%fZ'),
},
),
);
}
},
from12To13: (m, v13) async {
await m.create(v13.trashedLocalAssetEntity);
await m.createIndex(v13.idxTrashedLocalAssetChecksum);
await m.createIndex(v13.idxTrashedLocalAssetAlbum);
},
from13To14: (m, v14) async {
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.adjustmentTime);
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.latitude);
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.longitude);
},
from14To15: (m, v15) async {
await m.alterTable(
TableMigration(
v15.trashedLocalAssetEntity,
columnTransformer: {v15.trashedLocalAssetEntity.source: Constant(TrashOrigin.localSync.index)},
newColumns: [v15.trashedLocalAssetEntity.source],
),
);
},
from15To16: (m, v16) async {
// Add i_cloud_id to local and remote asset tables
await m.addColumn(v16.localAssetEntity, v16.localAssetEntity.iCloudId);
await m.createIndex(v16.idxLocalAssetCloudId);
await m.createTable(v16.remoteAssetCloudIdEntity);
},
from16To17: (m, v17) async {
await m.addColumn(v17.remoteAssetEntity, v17.remoteAssetEntity.isEdited);
},
from17To18: (m, v18) async {
await m.createIndex(v18.idxRemoteAssetCloudId);
},
from18To19: (m, v19) async {
await m.createIndex(v19.idxAssetFacePersonId);
await m.createIndex(v19.idxAssetFaceAssetId);
await m.createIndex(v19.idxLocalAlbumAssetAlbumAsset);
await m.createIndex(v19.idxPartnerSharedWithId);
await m.createIndex(v19.idxPersonOwnerId);
await m.createIndex(v19.idxRemoteAlbumOwnerId);
await m.createIndex(v19.idxRemoteAlbumAssetAlbumAsset);
await m.createIndex(v19.idxRemoteAssetStackId);
await m.createIndex(v19.idxRemoteAssetLocalDateTimeDay);
await m.createIndex(v19.idxRemoteAssetLocalDateTimeMonth);
await m.createIndex(v19.idxStackPrimaryAssetId);
},
from19To20: (m, v20) async {
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.isVisible);
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.deletedAt);
},
from20To21: (m, v21) async {
await m.addColumn(v21.localAssetEntity, v21.localAssetEntity.playbackStyle);
await m.addColumn(v21.trashedLocalAssetEntity, v21.trashedLocalAssetEntity.playbackStyle);
},
from21To22: (m, v22) async {
await m.createTable(v22.assetEditEntity);
await m.createIndex(v22.idxAssetEditAssetId);
},
from22To23: (m, v23) async {
await m.renameColumn(v23.localAssetEntity, 'duration_in_seconds', v23.localAssetEntity.durationMs);
await m.renameColumn(v23.remoteAssetEntity, 'duration_in_seconds', v23.remoteAssetEntity.durationMs);
await m.renameColumn(
v23.trashedLocalAssetEntity,
'duration_in_seconds',
v23.trashedLocalAssetEntity.durationMs,
);
await localAssetEntity.update().write(
LocalAssetEntityCompanion.custom(durationMs: v23.localAssetEntity.durationMs * const Constant(1000)),
);
await remoteAssetEntity.update().write(
RemoteAssetEntityCompanion.custom(durationMs: v23.remoteAssetEntity.durationMs * const Constant(1000)),
);
await trashedLocalAssetEntity.update().write(
TrashedLocalAssetEntityCompanion.custom(
durationMs: v23.trashedLocalAssetEntity.durationMs * const Constant(1000),
),
);
},
from23To24: (m, v24) async {
await customStatement('DROP INDEX IF EXISTS idx_remote_album_owner_id');
await m.alterTable(TableMigration(v24.remoteAlbumEntity));
},
from24To25: (m, v25) async {
await m.createTable(v25.metadata);
await customStatement('DROP INDEX IF EXISTS idx_remote_asset_owner_checksum');
await customStatement('DROP INDEX IF EXISTS idx_remote_asset_local_date_time_day');
await customStatement('DROP INDEX IF EXISTS idx_remote_asset_local_date_time_month');
await m.createIndex(v25.idxRemoteAssetOwnerVisibilityDeletedCreated);
await m.createIndex(v25.idxRemoteExifCity);
await m.createIndex(v25.idxAssetFaceVisiblePerson);
},
from25To26: (m, v26) async {
await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt);
},
from26To27: (m, v27) async {
await customStatement('ALTER TABLE metadata RENAME TO settings');
},
from27To28: (m, v28) async {
await m.createIndex(v28.idxLocalAssetCreatedAt);
},
from28To29: (m, v29) async {
await m.createTable(v29.assetOcrEntity);
await m.createIndex(v29.idxAssetOcrAssetId);
},
from29To30: (m, v30) async {
await m.alterTable(TableMigration(v30.settings));
},
),
);
await localAssetEntity.update().write(
LocalAssetEntityCompanion.custom(durationMs: v23.localAssetEntity.durationMs * const Constant(1000)),
);
await remoteAssetEntity.update().write(
RemoteAssetEntityCompanion.custom(
durationMs: v23.remoteAssetEntity.durationMs * const Constant(1000),
),
);
await trashedLocalAssetEntity.update().write(
TrashedLocalAssetEntityCompanion.custom(
durationMs: v23.trashedLocalAssetEntity.durationMs * const Constant(1000),
),
);
},
from23To24: (m, v24) async {
await customStatement('DROP INDEX IF EXISTS idx_remote_album_owner_id');
await m.alterTable(TableMigration(v24.remoteAlbumEntity));
},
from24To25: (m, v25) async {
await m.createTable(v25.metadata);
await customStatement('DROP INDEX IF EXISTS idx_remote_asset_owner_checksum');
await customStatement('DROP INDEX IF EXISTS idx_remote_asset_local_date_time_day');
await customStatement('DROP INDEX IF EXISTS idx_remote_asset_local_date_time_month');
await m.createIndex(v25.idxRemoteAssetOwnerVisibilityDeletedCreated);
await m.createIndex(v25.idxRemoteExifCity);
await m.createIndex(v25.idxAssetFaceVisiblePerson);
},
from25To26: (m, v26) async {
await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt);
},
from26To27: (m, v27) async {
await customStatement('ALTER TABLE metadata RENAME TO settings');
},
from27To28: (m, v28) async {
await m.createIndex(v28.idxLocalAssetCreatedAt);
},
from28To29: (m, v29) async {
await m.createTable(v29.assetOcrEntity);
await m.createIndex(v29.idxAssetOcrAssetId);
},
from29To30: (m, v30) async {
await m.alterTable(TableMigration(v30.settings));
},
from30To31: (m, v31) async {
await m.createIndex(v31.idxRemoteAssetUploaded);
},
),
),
);
if (kDebugMode) {
// Fail if the migration broke foreign keys
final wrongFKs = await customSelect('PRAGMA foreign_key_check').get();
assert(wrongFKs.isEmpty, '${wrongFKs.map((e) => e.data)}');
if (kDebugMode) {
// Fail if the migration broke foreign keys
final wrongFKs = await customSelect('PRAGMA foreign_key_check').get();
assert(wrongFKs.isEmpty, '${wrongFKs.map((e) => e.data)}');
}
} finally {
await customStatement('PRAGMA foreign_keys = ON;');
}
await customStatement('PRAGMA foreign_keys = ON;');
await optimize();
},
beforeOpen: (details) async {
@@ -124,6 +124,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
i2.idxRemoteAssetChecksum,
i2.idxRemoteAssetStackId,
i2.idxRemoteAssetOwnerVisibilityDeletedCreated,
i2.idxRemoteAssetUploaded,
authUserEntity,
userMetadataEntity,
partnerEntity,
@@ -15920,6 +15920,592 @@ i1.GeneratedColumn<String> _column_224(String aliasedName) =>
type: i1.DriftSqlType.string,
$customConstraints: 'NULL',
);
final class Schema31 extends i0.VersionedSchema {
Schema31({required super.database}) : super(version: 31);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAlbumAssetAlbumAsset,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxLocalAssetCreatedAt,
idxStackPrimaryAssetId,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetStackId,
idxRemoteAssetOwnerVisibilityDeletedCreated,
idxRemoteAssetUploaded,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
settings,
assetOcrEntity,
idxPartnerSharedWithId,
idxLatLng,
idxRemoteExifCity,
idxRemoteAlbumAssetAlbumAsset,
idxRemoteAssetCloudId,
idxPersonOwnerId,
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxAssetFaceVisiblePerson,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
idxAssetEditAssetId,
idxAssetOcrAssetId,
];
late final Shape33 userEntity = Shape33(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_110,
_column_111,
_column_112,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape50 remoteAssetEntity = Shape50(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_119,
_column_120,
_column_121,
_column_122,
_column_123,
_column_124,
_column_212,
_column_125,
_column_126,
_column_127,
_column_128,
_column_129,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape35 stackEntity = Shape35(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_130,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape36 localAssetEntity = Shape36(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_131,
_column_120,
_column_132,
_column_133,
_column_134,
_column_135,
_column_136,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape48 remoteAlbumEntity = Shape48(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_138,
_column_114,
_column_115,
_column_139,
_column_140,
_column_141,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape38 localAlbumEntity = Shape38(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_115,
_column_142,
_column_143,
_column_144,
_column_145,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape39 localAlbumAssetEntity = Shape39(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_146, _column_147, _column_145],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
'idx_local_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxLocalAssetCreatedAt = i1.Index(
'idx_local_asset_created_at',
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
);
final i1.Index idxStackPrimaryAssetId = i1.Index(
'idx_stack_primary_asset_id',
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetStackId = i1.Index(
'idx_remote_asset_stack_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
);
final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
'idx_remote_asset_owner_visibility_deleted_created',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
);
final i1.Index idxRemoteAssetUploaded = i1.Index(
'idx_remote_asset_uploaded',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_uploaded ON remote_asset_entity (uploaded_at)',
);
late final Shape40 authUserEntity = Shape40(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_148,
_column_110,
_column_111,
_column_149,
_column_150,
_column_151,
_column_152,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_153, _column_154, _column_155],
attachedDatabase: database,
),
alias: null,
);
late final Shape41 partnerEntity = Shape41(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_156, _column_157, _column_158],
attachedDatabase: database,
),
alias: null,
);
late final Shape42 remoteExifEntity = Shape42(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_160,
_column_161,
_column_162,
_column_163,
_column_164,
_column_117,
_column_116,
_column_165,
_column_166,
_column_167,
_column_168,
_column_135,
_column_136,
_column_169,
_column_170,
_column_171,
_column_172,
_column_173,
_column_174,
_column_175,
_column_176,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_159, _column_177],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_177, _column_153, _column_178],
attachedDatabase: database,
),
alias: null,
);
late final Shape43 remoteAssetCloudIdEntity = Shape43(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_179,
_column_180,
_column_134,
_column_135,
_column_136,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape44 memoryEntity = Shape44(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_124,
_column_121,
_column_113,
_column_181,
_column_182,
_column_183,
_column_184,
_column_185,
_column_186,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_159, _column_187],
attachedDatabase: database,
),
alias: null,
);
late final Shape45 personEntity = Shape45(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_108,
_column_188,
_column_189,
_column_190,
_column_191,
_column_192,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape46 assetFaceEntity = Shape46(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_193,
_column_194,
_column_195,
_column_196,
_column_197,
_column_198,
_column_199,
_column_200,
_column_201,
_column_124,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_202, _column_203, _column_204],
attachedDatabase: database,
),
alias: null,
);
late final Shape47 trashedLocalAssetEntity = Shape47(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_205,
_column_131,
_column_120,
_column_132,
_column_206,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 assetEditEntity = Shape32(
source: i0.VersionedTable(
entityName: 'asset_edit_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_207,
_column_208,
_column_209,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape49 settings = Shape49(
source: i0.VersionedTable(
entityName: 'settings',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY("key")'],
columns: [_column_210, _column_224, _column_115],
attachedDatabase: database,
),
alias: null,
);
late final Shape51 assetOcrEntity = Shape51(
source: i0.VersionedTable(
entityName: 'asset_ocr_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_213,
_column_214,
_column_215,
_column_216,
_column_217,
_column_218,
_column_219,
_column_220,
_column_221,
_column_222,
_column_223,
_column_201,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxPartnerSharedWithId = i1.Index(
'idx_partner_shared_with_id',
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxRemoteExifCity = i1.Index(
'idx_remote_exif_city',
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
);
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
'idx_remote_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAssetCloudId = i1.Index(
'idx_remote_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
);
final i1.Index idxPersonOwnerId = i1.Index(
'idx_person_owner_id',
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
);
final i1.Index idxAssetFacePersonId = i1.Index(
'idx_asset_face_person_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
);
final i1.Index idxAssetFaceAssetId = i1.Index(
'idx_asset_face_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
);
final i1.Index idxAssetFaceVisiblePerson = i1.Index(
'idx_asset_face_visible_person',
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
final i1.Index idxAssetEditAssetId = i1.Index(
'idx_asset_edit_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
);
final i1.Index idxAssetOcrAssetId = i1.Index(
'idx_asset_ocr_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_ocr_asset_id ON asset_ocr_entity (asset_id)',
);
}
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -15950,6 +16536,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -16098,6 +16685,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from29To30(migrator, schema);
return 30;
case 30:
final schema = Schema31(database: database);
final migrator = i1.Migrator(database, schema);
await from30To31(migrator, schema);
return 31;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -16134,6 +16726,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -16165,5 +16758,6 @@ i1.OnUpgrade stepByStep({
from27To28: from27To28,
from28To29: from28To29,
from29To30: from29To30,
from30To31: from30To31,
),
);
@@ -614,7 +614,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return (
bucketSource: () => _watchRemoteBucket(filter: filter, groupBy: groupBy, sortBy: sortBy),
assetSource: (offset, count) =>
_getRemoteAssets(filter: filter, offset: offset, count: count, joinLocal: joinLocal),
_getRemoteAssets(filter: filter, offset: offset, count: count, joinLocal: joinLocal, sortBy: sortBy),
origin: origin,
);
}
@@ -651,6 +651,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
required int offset,
required int count,
bool joinLocal = false,
SortAssetsBy sortBy = SortAssetsBy.taken,
}) {
if (joinLocal) {
final query =
@@ -663,7 +664,11 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
])
..addColumns([_db.localAssetEntity.id])
..where(filter(_db.remoteAssetEntity))
..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
..orderBy([
OrderingTerm.desc(
sortBy == SortAssetsBy.uploaded ? _db.remoteAssetEntity.uploadedAt : _db.remoteAssetEntity.createdAt,
),
])
..limit(count, offset: offset);
return query
@@ -672,7 +677,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
} else {
final query = _db.remoteAssetEntity.select()
..where(filter)
..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
..orderBy([(row) => OrderingTerm.desc(sortBy == SortAssetsBy.uploaded ? row.uploadedAt : row.createdAt)])
..limit(count, offset: offset);
return query.map((row) => row.toDto()).get();
+14 -5
View File
@@ -58,6 +58,17 @@ void main() async {
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5));
await migrateDatabaseIfNeeded(drift);
final viewSize = WidgetsBinding.instance.platformDispatcher.implicitView?.physicalSize;
final launchContext =
'prewarm=${Platform.environment['ActivePrewarm'] ?? '0'}, '
'view=${viewSize == null ? 'none' : '${viewSize.width}x${viewSize.height}'}, '
'lifecycle=${WidgetsBinding.instance.lifecycleState}';
if (viewSize == null || viewSize.isEmpty) {
Logger('LaunchProbe').warning('launch with zero view bounds: $launchContext');
} else {
Logger('LaunchProbe').info('launch: $launchContext');
}
runApp(ProviderScope(overrides: [driftProvider.overrideWith(driftOverride(drift))], child: const MainWidget()));
} catch (error, stack) {
runApp(BootstrapErrorWidget(error: error.toString(), stack: stack.toString()));
@@ -123,28 +134,26 @@ class ImmichApp extends ConsumerStatefulWidget {
}
class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserver {
final _lifecycleLog = Logger('AppLifeCycle');
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_lifecycleLog.info(state.name);
switch (state) {
case AppLifecycleState.resumed:
dPrint(() => "[APP STATE] resumed");
ref.read(appStateProvider.notifier).handleAppResume();
unawaited(ref.read(viewIntentHandlerProvider).onAppResumed());
break;
case AppLifecycleState.inactive:
dPrint(() => "[APP STATE] inactive");
ref.read(appStateProvider.notifier).handleAppInactivity();
break;
case AppLifecycleState.paused:
dPrint(() => "[APP STATE] paused");
ref.read(appStateProvider.notifier).handleAppPause();
break;
case AppLifecycleState.detached:
dPrint(() => "[APP STATE] detached");
ref.read(appStateProvider.notifier).handleAppDetached();
break;
case AppLifecycleState.hidden:
dPrint(() => "[APP STATE] hidden");
ref.read(appStateProvider.notifier).handleAppHidden();
break;
}
@@ -7,6 +7,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment_builde
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:logging/logging.dart';
class TimelineArgs {
final double maxWidth;
@@ -86,13 +87,15 @@ class TimelineStateNotifier extends Notifier<TimelineState> {
// This provider watches the buckets from the timeline service & args and serves the segments.
// It should be used only after the timeline service and timeline args provider is overridden
final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>((ref) async* {
final args = ref.watch(timelineArgsProvider);
final columnCount = args.columnCount;
final spacing = args.spacing;
final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1));
// maxHeight is left out on purpose, a height-only change must not restart the bucket stream
final (maxWidth, columnCount, spacing, groupByArg) = ref.watch(
timelineArgsProvider.select((args) => (args.maxWidth, args.columnCount, args.spacing, args.groupBy)),
);
final availableTileWidth = maxWidth - (spacing * (columnCount - 1));
final tileExtent = math.max(0, availableTileWidth) / columnCount;
Logger('TimelineProbe').info('segments: maxWidth=$maxWidth tileExtent=$tileExtent columns=$columnCount');
final groupBy = args.groupBy ?? ref.watch(appConfigProvider.select((config) => config.timeline.groupAssetsBy));
final groupBy = groupByArg ?? ref.watch(appConfigProvider.select((config) => config.timeline.groupAssetsBy));
final timelineService = ref.watch(timelineServiceProvider);
yield* timelineService.watchBuckets().map((buckets) {
@@ -28,8 +28,9 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
import 'package:logging/logging.dart';
class Timeline extends StatelessWidget {
class Timeline extends ConsumerWidget {
const Timeline({
super.key,
this.topSliverWidget,
@@ -62,35 +63,41 @@ class Timeline extends StatelessWidget {
final Widget? loadingWidget;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final columnCount = ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow));
return LayoutBuilder(
builder: (_, constraints) => ProviderScope(
overrides: [
timelineArgsProvider.overrideWith(
(ref) => TimelineArgs(
maxWidth: constraints.maxWidth,
maxHeight: constraints.maxHeight,
columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)),
showStorageIndicator: showStorageIndicator,
withStack: withStack,
groupBy: groupBy,
builder: (_, constraints) {
Logger('TimelineProbe').fine('layout pass ${constraints.maxWidth}x${constraints.maxHeight}');
return ProviderScope(
overrides: [
// overrideWithValue keeps the scoped args in sync with the latest constraints on rebuilds,
// a function override would stay locked to the first frame's constraints for the whole session
timelineArgsProvider.overrideWithValue(
TimelineArgs(
maxWidth: constraints.maxWidth,
maxHeight: constraints.maxHeight,
columnCount: columnCount,
showStorageIndicator: showStorageIndicator,
withStack: withStack,
groupBy: groupBy,
),
),
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
],
child: _SliverTimeline(
topSliverWidget: topSliverWidget,
topSliverWidgetHeight: topSliverWidgetHeight,
bottomSliverWidget: bottomSliverWidget,
appBar: appBar,
bottomSheet: bottomSheet,
withScrubber: withScrubber,
persistentBottomBar: persistentBottomBar,
snapToMonth: snapToMonth,
maxWidth: constraints.maxWidth,
loadingWidget: loadingWidget,
),
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
],
child: _SliverTimeline(
topSliverWidget: topSliverWidget,
topSliverWidgetHeight: topSliverWidgetHeight,
bottomSliverWidget: bottomSliverWidget,
appBar: appBar,
bottomSheet: bottomSheet,
withScrubber: withScrubber,
persistentBottomBar: persistentBottomBar,
snapToMonth: snapToMonth,
maxWidth: constraints.maxWidth,
loadingWidget: loadingWidget,
),
),
);
},
);
}
}
@@ -106,6 +113,8 @@ class _AlwaysReadOnlyNotifier extends ReadOnlyModeNotifier {
void toggleReadonlyMode() {}
}
const _kZeroWidthAlarmDelay = Duration(seconds: 5);
class _SliverTimeline extends ConsumerStatefulWidget {
const _SliverTimeline({
this.topSliverWidget,
@@ -135,7 +144,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
ConsumerState createState() => _SliverTimelineState();
}
class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBindingObserver {
late final ScrollController _scrollController;
StreamSubscription? _eventSubscription;
@@ -149,10 +158,24 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
double _scaleFactor = 3.0;
double _baseScaleFactor = 3.0;
int? _restoreAssetIndex;
Timer? _zeroWidthAlarm;
DateTime? _zeroWidthSince;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
Logger('TimelineProbe').info('timeline mounted maxWidth=${widget.maxWidth}');
if ((widget.maxWidth ?? 0) <= 0) {
_zeroWidthSince = DateTime.now();
_zeroWidthAlarm = Timer(_kZeroWidthAlarmDelay, () {
if (mounted && (widget.maxWidth ?? 0) <= 0) {
Logger(
'TimelineProbe',
).severe('timeline width still 0 ${_kZeroWidthAlarmDelay.inSeconds}s after mount, tiles have no extent');
}
});
}
_scrollController = ScrollController(onAttach: _restoreAssetPosition);
_eventSubscription = EventStream.shared.listen(_onEvent);
@@ -168,27 +191,33 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
void didUpdateWidget(covariant _SliverTimeline oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.maxWidth != oldWidget.maxWidth) {
final asyncSegments = ref.read(timelineSegmentProvider);
asyncSegments.whenData((segments) {
final index = _getCurrentAssetIndex(segments);
// Refresh to wait for new segments to be generated with the updated width before restoring the scroll position
final _ = ref.refresh(timelineArgsProvider);
_restoreAssetIndex = index;
});
Logger('TimelineProbe').info('timeline maxWidth ${oldWidget.maxWidth} -> ${widget.maxWidth}');
if ((widget.maxWidth ?? 0) > 0 && _zeroWidthSince != null) {
final stuckFor = DateTime.now().difference(_zeroWidthSince!);
_zeroWidthAlarm?.cancel();
_zeroWidthSince = null;
if (stuckFor >= _kZeroWidthAlarmDelay) {
Logger(
'TimelineProbe',
).severe('timeline width recovered to ${widget.maxWidth} after ${stuckFor.inSeconds}s at 0');
}
}
// The updated args already regenerate the segments, only remember the scroll position to restore it afterwards
final segments = ref.read(timelineSegmentProvider).valueOrNull;
if (segments != null && _scrollController.hasClients) {
_restoreAssetIndex = _getCurrentAssetIndex(segments);
}
}
}
// Capture iOS status bar tap
@override
void handleStatusBarTap() => _scrollToTop();
void _onEvent(Event event) {
switch (event) {
case ScrollToTopEvent():
{
final timelineState = ref.read(timelineStateProvider.notifier);
timelineState.setScrubbing(true);
_scrollController
.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut)
.whenComplete(() => timelineState.setScrubbing(false));
}
_scrollToTop();
case ScrollToDateEvent scrollToDateEvent:
_scrollToDate(scrollToDateEvent.date);
case TimelineReloadEvent():
@@ -246,11 +275,25 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_zeroWidthAlarm?.cancel();
_scrollController.dispose();
_eventSubscription?.cancel();
super.dispose();
}
void _scrollToTop() {
if (!_scrollController.hasClients) {
return;
}
final timelineState = ref.read(timelineStateProvider.notifier);
timelineState.setScrubbing(true);
_scrollController
.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut)
.whenComplete(() => timelineState.setScrubbing(false));
}
void _scrollToDate(DateTime date) {
final timelineState = ref.read(timelineStateProvider.notifier);
final asyncSegments = ref.read(timelineSegmentProvider);
@@ -381,6 +424,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
child: PrimaryScrollController(
controller: _scrollController,
child: Scaffold(
// This removes the built in Scaffold `handleStatusBarTap` implementation, preventing duplicate
// events when we provide our own
primary: false,
resizeToAvoidBottomInset: false,
floatingActionButton: const DownloadStatusFloatingButton(),
body: asyncSegments.widgetWhen(
@@ -1,4 +1,6 @@
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
@@ -17,8 +19,12 @@ final timelineArgsProvider = Provider.autoDispose<TimelineArgs>(
final timelineServiceProvider = Provider<TimelineService>(
(ref) {
final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? [];
Logger('TimelineProbe').info('main timeline service built with ${timelineUsers.length} users');
final timelineService = ref.watch(timelineFactoryProvider).main(timelineUsers);
ref.onDispose(timelineService.dispose);
ref.onDispose(() {
Logger('TimelineProbe').info('main timeline service disposed');
timelineService.dispose();
});
return timelineService;
},
// Empty dependencies to inform the framework that this provider
@@ -39,5 +45,14 @@ final timelineUsersProvider = StreamProvider<List<String>>((ref) {
return Stream.value([]);
}
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId);
// Drift re-emits a fresh but content-identical list on unrelated table updates,
// which would dispose and rebuild the timeline service mid-load
return ref
.watch(timelineRepositoryProvider)
.watchTimelineUserIds(currentUserId)
.distinct(const ListEquality<String>().equals)
.map((users) {
Logger('TimelineProbe').info('timeline users emission: ${users.length}');
return users;
});
});
@@ -22,6 +22,7 @@ import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart' as api;
import 'package:path/path.dart' as p;
final backgroundUploadServiceProvider = Provider((ref) {
@@ -335,7 +336,8 @@ class BackgroundUploadService {
return null;
}
final fields = {'livePhotoVideoId': livePhotoVideoId};
// Visibility hidden on upload to prevent the server from running regular jobs on the live photo asset
final fields = {'livePhotoVideoId': livePhotoVideoId, 'visibility': api.AssetVisibility.hidden.value};
final requiresWiFi = _shouldRequireWiFi(asset);
final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
@@ -4,7 +4,7 @@ import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' hide AssetVisibility;
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
@@ -19,6 +19,7 @@ import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:path/path.dart' as p;
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
@@ -338,7 +339,8 @@ class ForegroundUploadService {
final livePhotoResult = await _uploadRepository.uploadFile(
file: livePhotoFile,
originalFileName: livePhotoTitle,
fields: fields,
// Visibility hidden on upload to prevent the server from running regular jobs on the live photo asset
fields: {...fields, 'visibility': AssetVisibility.hidden.value},
cancelToken: cancelToken,
onProgress: onProgress != null
? (bytes, totalBytes) => onProgress(asset.localId!, livePhotoTitle, bytes, totalBytes)
@@ -0,0 +1,81 @@
import 'dart:async';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:logging/logging.dart';
void main() {
late List<LogRecord> records;
late StreamSubscription<LogRecord> logSubscription;
bool isNoEmissionAlarm(LogRecord r) => r.level == Level.SEVERE && r.message.contains('no bucket emission');
setUp(() {
records = [];
Logger.root.level = Level.INFO;
logSubscription = Logger.root.onRecord.listen(records.add);
});
tearDown(() {
Logger.root.level = Level.OFF;
return logSubscription.cancel();
});
test('logs a severe signature when the first bucket emission never arrives', () {
fakeAsync((async) {
final ctrl = StreamController<List<Bucket>>();
final sut = TimelineService((
assetSource: (index, count) async => const [],
bucketSource: () => ctrl.stream,
origin: TimelineOrigin.main,
));
async.elapse(const Duration(seconds: 9));
expect(records.where(isNoEmissionAlarm), isEmpty);
async.elapse(const Duration(seconds: 2));
expect(records.where(isNoEmissionAlarm), isNotEmpty);
sut.dispose();
ctrl.close();
});
});
test('stays quiet when the first bucket emission arrives in time', () {
fakeAsync((async) {
final ctrl = StreamController<List<Bucket>>();
final sut = TimelineService((
assetSource: (index, count) async => const [],
bucketSource: () => ctrl.stream,
origin: TimelineOrigin.main,
));
ctrl.add(const []);
async.elapse(const Duration(seconds: 15));
expect(records.where(isNoEmissionAlarm), isEmpty);
sut.dispose();
ctrl.close();
});
});
test('stays quiet when the service is disposed before the alarm', () {
fakeAsync((async) {
final ctrl = StreamController<List<Bucket>>();
final sut = TimelineService((
assetSource: (index, count) async => const [],
bucketSource: () => ctrl.stream,
origin: TimelineOrigin.main,
));
async.elapse(const Duration(seconds: 2));
sut.dispose();
async.elapse(const Duration(seconds: 15));
expect(records.where(isNoEmissionAlarm), isEmpty);
ctrl.close();
});
});
}
+4
View File
@@ -34,6 +34,7 @@ import 'schema_v27.dart' as v27;
import 'schema_v28.dart' as v28;
import 'schema_v29.dart' as v29;
import 'schema_v30.dart' as v30;
import 'schema_v31.dart' as v31;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -99,6 +100,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v29.DatabaseAtV29(db);
case 30:
return v30.DatabaseAtV30(db);
case 31:
return v31.DatabaseAtV31(db);
default:
throw MissingSchemaException(version, versions);
}
@@ -135,5 +138,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
28,
29,
30,
31,
];
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,150 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:logging/logging.dart';
// A first fetch that never delivers - the state a suspended or storm-starved
// bucket watch is stuck in when the timeline mounts on a zero-sized first frame
class _FrozenBucketService implements TimelineService {
final _ctrl = StreamController<List<Bucket>>.broadcast();
@override
Stream<List<Bucket>> Function() get watchBuckets =>
() => _ctrl.stream;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _EmptyBucketService implements TimelineService {
@override
Stream<List<Bucket>> Function() get watchBuckets =>
() => Stream.value(const []);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
testWidgets('timeline args follow constraints after a zero-sized first frame while buckets are still loading', (
tester,
) async {
tester.view.physicalSize = Size.zero;
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
TimelineArgs? probed;
final probe = Consumer(
builder: (_, ref, __) {
probed = ref.watch(timelineArgsProvider);
return const SizedBox.shrink();
},
);
await tester.pumpWidget(
ProviderScope(
overrides: [
timelineServiceProvider.overrideWithValue(_FrozenBucketService()),
appConfigProvider.overrideWithValue(const AppConfig()),
],
child: MaterialApp(home: Timeline(withScrubber: false, readOnly: true, loadingWidget: probe)),
),
);
await tester.pump();
expect(probed, isNotNull);
expect(probed!.maxWidth, 0.0);
tester.view.physicalSize = const Size(1206, 2622);
await tester.pump();
await tester.pump();
expect(
probed!.maxWidth,
402.0,
reason: 'args locked to the zero-sized first frame leave the timeline blank for the whole session',
);
});
testWidgets('timeline args follow constraints after a zero-sized first frame once buckets resolve', (tester) async {
tester.view.physicalSize = Size.zero;
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
TimelineArgs? probed;
final probe = SliverToBoxAdapter(
child: Consumer(
builder: (_, ref, __) {
probed = ref.watch(timelineArgsProvider);
return const SizedBox.shrink();
},
),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
timelineServiceProvider.overrideWithValue(_EmptyBucketService()),
appConfigProvider.overrideWithValue(const AppConfig()),
],
child: MaterialApp(
home: Timeline(
withScrubber: false,
readOnly: true,
appBar: const SliverToBoxAdapter(child: SizedBox.shrink()),
topSliverWidget: probe,
),
),
),
);
await tester.pump();
await tester.pump();
tester.view.physicalSize = const Size(1206, 2622);
await tester.pump();
await tester.pump();
await tester.pump();
expect(probed, isNotNull);
expect(probed!.maxWidth, 402.0);
});
testWidgets('a timeline stuck at zero width logs a severe blank timeline signature after 5s', (tester) async {
final records = <LogRecord>[];
Logger.root.level = Level.INFO;
addTearDown(() => Logger.root.level = Level.OFF);
final logSubscription = Logger.root.onRecord.listen(records.add);
addTearDown(logSubscription.cancel);
bool isZeroWidthAlarm(LogRecord r) => r.level == Level.SEVERE && r.message.contains('width still 0');
tester.view.physicalSize = Size.zero;
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
ProviderScope(
overrides: [
timelineServiceProvider.overrideWithValue(_FrozenBucketService()),
appConfigProvider.overrideWithValue(const AppConfig()),
],
child: const MaterialApp(home: Timeline(withScrubber: false, readOnly: true)),
),
);
await tester.pump(const Duration(seconds: 2));
expect(records.where(isZeroWidthAlarm), isEmpty);
await tester.pump(const Duration(seconds: 4));
expect(records.where(isZeroWidthAlarm), isNotEmpty);
});
}
@@ -140,6 +140,7 @@ void main() {
expect(task, isNotNull);
expect(task!.fields['filename'], equals('OriginalLivePhoto.HEIC'));
expect(task.fields['livePhotoVideoId'], equals('video-id-123'));
expect(task.fields['visibility'], equals('hidden'));
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
});
@@ -333,6 +334,7 @@ void main() {
expect(task, isNotNull);
expect(task!.fields.containsKey('metadata'), isTrue);
expect(task.fields['livePhotoVideoId'], equals('video-123'));
expect(task.fields['visibility'], equals('hidden'));
final metadata = jsonDecode(task.fields['metadata']!) as List;
expect(metadata, hasLength(1));
+3 -3
View File
@@ -151,14 +151,14 @@ const methods = wrapper<Manifest>({
}),
webhook: ({ config, data, functions, type, trigger }) => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const headers: Record<string, string> = {};
if (config.headerName && config.headerValue) {
headers[config.headerName] = config.headerValue;
}
headers['Content-Type'] = 'application/json';
functions.httpRequest(config.url, {
method: config.method ?? 'POST',
body: JSON.stringify({
+2 -3
View File
@@ -130,10 +130,9 @@ from
where
"asset"."deletedAt" is null
and "asset"."isExternal" = false
and "integrity_report"."createdAt" >= $2
and "integrity_report"."createdAt" <= $3
and "asset"."createdAt" >= $2
order by
"integrity_report"."createdAt" asc
"asset"."createdAt" asc
-- IntegrityRepository.streamIntegrityReports
select
@@ -160,8 +160,8 @@ export class IntegrityRepository {
>;
}
@GenerateSql({ params: [DummyValue.DATE, DummyValue.DATE], stream: true })
streamAssetChecksums(startMarker?: Date, endMarker?: Date) {
@GenerateSql({ params: [DummyValue.DATE], stream: true })
streamAssetChecksums(startMarker?: Date) {
return this.db
.selectFrom('asset')
.where('asset.deletedAt', 'is', null)
@@ -178,9 +178,8 @@ export class IntegrityRepository {
'integrity_report.id as reportId',
])
.where('asset.isExternal', '=', sql.lit(false))
.$if(startMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '>=', startMarker!))
.$if(endMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '<=', endMarker!))
.orderBy('integrity_report.createdAt', 'asc')
.$if(startMarker !== undefined, (qb) => qb.where('asset.createdAt', '>=', startMarker!))
.orderBy('asset.createdAt', 'asc')
.stream();
}
+1 -1
View File
@@ -426,7 +426,7 @@ export class AuthService extends BaseService {
throw new BadRequestException('This OAuth account has already been linked to another user.');
}
if (auth.session) {
if (auth.session && sid) {
await this.sessionRepository.update(auth.session.id, { oauthSid: sid });
}
+15 -22
View File
@@ -532,8 +532,7 @@ export class IntegrityService extends BaseService {
const { count } = await this.integrityRepository.getAssetCount();
const checkpoint = await this.systemMetadataRepository.get(SystemMetadataKey.IntegrityChecksumCheckpoint);
let startMarker: Date | undefined = checkpoint?.date ? new Date(checkpoint.date) : undefined;
let endMarker: Date | undefined;
const startMarker = checkpoint?.date ? new Date(checkpoint.date) : undefined;
const printStats = () => {
const averageTime = ((Date.now() - startedAt) / processed).toFixed(2);
@@ -546,31 +545,25 @@ export class IntegrityService extends BaseService {
let lastCreatedAt: Date | undefined;
finishEarly: do {
this.logger.log(
`Processing assets in range [${startMarker?.toISOString() ?? 'beginning'}, ${endMarker?.toISOString() ?? 'end'}]`,
);
this.logger.log(`Processing assets from ${startMarker?.toISOString() ?? 'beginning'}`);
const assets = this.integrityRepository.streamAssetChecksums(startMarker, endMarker);
endMarker = startMarker;
startMarker = undefined;
const assets = this.integrityRepository.streamAssetChecksums(startMarker);
for await (const { originalPath, checksum, createdAt, assetId, reportId } of assets) {
await this.checkAssetChecksum(originalPath, checksum, assetId, reportId);
for await (const { originalPath, checksum, createdAt, assetId, reportId } of assets) {
await this.checkAssetChecksum(originalPath, checksum, assetId, reportId);
processed++;
processed++;
if (processed % 100 === 0) {
printStats();
}
if (Date.now() > startedAt + timeLimit || processed > count * percentageLimit) {
this.logger.log('Reached stop criteria.');
lastCreatedAt = createdAt;
break finishEarly;
}
if (processed % 100 === 0) {
printStats();
}
} while (endMarker);
if (Date.now() > startedAt + timeLimit || processed > count * percentageLimit) {
this.logger.log('Reached stop criteria.');
lastCreatedAt = createdAt;
break;
}
}
await this.systemMetadataRepository.set(SystemMetadataKey.IntegrityChecksumCheckpoint, {
date: lastCreatedAt?.toISOString(),
@@ -1,9 +1,10 @@
import { Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { createHash, randomUUID } from 'node:crypto';
import { Readable } from 'node:stream';
import { text } from 'node:stream/consumers';
import { StorageCore } from 'src/cores/storage.core';
import { AssetFileType, IntegrityReport, JobName, JobStatus } from 'src/enum';
import { AssetFileType, IntegrityReport, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
import { AssetRepository } from 'src/repositories/asset.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { EventRepository } from 'src/repositories/event.repository';
@@ -702,6 +703,30 @@ describe(IntegrityService.name, () => {
ctx.get(IntegrityRepository).getIntegrityReport({ limit: 100 }, IntegrityReport.ChecksumFail),
).resolves.toEqual({ items: [], nextCursor: undefined });
});
it('should continue from checkpoint', async () => {
const { sut, ctx } = setup();
const job = ctx.getMock(JobRepository);
job.queue.mockResolvedValue();
const { user } = await ctx.newUser();
await ctx.newAsset({
ownerId: user.id,
createdAt: DateTime.now().minus({ days: 1 }).toISO(),
originalPath: '/foo/bar',
});
await ctx.newAsset({ ownerId: user.id, originalPath: '/foo/baz' });
await ctx
.get(SystemMetadataRepository)
.set(SystemMetadataKey.IntegrityChecksumCheckpoint, { date: DateTime.now().minus({ minutes: 5 }).toISO() });
await sut.handleChecksumFiles({ refreshOnly: false });
expect(ctx.getMock(StorageRepository).createPlainReadStream).not.toHaveBeenCalledWith('/foo/bar');
expect(ctx.getMock(StorageRepository).createPlainReadStream).toHaveBeenCalledWith('/foo/baz');
});
});
describe('handleChecksumRefresh', () => {
@@ -15,12 +15,12 @@ import {
fromTimelinePlainDate,
fromTimelinePlainDateTime,
fromTimelinePlainYearMonth,
fromISODateTimeUTCToObject,
getTimes,
setDifference,
type TimelineDateTime,
type TimelineYearMonth,
getOrderingDate,
fromISODateTimeUTC,
} from '$lib/utils/timeline-util';
import { GroupInsertionCache } from './group-insertion-cache.svelte';
import { TimelineDay } from './timeline-day.svelte';
@@ -190,7 +190,7 @@ export class TimelineMonth {
isVideo: !bucketAssets.isImage[i],
livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
localDateTime,
createdAt: fromISODateTimeUTCToObject(bucketAssets.createdAt[i]),
createdAt: fromISODateTimeUTC(bucketAssets.createdAt[i]).toLocal().toObject(),
fileCreatedAt,
ownerId: bucketAssets.ownerId[i],
projectionType: bucketAssets.projectionType[i],