feat: full local assets / album sync

This commit is contained in:
shenlong-tanwen
2024-10-17 23:33:00 +05:30
parent a09710ec7b
commit c91a2878dc
87 changed files with 2417 additions and 366 deletions
@@ -0,0 +1,76 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/entities/album.entity.drift.dart';
import 'package:immich_mobile/domain/interfaces/album.interface.dart';
import 'package:immich_mobile/domain/models/album.model.dart';
import 'package:immich_mobile/domain/repositories/database.repository.dart';
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
class AlbumRepository with LogMixin implements IAlbumRepository {
final DriftDatabaseRepository _db;
const AlbumRepository(this._db);
@override
FutureOr<Album?> upsert(Album album) async {
try {
final albumData = _toEntity(album);
final data = await _db.into(_db.album).insertReturningOrNull(
albumData,
onConflict: DoUpdate((_) => albumData, target: [_db.album.localId]),
);
if (data != null) {
return _toModel(data);
}
} catch (e, s) {
log.e("Error while adding an album to the DB", e, s);
}
return null;
}
@override
FutureOr<List<Album>> getAll({
bool localOnly = false,
bool remoteOnly = false,
}) async {
final query = _db.album.select();
if (localOnly == true) {
query.where((album) => album.localId.isNotNull());
}
if (remoteOnly == true) {
query.where((album) => album.remoteId.isNotNull());
}
query.orderBy([(album) => OrderingTerm.asc(album.name)]);
return await query.map(_toModel).get();
}
@override
FutureOr<void> deleteId(int id) async {
await _db.asset.deleteWhere((row) => row.id.equals(id));
}
}
AlbumCompanion _toEntity(Album album) {
return AlbumCompanion.insert(
id: Value.absentIfNull(album.id),
localId: Value(album.localId),
remoteId: Value(album.remoteId),
name: album.name,
modifiedTime: Value(album.modifiedTime),
thumbnailAssetId: Value(album.thumbnailAssetId),
);
}
Album _toModel(AlbumData album) {
return Album(
id: album.id,
localId: album.localId,
remoteId: album.remoteId,
name: album.name,
modifiedTime: album.modifiedTime,
);
}
@@ -0,0 +1,78 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/entities/album_asset.entity.drift.dart';
import 'package:immich_mobile/domain/interfaces/album_asset.interface.dart';
import 'package:immich_mobile/domain/models/asset.model.dart';
import 'package:immich_mobile/domain/repositories/database.repository.dart';
import 'package:immich_mobile/domain/utils/drift_model_converters.dart';
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
class AlbumToAssetRepository with LogMixin implements IAlbumToAssetRepository {
final DriftDatabaseRepository _db;
const AlbumToAssetRepository(this._db);
@override
FutureOr<bool> addAssetIds(int albumId, Iterable<int> assetIds) async {
try {
await _db.albumToAsset.insertAll(
assetIds.map((a) => AlbumToAssetCompanion.insert(
assetId: a,
albumId: albumId,
)),
onConflict: DoNothing(
target: [_db.albumToAsset.assetId, _db.albumToAsset.albumId],
),
);
return true;
} catch (e, s) {
log.e("Error while adding assets to albumId - $albumId", e, s);
return false;
}
}
@override
FutureOr<List<int>> getAssetIdsOnlyInAlbum(int albumId) async {
final assetId = _db.asset.id;
final query = _db.asset.selectOnly()
..addColumns([assetId])
..join([
innerJoin(
_db.albumToAsset,
_db.albumToAsset.assetId.equalsExp(_db.asset.id) &
_db.asset.remoteId.isNull(),
useColumns: false,
),
])
..groupBy(
[assetId],
having: _db.albumToAsset.albumId.count().equals(1) &
_db.albumToAsset.albumId.max().equals(albumId),
);
return await query.map((row) => row.read(assetId)!).get();
}
@override
FutureOr<List<Asset>> getAssetsForAlbum(int albumId) async {
final query = _db.asset.select().join([
innerJoin(
_db.albumToAsset,
_db.albumToAsset.assetId.equalsExp(_db.asset.id) &
_db.albumToAsset.albumId.equals(albumId),
useColumns: false,
),
]);
return await query
.map((row) =>
DriftModelConverters.toAssetModel(row.readTable(_db.asset)))
.get();
}
@override
FutureOr<void> deleteAlbumId(int albumId) async {
await _db.albumToAsset.deleteWhere((row) => row.albumId.equals(albumId));
}
}
@@ -0,0 +1,56 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/entities/album_etag.entity.drift.dart';
import 'package:immich_mobile/domain/interfaces/album_etag.interface.dart';
import 'package:immich_mobile/domain/models/album_etag.model.dart';
import 'package:immich_mobile/domain/repositories/database.repository.dart';
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
class AlbumETagRepository with LogMixin implements IAlbumETagRepository {
final DriftDatabaseRepository _db;
const AlbumETagRepository(this._db);
@override
FutureOr<bool> upsert(AlbumETag albumETag) async {
try {
final entity = _toEntity(albumETag);
await _db.into(_db.albumETag).insert(
entity,
onConflict:
DoUpdate((_) => entity, target: [_db.albumETag.albumId]),
);
return true;
} catch (e, s) {
log.e("Error while adding an album etag to the DB", e, s);
}
return false;
}
@override
FutureOr<AlbumETag?> get(int albumId) async {
return await _db.managers.albumETag
.filter((r) => r.albumId.id.equals(albumId))
.map(_toModel)
.getSingleOrNull();
}
}
AlbumETagCompanion _toEntity(AlbumETag albumETag) {
return AlbumETagCompanion.insert(
id: Value.absentIfNull(albumETag.id),
modifiedTime: Value(albumETag.modifiedTime),
albumId: albumETag.albumId,
assetCount: Value(albumETag.assetCount),
);
}
AlbumETag _toModel(AlbumETagData albumETag) {
return AlbumETag(
albumId: albumETag.albumId,
assetCount: albumETag.assetCount,
modifiedTime: albumETag.modifiedTime,
id: albumETag.id,
);
}
@@ -5,24 +5,31 @@ import 'package:immich_mobile/domain/entities/asset.entity.drift.dart';
import 'package:immich_mobile/domain/interfaces/asset.interface.dart';
import 'package:immich_mobile/domain/models/asset.model.dart';
import 'package:immich_mobile/domain/repositories/database.repository.dart';
import 'package:immich_mobile/domain/utils/drift_model_converters.dart';
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
class AssetDriftRepository with LogMixin implements IAssetRepository {
class AssetRepository with LogMixin implements IAssetRepository {
final DriftDatabaseRepository _db;
const AssetDriftRepository(this._db);
const AssetRepository(this._db);
@override
Future<bool> upsertAll(Iterable<Asset> assets) async {
try {
await _db.batch((batch) => batch.insertAllOnConflictUpdate(
await _db.batch((batch) {
final rows = assets.map(_toEntity);
for (final row in rows) {
batch.insert(
_db.asset,
assets.map(_toEntity),
));
row,
onConflict: DoUpdate((_) => row, target: [_db.asset.hash]),
);
}
});
return true;
} catch (e, s) {
log.e("Cannot insert remote assets into table", e, s);
log.e("Cannot insert assets into table", e, s);
return false;
}
}
@@ -33,7 +40,7 @@ class AssetDriftRepository with LogMixin implements IAssetRepository {
await _db.asset.deleteAll();
return true;
} catch (e, s) {
log.e("Cannot clear remote assets", e, s);
log.e("Cannot clear assets", e, s);
return false;
}
}
@@ -47,35 +54,45 @@ class AssetDriftRepository with LogMixin implements IAssetRepository {
query.limit(limit, offset: offset);
}
return (await query.get()).map(_toModel).toList();
return (await query.map(DriftModelConverters.toAssetModel).get()).toList();
}
@override
Future<List<Asset>> getForLocalIds(List<String> localIds) async {
Future<List<Asset>> getForLocalIds(Iterable<String> localIds) async {
final query = _db.asset.select()
..where((row) => row.localId.isIn(localIds))
..orderBy([(asset) => OrderingTerm.asc(asset.localId)]);
..orderBy([(asset) => OrderingTerm.asc(asset.hash)]);
return (await query.get()).map(_toModel).toList();
return (await query.get()).map(DriftModelConverters.toAssetModel).toList();
}
@override
Future<List<Asset>> getForRemoteIds(List<String> remoteIds) async {
Future<List<Asset>> getForRemoteIds(Iterable<String> remoteIds) async {
final query = _db.asset.select()
..where((row) => row.remoteId.isIn(remoteIds))
..orderBy([(asset) => OrderingTerm.asc(asset.remoteId)]);
..orderBy([(asset) => OrderingTerm.asc(asset.hash)]);
return (await query.get()).map(_toModel).toList();
return (await query.get()).map(DriftModelConverters.toAssetModel).toList();
}
@override
FutureOr<void> deleteIds(List<int> ids) async {
Future<List<Asset>> getForHashes(Iterable<String> hashes) async {
final query = _db.asset.select()
..where((row) => row.hash.isIn(hashes))
..orderBy([(asset) => OrderingTerm.asc(asset.hash)]);
return (await query.get()).map(DriftModelConverters.toAssetModel).toList();
}
@override
FutureOr<void> deleteIds(Iterable<int> ids) async {
await _db.asset.deleteWhere((row) => row.id.isIn(ids));
}
}
AssetCompanion _toEntity(Asset asset) {
return AssetCompanion.insert(
id: Value.absentIfNull(asset.id),
localId: Value(asset.localId),
remoteId: Value(asset.remoteId),
name: asset.name,
@@ -89,20 +106,3 @@ AssetCompanion _toEntity(Asset asset) {
livePhotoVideoId: Value(asset.livePhotoVideoId),
);
}
Asset _toModel(AssetData asset) {
return Asset(
id: asset.id,
localId: asset.localId,
remoteId: asset.remoteId,
name: asset.name,
type: asset.type,
hash: asset.hash,
createdTime: asset.createdTime,
modifiedTime: asset.modifiedTime,
height: asset.height,
width: asset.width,
livePhotoVideoId: asset.livePhotoVideoId,
duration: asset.duration,
);
}
@@ -1,18 +1,36 @@
import 'dart:async';
import 'package:drift/drift.dart';
// ignore: depend_on_referenced_packages
import 'package:drift_dev/api/migrations.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/entities/album.entity.dart';
import 'package:immich_mobile/domain/entities/album_asset.entity.dart';
import 'package:immich_mobile/domain/entities/album_etag.entity.dart';
import 'package:immich_mobile/domain/entities/asset.entity.dart';
import 'package:immich_mobile/domain/entities/device_asset_hash.entity.dart';
import 'package:immich_mobile/domain/entities/log.entity.dart';
import 'package:immich_mobile/domain/entities/store.entity.dart';
import 'package:immich_mobile/domain/entities/user.entity.dart';
import 'package:immich_mobile/domain/interfaces/database.interface.dart';
import 'database.repository.drift.dart';
@DriftDatabase(tables: [Logs, Store, LocalAlbum, Asset, User])
class DriftDatabaseRepository extends $DriftDatabaseRepository {
@DriftDatabase(
tables: [
Logs,
Store,
User,
Asset,
DeviceAssetToHash,
Album,
AlbumToAsset,
AlbumETag,
],
)
class DriftDatabaseRepository extends $DriftDatabaseRepository
implements IDatabaseRepository {
DriftDatabaseRepository([QueryExecutor? executor])
: super(executor ??
driftDatabase(
@@ -37,4 +55,7 @@ class DriftDatabaseRepository extends $DriftDatabaseRepository {
// ignore: no-empty-block
onUpgrade: (m, from, to) async {},
);
@override
Future<T> txn<T>(Future<T> Function() action) => transaction(action);
}
@@ -0,0 +1,83 @@
import 'dart:io';
import 'package:immich_mobile/domain/interfaces/device_album.interface.dart';
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
import 'package:immich_mobile/domain/models/album.model.dart';
import 'package:immich_mobile/domain/models/asset.model.dart';
import 'package:immich_mobile/service_locator.dart';
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
import 'package:photo_manager/photo_manager.dart';
class DeviceAlbumRepository with LogMixin implements IDeviceAlbumRepository {
const DeviceAlbumRepository();
@override
Future<List<Album>> getAll() async {
final List<AssetPathEntity> assetPathEntities =
await PhotoManager.getAssetPathList(
hasAll: Platform.isIOS,
filterOption: FilterOptionGroup(containsPathModified: true),
);
return assetPathEntities.map(_toModel).toList();
}
@override
Future<List<Asset>> getAssetsForAlbum(
String albumId, {
int start = 0,
int end = 0x7fffffffffffffff,
DateTime? modifiedFrom,
DateTime? modifiedUntil,
bool orderByModificationDate = false,
}) async {
final album = await _getDeviceAlbum(
albumId,
modifiedFrom: modifiedFrom,
modifiedUntil: modifiedUntil,
orderByModificationDate: orderByModificationDate,
);
final List<AssetEntity> assets =
await album.getAssetListRange(start: start, end: end);
return await Future.wait(
assets.map((a) async => await di<IDeviceAssetRepository>().toAsset(a)),
);
}
Future<AssetPathEntity> _getDeviceAlbum(
String albumId, {
DateTime? modifiedFrom,
DateTime? modifiedUntil,
bool orderByModificationDate = false,
}) async {
return await AssetPathEntity.fromId(
albumId,
filterOption: FilterOptionGroup(
containsPathModified: true,
orders: orderByModificationDate
? [const OrderOption(type: OrderOptionType.updateDate)]
: [],
imageOption: const FilterOption(needTitle: true),
videoOption: const FilterOption(needTitle: true),
updateTimeCond: DateTimeCond(
min: modifiedFrom ?? DateTime.utc(-271820),
max: modifiedUntil ?? DateTime.utc(275760),
ignore: modifiedFrom != null || modifiedUntil != null,
),
),
);
}
@override
Future<int> getAssetCount(String albumId) async {
final album = await _getDeviceAlbum(albumId);
return await album.assetCountAsync;
}
}
Album _toModel(AssetPathEntity album) {
return Album(
localId: album.id,
name: album.name,
modifiedTime: album.lastModified ?? DateTime.now(),
);
}
@@ -0,0 +1,100 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
import 'package:immich_mobile/domain/models/asset.model.dart';
import 'package:immich_mobile/domain/models/device_asset_download.model.dart';
import 'package:immich_mobile/utils/constants/globals.dart';
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
import 'package:photo_manager/photo_manager.dart' as ph;
class DeviceAssetRepository
with LogMixin
implements IDeviceAssetRepository<ph.AssetEntity> {
const DeviceAssetRepository();
@override
Future<Asset> toAsset(ph.AssetEntity entity) async {
var asset = Asset(
hash: '',
name: await entity.titleAsync,
type: _toAssetType(entity.type),
createdTime: entity.createDateTime,
modifiedTime: entity.modifiedDateTime,
duration: entity.duration,
height: entity.height,
width: entity.width,
localId: entity.id,
);
if (asset.createdTime.year == 1970) {
asset = asset.copyWith(createdTime: asset.modifiedTime);
}
return asset;
}
@override
FutureOr<File?> getOriginalFile(String localId) async {
try {
final entity = await ph.AssetEntity.fromId(localId);
if (entity == null) {
return null;
}
return await entity.originFile;
} catch (e, s) {
log.e("Exception while fetching file for localId - $localId", e, s);
}
return null;
}
@override
FutureOr<Uint8List?> getThumbnail(
String assetId, {
int width = kGridThumbnailSize,
int height = kGridThumbnailSize,
int quality = kGridThumbnailQuality,
DeviceAssetDownloadHandler? downloadHandler,
}) async {
try {
final entity = await ph.AssetEntity.fromId(assetId);
if (entity == null) {
return null;
}
ph.PMProgressHandler? progressHandler;
if (downloadHandler != null) {
progressHandler = ph.PMProgressHandler();
downloadHandler.stream = progressHandler.stream.map(_toDownloadState);
}
return await entity.thumbnailDataWithSize(
ph.ThumbnailSize(width, height),
quality: quality,
progressHandler: progressHandler,
);
} catch (e, s) {
log.e("Exception while fetching thumbnail for assetId - $assetId", e, s);
}
return null;
}
}
AssetType _toAssetType(ph.AssetType type) => switch (type) {
ph.AssetType.audio => AssetType.audio,
ph.AssetType.image => AssetType.image,
ph.AssetType.video => AssetType.video,
ph.AssetType.other => AssetType.other,
};
DeviceAssetDownloadState _toDownloadState(ph.PMProgressState state) {
return DeviceAssetDownloadState(
progress: state.progress,
status: switch (state.state) {
ph.PMRequestState.prepare => DeviceAssetRequestStatus.preparing,
ph.PMRequestState.loading => DeviceAssetRequestStatus.downloading,
ph.PMRequestState.success => DeviceAssetRequestStatus.success,
ph.PMRequestState.failed => DeviceAssetRequestStatus.failed,
},
);
}
@@ -0,0 +1,62 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/entities/device_asset_hash.entity.drift.dart';
import 'package:immich_mobile/domain/interfaces/device_asset_hash.interface.dart';
import 'package:immich_mobile/domain/models/device_asset_hash.model.dart';
import 'package:immich_mobile/domain/repositories/database.repository.dart';
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
class DeviceAssetToHashRepository
with LogMixin
implements IDeviceAssetToHashRepository {
final DriftDatabaseRepository _db;
const DeviceAssetToHashRepository(this._db);
@override
FutureOr<bool> upsertAll(Iterable<DeviceAssetToHash> assetHash) async {
try {
await _db.batch((batch) => batch.insertAllOnConflictUpdate(
_db.deviceAssetToHash,
assetHash.map(_toEntity),
));
return true;
} catch (e, s) {
log.e("Cannot add device assets to hash entry", e, s);
return false;
}
}
@override
Future<List<DeviceAssetToHash>> getForIds(Iterable<String> localIds) async {
return await _db.managers.deviceAssetToHash
.filter((f) => f.localId.isIn(localIds))
.map(_toModel)
.get();
}
@override
FutureOr<void> deleteIds(Iterable<int> ids) async {
await _db.deviceAssetToHash.deleteWhere((row) => row.id.isIn(ids));
}
}
DeviceAssetToHashCompanion _toEntity(DeviceAssetToHash asset) {
return DeviceAssetToHashCompanion.insert(
id: Value.absentIfNull(asset.id),
localId: asset.localId,
hash: asset.hash,
modifiedTime: Value(asset.modifiedTime),
);
}
DeviceAssetToHash _toModel(DeviceAssetToHashData asset) {
return DeviceAssetToHash(
id: asset.id,
localId: asset.localId,
hash: asset.hash,
modifiedTime: asset.modifiedTime,
);
}
@@ -7,10 +7,10 @@ import 'package:immich_mobile/domain/interfaces/log.interface.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/repositories/database.repository.dart';
class LogDriftRepository implements ILogRepository {
class LogRepository implements ILogRepository {
final DriftDatabaseRepository _db;
const LogDriftRepository(this._db);
const LogRepository(this._db);
@override
Future<List<LogMessage>> getAll() async {
@@ -32,14 +32,7 @@ class LogDriftRepository implements ILogRepository {
@override
FutureOr<bool> create(LogMessage log) async {
try {
await _db.into(_db.logs).insert(LogsCompanion.insert(
content: log.content,
level: log.level,
createdAt: Value(log.createdAt),
error: Value(log.error),
logger: Value(log.logger),
stack: Value(log.stack),
));
await _db.into(_db.logs).insert(_toEntity(log));
return true;
} catch (e) {
debugPrint("Error while adding a log to the DB - $e");
@@ -48,20 +41,10 @@ class LogDriftRepository implements ILogRepository {
}
@override
FutureOr<bool> createAll(List<LogMessage> logs) async {
FutureOr<bool> createAll(Iterable<LogMessage> logs) async {
try {
await _db.batch((b) {
b.insertAll(
_db.logs,
logs.map((log) => LogsCompanion.insert(
content: log.content,
level: log.level,
createdAt: Value(log.createdAt),
error: Value(log.error),
logger: Value(log.logger),
stack: Value(log.stack),
)),
);
b.insertAll(_db.logs, logs.map(_toEntity));
});
return true;
} catch (e) {
@@ -82,6 +65,17 @@ class LogDriftRepository implements ILogRepository {
}
}
LogsCompanion _toEntity(LogMessage log) {
return LogsCompanion.insert(
content: log.content,
level: log.level,
createdAt: Value(log.createdAt),
error: Value(log.error),
logger: Value(log.logger),
stack: Value(log.stack),
);
}
LogMessage _toModel(Log log) {
return LogMessage(
content: log.content,
@@ -6,10 +6,10 @@ import 'package:immich_mobile/domain/repositories/database.repository.dart';
import 'package:immich_mobile/utils/extensions/drift.extension.dart';
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
class RenderListDriftRepository with LogMixin implements IRenderListRepository {
class RenderListRepository with LogMixin implements IRenderListRepository {
final DriftDatabaseRepository _db;
const RenderListDriftRepository(this._db);
const RenderListRepository(this._db);
@override
Stream<RenderList> watchAll() {
@@ -7,10 +7,10 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/repositories/database.repository.dart';
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
class StoreDriftRepository with LogMixin implements IStoreRepository {
class StoreRepository with LogMixin implements IStoreRepository {
final DriftDatabaseRepository _db;
const StoreDriftRepository(this._db);
const StoreRepository(this._db);
@override
FutureOr<T?> tryGet<T, U>(StoreKey<T, U> key) async {
@@ -7,10 +7,10 @@ import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/repositories/database.repository.dart';
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
class UserDriftRepository with LogMixin implements IUserRepository {
class UserRepository with LogMixin implements IUserRepository {
final DriftDatabaseRepository _db;
const UserDriftRepository(this._db);
const UserRepository(this._db);
@override
FutureOr<User?> getForId(String userId) async {