feat: full local assets / album sync
This commit is contained in:
@@ -1,11 +1,21 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/entities/asset.entity.dart';
|
||||
|
||||
class LocalAlbum extends Table {
|
||||
const LocalAlbum();
|
||||
class Album extends Table {
|
||||
const Album();
|
||||
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
TextColumn get localId => text().unique()();
|
||||
TextColumn get name => text()();
|
||||
DateTimeColumn get modifiedTime =>
|
||||
dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
IntColumn get thumbnailAssetId => integer()
|
||||
.references(Asset, #id, onDelete: KeyAction.setNull)
|
||||
.nullable()();
|
||||
|
||||
// Local only
|
||||
TextColumn get localId => text().nullable().unique()();
|
||||
|
||||
// Remote only
|
||||
TextColumn get remoteId => text().nullable().unique()();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/domain/entities/asset.entity.dart';
|
||||
|
||||
class AlbumToAsset extends Table {
|
||||
const AlbumToAsset();
|
||||
|
||||
IntColumn get assetId =>
|
||||
integer().references(Asset, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
IntColumn get albumId =>
|
||||
integer().references(Album, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {assetId, albumId};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/entities/album.entity.dart';
|
||||
|
||||
class AlbumETag extends Table {
|
||||
const AlbumETag();
|
||||
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
|
||||
IntColumn get albumId =>
|
||||
integer().references(Album, #id, onDelete: KeyAction.cascade).unique()();
|
||||
DateTimeColumn get modifiedTime =>
|
||||
dateTime().withDefault(currentDateAndTime)();
|
||||
IntColumn get assetCount => integer().withDefault(const Constant(0))();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
@TableIndex(name: 'deviceassethash_localId', columns: {#localId})
|
||||
@TableIndex(name: 'deviceassethash_hash', columns: {#hash})
|
||||
class DeviceAssetToHash extends Table {
|
||||
const DeviceAssetToHash();
|
||||
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
|
||||
TextColumn get localId => text().unique()();
|
||||
TextColumn get hash => text()();
|
||||
DateTimeColumn get modifiedTime =>
|
||||
dateTime().withDefault(currentDateAndTime)();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:immich_mobile/domain/models/album.model.dart';
|
||||
|
||||
abstract interface class IAlbumRepository {
|
||||
/// Inserts a new album into the DB or updates if existing and returns the updated data
|
||||
FutureOr<Album?> upsert(Album album);
|
||||
|
||||
/// Fetch all albums
|
||||
FutureOr<List<Album>> getAll({bool localOnly, bool remoteOnly});
|
||||
|
||||
/// Removes album with the given [id]
|
||||
FutureOr<void> deleteId(int id);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:immich_mobile/domain/models/asset.model.dart';
|
||||
|
||||
abstract interface class IAlbumToAssetRepository {
|
||||
/// Link a list of assetIds to the given albumId
|
||||
FutureOr<bool> addAssetIds(int albumId, Iterable<int> assetIds);
|
||||
|
||||
/// Returns assets that are only part of the given album and nothing else
|
||||
FutureOr<List<int>> getAssetIdsOnlyInAlbum(int albumId);
|
||||
|
||||
/// Returns the assets for the given [albumId]
|
||||
FutureOr<List<Asset>> getAssetsForAlbum(int albumId);
|
||||
|
||||
/// Removes album with the given [albumId]
|
||||
FutureOr<void> deleteAlbumId(int albumId);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:immich_mobile/domain/models/album_etag.model.dart';
|
||||
|
||||
abstract interface class IAlbumETagRepository {
|
||||
/// Inserts or updates the album etag for the given [albumId]
|
||||
FutureOr<bool> upsert(AlbumETag albumETag);
|
||||
|
||||
/// Fetches the album etag for the given [albumId]
|
||||
FutureOr<AlbumETag?> get(int albumId);
|
||||
}
|
||||
@@ -7,16 +7,19 @@ abstract interface class IAssetRepository {
|
||||
FutureOr<bool> upsertAll(Iterable<Asset> assets);
|
||||
|
||||
/// Removes assets with the [localIds]
|
||||
FutureOr<List<Asset>> getForLocalIds(List<String> localIds);
|
||||
FutureOr<List<Asset>> getForLocalIds(Iterable<String> localIds);
|
||||
|
||||
/// Removes assets with the [remoteIds]
|
||||
FutureOr<List<Asset>> getForRemoteIds(List<String> remoteIds);
|
||||
FutureOr<List<Asset>> getForRemoteIds(Iterable<String> remoteIds);
|
||||
|
||||
/// Get assets with the [hashes]
|
||||
FutureOr<List<Asset>> getForHashes(Iterable<String> hashes);
|
||||
|
||||
/// Fetch assets from the [offset] with the [limit]
|
||||
FutureOr<List<Asset>> getAll({int? offset, int? limit});
|
||||
|
||||
/// Removes assets with the given [ids]
|
||||
FutureOr<void> deleteIds(List<int> ids);
|
||||
FutureOr<void> deleteIds(Iterable<int> ids);
|
||||
|
||||
/// Removes all assets
|
||||
FutureOr<bool> deleteAll();
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
abstract interface class IDatabaseRepository {
|
||||
/// Runs the [action] in a transaction
|
||||
Future<T> txn<T>(Future<T> Function() action);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:immich_mobile/domain/models/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset.model.dart';
|
||||
|
||||
abstract interface class IDeviceAlbumRepository {
|
||||
/// Fetches all [Album] from device
|
||||
FutureOr<List<Album>> getAll();
|
||||
|
||||
/// Returns the number of asset in the album
|
||||
FutureOr<int> getAssetCount(String albumId);
|
||||
|
||||
/// Fetches assets belong to the albumId
|
||||
FutureOr<List<Asset>> getAssetsForAlbum(
|
||||
String albumId, {
|
||||
int start = 0,
|
||||
int end = 0x7fffffffffffffff,
|
||||
DateTime? modifiedFrom,
|
||||
DateTime? modifiedUntil,
|
||||
bool orderByModificationDate = false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
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';
|
||||
|
||||
abstract interface class IDeviceAssetRepository<T> {
|
||||
/// Fetches the [File] for the given [assetId]
|
||||
FutureOr<File?> getOriginalFile(String assetId);
|
||||
|
||||
/// Fetches the thumbnail for the given [assetId]
|
||||
FutureOr<Uint8List?> getThumbnail(
|
||||
String assetId, {
|
||||
int width = kGridThumbnailSize,
|
||||
int height = kGridThumbnailSize,
|
||||
int quality = kGridThumbnailQuality,
|
||||
DeviceAssetDownloadHandler? downloadHandler,
|
||||
});
|
||||
|
||||
/// Converts the given [entity] to an [Asset]
|
||||
Future<Asset> toAsset(T entity);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:immich_mobile/domain/models/device_asset_hash.model.dart';
|
||||
|
||||
abstract interface class IDeviceAssetToHashRepository {
|
||||
/// Add a new device asset to hash entry
|
||||
FutureOr<bool> upsertAll(Iterable<DeviceAssetToHash> assetHash);
|
||||
|
||||
// Gets the asset with the local ID from the device
|
||||
FutureOr<List<DeviceAssetToHash>> getForIds(Iterable<String> localIds);
|
||||
|
||||
/// Removes assets with the given [ids]
|
||||
FutureOr<void> deleteIds(Iterable<int> ids);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ abstract interface class ILogRepository {
|
||||
FutureOr<bool> create(LogMessage log);
|
||||
|
||||
/// Bulk insert logs into DB
|
||||
FutureOr<bool> createAll(List<LogMessage> log);
|
||||
FutureOr<bool> createAll(Iterable<LogMessage> log);
|
||||
|
||||
/// Fetches all logs
|
||||
FutureOr<List<LogMessage>> getAll();
|
||||
|
||||
@@ -1,31 +1,82 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/utils/collection_util.dart';
|
||||
|
||||
@immutable
|
||||
class LocalAlbum {
|
||||
final int id;
|
||||
final String localId;
|
||||
class Album {
|
||||
final int? id;
|
||||
final String? localId;
|
||||
final String? remoteId;
|
||||
final String name;
|
||||
final DateTime modifiedTime;
|
||||
final int? thumbnailAssetId;
|
||||
|
||||
const LocalAlbum({
|
||||
required this.id,
|
||||
required this.localId,
|
||||
bool get isRemote => remoteId != null;
|
||||
bool get isLocal => localId != null;
|
||||
|
||||
const Album({
|
||||
this.id,
|
||||
this.localId,
|
||||
this.remoteId,
|
||||
required this.name,
|
||||
required this.modifiedTime,
|
||||
this.thumbnailAssetId,
|
||||
});
|
||||
|
||||
@override
|
||||
bool operator ==(covariant LocalAlbum other) {
|
||||
bool operator ==(covariant Album other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.hashCode == hashCode;
|
||||
return other.id == id &&
|
||||
other.localId == localId &&
|
||||
other.remoteId == remoteId &&
|
||||
other.name == name &&
|
||||
other.modifiedTime == modifiedTime &&
|
||||
other.thumbnailAssetId == thumbnailAssetId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^
|
||||
localId.hashCode ^
|
||||
remoteId.hashCode ^
|
||||
name.hashCode ^
|
||||
modifiedTime.hashCode;
|
||||
modifiedTime.hashCode ^
|
||||
thumbnailAssetId.hashCode;
|
||||
}
|
||||
|
||||
Album copyWith({
|
||||
int? id,
|
||||
String? localId,
|
||||
String? remoteId,
|
||||
String? name,
|
||||
DateTime? modifiedTime,
|
||||
int? thumbnailAssetId,
|
||||
}) {
|
||||
return Album(
|
||||
id: id ?? this.id,
|
||||
localId: localId ?? this.localId,
|
||||
remoteId: remoteId ?? this.remoteId,
|
||||
name: name ?? this.name,
|
||||
modifiedTime: modifiedTime ?? this.modifiedTime,
|
||||
thumbnailAssetId: thumbnailAssetId ?? this.thumbnailAssetId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => """
|
||||
{
|
||||
id: ${id ?? "-"},
|
||||
localId: "${localId ?? "-"}",
|
||||
remoteId: "${remoteId ?? "-"}",
|
||||
name: $name,
|
||||
modifiedTime:
|
||||
$modifiedTime,
|
||||
thumbnailAssetId: "${thumbnailAssetId ?? "-"}",
|
||||
}""";
|
||||
|
||||
static int compareByLocalId(Album a, Album b) =>
|
||||
CollectionUtil.compareToNullable(a.localId, b.localId);
|
||||
|
||||
static int compareByRemoteId(Album a, Album b) =>
|
||||
CollectionUtil.compareToNullable(a.remoteId, b.remoteId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
class AlbumETag {
|
||||
final int? id;
|
||||
final int albumId;
|
||||
final int assetCount;
|
||||
final DateTime modifiedTime;
|
||||
|
||||
const AlbumETag({
|
||||
this.id,
|
||||
required this.albumId,
|
||||
required this.assetCount,
|
||||
required this.modifiedTime,
|
||||
});
|
||||
|
||||
factory AlbumETag.empty() {
|
||||
return AlbumETag(
|
||||
albumId: -1,
|
||||
assetCount: 0,
|
||||
modifiedTime: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant AlbumETag other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.id == id &&
|
||||
other.albumId == albumId &&
|
||||
other.assetCount == assetCount &&
|
||||
other.modifiedTime == modifiedTime;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
id.hashCode ^
|
||||
albumId.hashCode ^
|
||||
assetCount.hashCode ^
|
||||
modifiedTime.hashCode;
|
||||
}
|
||||
@@ -6,11 +6,11 @@ import 'package:immich_mobile/presentation/modules/theme/models/app_theme.model.
|
||||
// This model is the only exclusion which refers to entities from the presentation layer
|
||||
// as well as the domain layer
|
||||
enum AppSetting<T> {
|
||||
appTheme<AppTheme>(StoreKey.appTheme, AppTheme.blue),
|
||||
themeMode<ThemeMode>(StoreKey.themeMode, ThemeMode.system),
|
||||
darkMode<bool>(StoreKey.darkMode, false);
|
||||
appTheme<AppTheme>._(StoreKey.appTheme, AppTheme.blue),
|
||||
themeMode<ThemeMode>._(StoreKey.themeMode, ThemeMode.system),
|
||||
darkMode<bool>._(StoreKey.darkMode, false);
|
||||
|
||||
const AppSetting(this.storeKey, this.defaultValue);
|
||||
const AppSetting._(this.storeKey, this.defaultValue);
|
||||
|
||||
// ignore: avoid-dynamic
|
||||
final StoreKey<T, dynamic> storeKey;
|
||||
|
||||
@@ -12,7 +12,7 @@ enum AssetType {
|
||||
}
|
||||
|
||||
class Asset {
|
||||
final int id;
|
||||
final int? id;
|
||||
final String name;
|
||||
final String hash;
|
||||
final int? height;
|
||||
@@ -32,9 +32,10 @@ class Asset {
|
||||
bool get isRemote => remoteId != null;
|
||||
bool get isLocal => localId != null;
|
||||
bool get isMerged => isRemote && isLocal;
|
||||
bool get isImage => type == AssetType.image;
|
||||
|
||||
const Asset({
|
||||
required this.id,
|
||||
this.id,
|
||||
required this.name,
|
||||
required this.hash,
|
||||
this.height,
|
||||
@@ -49,7 +50,6 @@ class Asset {
|
||||
});
|
||||
|
||||
factory Asset.remote(AssetResponseDto dto) => Asset(
|
||||
id: 0, // assign a temporary auto gen ID
|
||||
remoteId: dto.id,
|
||||
createdTime: dto.fileCreatedAt,
|
||||
duration: dto.duration.tryParseInt() ?? 0,
|
||||
@@ -93,29 +93,38 @@ class Asset {
|
||||
}
|
||||
|
||||
Asset merge(Asset newAsset) {
|
||||
if (newAsset.modifiedTime.isAfter(modifiedTime)) {
|
||||
final existingAsset = this;
|
||||
assert(existingAsset.id != null, "Existing asset must be from the db");
|
||||
|
||||
final oldestCreationTime =
|
||||
existingAsset.createdTime.isBefore(newAsset.createdTime)
|
||||
? existingAsset.createdTime
|
||||
: newAsset.createdTime;
|
||||
|
||||
if (newAsset.modifiedTime.isAfter(existingAsset.modifiedTime)) {
|
||||
return newAsset.copyWith(
|
||||
height: newAsset.height ?? height,
|
||||
width: newAsset.width ?? width,
|
||||
localId: () => newAsset.localId ?? localId,
|
||||
remoteId: () => newAsset.remoteId ?? remoteId,
|
||||
livePhotoVideoId: newAsset.livePhotoVideoId ?? livePhotoVideoId,
|
||||
id: newAsset.id ?? existingAsset.id,
|
||||
localId: () => existingAsset.localId ?? newAsset.localId,
|
||||
remoteId: () => existingAsset.remoteId ?? newAsset.remoteId,
|
||||
width: newAsset.width ?? existingAsset.width,
|
||||
height: newAsset.height ?? existingAsset.height,
|
||||
createdTime: oldestCreationTime,
|
||||
);
|
||||
}
|
||||
|
||||
return copyWith(
|
||||
height: height ?? newAsset.height,
|
||||
width: width ?? newAsset.width,
|
||||
localId: () => localId ?? newAsset.localId,
|
||||
remoteId: () => remoteId ?? newAsset.remoteId,
|
||||
livePhotoVideoId: livePhotoVideoId ?? newAsset.livePhotoVideoId,
|
||||
return existingAsset.copyWith(
|
||||
localId: () => existingAsset.localId ?? newAsset.localId,
|
||||
remoteId: () => existingAsset.remoteId ?? newAsset.remoteId,
|
||||
width: existingAsset.width ?? newAsset.width,
|
||||
height: existingAsset.height ?? newAsset.height,
|
||||
createdTime: oldestCreationTime,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => """
|
||||
{
|
||||
"id": "$id",
|
||||
"id": "${id ?? "-"}",
|
||||
"remoteId": "${remoteId ?? "-"}",
|
||||
"localId": "${localId ?? "-"}",
|
||||
"name": "$name",
|
||||
@@ -163,8 +172,7 @@ class Asset {
|
||||
livePhotoVideoId.hashCode;
|
||||
}
|
||||
|
||||
static int compareByRemoteId(Asset a, Asset b) =>
|
||||
CollectionUtil.compareToNullable(a.remoteId, b.remoteId);
|
||||
static int compareByHash(Asset a, Asset b) => a.hash.compareTo(b.hash);
|
||||
|
||||
static int compareByLocalId(Asset a, Asset b) =>
|
||||
CollectionUtil.compareToNullable(a.localId, b.localId);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
enum DeviceAssetRequestStatus {
|
||||
preparing,
|
||||
downloading,
|
||||
success,
|
||||
failed,
|
||||
}
|
||||
|
||||
class DeviceAssetDownloadHandler {
|
||||
DeviceAssetDownloadHandler() : stream = const Stream.empty() {
|
||||
assert(
|
||||
Platform.isIOS || Platform.isMacOS,
|
||||
'$runtimeType should only be used on iOS or macOS.',
|
||||
);
|
||||
}
|
||||
|
||||
/// A stream that provides information about the download status and progress of the asset being downloaded.
|
||||
Stream<DeviceAssetDownloadState> stream;
|
||||
}
|
||||
|
||||
class DeviceAssetDownloadState {
|
||||
final double progress;
|
||||
final DeviceAssetRequestStatus status;
|
||||
|
||||
const DeviceAssetDownloadState({
|
||||
required this.progress,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
DeviceAssetDownloadState copyWith({
|
||||
double? progress,
|
||||
DeviceAssetRequestStatus? status,
|
||||
}) {
|
||||
return DeviceAssetDownloadState(
|
||||
progress: progress ?? this.progress,
|
||||
status: status ?? this.status,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DeviceAssetDownloadState(progress: $progress, status: $status)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant DeviceAssetDownloadState other) {
|
||||
return other.progress == progress && other.status == status;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return progress.hashCode ^ status.hashCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/utils/collection_util.dart';
|
||||
|
||||
@immutable
|
||||
class DeviceAssetToHash {
|
||||
final int? id;
|
||||
final String localId;
|
||||
final String hash;
|
||||
final DateTime modifiedTime;
|
||||
|
||||
const DeviceAssetToHash({
|
||||
this.id,
|
||||
required this.localId,
|
||||
required this.hash,
|
||||
required this.modifiedTime,
|
||||
});
|
||||
|
||||
@override
|
||||
bool operator ==(covariant DeviceAssetToHash other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.id == id &&
|
||||
other.localId == localId &&
|
||||
other.hash == hash &&
|
||||
other.modifiedTime == modifiedTime;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^
|
||||
localId.hashCode ^
|
||||
hash.hashCode ^
|
||||
modifiedTime.hashCode;
|
||||
}
|
||||
|
||||
DeviceAssetToHash copyWith({
|
||||
int? id,
|
||||
String? localId,
|
||||
String? hash,
|
||||
DateTime? modifiedTime,
|
||||
}) {
|
||||
return DeviceAssetToHash(
|
||||
id: id ?? this.id,
|
||||
localId: localId ?? this.localId,
|
||||
hash: hash ?? this.hash,
|
||||
modifiedTime: modifiedTime ?? this.modifiedTime,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DeviceAssetToHash(id: ${id ?? "-"}, localId: $localId, hash: $hash, modifiedTime: $modifiedTime)';
|
||||
}
|
||||
|
||||
static int compareByLocalId(DeviceAssetToHash a, DeviceAssetToHash b) =>
|
||||
CollectionUtil.compareToNullable(a.localId, b.localId);
|
||||
}
|
||||
@@ -16,7 +16,6 @@ extension LevelExtension on Level {
|
||||
LogLevel toLogLevel() => switch (this) {
|
||||
Level.FINEST => LogLevel.verbose,
|
||||
Level.FINE => LogLevel.debug,
|
||||
Level.INFO => LogLevel.info,
|
||||
Level.WARNING => LogLevel.warning,
|
||||
Level.SEVERE => LogLevel.error,
|
||||
Level.SHOUT => LogLevel.wtf,
|
||||
|
||||
@@ -33,35 +33,35 @@ class StoreKeyNotFoundException implements Exception {
|
||||
/// Key for each possible value in the `Store`.
|
||||
/// Also stores the converter to convert the value to and from the store and the type of value stored in the Store
|
||||
enum StoreKey<T, U> {
|
||||
serverEndpoint<String, String>(
|
||||
serverEndpoint<String, String>._(
|
||||
0,
|
||||
converter: StoreStringConverter(),
|
||||
type: String,
|
||||
),
|
||||
accessToken<String, String>(
|
||||
accessToken<String, String>._(
|
||||
1,
|
||||
converter: StoreStringConverter(),
|
||||
type: String,
|
||||
),
|
||||
currentUser<User, String>(
|
||||
currentUser<User, String>._(
|
||||
2,
|
||||
converter: StoreUserConverter(),
|
||||
type: String,
|
||||
),
|
||||
// App settings
|
||||
appTheme<AppTheme, int>(
|
||||
appTheme<AppTheme, int>._(
|
||||
1000,
|
||||
converter: StoreEnumConverter(AppTheme.values),
|
||||
type: int,
|
||||
),
|
||||
themeMode<ThemeMode, int>(
|
||||
themeMode<ThemeMode, int>._(
|
||||
1001,
|
||||
converter: StoreEnumConverter(ThemeMode.values),
|
||||
type: int,
|
||||
),
|
||||
darkMode<bool, int>(1002, converter: StoreBooleanConverter(), type: int);
|
||||
darkMode<bool, int>._(1002, converter: StoreBooleanConverter(), type: int);
|
||||
|
||||
const StoreKey(this.id, {required this.converter, required this.type});
|
||||
const StoreKey._(this.id, {required this.converter, required this.type});
|
||||
final int id;
|
||||
|
||||
/// Primitive Type is also stored here to easily fetch it during runtime
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'package:immich_mobile/domain/interfaces/album.interface.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/album_asset.interface.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/album_etag.interface.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/asset.interface.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/database.interface.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/device_album.interface.dart';
|
||||
import 'package:immich_mobile/domain/models/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/album_etag.model.dart';
|
||||
import 'package:immich_mobile/domain/services/asset_sync.service.dart';
|
||||
import 'package:immich_mobile/domain/services/hash.service.dart';
|
||||
import 'package:immich_mobile/service_locator.dart';
|
||||
import 'package:immich_mobile/utils/collection_util.dart';
|
||||
import 'package:immich_mobile/utils/isolate_helper.dart';
|
||||
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
||||
|
||||
class AlbumSyncService with LogMixin {
|
||||
const AlbumSyncService();
|
||||
|
||||
Future<bool> performFullDeviceSyncIsolate() async {
|
||||
return await IsolateHelper.run(performFullDeviceSync);
|
||||
}
|
||||
|
||||
Future<bool> performFullDeviceSync() async {
|
||||
try {
|
||||
final deviceAlbums = await di<IDeviceAlbumRepository>().getAll();
|
||||
final dbAlbums = await di<IAlbumRepository>().getAll(localOnly: true);
|
||||
final hasChange = await CollectionUtil.diffLists(
|
||||
dbAlbums,
|
||||
deviceAlbums,
|
||||
compare: Album.compareByLocalId,
|
||||
both: _syncDeviceAlbum,
|
||||
// Album is in DB but not anymore in device. Remove album and album specific assets
|
||||
onlyFirst: _removeDeviceAlbum,
|
||||
onlySecond: _addDeviceAlbum,
|
||||
);
|
||||
|
||||
return hasChange;
|
||||
} catch (e, s) {
|
||||
log.e("Error performing full device sync", e, s);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _syncDeviceAlbum(
|
||||
Album dbAlbum,
|
||||
Album deviceAlbum, {
|
||||
DateTime? modifiedUntil,
|
||||
}) async {
|
||||
assert(dbAlbum.id != null, "Album ID from DB is null");
|
||||
final albumEtag =
|
||||
await di<IAlbumETagRepository>().get(dbAlbum.id!) ?? AlbumETag.empty();
|
||||
final assetCountInDevice =
|
||||
await di<IDeviceAlbumRepository>().getAssetCount(deviceAlbum.localId!);
|
||||
|
||||
final albumNotUpdated = deviceAlbum.name == dbAlbum.name &&
|
||||
dbAlbum.modifiedTime.isAtSameMomentAs(deviceAlbum.modifiedTime) &&
|
||||
assetCountInDevice == albumEtag.assetCount;
|
||||
if (albumNotUpdated) {
|
||||
log.i("Device Album ${deviceAlbum.name} not updated. Skipping sync.");
|
||||
return false;
|
||||
}
|
||||
|
||||
await _addDeviceAlbum(dbAlbum, modifiedUntil: modifiedUntil);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _addDeviceAlbum(Album album, {DateTime? modifiedUntil}) async {
|
||||
try {
|
||||
final albumId = (await di<IAlbumRepository>().upsert(album))?.id;
|
||||
// break fast if we cannot add an album
|
||||
if (albumId == null) {
|
||||
log.d("Failed creating device album. Skipped assets from album");
|
||||
return;
|
||||
}
|
||||
|
||||
final assets = await di<HashService>().getHashedAssetsForAlbum(
|
||||
album.localId!,
|
||||
modifiedUntil: modifiedUntil,
|
||||
);
|
||||
|
||||
await di<IDatabaseRepository>().txn(() async {
|
||||
final albumAssetsInDB =
|
||||
await di<IAlbumToAssetRepository>().getAssetsForAlbum(albumId);
|
||||
|
||||
await di<AssetSyncService>().upsertAssetsToDb(
|
||||
assets,
|
||||
albumAssetsInDB,
|
||||
isRemoteSync: false,
|
||||
);
|
||||
|
||||
// This is needed to get the updated assets for device album with valid db id field
|
||||
final albumAssets = await di<IAssetRepository>()
|
||||
.getForLocalIds(assets.map((a) => a.localId!));
|
||||
|
||||
await di<IAlbumToAssetRepository>().addAssetIds(
|
||||
albumId,
|
||||
albumAssets.map((a) => a.id!),
|
||||
);
|
||||
await di<IAlbumRepository>().upsert(
|
||||
album.copyWith(thumbnailAssetId: albumAssets.firstOrNull?.id),
|
||||
);
|
||||
|
||||
// Update ETag
|
||||
final albumETag = AlbumETag(
|
||||
albumId: albumId,
|
||||
assetCount: assets.length,
|
||||
modifiedTime: album.modifiedTime,
|
||||
);
|
||||
await di<IAlbumETagRepository>().upsert(albumETag);
|
||||
});
|
||||
} catch (e, s) {
|
||||
log.w("Error while adding device album", e, s);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeDeviceAlbum(Album album) async {
|
||||
assert(album.id != null, "Album ID from DB is null");
|
||||
final albumId = album.id!;
|
||||
try {
|
||||
await di<IDatabaseRepository>().txn(() async {
|
||||
final toRemove =
|
||||
await di<IAlbumToAssetRepository>().getAssetIdsOnlyInAlbum(albumId);
|
||||
await di<IAlbumRepository>().deleteId(albumId);
|
||||
await di<IAlbumToAssetRepository>().deleteAlbumId(albumId);
|
||||
await di<IAssetRepository>().deleteIds(toRemove);
|
||||
});
|
||||
} catch (e, s) {
|
||||
log.w("Error while removing device album", e, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/asset.interface.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/database.interface.dart';
|
||||
import 'package:immich_mobile/domain/models/asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/service_locator.dart';
|
||||
@@ -9,76 +10,87 @@ import 'package:immich_mobile/utils/collection_util.dart';
|
||||
import 'package:immich_mobile/utils/constants/globals.dart';
|
||||
import 'package:immich_mobile/utils/immich_api_client.dart';
|
||||
import 'package:immich_mobile/utils/isolate_helper.dart';
|
||||
import 'package:immich_mobile/utils/log_manager.dart';
|
||||
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class AssetSyncService with LogMixin {
|
||||
const AssetSyncService();
|
||||
|
||||
Future<bool> performFullRemoteSyncForUser(
|
||||
Future<bool> performFullRemoteSyncIsolate(
|
||||
User user, {
|
||||
DateTime? updatedUtil,
|
||||
int? limit,
|
||||
}) async {
|
||||
return await IsolateHelper.run(() async {
|
||||
try {
|
||||
final logger = LogManager.I.get("SyncService <Isolate>");
|
||||
final syncClient = di<ImmichApiClient>().getSyncApi();
|
||||
|
||||
final chunkSize = limit ?? kFullSyncChunkSize;
|
||||
final updatedTill = updatedUtil ?? DateTime.now().toUtc();
|
||||
updatedUtil ??= DateTime.now().toUtc();
|
||||
String? lastAssetId;
|
||||
|
||||
while (true) {
|
||||
logger.d(
|
||||
"Requesting more chunks from lastId - ${lastAssetId ?? "<initial_fetch>"}",
|
||||
);
|
||||
|
||||
final assets = await syncClient.getFullSyncForUser(AssetFullSyncDto(
|
||||
limit: chunkSize,
|
||||
updatedUntil: updatedTill,
|
||||
lastId: lastAssetId,
|
||||
userId: user.id,
|
||||
));
|
||||
if (assets == null) {
|
||||
break;
|
||||
}
|
||||
|
||||
final assetsFromServer =
|
||||
assets.map(Asset.remote).sorted(Asset.compareByRemoteId);
|
||||
|
||||
final assetsInDb = await di<IAssetRepository>().getForRemoteIds(
|
||||
assetsFromServer.map((a) => a.remoteId!).toList(),
|
||||
);
|
||||
|
||||
await _syncAssetsToDb(
|
||||
assetsFromServer,
|
||||
assetsInDb,
|
||||
Asset.compareByRemoteId,
|
||||
isRemoteSync: true,
|
||||
);
|
||||
|
||||
lastAssetId = assets.lastOrNull?.id;
|
||||
if (assets.length != chunkSize) break;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e, s) {
|
||||
log.e("Error performing full sync for user - ${user.name}", e, s);
|
||||
}
|
||||
return false;
|
||||
return await performFullRemoteSync(
|
||||
user,
|
||||
updatedUtil: updatedUtil,
|
||||
limit: limit,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _syncAssetsToDb(
|
||||
List<Asset> newAssets,
|
||||
List<Asset> existingAssets,
|
||||
Comparator<Asset> compare, {
|
||||
bool? isRemoteSync,
|
||||
Future<bool> performFullRemoteSync(
|
||||
User user, {
|
||||
DateTime? updatedUtil,
|
||||
int? limit,
|
||||
}) async {
|
||||
final (toAdd, toUpdate, assetsToRemove) = _diffAssets(
|
||||
try {
|
||||
final syncClient = di<ImApiClient>().getSyncApi();
|
||||
final db = di<IDatabaseRepository>();
|
||||
final assetRepo = di<IAssetRepository>();
|
||||
|
||||
final chunkSize = limit ?? kFullSyncChunkSize;
|
||||
final updatedTill = updatedUtil ?? DateTime.now().toUtc();
|
||||
updatedUtil ??= DateTime.now().toUtc();
|
||||
String? lastAssetId;
|
||||
|
||||
while (true) {
|
||||
log.d(
|
||||
"Requesting more chunks from lastId - ${lastAssetId ?? "<initial_fetch>"}",
|
||||
);
|
||||
|
||||
final assets = await syncClient.getFullSyncForUser(AssetFullSyncDto(
|
||||
limit: chunkSize,
|
||||
updatedUntil: updatedTill,
|
||||
lastId: lastAssetId,
|
||||
userId: user.id,
|
||||
));
|
||||
if (assets == null) {
|
||||
break;
|
||||
}
|
||||
|
||||
final assetsFromServer = assets.map(Asset.remote).toList();
|
||||
|
||||
await db.txn(() async {
|
||||
final assetsInDb =
|
||||
await assetRepo.getForHashes(assetsFromServer.map((a) => a.hash));
|
||||
|
||||
await upsertAssetsToDb(
|
||||
assetsFromServer,
|
||||
assetsInDb,
|
||||
isRemoteSync: true,
|
||||
);
|
||||
});
|
||||
|
||||
lastAssetId = assets.lastOrNull?.id;
|
||||
if (assets.length != chunkSize) break;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e, s) {
|
||||
log.e("Error performing full remote sync for user - ${user.name}", e, s);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> upsertAssetsToDb(
|
||||
List<Asset> newAssets,
|
||||
List<Asset> existingAssets, {
|
||||
bool? isRemoteSync,
|
||||
Comparator<Asset> compare = Asset.compareByHash,
|
||||
}) async {
|
||||
final (toAdd, toUpdate, toRemove) = await _diffAssets(
|
||||
newAssets,
|
||||
existingAssets,
|
||||
compare: compare,
|
||||
@@ -88,37 +100,36 @@ class AssetSyncService with LogMixin {
|
||||
final assetsToAdd = toAdd.followedBy(toUpdate);
|
||||
|
||||
await di<IAssetRepository>().upsertAll(assetsToAdd);
|
||||
await di<IAssetRepository>()
|
||||
.deleteIds(assetsToRemove.map((a) => a.id).toList());
|
||||
await di<IAssetRepository>().deleteIds(toRemove.map((a) => a.id!).toList());
|
||||
}
|
||||
|
||||
/// Returns a triple (toAdd, toUpdate, toRemove)
|
||||
(List<Asset>, List<Asset>, List<Asset>) _diffAssets(
|
||||
FutureOr<(List<Asset>, List<Asset>, List<Asset>)> _diffAssets(
|
||||
List<Asset> newAssets,
|
||||
List<Asset> inDb, {
|
||||
bool? isRemoteSync,
|
||||
required Comparator<Asset> compare,
|
||||
}) {
|
||||
Comparator<Asset> compare = Asset.compareByHash,
|
||||
}) async {
|
||||
// fast paths for trivial cases: reduces memory usage during initial sync etc.
|
||||
if (newAssets.isEmpty && inDb.isEmpty) {
|
||||
return const ([], [], []);
|
||||
return const (<Asset>[], <Asset>[], <Asset>[]);
|
||||
} else if (newAssets.isEmpty && isRemoteSync == null) {
|
||||
// remove all from database
|
||||
return (const [], const [], inDb);
|
||||
return (const <Asset>[], const <Asset>[], inDb);
|
||||
} else if (inDb.isEmpty) {
|
||||
// add all assets
|
||||
return (newAssets, const [], const []);
|
||||
return (newAssets, const <Asset>[], const <Asset>[]);
|
||||
}
|
||||
|
||||
final List<Asset> toAdd = [];
|
||||
final List<Asset> toUpdate = [];
|
||||
final List<Asset> toRemove = [];
|
||||
CollectionUtil.diffSortedLists(
|
||||
await CollectionUtil.diffLists(
|
||||
inDb,
|
||||
newAssets,
|
||||
compare: compare,
|
||||
both: (Asset a, Asset b) {
|
||||
if (a == b) {
|
||||
if (a != b) {
|
||||
toUpdate.add(a.merge(b));
|
||||
return true;
|
||||
}
|
||||
@@ -140,7 +151,7 @@ class AssetSyncService with LogMixin {
|
||||
toRemove.add(a);
|
||||
}
|
||||
},
|
||||
// Only in remote (new asset)
|
||||
// Only in new assets
|
||||
onlySecond: (Asset b) => toAdd.add(b),
|
||||
);
|
||||
return (toAdd, toUpdate, toRemove);
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
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/interfaces/device_asset_hash.interface.dart';
|
||||
import 'package:immich_mobile/domain/models/asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/device_asset_hash.model.dart';
|
||||
import 'package:immich_mobile/platform/messages.g.dart';
|
||||
import 'package:immich_mobile/utils/constants/globals.dart';
|
||||
import 'package:immich_mobile/utils/extensions/file.extension.dart';
|
||||
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
||||
|
||||
class HashService with LogMixin {
|
||||
final ImHostService _hostService;
|
||||
final IDeviceAssetRepository _deviceAssetRepository;
|
||||
final IDeviceAlbumRepository _deviceAlbumRepository;
|
||||
final IDeviceAssetToHashRepository _assetHashRepository;
|
||||
|
||||
const HashService({
|
||||
required ImHostService hostService,
|
||||
required IDeviceAssetRepository deviceAssetRepo,
|
||||
required IDeviceAlbumRepository deviceAlbumRepo,
|
||||
required IDeviceAssetToHashRepository assetToHashRepo,
|
||||
}) : _hostService = hostService,
|
||||
_deviceAssetRepository = deviceAssetRepo,
|
||||
_deviceAlbumRepository = deviceAlbumRepo,
|
||||
_assetHashRepository = assetToHashRepo;
|
||||
|
||||
Future<List<Asset>> getHashedAssetsForAlbum(
|
||||
String albumId, {
|
||||
DateTime? modifiedUntil,
|
||||
}) async {
|
||||
final assets = await _deviceAlbumRepository.getAssetsForAlbum(
|
||||
albumId,
|
||||
modifiedUntil: modifiedUntil,
|
||||
);
|
||||
assets.sort(Asset.compareByLocalId);
|
||||
|
||||
final assetIds = assets.map((a) => a.localId!);
|
||||
final hashesInDB = await _assetHashRepository.getForIds(assetIds);
|
||||
hashesInDB.sort(DeviceAssetToHash.compareByLocalId);
|
||||
|
||||
final hashedAssets = <Asset>[];
|
||||
final orphanedHashes = <DeviceAssetToHash>[];
|
||||
int bytesToBeProcessed = 0;
|
||||
final filesToBeCleaned = <File>[];
|
||||
final toBeHashed = <_AssetPath>[];
|
||||
|
||||
for (final asset in assets) {
|
||||
if (hashesInDB.isNotEmpty && hashesInDB.first.localId == asset.localId) {
|
||||
final hashed = hashesInDB.removeAt(0);
|
||||
if (hashed.modifiedTime.isAtSameMomentAs(asset.modifiedTime)) {
|
||||
hashedAssets.add(asset.copyWith(hash: hashed.hash));
|
||||
continue;
|
||||
}
|
||||
// localID is matching, but the asset is modified. Discard the DeviceAssetToHash row
|
||||
orphanedHashes.add(hashed);
|
||||
}
|
||||
|
||||
final file = await _deviceAssetRepository.getOriginalFile(asset.localId!);
|
||||
if (file == null) {
|
||||
log.w("Cannot obtain file for localId ${asset.localId!}. Skipping");
|
||||
continue;
|
||||
}
|
||||
filesToBeCleaned.add(file);
|
||||
|
||||
bytesToBeProcessed += await file.length();
|
||||
toBeHashed.add(_AssetPath(asset: asset, path: file.path));
|
||||
|
||||
if (toBeHashed.length == kHashAssetsFileLimit ||
|
||||
bytesToBeProcessed >= kHashAssetsSizeLimit) {
|
||||
hashedAssets.addAll(await _processAssetBatch(toBeHashed));
|
||||
// Clear file cache
|
||||
await Future.wait(filesToBeCleaned.map((f) => f.deleteDarwinCache()));
|
||||
toBeHashed.clear();
|
||||
filesToBeCleaned.clear();
|
||||
bytesToBeProcessed = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (toBeHashed.isNotEmpty) {
|
||||
hashedAssets.addAll(await _processAssetBatch(toBeHashed));
|
||||
// Clear file cache
|
||||
await Future.wait(filesToBeCleaned.map((f) => f.deleteDarwinCache()));
|
||||
}
|
||||
|
||||
assert(hashesInDB.isEmpty, "All hashes should be processed at this point");
|
||||
_assetHashRepository.deleteIds(orphanedHashes.map((e) => e.id!).toList());
|
||||
|
||||
return hashedAssets;
|
||||
}
|
||||
|
||||
/// Processes a batch of files and returns a list of successfully hashed assets after saving
|
||||
/// them in [DeviceAssetToHash] for future retrieval
|
||||
Future<List<Asset>> _processAssetBatch(List<_AssetPath> toBeHashed) async {
|
||||
final hashes = await _hashFiles(toBeHashed.map((e) => e.path).toList());
|
||||
assert(hashes.length == toBeHashed.length,
|
||||
"Number of Hashes returned from platform should be the same as the input");
|
||||
|
||||
final hashedAssets = <Asset>[];
|
||||
|
||||
for (final (index, hash) in hashes.indexed) {
|
||||
// ignore: avoid-unsafe-collection-methods
|
||||
final asset = toBeHashed.elementAt(index).asset;
|
||||
if (hash?.length == 20) {
|
||||
hashedAssets.add(asset.copyWith(hash: base64.encode(hash!)));
|
||||
} else {
|
||||
log.w("Failed to hash file ${asset.localId ?? '<null>'}, skipping");
|
||||
}
|
||||
}
|
||||
|
||||
// Store the cache for future retrieval
|
||||
_assetHashRepository.upsertAll(hashedAssets.map((a) => DeviceAssetToHash(
|
||||
localId: a.localId!,
|
||||
hash: a.hash,
|
||||
modifiedTime: a.modifiedTime,
|
||||
)));
|
||||
|
||||
log.v("Hashed ${hashedAssets.length}/${toBeHashed.length} assets");
|
||||
return hashedAssets;
|
||||
}
|
||||
|
||||
/// Hashes the given files and returns a list of the same length.
|
||||
/// Files that could not be hashed will have a `null` value
|
||||
Future<List<Uint8List?>> _hashFiles(List<String> paths) async {
|
||||
try {
|
||||
final hashes = await _hostService.digestFiles(paths);
|
||||
return hashes;
|
||||
} catch (e, s) {
|
||||
log.e("Error occured while hashing assets", e, s);
|
||||
}
|
||||
|
||||
return paths.map((p) => null).toList();
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetPath {
|
||||
final Asset asset;
|
||||
final String path;
|
||||
|
||||
const _AssetPath({required this.asset, required this.path});
|
||||
|
||||
_AssetPath copyWith({Asset? asset, String? path}) {
|
||||
return _AssetPath(asset: asset ?? this.asset, path: path ?? this.path);
|
||||
}
|
||||
}
|
||||
@@ -15,15 +15,15 @@ import 'package:openapi/api.dart';
|
||||
class LoginService with LogMixin {
|
||||
const LoginService();
|
||||
|
||||
Future<bool> isEndpointAvailable(Uri uri, {ImmichApiClient? client}) async {
|
||||
Future<bool> isEndpointAvailable(Uri uri, {ImApiClient? client}) async {
|
||||
String baseUrl = uri.toString();
|
||||
|
||||
if (!baseUrl.endsWith('/api')) {
|
||||
baseUrl += '/api';
|
||||
}
|
||||
|
||||
final serverAPI = client?.getServerApi() ??
|
||||
ImmichApiClient(endpoint: baseUrl).getServerApi();
|
||||
final serverAPI =
|
||||
client?.getServerApi() ?? ImApiClient(endpoint: baseUrl).getServerApi();
|
||||
try {
|
||||
await serverAPI.pingServer();
|
||||
} catch (e) {
|
||||
@@ -35,7 +35,7 @@ class LoginService with LogMixin {
|
||||
|
||||
Future<String> resolveEndpoint(Uri uri, {Client? client}) async {
|
||||
String baseUrl = uri.toString();
|
||||
final d = client ?? ImmichApiClient(endpoint: baseUrl).client;
|
||||
final d = client ?? ImApiClient(endpoint: baseUrl).client;
|
||||
|
||||
try {
|
||||
// Check for well-known endpoint
|
||||
@@ -62,7 +62,7 @@ class LoginService with LogMixin {
|
||||
Future<String?> passwordLogin(String email, String password) async {
|
||||
try {
|
||||
final loginResponse =
|
||||
await di<ImmichApiClient>().getAuthenticationApi().login(
|
||||
await di<ImApiClient>().getAuthenticationApi().login(
|
||||
LoginCredentialDto(email: email, password: password),
|
||||
);
|
||||
|
||||
@@ -76,7 +76,7 @@ class LoginService with LogMixin {
|
||||
Future<String?> oAuthLogin() async {
|
||||
const String oAuthCallbackSchema = 'app.immich';
|
||||
|
||||
final oAuthApi = di<ImmichApiClient>().getOAuthApi();
|
||||
final oAuthApi = di<ImApiClient>().getOAuthApi();
|
||||
|
||||
try {
|
||||
final oAuthUrl = await oAuthApi.startOAuth(
|
||||
@@ -125,7 +125,7 @@ class LoginService with LogMixin {
|
||||
}
|
||||
|
||||
/// Set token to interceptor
|
||||
await di<ImmichApiClient>().init(accessToken: accessToken);
|
||||
await di<ImApiClient>().init(accessToken: accessToken);
|
||||
|
||||
final user = await di<UserService>().getMyUser().timeout(
|
||||
const Duration(seconds: 10),
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:immich_mobile/domain/entities/asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/domain/models/asset.model.dart';
|
||||
|
||||
class DriftModelConverters {
|
||||
static Asset toAssetModel(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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user