Merge branch 'main' into mobile/implement-locate
This commit is contained in:
@@ -75,8 +75,8 @@ describe('/timeline', () => {
|
|||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body).toEqual(
|
expect(body).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
{ count: 3, timeBucket: '1970-02-01T00:00:00.000Z' },
|
{ count: 3, timeBucket: '1970-02-01' },
|
||||||
{ count: 1, timeBucket: '1970-01-01T00:00:00.000Z' },
|
{ count: 1, timeBucket: '1970-01-01' },
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -167,7 +167,8 @@ describe('/timeline', () => {
|
|||||||
isImage: [],
|
isImage: [],
|
||||||
isTrashed: [],
|
isTrashed: [],
|
||||||
livePhotoVideoId: [],
|
livePhotoVideoId: [],
|
||||||
localDateTime: [],
|
fileCreatedAt: [],
|
||||||
|
localOffsetHours: [],
|
||||||
ownerId: [],
|
ownerId: [],
|
||||||
projectionType: [],
|
projectionType: [],
|
||||||
ratio: [],
|
ratio: [],
|
||||||
@@ -204,7 +205,8 @@ describe('/timeline', () => {
|
|||||||
isImage: [],
|
isImage: [],
|
||||||
isTrashed: [],
|
isTrashed: [],
|
||||||
livePhotoVideoId: [],
|
livePhotoVideoId: [],
|
||||||
localDateTime: [],
|
fileCreatedAt: [],
|
||||||
|
localOffsetHours: [],
|
||||||
ownerId: [],
|
ownerId: [],
|
||||||
projectionType: [],
|
projectionType: [],
|
||||||
ratio: [],
|
ratio: [],
|
||||||
|
|||||||
@@ -402,6 +402,9 @@
|
|||||||
"album_with_link_access": "Let anyone with the link see photos and people in this album.",
|
"album_with_link_access": "Let anyone with the link see photos and people in this album.",
|
||||||
"albums": "Albums",
|
"albums": "Albums",
|
||||||
"albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Albums}}",
|
"albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Albums}}",
|
||||||
|
"albums_default_sort_order": "Default album sort order",
|
||||||
|
"albums_default_sort_order_description": "Initial asset sort order when creating new albums.",
|
||||||
|
"albums_feature_description": "Collections of assets that can be shared with other users.",
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"all_albums": "All albums",
|
"all_albums": "All albums",
|
||||||
"all_people": "All people",
|
"all_people": "All people",
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ custom_lint:
|
|||||||
allowed:
|
allowed:
|
||||||
# required / wanted
|
# required / wanted
|
||||||
- 'lib/infrastructure/repositories/album_media.repository.dart'
|
- 'lib/infrastructure/repositories/album_media.repository.dart'
|
||||||
|
- 'lib/infrastructure/repositories/storage.repository.dart'
|
||||||
- 'lib/repositories/{album,asset,file}_media.repository.dart'
|
- 'lib/repositories/{album,asset,file}_media.repository.dart'
|
||||||
# acceptable exceptions for the time being
|
# acceptable exceptions for the time being
|
||||||
- lib/entities/asset.entity.dart # to provide local AssetEntity for now
|
- lib/entities/asset.entity.dart # to provide local AssetEntity for now
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ interface NativeSyncApi {
|
|||||||
fun getAlbums(): List<PlatformAlbum>
|
fun getAlbums(): List<PlatformAlbum>
|
||||||
fun getAssetsCountSince(albumId: String, timestamp: Long): Long
|
fun getAssetsCountSince(albumId: String, timestamp: Long): Long
|
||||||
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
|
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
|
||||||
|
fun hashPaths(paths: List<String>): List<ByteArray?>
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The codec used by NativeSyncApi. */
|
/** The codec used by NativeSyncApi. */
|
||||||
@@ -388,6 +389,23 @@ interface NativeSyncApi {
|
|||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths$separatedMessageChannelSuffix", codec, taskQueue)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { message, reply ->
|
||||||
|
val args = message as List<Any?>
|
||||||
|
val pathsArg = args[0] as List<String>
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
listOf(api.hashPaths(pathsArg))
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
MessagesPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import android.annotation.SuppressLint
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
|
import android.util.Log
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
sealed class AssetResult {
|
sealed class AssetResult {
|
||||||
data class ValidAsset(val asset: PlatformAsset, val albumId: String) : AssetResult()
|
data class ValidAsset(val asset: PlatformAsset, val albumId: String) : AssetResult()
|
||||||
@@ -16,6 +19,8 @@ open class NativeSyncApiImplBase(context: Context) {
|
|||||||
private val ctx: Context = context.applicationContext
|
private val ctx: Context = context.applicationContext
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val TAG = "NativeSyncApiImplBase"
|
||||||
|
|
||||||
const val MEDIA_SELECTION =
|
const val MEDIA_SELECTION =
|
||||||
"(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)"
|
"(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)"
|
||||||
val MEDIA_SELECTION_ARGS = arrayOf(
|
val MEDIA_SELECTION_ARGS = arrayOf(
|
||||||
@@ -34,6 +39,8 @@ open class NativeSyncApiImplBase(context: Context) {
|
|||||||
MediaStore.MediaColumns.BUCKET_ID,
|
MediaStore.MediaColumns.BUCKET_ID,
|
||||||
MediaStore.MediaColumns.DURATION
|
MediaStore.MediaColumns.DURATION
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun getCursor(
|
protected fun getCursor(
|
||||||
@@ -174,4 +181,24 @@ open class NativeSyncApiImplBase(context: Context) {
|
|||||||
.mapNotNull { result -> (result as? AssetResult.ValidAsset)?.asset }
|
.mapNotNull { result -> (result as? AssetResult.ValidAsset)?.asset }
|
||||||
.toList()
|
.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun hashPaths(paths: List<String>): List<ByteArray?> {
|
||||||
|
val buffer = ByteArray(HASH_BUFFER_SIZE)
|
||||||
|
val digest = MessageDigest.getInstance("SHA-1")
|
||||||
|
|
||||||
|
return paths.map { path ->
|
||||||
|
try {
|
||||||
|
FileInputStream(path).use { file ->
|
||||||
|
var bytesRead: Int
|
||||||
|
while (file.read(buffer).also { bytesRead = it } > 0) {
|
||||||
|
digest.update(buffer, 0, bytesRead)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
digest.digest()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to hash file $path: $e")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -307,6 +307,7 @@ protocol NativeSyncApi {
|
|||||||
func getAlbums() throws -> [PlatformAlbum]
|
func getAlbums() throws -> [PlatformAlbum]
|
||||||
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64
|
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64
|
||||||
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
|
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
|
||||||
|
func hashPaths(paths: [String]) throws -> [FlutterStandardTypedData?]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||||
@@ -442,5 +443,22 @@ class NativeSyncApiSetup {
|
|||||||
} else {
|
} else {
|
||||||
getAssetsForAlbumChannel.setMessageHandler(nil)
|
getAssetsForAlbumChannel.setMessageHandler(nil)
|
||||||
}
|
}
|
||||||
|
let hashPathsChannel = taskQueue == nil
|
||||||
|
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||||
|
if let api = api {
|
||||||
|
hashPathsChannel.setMessageHandler { message, reply in
|
||||||
|
let args = message as! [Any?]
|
||||||
|
let pathsArg = args[0] as! [String]
|
||||||
|
do {
|
||||||
|
let result = try api.hashPaths(paths: pathsArg)
|
||||||
|
reply(wrapResult(result))
|
||||||
|
} catch {
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hashPathsChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Photos
|
import Photos
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
struct AssetWrapper: Hashable, Equatable {
|
struct AssetWrapper: Hashable, Equatable {
|
||||||
let asset: PlatformAsset
|
let asset: PlatformAsset
|
||||||
@@ -34,6 +35,8 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
private let changeTokenKey = "immich:changeToken"
|
private let changeTokenKey = "immich:changeToken"
|
||||||
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
||||||
|
|
||||||
|
private let hashBufferSize = 2 * 1024 * 1024
|
||||||
|
|
||||||
init(with defaults: UserDefaults = .standard) {
|
init(with defaults: UserDefaults = .standard) {
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
}
|
}
|
||||||
@@ -243,4 +246,24 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
}
|
}
|
||||||
return assets
|
return assets
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hashPaths(paths: [String]) throws -> [FlutterStandardTypedData?] {
|
||||||
|
return paths.map { path in
|
||||||
|
guard let file = FileHandle(forReadingAtPath: path) else {
|
||||||
|
print("Cannot open file: \(path)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasher = Insecure.SHA1()
|
||||||
|
while autoreleasepool(invoking: {
|
||||||
|
let chunk = file.readData(ofLength: hashBufferSize)
|
||||||
|
guard !chunk.isEmpty else { return false }
|
||||||
|
hasher.update(data: chunk)
|
||||||
|
return true
|
||||||
|
}) { }
|
||||||
|
|
||||||
|
let digest = hasher.finalize()
|
||||||
|
return FlutterStandardTypedData(bytes: Data(digest))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ abstract interface class ILocalAlbumRepository implements IDatabaseRepository {
|
|||||||
String albumId,
|
String albumId,
|
||||||
Iterable<String> assetIdsToKeep,
|
Iterable<String> assetIdsToKeep,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Future<List<LocalAsset>> getAssetsToHash(String albumId);
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SortLocalAlbumsBy { id }
|
enum SortLocalAlbumsBy { id }
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
|
||||||
|
abstract interface class ILocalAssetRepository implements IDatabaseRepository {
|
||||||
|
Future<void> updateHashes(Iterable<LocalAsset> hashes);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
|
||||||
|
abstract interface class IStorageRepository {
|
||||||
|
Future<File?> getFileForAsset(LocalAsset asset);
|
||||||
|
}
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
enum BackupSelection {
|
enum BackupSelection {
|
||||||
none,
|
none._(1),
|
||||||
selected,
|
selected._(0),
|
||||||
excluded,
|
excluded._(2);
|
||||||
|
|
||||||
|
// Used to sort albums based on the backupSelection
|
||||||
|
// selected -> none -> excluded
|
||||||
|
final int sortOrder;
|
||||||
|
const BackupSelection._(this.sortOrder);
|
||||||
}
|
}
|
||||||
|
|
||||||
class LocalAlbum {
|
class LocalAlbum {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
final DateTime updatedAt;
|
final DateTime updatedAt;
|
||||||
|
final bool isIosSharedAlbum;
|
||||||
|
|
||||||
final int assetCount;
|
final int assetCount;
|
||||||
final BackupSelection backupSelection;
|
final BackupSelection backupSelection;
|
||||||
@@ -18,6 +24,7 @@ class LocalAlbum {
|
|||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
this.assetCount = 0,
|
this.assetCount = 0,
|
||||||
this.backupSelection = BackupSelection.none,
|
this.backupSelection = BackupSelection.none,
|
||||||
|
this.isIosSharedAlbum = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
LocalAlbum copyWith({
|
LocalAlbum copyWith({
|
||||||
@@ -26,6 +33,7 @@ class LocalAlbum {
|
|||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
int? assetCount,
|
int? assetCount,
|
||||||
BackupSelection? backupSelection,
|
BackupSelection? backupSelection,
|
||||||
|
bool? isIosSharedAlbum,
|
||||||
}) {
|
}) {
|
||||||
return LocalAlbum(
|
return LocalAlbum(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -33,6 +41,7 @@ class LocalAlbum {
|
|||||||
updatedAt: updatedAt ?? this.updatedAt,
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
assetCount: assetCount ?? this.assetCount,
|
assetCount: assetCount ?? this.assetCount,
|
||||||
backupSelection: backupSelection ?? this.backupSelection,
|
backupSelection: backupSelection ?? this.backupSelection,
|
||||||
|
isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +54,8 @@ class LocalAlbum {
|
|||||||
other.name == name &&
|
other.name == name &&
|
||||||
other.updatedAt == updatedAt &&
|
other.updatedAt == updatedAt &&
|
||||||
other.assetCount == assetCount &&
|
other.assetCount == assetCount &&
|
||||||
other.backupSelection == backupSelection;
|
other.backupSelection == backupSelection &&
|
||||||
|
other.isIosSharedAlbum == isIosSharedAlbum;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -54,7 +64,8 @@ class LocalAlbum {
|
|||||||
name.hashCode ^
|
name.hashCode ^
|
||||||
updatedAt.hashCode ^
|
updatedAt.hashCode ^
|
||||||
assetCount.hashCode ^
|
assetCount.hashCode ^
|
||||||
backupSelection.hashCode;
|
backupSelection.hashCode ^
|
||||||
|
isIosSharedAlbum.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -65,6 +76,7 @@ name: $name,
|
|||||||
updatedAt: $updatedAt,
|
updatedAt: $updatedAt,
|
||||||
assetCount: $assetCount,
|
assetCount: $assetCount,
|
||||||
backupSelection: $backupSelection,
|
backupSelection: $backupSelection,
|
||||||
|
isIosSharedAlbum: $isIosSharedAlbum
|
||||||
}''';
|
}''';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/local_album.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/local_asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/storage.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
|
import 'package:immich_mobile/presentation/pages/dev/dev_logger.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
class HashService {
|
||||||
|
final int batchSizeLimit;
|
||||||
|
final int batchFileLimit;
|
||||||
|
final ILocalAlbumRepository _localAlbumRepository;
|
||||||
|
final ILocalAssetRepository _localAssetRepository;
|
||||||
|
final IStorageRepository _storageRepository;
|
||||||
|
final NativeSyncApi _nativeSyncApi;
|
||||||
|
final _log = Logger('HashService');
|
||||||
|
|
||||||
|
HashService({
|
||||||
|
required ILocalAlbumRepository localAlbumRepository,
|
||||||
|
required ILocalAssetRepository localAssetRepository,
|
||||||
|
required IStorageRepository storageRepository,
|
||||||
|
required NativeSyncApi nativeSyncApi,
|
||||||
|
this.batchSizeLimit = kBatchHashSizeLimit,
|
||||||
|
this.batchFileLimit = kBatchHashFileLimit,
|
||||||
|
}) : _localAlbumRepository = localAlbumRepository,
|
||||||
|
_localAssetRepository = localAssetRepository,
|
||||||
|
_storageRepository = storageRepository,
|
||||||
|
_nativeSyncApi = nativeSyncApi;
|
||||||
|
|
||||||
|
Future<void> hashAssets() async {
|
||||||
|
final Stopwatch stopwatch = Stopwatch()..start();
|
||||||
|
// Sorted by backupSelection followed by isCloud
|
||||||
|
final localAlbums = await _localAlbumRepository.getAll();
|
||||||
|
localAlbums.sort((a, b) {
|
||||||
|
final backupComparison =
|
||||||
|
a.backupSelection.sortOrder.compareTo(b.backupSelection.sortOrder);
|
||||||
|
|
||||||
|
if (backupComparison != 0) {
|
||||||
|
return backupComparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local albums come before iCloud albums
|
||||||
|
return (a.isIosSharedAlbum ? 1 : 0).compareTo(b.isIosSharedAlbum ? 1 : 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (final album in localAlbums) {
|
||||||
|
final assetsToHash =
|
||||||
|
await _localAlbumRepository.getAssetsToHash(album.id);
|
||||||
|
if (assetsToHash.isNotEmpty) {
|
||||||
|
await _hashAssets(assetsToHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopwatch.stop();
|
||||||
|
_log.info("Hashing took - ${stopwatch.elapsedMilliseconds}ms");
|
||||||
|
DLog.log("Hashing took - ${stopwatch.elapsedMilliseconds}ms");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
|
||||||
|
/// with hash for those that were successfully hashed. Hashes are looked up in a table
|
||||||
|
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
|
||||||
|
Future<void> _hashAssets(List<LocalAsset> assetsToHash) async {
|
||||||
|
int bytesProcessed = 0;
|
||||||
|
final toHash = <_AssetToPath>[];
|
||||||
|
|
||||||
|
for (final asset in assetsToHash) {
|
||||||
|
final file = await _storageRepository.getFileForAsset(asset);
|
||||||
|
if (file == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bytesProcessed += await file.length();
|
||||||
|
toHash.add(_AssetToPath(asset: asset, path: file.path));
|
||||||
|
|
||||||
|
if (toHash.length >= batchFileLimit || bytesProcessed >= batchSizeLimit) {
|
||||||
|
await _processBatch(toHash);
|
||||||
|
toHash.clear();
|
||||||
|
bytesProcessed = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _processBatch(toHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Processes a batch of assets.
|
||||||
|
Future<void> _processBatch(List<_AssetToPath> toHash) async {
|
||||||
|
if (toHash.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.fine("Hashing ${toHash.length} files");
|
||||||
|
|
||||||
|
final hashed = <LocalAsset>[];
|
||||||
|
final hashes =
|
||||||
|
await _nativeSyncApi.hashPaths(toHash.map((e) => e.path).toList());
|
||||||
|
|
||||||
|
for (final (index, hash) in hashes.indexed) {
|
||||||
|
final asset = toHash[index].asset;
|
||||||
|
if (hash?.length == 20) {
|
||||||
|
hashed.add(asset.copyWith(checksum: base64.encode(hash!)));
|
||||||
|
} else {
|
||||||
|
_log.warning("Failed to hash file ${asset.id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.fine("Hashed ${hashed.length}/${toHash.length} assets");
|
||||||
|
DLog.log("Hashed ${hashed.length}/${toHash.length} assets");
|
||||||
|
|
||||||
|
await _localAssetRepository.updateHashes(hashed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AssetToPath {
|
||||||
|
final LocalAsset asset;
|
||||||
|
final String path;
|
||||||
|
|
||||||
|
const _AssetToPath({required this.asset, required this.path});
|
||||||
|
}
|
||||||
@@ -365,6 +365,7 @@ extension on Iterable<PlatformAsset> {
|
|||||||
(e) => LocalAsset(
|
(e) => LocalAsset(
|
||||||
id: e.id,
|
id: e.id,
|
||||||
name: e.name,
|
name: e.name,
|
||||||
|
checksum: null,
|
||||||
type: AssetType.values.elementAtOrNull(e.type) ?? AssetType.other,
|
type: AssetType.values.elementAtOrNull(e.type) ?? AssetType.other,
|
||||||
createdAt: e.createdAt == null
|
createdAt: e.createdAt == null
|
||||||
? DateTime.now()
|
? DateTime.now()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:worker_manager/worker_manager.dart';
|
|||||||
class BackgroundSyncManager {
|
class BackgroundSyncManager {
|
||||||
Cancelable<void>? _syncTask;
|
Cancelable<void>? _syncTask;
|
||||||
Cancelable<void>? _deviceAlbumSyncTask;
|
Cancelable<void>? _deviceAlbumSyncTask;
|
||||||
|
Cancelable<void>? _hashTask;
|
||||||
|
|
||||||
BackgroundSyncManager();
|
BackgroundSyncManager();
|
||||||
|
|
||||||
@@ -45,6 +46,20 @@ class BackgroundSyncManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No need to cancel the task, as it can also be run when the user logs out
|
||||||
|
Future<void> hashAssets() {
|
||||||
|
if (_hashTask != null) {
|
||||||
|
return _hashTask!.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
_hashTask = runInIsolateGentle(
|
||||||
|
computation: (ref) => ref.read(hashServiceProvider).hashAssets(),
|
||||||
|
);
|
||||||
|
return _hashTask!.whenComplete(() {
|
||||||
|
_hashTask = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> syncRemote() {
|
Future<void> syncRemote() {
|
||||||
if (_syncTask != null) {
|
if (_syncTask != null) {
|
||||||
return _syncTask!.future;
|
return _syncTask!.future;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ class LocalAlbumEntity extends Table with DriftDefaultsMixin {
|
|||||||
TextColumn get name => text()();
|
TextColumn get name => text()();
|
||||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
IntColumn get backupSelection => intEnum<BackupSelection>()();
|
IntColumn get backupSelection => intEnum<BackupSelection>()();
|
||||||
|
BoolColumn get isIosSharedAlbum =>
|
||||||
|
boolean().withDefault(const Constant(false))();
|
||||||
|
|
||||||
// Used for mark & sweep
|
// Used for mark & sweep
|
||||||
BoolColumn get marker_ => boolean().nullable()();
|
BoolColumn get marker_ => boolean().nullable()();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ typedef $$LocalAlbumEntityTableCreateCompanionBuilder
|
|||||||
required String name,
|
required String name,
|
||||||
i0.Value<DateTime> updatedAt,
|
i0.Value<DateTime> updatedAt,
|
||||||
required i2.BackupSelection backupSelection,
|
required i2.BackupSelection backupSelection,
|
||||||
|
i0.Value<bool> isIosSharedAlbum,
|
||||||
i0.Value<bool?> marker_,
|
i0.Value<bool?> marker_,
|
||||||
});
|
});
|
||||||
typedef $$LocalAlbumEntityTableUpdateCompanionBuilder
|
typedef $$LocalAlbumEntityTableUpdateCompanionBuilder
|
||||||
@@ -22,6 +23,7 @@ typedef $$LocalAlbumEntityTableUpdateCompanionBuilder
|
|||||||
i0.Value<String> name,
|
i0.Value<String> name,
|
||||||
i0.Value<DateTime> updatedAt,
|
i0.Value<DateTime> updatedAt,
|
||||||
i0.Value<i2.BackupSelection> backupSelection,
|
i0.Value<i2.BackupSelection> backupSelection,
|
||||||
|
i0.Value<bool> isIosSharedAlbum,
|
||||||
i0.Value<bool?> marker_,
|
i0.Value<bool?> marker_,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,6 +50,10 @@ class $$LocalAlbumEntityTableFilterComposer
|
|||||||
column: $table.backupSelection,
|
column: $table.backupSelection,
|
||||||
builder: (column) => i0.ColumnWithTypeConverterFilters(column));
|
builder: (column) => i0.ColumnWithTypeConverterFilters(column));
|
||||||
|
|
||||||
|
i0.ColumnFilters<bool> get isIosSharedAlbum => $composableBuilder(
|
||||||
|
column: $table.isIosSharedAlbum,
|
||||||
|
builder: (column) => i0.ColumnFilters(column));
|
||||||
|
|
||||||
i0.ColumnFilters<bool> get marker_ => $composableBuilder(
|
i0.ColumnFilters<bool> get marker_ => $composableBuilder(
|
||||||
column: $table.marker_, builder: (column) => i0.ColumnFilters(column));
|
column: $table.marker_, builder: (column) => i0.ColumnFilters(column));
|
||||||
}
|
}
|
||||||
@@ -75,6 +81,10 @@ class $$LocalAlbumEntityTableOrderingComposer
|
|||||||
column: $table.backupSelection,
|
column: $table.backupSelection,
|
||||||
builder: (column) => i0.ColumnOrderings(column));
|
builder: (column) => i0.ColumnOrderings(column));
|
||||||
|
|
||||||
|
i0.ColumnOrderings<bool> get isIosSharedAlbum => $composableBuilder(
|
||||||
|
column: $table.isIosSharedAlbum,
|
||||||
|
builder: (column) => i0.ColumnOrderings(column));
|
||||||
|
|
||||||
i0.ColumnOrderings<bool> get marker_ => $composableBuilder(
|
i0.ColumnOrderings<bool> get marker_ => $composableBuilder(
|
||||||
column: $table.marker_, builder: (column) => i0.ColumnOrderings(column));
|
column: $table.marker_, builder: (column) => i0.ColumnOrderings(column));
|
||||||
}
|
}
|
||||||
@@ -101,6 +111,9 @@ class $$LocalAlbumEntityTableAnnotationComposer
|
|||||||
get backupSelection => $composableBuilder(
|
get backupSelection => $composableBuilder(
|
||||||
column: $table.backupSelection, builder: (column) => column);
|
column: $table.backupSelection, builder: (column) => column);
|
||||||
|
|
||||||
|
i0.GeneratedColumn<bool> get isIosSharedAlbum => $composableBuilder(
|
||||||
|
column: $table.isIosSharedAlbum, builder: (column) => column);
|
||||||
|
|
||||||
i0.GeneratedColumn<bool> get marker_ =>
|
i0.GeneratedColumn<bool> get marker_ =>
|
||||||
$composableBuilder(column: $table.marker_, builder: (column) => column);
|
$composableBuilder(column: $table.marker_, builder: (column) => column);
|
||||||
}
|
}
|
||||||
@@ -139,6 +152,7 @@ class $$LocalAlbumEntityTableTableManager extends i0.RootTableManager<
|
|||||||
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
|
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
|
||||||
i0.Value<i2.BackupSelection> backupSelection =
|
i0.Value<i2.BackupSelection> backupSelection =
|
||||||
const i0.Value.absent(),
|
const i0.Value.absent(),
|
||||||
|
i0.Value<bool> isIosSharedAlbum = const i0.Value.absent(),
|
||||||
i0.Value<bool?> marker_ = const i0.Value.absent(),
|
i0.Value<bool?> marker_ = const i0.Value.absent(),
|
||||||
}) =>
|
}) =>
|
||||||
i1.LocalAlbumEntityCompanion(
|
i1.LocalAlbumEntityCompanion(
|
||||||
@@ -146,6 +160,7 @@ class $$LocalAlbumEntityTableTableManager extends i0.RootTableManager<
|
|||||||
name: name,
|
name: name,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
backupSelection: backupSelection,
|
backupSelection: backupSelection,
|
||||||
|
isIosSharedAlbum: isIosSharedAlbum,
|
||||||
marker_: marker_,
|
marker_: marker_,
|
||||||
),
|
),
|
||||||
createCompanionCallback: ({
|
createCompanionCallback: ({
|
||||||
@@ -153,6 +168,7 @@ class $$LocalAlbumEntityTableTableManager extends i0.RootTableManager<
|
|||||||
required String name,
|
required String name,
|
||||||
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
|
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
|
||||||
required i2.BackupSelection backupSelection,
|
required i2.BackupSelection backupSelection,
|
||||||
|
i0.Value<bool> isIosSharedAlbum = const i0.Value.absent(),
|
||||||
i0.Value<bool?> marker_ = const i0.Value.absent(),
|
i0.Value<bool?> marker_ = const i0.Value.absent(),
|
||||||
}) =>
|
}) =>
|
||||||
i1.LocalAlbumEntityCompanion.insert(
|
i1.LocalAlbumEntityCompanion.insert(
|
||||||
@@ -160,6 +176,7 @@ class $$LocalAlbumEntityTableTableManager extends i0.RootTableManager<
|
|||||||
name: name,
|
name: name,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
backupSelection: backupSelection,
|
backupSelection: backupSelection,
|
||||||
|
isIosSharedAlbum: isIosSharedAlbum,
|
||||||
marker_: marker_,
|
marker_: marker_,
|
||||||
),
|
),
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
@@ -218,6 +235,16 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
|
|||||||
type: i0.DriftSqlType.int, requiredDuringInsert: true)
|
type: i0.DriftSqlType.int, requiredDuringInsert: true)
|
||||||
.withConverter<i2.BackupSelection>(
|
.withConverter<i2.BackupSelection>(
|
||||||
i1.$LocalAlbumEntityTable.$converterbackupSelection);
|
i1.$LocalAlbumEntityTable.$converterbackupSelection);
|
||||||
|
static const i0.VerificationMeta _isIosSharedAlbumMeta =
|
||||||
|
const i0.VerificationMeta('isIosSharedAlbum');
|
||||||
|
@override
|
||||||
|
late final i0.GeneratedColumn<bool> isIosSharedAlbum =
|
||||||
|
i0.GeneratedColumn<bool>('is_ios_shared_album', aliasedName, false,
|
||||||
|
type: i0.DriftSqlType.bool,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
|
||||||
|
'CHECK ("is_ios_shared_album" IN (0, 1))'),
|
||||||
|
defaultValue: const i4.Constant(false));
|
||||||
static const i0.VerificationMeta _marker_Meta =
|
static const i0.VerificationMeta _marker_Meta =
|
||||||
const i0.VerificationMeta('marker_');
|
const i0.VerificationMeta('marker_');
|
||||||
@override
|
@override
|
||||||
@@ -229,7 +256,7 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
|
|||||||
i0.GeneratedColumn.constraintIsAlways('CHECK ("marker" IN (0, 1))'));
|
i0.GeneratedColumn.constraintIsAlways('CHECK ("marker" IN (0, 1))'));
|
||||||
@override
|
@override
|
||||||
List<i0.GeneratedColumn> get $columns =>
|
List<i0.GeneratedColumn> get $columns =>
|
||||||
[id, name, updatedAt, backupSelection, marker_];
|
[id, name, updatedAt, backupSelection, isIosSharedAlbum, marker_];
|
||||||
@override
|
@override
|
||||||
String get aliasedName => _alias ?? actualTableName;
|
String get aliasedName => _alias ?? actualTableName;
|
||||||
@override
|
@override
|
||||||
@@ -256,6 +283,12 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
|
|||||||
context.handle(_updatedAtMeta,
|
context.handle(_updatedAtMeta,
|
||||||
updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta));
|
updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta));
|
||||||
}
|
}
|
||||||
|
if (data.containsKey('is_ios_shared_album')) {
|
||||||
|
context.handle(
|
||||||
|
_isIosSharedAlbumMeta,
|
||||||
|
isIosSharedAlbum.isAcceptableOrUnknown(
|
||||||
|
data['is_ios_shared_album']!, _isIosSharedAlbumMeta));
|
||||||
|
}
|
||||||
if (data.containsKey('marker')) {
|
if (data.containsKey('marker')) {
|
||||||
context.handle(_marker_Meta,
|
context.handle(_marker_Meta,
|
||||||
marker_.isAcceptableOrUnknown(data['marker']!, _marker_Meta));
|
marker_.isAcceptableOrUnknown(data['marker']!, _marker_Meta));
|
||||||
@@ -279,6 +312,8 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
|
|||||||
backupSelection: i1.$LocalAlbumEntityTable.$converterbackupSelection
|
backupSelection: i1.$LocalAlbumEntityTable.$converterbackupSelection
|
||||||
.fromSql(attachedDatabase.typeMapping.read(i0.DriftSqlType.int,
|
.fromSql(attachedDatabase.typeMapping.read(i0.DriftSqlType.int,
|
||||||
data['${effectivePrefix}backup_selection'])!),
|
data['${effectivePrefix}backup_selection'])!),
|
||||||
|
isIosSharedAlbum: attachedDatabase.typeMapping.read(
|
||||||
|
i0.DriftSqlType.bool, data['${effectivePrefix}is_ios_shared_album'])!,
|
||||||
marker_: attachedDatabase.typeMapping
|
marker_: attachedDatabase.typeMapping
|
||||||
.read(i0.DriftSqlType.bool, data['${effectivePrefix}marker']),
|
.read(i0.DriftSqlType.bool, data['${effectivePrefix}marker']),
|
||||||
);
|
);
|
||||||
@@ -305,12 +340,14 @@ class LocalAlbumEntityData extends i0.DataClass
|
|||||||
final String name;
|
final String name;
|
||||||
final DateTime updatedAt;
|
final DateTime updatedAt;
|
||||||
final i2.BackupSelection backupSelection;
|
final i2.BackupSelection backupSelection;
|
||||||
|
final bool isIosSharedAlbum;
|
||||||
final bool? marker_;
|
final bool? marker_;
|
||||||
const LocalAlbumEntityData(
|
const LocalAlbumEntityData(
|
||||||
{required this.id,
|
{required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
required this.backupSelection,
|
required this.backupSelection,
|
||||||
|
required this.isIosSharedAlbum,
|
||||||
this.marker_});
|
this.marker_});
|
||||||
@override
|
@override
|
||||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||||
@@ -323,6 +360,7 @@ class LocalAlbumEntityData extends i0.DataClass
|
|||||||
.$LocalAlbumEntityTable.$converterbackupSelection
|
.$LocalAlbumEntityTable.$converterbackupSelection
|
||||||
.toSql(backupSelection));
|
.toSql(backupSelection));
|
||||||
}
|
}
|
||||||
|
map['is_ios_shared_album'] = i0.Variable<bool>(isIosSharedAlbum);
|
||||||
if (!nullToAbsent || marker_ != null) {
|
if (!nullToAbsent || marker_ != null) {
|
||||||
map['marker'] = i0.Variable<bool>(marker_);
|
map['marker'] = i0.Variable<bool>(marker_);
|
||||||
}
|
}
|
||||||
@@ -338,6 +376,7 @@ class LocalAlbumEntityData extends i0.DataClass
|
|||||||
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
|
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
|
||||||
backupSelection: i1.$LocalAlbumEntityTable.$converterbackupSelection
|
backupSelection: i1.$LocalAlbumEntityTable.$converterbackupSelection
|
||||||
.fromJson(serializer.fromJson<int>(json['backupSelection'])),
|
.fromJson(serializer.fromJson<int>(json['backupSelection'])),
|
||||||
|
isIosSharedAlbum: serializer.fromJson<bool>(json['isIosSharedAlbum']),
|
||||||
marker_: serializer.fromJson<bool?>(json['marker_']),
|
marker_: serializer.fromJson<bool?>(json['marker_']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -351,6 +390,7 @@ class LocalAlbumEntityData extends i0.DataClass
|
|||||||
'backupSelection': serializer.toJson<int>(i1
|
'backupSelection': serializer.toJson<int>(i1
|
||||||
.$LocalAlbumEntityTable.$converterbackupSelection
|
.$LocalAlbumEntityTable.$converterbackupSelection
|
||||||
.toJson(backupSelection)),
|
.toJson(backupSelection)),
|
||||||
|
'isIosSharedAlbum': serializer.toJson<bool>(isIosSharedAlbum),
|
||||||
'marker_': serializer.toJson<bool?>(marker_),
|
'marker_': serializer.toJson<bool?>(marker_),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -360,12 +400,14 @@ class LocalAlbumEntityData extends i0.DataClass
|
|||||||
String? name,
|
String? name,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
i2.BackupSelection? backupSelection,
|
i2.BackupSelection? backupSelection,
|
||||||
|
bool? isIosSharedAlbum,
|
||||||
i0.Value<bool?> marker_ = const i0.Value.absent()}) =>
|
i0.Value<bool?> marker_ = const i0.Value.absent()}) =>
|
||||||
i1.LocalAlbumEntityData(
|
i1.LocalAlbumEntityData(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
updatedAt: updatedAt ?? this.updatedAt,
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
backupSelection: backupSelection ?? this.backupSelection,
|
backupSelection: backupSelection ?? this.backupSelection,
|
||||||
|
isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum,
|
||||||
marker_: marker_.present ? marker_.value : this.marker_,
|
marker_: marker_.present ? marker_.value : this.marker_,
|
||||||
);
|
);
|
||||||
LocalAlbumEntityData copyWithCompanion(i1.LocalAlbumEntityCompanion data) {
|
LocalAlbumEntityData copyWithCompanion(i1.LocalAlbumEntityCompanion data) {
|
||||||
@@ -376,6 +418,9 @@ class LocalAlbumEntityData extends i0.DataClass
|
|||||||
backupSelection: data.backupSelection.present
|
backupSelection: data.backupSelection.present
|
||||||
? data.backupSelection.value
|
? data.backupSelection.value
|
||||||
: this.backupSelection,
|
: this.backupSelection,
|
||||||
|
isIosSharedAlbum: data.isIosSharedAlbum.present
|
||||||
|
? data.isIosSharedAlbum.value
|
||||||
|
: this.isIosSharedAlbum,
|
||||||
marker_: data.marker_.present ? data.marker_.value : this.marker_,
|
marker_: data.marker_.present ? data.marker_.value : this.marker_,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -387,14 +432,15 @@ class LocalAlbumEntityData extends i0.DataClass
|
|||||||
..write('name: $name, ')
|
..write('name: $name, ')
|
||||||
..write('updatedAt: $updatedAt, ')
|
..write('updatedAt: $updatedAt, ')
|
||||||
..write('backupSelection: $backupSelection, ')
|
..write('backupSelection: $backupSelection, ')
|
||||||
|
..write('isIosSharedAlbum: $isIosSharedAlbum, ')
|
||||||
..write('marker_: $marker_')
|
..write('marker_: $marker_')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode => Object.hash(
|
||||||
Object.hash(id, name, updatedAt, backupSelection, marker_);
|
id, name, updatedAt, backupSelection, isIosSharedAlbum, marker_);
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
identical(this, other) ||
|
identical(this, other) ||
|
||||||
@@ -403,6 +449,7 @@ class LocalAlbumEntityData extends i0.DataClass
|
|||||||
other.name == this.name &&
|
other.name == this.name &&
|
||||||
other.updatedAt == this.updatedAt &&
|
other.updatedAt == this.updatedAt &&
|
||||||
other.backupSelection == this.backupSelection &&
|
other.backupSelection == this.backupSelection &&
|
||||||
|
other.isIosSharedAlbum == this.isIosSharedAlbum &&
|
||||||
other.marker_ == this.marker_);
|
other.marker_ == this.marker_);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,12 +459,14 @@ class LocalAlbumEntityCompanion
|
|||||||
final i0.Value<String> name;
|
final i0.Value<String> name;
|
||||||
final i0.Value<DateTime> updatedAt;
|
final i0.Value<DateTime> updatedAt;
|
||||||
final i0.Value<i2.BackupSelection> backupSelection;
|
final i0.Value<i2.BackupSelection> backupSelection;
|
||||||
|
final i0.Value<bool> isIosSharedAlbum;
|
||||||
final i0.Value<bool?> marker_;
|
final i0.Value<bool?> marker_;
|
||||||
const LocalAlbumEntityCompanion({
|
const LocalAlbumEntityCompanion({
|
||||||
this.id = const i0.Value.absent(),
|
this.id = const i0.Value.absent(),
|
||||||
this.name = const i0.Value.absent(),
|
this.name = const i0.Value.absent(),
|
||||||
this.updatedAt = const i0.Value.absent(),
|
this.updatedAt = const i0.Value.absent(),
|
||||||
this.backupSelection = const i0.Value.absent(),
|
this.backupSelection = const i0.Value.absent(),
|
||||||
|
this.isIosSharedAlbum = const i0.Value.absent(),
|
||||||
this.marker_ = const i0.Value.absent(),
|
this.marker_ = const i0.Value.absent(),
|
||||||
});
|
});
|
||||||
LocalAlbumEntityCompanion.insert({
|
LocalAlbumEntityCompanion.insert({
|
||||||
@@ -425,6 +474,7 @@ class LocalAlbumEntityCompanion
|
|||||||
required String name,
|
required String name,
|
||||||
this.updatedAt = const i0.Value.absent(),
|
this.updatedAt = const i0.Value.absent(),
|
||||||
required i2.BackupSelection backupSelection,
|
required i2.BackupSelection backupSelection,
|
||||||
|
this.isIosSharedAlbum = const i0.Value.absent(),
|
||||||
this.marker_ = const i0.Value.absent(),
|
this.marker_ = const i0.Value.absent(),
|
||||||
}) : id = i0.Value(id),
|
}) : id = i0.Value(id),
|
||||||
name = i0.Value(name),
|
name = i0.Value(name),
|
||||||
@@ -434,6 +484,7 @@ class LocalAlbumEntityCompanion
|
|||||||
i0.Expression<String>? name,
|
i0.Expression<String>? name,
|
||||||
i0.Expression<DateTime>? updatedAt,
|
i0.Expression<DateTime>? updatedAt,
|
||||||
i0.Expression<int>? backupSelection,
|
i0.Expression<int>? backupSelection,
|
||||||
|
i0.Expression<bool>? isIosSharedAlbum,
|
||||||
i0.Expression<bool>? marker_,
|
i0.Expression<bool>? marker_,
|
||||||
}) {
|
}) {
|
||||||
return i0.RawValuesInsertable({
|
return i0.RawValuesInsertable({
|
||||||
@@ -441,6 +492,7 @@ class LocalAlbumEntityCompanion
|
|||||||
if (name != null) 'name': name,
|
if (name != null) 'name': name,
|
||||||
if (updatedAt != null) 'updated_at': updatedAt,
|
if (updatedAt != null) 'updated_at': updatedAt,
|
||||||
if (backupSelection != null) 'backup_selection': backupSelection,
|
if (backupSelection != null) 'backup_selection': backupSelection,
|
||||||
|
if (isIosSharedAlbum != null) 'is_ios_shared_album': isIosSharedAlbum,
|
||||||
if (marker_ != null) 'marker': marker_,
|
if (marker_ != null) 'marker': marker_,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -450,12 +502,14 @@ class LocalAlbumEntityCompanion
|
|||||||
i0.Value<String>? name,
|
i0.Value<String>? name,
|
||||||
i0.Value<DateTime>? updatedAt,
|
i0.Value<DateTime>? updatedAt,
|
||||||
i0.Value<i2.BackupSelection>? backupSelection,
|
i0.Value<i2.BackupSelection>? backupSelection,
|
||||||
|
i0.Value<bool>? isIosSharedAlbum,
|
||||||
i0.Value<bool?>? marker_}) {
|
i0.Value<bool?>? marker_}) {
|
||||||
return i1.LocalAlbumEntityCompanion(
|
return i1.LocalAlbumEntityCompanion(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
updatedAt: updatedAt ?? this.updatedAt,
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
backupSelection: backupSelection ?? this.backupSelection,
|
backupSelection: backupSelection ?? this.backupSelection,
|
||||||
|
isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum,
|
||||||
marker_: marker_ ?? this.marker_,
|
marker_: marker_ ?? this.marker_,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -477,6 +531,9 @@ class LocalAlbumEntityCompanion
|
|||||||
.$LocalAlbumEntityTable.$converterbackupSelection
|
.$LocalAlbumEntityTable.$converterbackupSelection
|
||||||
.toSql(backupSelection.value));
|
.toSql(backupSelection.value));
|
||||||
}
|
}
|
||||||
|
if (isIosSharedAlbum.present) {
|
||||||
|
map['is_ios_shared_album'] = i0.Variable<bool>(isIosSharedAlbum.value);
|
||||||
|
}
|
||||||
if (marker_.present) {
|
if (marker_.present) {
|
||||||
map['marker'] = i0.Variable<bool>(marker_.value);
|
map['marker'] = i0.Variable<bool>(marker_.value);
|
||||||
}
|
}
|
||||||
@@ -490,6 +547,7 @@ class LocalAlbumEntityCompanion
|
|||||||
..write('name: $name, ')
|
..write('name: $name, ')
|
||||||
..write('updatedAt: $updatedAt, ')
|
..write('updatedAt: $updatedAt, ')
|
||||||
..write('backupSelection: $backupSelection, ')
|
..write('backupSelection: $backupSelection, ')
|
||||||
|
..write('isIosSharedAlbum: $isIosSharedAlbum, ')
|
||||||
..write('marker_: $marker_')
|
..write('marker_: $marker_')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
|
|||||||
@@ -98,12 +98,24 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository
|
|||||||
name: localAlbum.name,
|
name: localAlbum.name,
|
||||||
updatedAt: Value(localAlbum.updatedAt),
|
updatedAt: Value(localAlbum.updatedAt),
|
||||||
backupSelection: localAlbum.backupSelection,
|
backupSelection: localAlbum.backupSelection,
|
||||||
|
isIosSharedAlbum: Value(localAlbum.isIosSharedAlbum),
|
||||||
);
|
);
|
||||||
|
|
||||||
return _db.transaction(() async {
|
return _db.transaction(() async {
|
||||||
await _db.localAlbumEntity
|
await _db.localAlbumEntity
|
||||||
.insertOne(companion, onConflict: DoUpdate((_) => companion));
|
.insertOne(companion, onConflict: DoUpdate((_) => companion));
|
||||||
await _addAssets(localAlbum.id, toUpsert);
|
if (toUpsert.isNotEmpty) {
|
||||||
|
await _upsertAssets(toUpsert);
|
||||||
|
await _db.localAlbumAssetEntity.insertAll(
|
||||||
|
toUpsert.map(
|
||||||
|
(a) => LocalAlbumAssetEntityCompanion.insert(
|
||||||
|
assetId: a.id,
|
||||||
|
albumId: localAlbum.id,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
mode: InsertMode.insertOrIgnore,
|
||||||
|
);
|
||||||
|
}
|
||||||
await _removeAssets(localAlbum.id, toDelete);
|
await _removeAssets(localAlbum.id, toDelete);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -122,6 +134,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository
|
|||||||
name: album.name,
|
name: album.name,
|
||||||
updatedAt: Value(album.updatedAt),
|
updatedAt: Value(album.updatedAt),
|
||||||
backupSelection: album.backupSelection,
|
backupSelection: album.backupSelection,
|
||||||
|
isIosSharedAlbum: Value(album.isIosSharedAlbum),
|
||||||
marker_: const Value(null),
|
marker_: const Value(null),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -226,21 +239,52 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _addAssets(String albumId, Iterable<LocalAsset> assets) {
|
@override
|
||||||
if (assets.isEmpty) {
|
Future<List<LocalAsset>> getAssetsToHash(String albumId) {
|
||||||
|
final query = _db.localAlbumAssetEntity.select().join(
|
||||||
|
[
|
||||||
|
innerJoin(
|
||||||
|
_db.localAssetEntity,
|
||||||
|
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
..where(
|
||||||
|
_db.localAlbumAssetEntity.albumId.equals(albumId) &
|
||||||
|
_db.localAssetEntity.checksum.isNull(),
|
||||||
|
)
|
||||||
|
..orderBy([OrderingTerm.asc(_db.localAssetEntity.id)]);
|
||||||
|
|
||||||
|
return query
|
||||||
|
.map((row) => row.readTable(_db.localAssetEntity).toDto())
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _upsertAssets(Iterable<LocalAsset> localAssets) {
|
||||||
|
if (localAssets.isEmpty) {
|
||||||
return Future.value();
|
return Future.value();
|
||||||
}
|
}
|
||||||
return transaction(() async {
|
|
||||||
await _upsertAssets(assets);
|
return _db.batch((batch) async {
|
||||||
await _db.localAlbumAssetEntity.insertAll(
|
for (final asset in localAssets) {
|
||||||
assets.map(
|
final companion = LocalAssetEntityCompanion.insert(
|
||||||
(a) => LocalAlbumAssetEntityCompanion.insert(
|
name: asset.name,
|
||||||
assetId: a.id,
|
type: asset.type,
|
||||||
albumId: albumId,
|
createdAt: Value(asset.createdAt),
|
||||||
|
updatedAt: Value(asset.updatedAt),
|
||||||
|
durationInSeconds: Value.absentIfNull(asset.durationInSeconds),
|
||||||
|
id: asset.id,
|
||||||
|
checksum: const Value(null),
|
||||||
|
);
|
||||||
|
batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>(
|
||||||
|
_db.localAssetEntity,
|
||||||
|
companion,
|
||||||
|
onConflict: DoUpdate(
|
||||||
|
(_) => companion,
|
||||||
|
where: (old) => old.updatedAt.isNotValue(asset.updatedAt),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
mode: InsertMode.insertOrIgnore,
|
}
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,40 +345,14 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository
|
|||||||
return query.map((row) => row.read(assetId)!).get();
|
return query.map((row) => row.read(assetId)!).get();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _upsertAssets(Iterable<LocalAsset> localAssets) {
|
|
||||||
if (localAssets.isEmpty) {
|
|
||||||
return Future.value();
|
|
||||||
}
|
|
||||||
|
|
||||||
return _db.batch((batch) async {
|
|
||||||
batch.insertAllOnConflictUpdate(
|
|
||||||
_db.localAssetEntity,
|
|
||||||
localAssets.map(
|
|
||||||
(a) => LocalAssetEntityCompanion.insert(
|
|
||||||
name: a.name,
|
|
||||||
type: a.type,
|
|
||||||
createdAt: Value(a.createdAt),
|
|
||||||
updatedAt: Value(a.updatedAt),
|
|
||||||
durationInSeconds: Value.absentIfNull(a.durationInSeconds),
|
|
||||||
id: a.id,
|
|
||||||
checksum: Value.absentIfNull(a.checksum),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _deleteAssets(Iterable<String> ids) {
|
Future<void> _deleteAssets(Iterable<String> ids) {
|
||||||
if (ids.isEmpty) {
|
if (ids.isEmpty) {
|
||||||
return Future.value();
|
return Future.value();
|
||||||
}
|
}
|
||||||
|
|
||||||
return _db.batch(
|
return _db.batch((batch) {
|
||||||
(batch) => batch.deleteWhere(
|
batch.deleteWhere(_db.localAssetEntity, (f) => f.id.isIn(ids));
|
||||||
_db.localAssetEntity,
|
});
|
||||||
(f) => f.id.isIn(ids),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/local_asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
|
|
||||||
|
class DriftLocalAssetRepository extends DriftDatabaseRepository
|
||||||
|
implements ILocalAssetRepository {
|
||||||
|
final Drift _db;
|
||||||
|
const DriftLocalAssetRepository(this._db) : super(_db);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateHashes(Iterable<LocalAsset> hashes) {
|
||||||
|
if (hashes.isEmpty) {
|
||||||
|
return Future.value();
|
||||||
|
}
|
||||||
|
|
||||||
|
return _db.batch((batch) async {
|
||||||
|
for (final asset in hashes) {
|
||||||
|
batch.update(
|
||||||
|
_db.localAssetEntity,
|
||||||
|
LocalAssetEntityCompanion(checksum: Value(asset.checksum)),
|
||||||
|
where: (e) => e.id.equals(asset.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/domain/interfaces/storage.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
|
class StorageRepository implements IStorageRepository {
|
||||||
|
final _log = Logger('StorageRepository');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<File?> getFileForAsset(LocalAsset asset) async {
|
||||||
|
File? file;
|
||||||
|
try {
|
||||||
|
final entity = await AssetEntity.fromId(asset.id);
|
||||||
|
file = await entity?.originFile;
|
||||||
|
if (file == null) {
|
||||||
|
_log.warning(
|
||||||
|
"Cannot get file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
_log.warning(
|
||||||
|
"Error getting file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
|
||||||
|
error,
|
||||||
|
stackTrace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,8 +18,8 @@ import 'package:immich_mobile/providers/db.provider.dart';
|
|||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/locale_provider.dart';
|
import 'package:immich_mobile/providers/locale_provider.dart';
|
||||||
import 'package:immich_mobile/providers/theme.provider.dart';
|
import 'package:immich_mobile/providers/theme.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
|
||||||
import 'package:immich_mobile/routing/app_navigation_observer.dart';
|
import 'package:immich_mobile/routing/app_navigation_observer.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/services/background.service.dart';
|
import 'package:immich_mobile/services/background.service.dart';
|
||||||
import 'package:immich_mobile/services/local_notification.service.dart';
|
import 'package:immich_mobile/services/local_notification.service.dart';
|
||||||
import 'package:immich_mobile/theme/dynamic_theme.dart';
|
import 'package:immich_mobile/theme/dynamic_theme.dart';
|
||||||
|
|||||||
+31
@@ -498,4 +498,35 @@ class NativeSyncApi {
|
|||||||
return (pigeonVar_replyList[0] as List<Object?>?)!.cast<PlatformAsset>();
|
return (pigeonVar_replyList[0] as List<Object?>?)!.cast<PlatformAsset>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<Uint8List?>> hashPaths(List<String> paths) async {
|
||||||
|
final String pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashPaths$pigeonVar_messageChannelSuffix';
|
||||||
|
final BasicMessageChannel<Object?> pigeonVar_channel =
|
||||||
|
BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture =
|
||||||
|
pigeonVar_channel.send(<Object?>[paths]);
|
||||||
|
final List<Object?>? pigeonVar_replyList =
|
||||||
|
await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
if (pigeonVar_replyList == null) {
|
||||||
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
} else if (pigeonVar_replyList.length > 1) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: pigeonVar_replyList[0]! as String,
|
||||||
|
message: pigeonVar_replyList[1] as String?,
|
||||||
|
details: pigeonVar_replyList[2],
|
||||||
|
);
|
||||||
|
} else if (pigeonVar_replyList[0] == null) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: 'null-error',
|
||||||
|
message: 'Host platform returned null value for non-null return value.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (pigeonVar_replyList[0] as List<Object?>?)!.cast<Uint8List?>();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||||
@@ -15,7 +16,6 @@ abstract final class DLog {
|
|||||||
static Stream<List<LogMessage>> watchLog() {
|
static Stream<List<LogMessage>> watchLog() {
|
||||||
final db = Isar.getInstance();
|
final db = Isar.getInstance();
|
||||||
if (db == null) {
|
if (db == null) {
|
||||||
debugPrint('Isar is not initialized');
|
|
||||||
return const Stream.empty();
|
return const Stream.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +30,6 @@ abstract final class DLog {
|
|||||||
static void clearLog() {
|
static void clearLog() {
|
||||||
final db = Isar.getInstance();
|
final db = Isar.getInstance();
|
||||||
if (db == null) {
|
if (db == null) {
|
||||||
debugPrint('Isar is not initialized');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +39,9 @@ abstract final class DLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void log(String message, [Object? error, StackTrace? stackTrace]) {
|
static void log(String message, [Object? error, StackTrace? stackTrace]) {
|
||||||
debugPrint('[$kDevLoggerTag] [${DateTime.now()}] $message');
|
if (!Platform.environment.containsKey('FLUTTER_TEST')) {
|
||||||
|
debugPrint('[$kDevLoggerTag] [${DateTime.now()}] $message');
|
||||||
|
}
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
debugPrint('Error: $error');
|
debugPrint('Error: $error');
|
||||||
}
|
}
|
||||||
@@ -50,7 +51,6 @@ abstract final class DLog {
|
|||||||
|
|
||||||
final isar = Isar.getInstance();
|
final isar = Isar.getInstance();
|
||||||
if (isar == null) {
|
if (isar == null) {
|
||||||
debugPrint('Isar is not initialized');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ final _features = [
|
|||||||
icon: Icons.photo_library_rounded,
|
icon: Icons.photo_library_rounded,
|
||||||
onTap: (_, ref) => ref.read(backgroundSyncProvider).syncLocal(full: true),
|
onTap: (_, ref) => ref.read(backgroundSyncProvider).syncLocal(full: true),
|
||||||
),
|
),
|
||||||
|
_Feature(
|
||||||
|
name: 'Hash Local Assets',
|
||||||
|
icon: Icons.numbers_outlined,
|
||||||
|
onTap: (_, ref) => ref.read(backgroundSyncProvider).hashAssets(),
|
||||||
|
),
|
||||||
_Feature(
|
_Feature(
|
||||||
name: 'Sync Remote',
|
name: 'Sync Remote',
|
||||||
icon: Icons.refresh_rounded,
|
icon: Icons.refresh_rounded,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import 'package:auto_route/auto_route.dart';
|
|||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/local_album.model.dart';
|
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||||
@@ -94,9 +93,8 @@ class LocalMediaSummaryPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
FutureBuilder(
|
FutureBuilder(
|
||||||
future: albumsFuture,
|
future: albumsFuture,
|
||||||
initialData: <LocalAlbum>[],
|
|
||||||
builder: (_, snap) {
|
builder: (_, snap) {
|
||||||
final albums = snap.data!;
|
final albums = snap.data ?? [];
|
||||||
if (albums.isEmpty) {
|
if (albums.isEmpty) {
|
||||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/local_asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
|
|
||||||
|
final localAssetRepository = Provider<ILocalAssetRepository>(
|
||||||
|
(ref) => DriftLocalAssetRepository(ref.watch(driftProvider)),
|
||||||
|
);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/storage.interface.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
|
|
||||||
|
final storageRepositoryProvider = Provider<IStorageRepository>(
|
||||||
|
(ref) => StorageRepository(),
|
||||||
|
);
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/hash.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/local_sync.service.dart';
|
import 'package:immich_mobile/domain/services/local_sync.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
|
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.dart';
|
import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
|
||||||
|
|
||||||
final syncStreamServiceProvider = Provider(
|
final syncStreamServiceProvider = Provider(
|
||||||
@@ -33,3 +36,12 @@ final localSyncServiceProvider = Provider(
|
|||||||
storeService: ref.watch(storeServiceProvider),
|
storeService: ref.watch(storeServiceProvider),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final hashServiceProvider = Provider(
|
||||||
|
(ref) => HashService(
|
||||||
|
localAlbumRepository: ref.watch(localAlbumRepository),
|
||||||
|
localAssetRepository: ref.watch(localAssetRepository),
|
||||||
|
storageRepository: ref.watch(storageRepositoryProvider),
|
||||||
|
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
// ignore_for_file: avoid-unsafe-collection-methods
|
// ignore_for_file: avoid-unsafe-collection-methods
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||||
import 'package:immich_mobile/entities/album.entity.dart';
|
import 'package:immich_mobile/entities/album.entity.dart';
|
||||||
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
|
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
@@ -13,14 +16,16 @@ import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
|
|||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
import 'package:immich_mobile/utils/diff.dart';
|
import 'package:immich_mobile/utils/diff.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
// ignore: import_rule_photo_manager
|
// ignore: import_rule_photo_manager
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
const int targetVersion = 11;
|
const int targetVersion = 12;
|
||||||
|
|
||||||
Future<void> migrateDatabaseIfNeeded(Isar db) async {
|
Future<void> migrateDatabaseIfNeeded(Isar db) async {
|
||||||
final int version = Store.get(StoreKey.version, targetVersion);
|
final int version = Store.get(StoreKey.version, targetVersion);
|
||||||
@@ -45,7 +50,15 @@ Future<void> migrateDatabaseIfNeeded(Isar db) async {
|
|||||||
await _migrateDeviceAsset(db);
|
await _migrateDeviceAsset(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
final shouldTruncate = version < 8 && version < targetVersion;
|
if (version < 12 && (!kReleaseMode)) {
|
||||||
|
final backgroundSync = BackgroundSyncManager();
|
||||||
|
await backgroundSync.syncLocal();
|
||||||
|
final drift = Drift();
|
||||||
|
await _migrateDeviceAssetToSqlite(db, drift);
|
||||||
|
await drift.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
final shouldTruncate = version < 8 || version < targetVersion;
|
||||||
if (shouldTruncate) {
|
if (shouldTruncate) {
|
||||||
await _migrateTo(db, targetVersion);
|
await _migrateTo(db, targetVersion);
|
||||||
}
|
}
|
||||||
@@ -154,6 +167,28 @@ Future<void> _migrateDeviceAsset(Isar db) async {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _migrateDeviceAssetToSqlite(Isar db, Drift drift) async {
|
||||||
|
final isarDeviceAssets =
|
||||||
|
await db.deviceAssetEntitys.where().sortByAssetId().findAll();
|
||||||
|
await drift.batch((batch) {
|
||||||
|
for (final deviceAsset in isarDeviceAssets) {
|
||||||
|
final companion = LocalAssetEntityCompanion(
|
||||||
|
updatedAt: Value(deviceAsset.modifiedTime),
|
||||||
|
id: Value(deviceAsset.assetId),
|
||||||
|
checksum: Value(base64.encode(deviceAsset.hash)),
|
||||||
|
);
|
||||||
|
batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>(
|
||||||
|
drift.localAssetEntity,
|
||||||
|
companion,
|
||||||
|
onConflict: DoUpdate(
|
||||||
|
(_) => companion,
|
||||||
|
where: (old) => old.updatedAt.equals(deviceAsset.modifiedTime),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class _DeviceAsset {
|
class _DeviceAsset {
|
||||||
final String assetId;
|
final String assetId;
|
||||||
final List<int>? hash;
|
final List<int>? hash;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ dynamic upgradeDto(dynamic value, String targetType) {
|
|||||||
addDefault(value, 'tags', TagsResponse().toJson());
|
addDefault(value, 'tags', TagsResponse().toJson());
|
||||||
addDefault(value, 'sharedLinks', SharedLinksResponse().toJson());
|
addDefault(value, 'sharedLinks', SharedLinksResponse().toJson());
|
||||||
addDefault(value, 'cast', CastResponse().toJson());
|
addDefault(value, 'cast', CastResponse().toJson());
|
||||||
|
addDefault(value, 'albums', {'defaultAssetOrder': 'desc'});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'ServerConfigDto':
|
case 'ServerConfigDto':
|
||||||
|
|||||||
Generated
+2
@@ -289,6 +289,8 @@ Class | Method | HTTP request | Description
|
|||||||
- [AlbumUserCreateDto](doc//AlbumUserCreateDto.md)
|
- [AlbumUserCreateDto](doc//AlbumUserCreateDto.md)
|
||||||
- [AlbumUserResponseDto](doc//AlbumUserResponseDto.md)
|
- [AlbumUserResponseDto](doc//AlbumUserResponseDto.md)
|
||||||
- [AlbumUserRole](doc//AlbumUserRole.md)
|
- [AlbumUserRole](doc//AlbumUserRole.md)
|
||||||
|
- [AlbumsResponse](doc//AlbumsResponse.md)
|
||||||
|
- [AlbumsUpdate](doc//AlbumsUpdate.md)
|
||||||
- [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md)
|
- [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md)
|
||||||
- [AssetBulkDeleteDto](doc//AssetBulkDeleteDto.md)
|
- [AssetBulkDeleteDto](doc//AssetBulkDeleteDto.md)
|
||||||
- [AssetBulkUpdateDto](doc//AssetBulkUpdateDto.md)
|
- [AssetBulkUpdateDto](doc//AssetBulkUpdateDto.md)
|
||||||
|
|||||||
Generated
+2
@@ -78,6 +78,8 @@ part 'model/album_user_add_dto.dart';
|
|||||||
part 'model/album_user_create_dto.dart';
|
part 'model/album_user_create_dto.dart';
|
||||||
part 'model/album_user_response_dto.dart';
|
part 'model/album_user_response_dto.dart';
|
||||||
part 'model/album_user_role.dart';
|
part 'model/album_user_role.dart';
|
||||||
|
part 'model/albums_response.dart';
|
||||||
|
part 'model/albums_update.dart';
|
||||||
part 'model/all_job_status_response_dto.dart';
|
part 'model/all_job_status_response_dto.dart';
|
||||||
part 'model/asset_bulk_delete_dto.dart';
|
part 'model/asset_bulk_delete_dto.dart';
|
||||||
part 'model/asset_bulk_update_dto.dart';
|
part 'model/asset_bulk_update_dto.dart';
|
||||||
|
|||||||
Generated
+42
@@ -20,28 +20,39 @@ class TimelineApi {
|
|||||||
/// Parameters:
|
/// Parameters:
|
||||||
///
|
///
|
||||||
/// * [String] timeBucket (required):
|
/// * [String] timeBucket (required):
|
||||||
|
/// Time bucket identifier in YYYY-MM-DD format (e.g., \"2024-01-01\" for January 2024)
|
||||||
///
|
///
|
||||||
/// * [String] albumId:
|
/// * [String] albumId:
|
||||||
|
/// Filter assets belonging to a specific album
|
||||||
///
|
///
|
||||||
/// * [bool] isFavorite:
|
/// * [bool] isFavorite:
|
||||||
|
/// Filter by favorite status (true for favorites only, false for non-favorites only)
|
||||||
///
|
///
|
||||||
/// * [bool] isTrashed:
|
/// * [bool] isTrashed:
|
||||||
|
/// Filter by trash status (true for trashed assets only, false for non-trashed only)
|
||||||
///
|
///
|
||||||
/// * [String] key:
|
/// * [String] key:
|
||||||
///
|
///
|
||||||
/// * [AssetOrder] order:
|
/// * [AssetOrder] order:
|
||||||
|
/// Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)
|
||||||
///
|
///
|
||||||
/// * [String] personId:
|
/// * [String] personId:
|
||||||
|
/// Filter assets containing a specific person (face recognition)
|
||||||
///
|
///
|
||||||
/// * [String] tagId:
|
/// * [String] tagId:
|
||||||
|
/// Filter assets with a specific tag
|
||||||
///
|
///
|
||||||
/// * [String] userId:
|
/// * [String] userId:
|
||||||
|
/// Filter assets by specific user ID
|
||||||
///
|
///
|
||||||
/// * [AssetVisibility] visibility:
|
/// * [AssetVisibility] visibility:
|
||||||
|
/// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)
|
||||||
///
|
///
|
||||||
/// * [bool] withPartners:
|
/// * [bool] withPartners:
|
||||||
|
/// Include assets shared by partners
|
||||||
///
|
///
|
||||||
/// * [bool] withStacked:
|
/// * [bool] withStacked:
|
||||||
|
/// Include stacked assets in the response. When true, only primary assets from stacks are returned.
|
||||||
Future<Response> getTimeBucketWithHttpInfo(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
|
Future<Response> getTimeBucketWithHttpInfo(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
|
||||||
// ignore: prefer_const_declarations
|
// ignore: prefer_const_declarations
|
||||||
final apiPath = r'/timeline/bucket';
|
final apiPath = r'/timeline/bucket';
|
||||||
@@ -105,28 +116,39 @@ class TimelineApi {
|
|||||||
/// Parameters:
|
/// Parameters:
|
||||||
///
|
///
|
||||||
/// * [String] timeBucket (required):
|
/// * [String] timeBucket (required):
|
||||||
|
/// Time bucket identifier in YYYY-MM-DD format (e.g., \"2024-01-01\" for January 2024)
|
||||||
///
|
///
|
||||||
/// * [String] albumId:
|
/// * [String] albumId:
|
||||||
|
/// Filter assets belonging to a specific album
|
||||||
///
|
///
|
||||||
/// * [bool] isFavorite:
|
/// * [bool] isFavorite:
|
||||||
|
/// Filter by favorite status (true for favorites only, false for non-favorites only)
|
||||||
///
|
///
|
||||||
/// * [bool] isTrashed:
|
/// * [bool] isTrashed:
|
||||||
|
/// Filter by trash status (true for trashed assets only, false for non-trashed only)
|
||||||
///
|
///
|
||||||
/// * [String] key:
|
/// * [String] key:
|
||||||
///
|
///
|
||||||
/// * [AssetOrder] order:
|
/// * [AssetOrder] order:
|
||||||
|
/// Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)
|
||||||
///
|
///
|
||||||
/// * [String] personId:
|
/// * [String] personId:
|
||||||
|
/// Filter assets containing a specific person (face recognition)
|
||||||
///
|
///
|
||||||
/// * [String] tagId:
|
/// * [String] tagId:
|
||||||
|
/// Filter assets with a specific tag
|
||||||
///
|
///
|
||||||
/// * [String] userId:
|
/// * [String] userId:
|
||||||
|
/// Filter assets by specific user ID
|
||||||
///
|
///
|
||||||
/// * [AssetVisibility] visibility:
|
/// * [AssetVisibility] visibility:
|
||||||
|
/// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)
|
||||||
///
|
///
|
||||||
/// * [bool] withPartners:
|
/// * [bool] withPartners:
|
||||||
|
/// Include assets shared by partners
|
||||||
///
|
///
|
||||||
/// * [bool] withStacked:
|
/// * [bool] withStacked:
|
||||||
|
/// Include stacked assets in the response. When true, only primary assets from stacks are returned.
|
||||||
Future<TimeBucketAssetResponseDto?> getTimeBucket(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
|
Future<TimeBucketAssetResponseDto?> getTimeBucket(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
|
||||||
final response = await getTimeBucketWithHttpInfo(timeBucket, albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, visibility: visibility, withPartners: withPartners, withStacked: withStacked, );
|
final response = await getTimeBucketWithHttpInfo(timeBucket, albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, visibility: visibility, withPartners: withPartners, withStacked: withStacked, );
|
||||||
if (response.statusCode >= HttpStatus.badRequest) {
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
@@ -146,26 +168,36 @@ class TimelineApi {
|
|||||||
/// Parameters:
|
/// Parameters:
|
||||||
///
|
///
|
||||||
/// * [String] albumId:
|
/// * [String] albumId:
|
||||||
|
/// Filter assets belonging to a specific album
|
||||||
///
|
///
|
||||||
/// * [bool] isFavorite:
|
/// * [bool] isFavorite:
|
||||||
|
/// Filter by favorite status (true for favorites only, false for non-favorites only)
|
||||||
///
|
///
|
||||||
/// * [bool] isTrashed:
|
/// * [bool] isTrashed:
|
||||||
|
/// Filter by trash status (true for trashed assets only, false for non-trashed only)
|
||||||
///
|
///
|
||||||
/// * [String] key:
|
/// * [String] key:
|
||||||
///
|
///
|
||||||
/// * [AssetOrder] order:
|
/// * [AssetOrder] order:
|
||||||
|
/// Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)
|
||||||
///
|
///
|
||||||
/// * [String] personId:
|
/// * [String] personId:
|
||||||
|
/// Filter assets containing a specific person (face recognition)
|
||||||
///
|
///
|
||||||
/// * [String] tagId:
|
/// * [String] tagId:
|
||||||
|
/// Filter assets with a specific tag
|
||||||
///
|
///
|
||||||
/// * [String] userId:
|
/// * [String] userId:
|
||||||
|
/// Filter assets by specific user ID
|
||||||
///
|
///
|
||||||
/// * [AssetVisibility] visibility:
|
/// * [AssetVisibility] visibility:
|
||||||
|
/// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)
|
||||||
///
|
///
|
||||||
/// * [bool] withPartners:
|
/// * [bool] withPartners:
|
||||||
|
/// Include assets shared by partners
|
||||||
///
|
///
|
||||||
/// * [bool] withStacked:
|
/// * [bool] withStacked:
|
||||||
|
/// Include stacked assets in the response. When true, only primary assets from stacks are returned.
|
||||||
Future<Response> getTimeBucketsWithHttpInfo({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
|
Future<Response> getTimeBucketsWithHttpInfo({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
|
||||||
// ignore: prefer_const_declarations
|
// ignore: prefer_const_declarations
|
||||||
final apiPath = r'/timeline/buckets';
|
final apiPath = r'/timeline/buckets';
|
||||||
@@ -228,26 +260,36 @@ class TimelineApi {
|
|||||||
/// Parameters:
|
/// Parameters:
|
||||||
///
|
///
|
||||||
/// * [String] albumId:
|
/// * [String] albumId:
|
||||||
|
/// Filter assets belonging to a specific album
|
||||||
///
|
///
|
||||||
/// * [bool] isFavorite:
|
/// * [bool] isFavorite:
|
||||||
|
/// Filter by favorite status (true for favorites only, false for non-favorites only)
|
||||||
///
|
///
|
||||||
/// * [bool] isTrashed:
|
/// * [bool] isTrashed:
|
||||||
|
/// Filter by trash status (true for trashed assets only, false for non-trashed only)
|
||||||
///
|
///
|
||||||
/// * [String] key:
|
/// * [String] key:
|
||||||
///
|
///
|
||||||
/// * [AssetOrder] order:
|
/// * [AssetOrder] order:
|
||||||
|
/// Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)
|
||||||
///
|
///
|
||||||
/// * [String] personId:
|
/// * [String] personId:
|
||||||
|
/// Filter assets containing a specific person (face recognition)
|
||||||
///
|
///
|
||||||
/// * [String] tagId:
|
/// * [String] tagId:
|
||||||
|
/// Filter assets with a specific tag
|
||||||
///
|
///
|
||||||
/// * [String] userId:
|
/// * [String] userId:
|
||||||
|
/// Filter assets by specific user ID
|
||||||
///
|
///
|
||||||
/// * [AssetVisibility] visibility:
|
/// * [AssetVisibility] visibility:
|
||||||
|
/// Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)
|
||||||
///
|
///
|
||||||
/// * [bool] withPartners:
|
/// * [bool] withPartners:
|
||||||
|
/// Include assets shared by partners
|
||||||
///
|
///
|
||||||
/// * [bool] withStacked:
|
/// * [bool] withStacked:
|
||||||
|
/// Include stacked assets in the response. When true, only primary assets from stacks are returned.
|
||||||
Future<List<TimeBucketsResponseDto>?> getTimeBuckets({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
|
Future<List<TimeBucketsResponseDto>?> getTimeBuckets({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
|
||||||
final response = await getTimeBucketsWithHttpInfo( albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, visibility: visibility, withPartners: withPartners, withStacked: withStacked, );
|
final response = await getTimeBucketsWithHttpInfo( albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, visibility: visibility, withPartners: withPartners, withStacked: withStacked, );
|
||||||
if (response.statusCode >= HttpStatus.badRequest) {
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
|||||||
Generated
+4
@@ -212,6 +212,10 @@ class ApiClient {
|
|||||||
return AlbumUserResponseDto.fromJson(value);
|
return AlbumUserResponseDto.fromJson(value);
|
||||||
case 'AlbumUserRole':
|
case 'AlbumUserRole':
|
||||||
return AlbumUserRoleTypeTransformer().decode(value);
|
return AlbumUserRoleTypeTransformer().decode(value);
|
||||||
|
case 'AlbumsResponse':
|
||||||
|
return AlbumsResponse.fromJson(value);
|
||||||
|
case 'AlbumsUpdate':
|
||||||
|
return AlbumsUpdate.fromJson(value);
|
||||||
case 'AllJobStatusResponseDto':
|
case 'AllJobStatusResponseDto':
|
||||||
return AllJobStatusResponseDto.fromJson(value);
|
return AllJobStatusResponseDto.fromJson(value);
|
||||||
case 'AssetBulkDeleteDto':
|
case 'AssetBulkDeleteDto':
|
||||||
|
|||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class AlbumsResponse {
|
||||||
|
/// Returns a new [AlbumsResponse] instance.
|
||||||
|
AlbumsResponse({
|
||||||
|
this.defaultAssetOrder = AssetOrder.desc,
|
||||||
|
});
|
||||||
|
|
||||||
|
AssetOrder defaultAssetOrder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is AlbumsResponse &&
|
||||||
|
other.defaultAssetOrder == defaultAssetOrder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(defaultAssetOrder.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'AlbumsResponse[defaultAssetOrder=$defaultAssetOrder]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'defaultAssetOrder'] = this.defaultAssetOrder;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [AlbumsResponse] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static AlbumsResponse? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "AlbumsResponse");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return AlbumsResponse(
|
||||||
|
defaultAssetOrder: AssetOrder.fromJson(json[r'defaultAssetOrder'])!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<AlbumsResponse> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <AlbumsResponse>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = AlbumsResponse.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, AlbumsResponse> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, AlbumsResponse>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = AlbumsResponse.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of AlbumsResponse-objects as value to a dart map
|
||||||
|
static Map<String, List<AlbumsResponse>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<AlbumsResponse>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = AlbumsResponse.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'defaultAssetOrder',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class AlbumsUpdate {
|
||||||
|
/// Returns a new [AlbumsUpdate] instance.
|
||||||
|
AlbumsUpdate({
|
||||||
|
this.defaultAssetOrder,
|
||||||
|
});
|
||||||
|
|
||||||
|
///
|
||||||
|
/// Please note: This property should have been non-nullable! Since the specification file
|
||||||
|
/// does not include a default value (using the "default:" property), however, the generated
|
||||||
|
/// source code must fall back to having a nullable type.
|
||||||
|
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||||
|
///
|
||||||
|
AssetOrder? defaultAssetOrder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is AlbumsUpdate &&
|
||||||
|
other.defaultAssetOrder == defaultAssetOrder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(defaultAssetOrder == null ? 0 : defaultAssetOrder!.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'AlbumsUpdate[defaultAssetOrder=$defaultAssetOrder]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
if (this.defaultAssetOrder != null) {
|
||||||
|
json[r'defaultAssetOrder'] = this.defaultAssetOrder;
|
||||||
|
} else {
|
||||||
|
// json[r'defaultAssetOrder'] = null;
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [AlbumsUpdate] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static AlbumsUpdate? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "AlbumsUpdate");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return AlbumsUpdate(
|
||||||
|
defaultAssetOrder: AssetOrder.fromJson(json[r'defaultAssetOrder']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<AlbumsUpdate> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <AlbumsUpdate>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = AlbumsUpdate.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, AlbumsUpdate> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, AlbumsUpdate>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = AlbumsUpdate.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of AlbumsUpdate-objects as value to a dart map
|
||||||
|
static Map<String, List<AlbumsUpdate>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<AlbumsUpdate>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = AlbumsUpdate.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -65,8 +65,10 @@ class AssetResponseDto {
|
|||||||
///
|
///
|
||||||
ExifResponseDto? exifInfo;
|
ExifResponseDto? exifInfo;
|
||||||
|
|
||||||
|
/// The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken.
|
||||||
DateTime fileCreatedAt;
|
DateTime fileCreatedAt;
|
||||||
|
|
||||||
|
/// The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken.
|
||||||
DateTime fileModifiedAt;
|
DateTime fileModifiedAt;
|
||||||
|
|
||||||
bool hasMetadata;
|
bool hasMetadata;
|
||||||
@@ -86,6 +88,7 @@ class AssetResponseDto {
|
|||||||
|
|
||||||
String? livePhotoVideoId;
|
String? livePhotoVideoId;
|
||||||
|
|
||||||
|
/// The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer's local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by \"local\" days and months.
|
||||||
DateTime localDateTime;
|
DateTime localDateTime;
|
||||||
|
|
||||||
String originalFileName;
|
String originalFileName;
|
||||||
@@ -131,6 +134,7 @@ class AssetResponseDto {
|
|||||||
|
|
||||||
List<AssetFaceWithoutPersonResponseDto> unassignedFaces;
|
List<AssetFaceWithoutPersonResponseDto> unassignedFaces;
|
||||||
|
|
||||||
|
/// The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified.
|
||||||
DateTime updatedAt;
|
DateTime updatedAt;
|
||||||
|
|
||||||
AssetVisibility visibility;
|
AssetVisibility visibility;
|
||||||
|
|||||||
+35
-10
@@ -16,12 +16,13 @@ class TimeBucketAssetResponseDto {
|
|||||||
this.city = const [],
|
this.city = const [],
|
||||||
this.country = const [],
|
this.country = const [],
|
||||||
this.duration = const [],
|
this.duration = const [],
|
||||||
|
this.fileCreatedAt = const [],
|
||||||
this.id = const [],
|
this.id = const [],
|
||||||
this.isFavorite = const [],
|
this.isFavorite = const [],
|
||||||
this.isImage = const [],
|
this.isImage = const [],
|
||||||
this.isTrashed = const [],
|
this.isTrashed = const [],
|
||||||
this.livePhotoVideoId = const [],
|
this.livePhotoVideoId = const [],
|
||||||
this.localDateTime = const [],
|
this.localOffsetHours = const [],
|
||||||
this.ownerId = const [],
|
this.ownerId = const [],
|
||||||
this.projectionType = const [],
|
this.projectionType = const [],
|
||||||
this.ratio = const [],
|
this.ratio = const [],
|
||||||
@@ -30,35 +31,52 @@ class TimeBucketAssetResponseDto {
|
|||||||
this.visibility = const [],
|
this.visibility = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Array of city names extracted from EXIF GPS data
|
||||||
List<String?> city;
|
List<String?> city;
|
||||||
|
|
||||||
|
/// Array of country names extracted from EXIF GPS data
|
||||||
List<String?> country;
|
List<String?> country;
|
||||||
|
|
||||||
|
/// Array of video durations in HH:MM:SS format (null for images)
|
||||||
List<String?> duration;
|
List<String?> duration;
|
||||||
|
|
||||||
|
/// Array of file creation timestamps in UTC (ISO 8601 format, without timezone)
|
||||||
|
List<String> fileCreatedAt;
|
||||||
|
|
||||||
|
/// Array of asset IDs in the time bucket
|
||||||
List<String> id;
|
List<String> id;
|
||||||
|
|
||||||
|
/// Array indicating whether each asset is favorited
|
||||||
List<bool> isFavorite;
|
List<bool> isFavorite;
|
||||||
|
|
||||||
|
/// Array indicating whether each asset is an image (false for videos)
|
||||||
List<bool> isImage;
|
List<bool> isImage;
|
||||||
|
|
||||||
|
/// Array indicating whether each asset is in the trash
|
||||||
List<bool> isTrashed;
|
List<bool> isTrashed;
|
||||||
|
|
||||||
|
/// Array of live photo video asset IDs (null for non-live photos)
|
||||||
List<String?> livePhotoVideoId;
|
List<String?> livePhotoVideoId;
|
||||||
|
|
||||||
List<String> localDateTime;
|
/// Array of UTC offset hours at the time each photo was taken. Positive values are east of UTC, negative values are west of UTC. Values may be fractional (e.g., 5.5 for +05:30, -9.75 for -09:45). Applying this offset to 'fileCreatedAt' will give you the time the photo was taken from the photographer's perspective.
|
||||||
|
List<num> localOffsetHours;
|
||||||
|
|
||||||
|
/// Array of owner IDs for each asset
|
||||||
List<String> ownerId;
|
List<String> ownerId;
|
||||||
|
|
||||||
|
/// Array of projection types for 360° content (e.g., \"EQUIRECTANGULAR\", \"CUBEFACE\", \"CYLINDRICAL\")
|
||||||
List<String?> projectionType;
|
List<String?> projectionType;
|
||||||
|
|
||||||
|
/// Array of aspect ratios (width/height) for each asset
|
||||||
List<num> ratio;
|
List<num> ratio;
|
||||||
|
|
||||||
/// (stack ID, stack asset count) tuple
|
/// Array of stack information as [stackId, assetCount] tuples (null for non-stacked assets)
|
||||||
List<List<String>?> stack;
|
List<List<String>?> stack;
|
||||||
|
|
||||||
|
/// Array of BlurHash strings for generating asset previews (base64 encoded)
|
||||||
List<String?> thumbhash;
|
List<String?> thumbhash;
|
||||||
|
|
||||||
|
/// Array of visibility statuses for each asset (e.g., ARCHIVE, TIMELINE, HIDDEN, LOCKED)
|
||||||
List<AssetVisibility> visibility;
|
List<AssetVisibility> visibility;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -66,12 +84,13 @@ class TimeBucketAssetResponseDto {
|
|||||||
_deepEquality.equals(other.city, city) &&
|
_deepEquality.equals(other.city, city) &&
|
||||||
_deepEquality.equals(other.country, country) &&
|
_deepEquality.equals(other.country, country) &&
|
||||||
_deepEquality.equals(other.duration, duration) &&
|
_deepEquality.equals(other.duration, duration) &&
|
||||||
|
_deepEquality.equals(other.fileCreatedAt, fileCreatedAt) &&
|
||||||
_deepEquality.equals(other.id, id) &&
|
_deepEquality.equals(other.id, id) &&
|
||||||
_deepEquality.equals(other.isFavorite, isFavorite) &&
|
_deepEquality.equals(other.isFavorite, isFavorite) &&
|
||||||
_deepEquality.equals(other.isImage, isImage) &&
|
_deepEquality.equals(other.isImage, isImage) &&
|
||||||
_deepEquality.equals(other.isTrashed, isTrashed) &&
|
_deepEquality.equals(other.isTrashed, isTrashed) &&
|
||||||
_deepEquality.equals(other.livePhotoVideoId, livePhotoVideoId) &&
|
_deepEquality.equals(other.livePhotoVideoId, livePhotoVideoId) &&
|
||||||
_deepEquality.equals(other.localDateTime, localDateTime) &&
|
_deepEquality.equals(other.localOffsetHours, localOffsetHours) &&
|
||||||
_deepEquality.equals(other.ownerId, ownerId) &&
|
_deepEquality.equals(other.ownerId, ownerId) &&
|
||||||
_deepEquality.equals(other.projectionType, projectionType) &&
|
_deepEquality.equals(other.projectionType, projectionType) &&
|
||||||
_deepEquality.equals(other.ratio, ratio) &&
|
_deepEquality.equals(other.ratio, ratio) &&
|
||||||
@@ -85,12 +104,13 @@ class TimeBucketAssetResponseDto {
|
|||||||
(city.hashCode) +
|
(city.hashCode) +
|
||||||
(country.hashCode) +
|
(country.hashCode) +
|
||||||
(duration.hashCode) +
|
(duration.hashCode) +
|
||||||
|
(fileCreatedAt.hashCode) +
|
||||||
(id.hashCode) +
|
(id.hashCode) +
|
||||||
(isFavorite.hashCode) +
|
(isFavorite.hashCode) +
|
||||||
(isImage.hashCode) +
|
(isImage.hashCode) +
|
||||||
(isTrashed.hashCode) +
|
(isTrashed.hashCode) +
|
||||||
(livePhotoVideoId.hashCode) +
|
(livePhotoVideoId.hashCode) +
|
||||||
(localDateTime.hashCode) +
|
(localOffsetHours.hashCode) +
|
||||||
(ownerId.hashCode) +
|
(ownerId.hashCode) +
|
||||||
(projectionType.hashCode) +
|
(projectionType.hashCode) +
|
||||||
(ratio.hashCode) +
|
(ratio.hashCode) +
|
||||||
@@ -99,19 +119,20 @@ class TimeBucketAssetResponseDto {
|
|||||||
(visibility.hashCode);
|
(visibility.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'TimeBucketAssetResponseDto[city=$city, country=$country, duration=$duration, id=$id, isFavorite=$isFavorite, isImage=$isImage, isTrashed=$isTrashed, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, ownerId=$ownerId, projectionType=$projectionType, ratio=$ratio, stack=$stack, thumbhash=$thumbhash, visibility=$visibility]';
|
String toString() => 'TimeBucketAssetResponseDto[city=$city, country=$country, duration=$duration, fileCreatedAt=$fileCreatedAt, id=$id, isFavorite=$isFavorite, isImage=$isImage, isTrashed=$isTrashed, livePhotoVideoId=$livePhotoVideoId, localOffsetHours=$localOffsetHours, ownerId=$ownerId, projectionType=$projectionType, ratio=$ratio, stack=$stack, thumbhash=$thumbhash, visibility=$visibility]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
json[r'city'] = this.city;
|
json[r'city'] = this.city;
|
||||||
json[r'country'] = this.country;
|
json[r'country'] = this.country;
|
||||||
json[r'duration'] = this.duration;
|
json[r'duration'] = this.duration;
|
||||||
|
json[r'fileCreatedAt'] = this.fileCreatedAt;
|
||||||
json[r'id'] = this.id;
|
json[r'id'] = this.id;
|
||||||
json[r'isFavorite'] = this.isFavorite;
|
json[r'isFavorite'] = this.isFavorite;
|
||||||
json[r'isImage'] = this.isImage;
|
json[r'isImage'] = this.isImage;
|
||||||
json[r'isTrashed'] = this.isTrashed;
|
json[r'isTrashed'] = this.isTrashed;
|
||||||
json[r'livePhotoVideoId'] = this.livePhotoVideoId;
|
json[r'livePhotoVideoId'] = this.livePhotoVideoId;
|
||||||
json[r'localDateTime'] = this.localDateTime;
|
json[r'localOffsetHours'] = this.localOffsetHours;
|
||||||
json[r'ownerId'] = this.ownerId;
|
json[r'ownerId'] = this.ownerId;
|
||||||
json[r'projectionType'] = this.projectionType;
|
json[r'projectionType'] = this.projectionType;
|
||||||
json[r'ratio'] = this.ratio;
|
json[r'ratio'] = this.ratio;
|
||||||
@@ -139,6 +160,9 @@ class TimeBucketAssetResponseDto {
|
|||||||
duration: json[r'duration'] is Iterable
|
duration: json[r'duration'] is Iterable
|
||||||
? (json[r'duration'] as Iterable).cast<String>().toList(growable: false)
|
? (json[r'duration'] as Iterable).cast<String>().toList(growable: false)
|
||||||
: const [],
|
: const [],
|
||||||
|
fileCreatedAt: json[r'fileCreatedAt'] is Iterable
|
||||||
|
? (json[r'fileCreatedAt'] as Iterable).cast<String>().toList(growable: false)
|
||||||
|
: const [],
|
||||||
id: json[r'id'] is Iterable
|
id: json[r'id'] is Iterable
|
||||||
? (json[r'id'] as Iterable).cast<String>().toList(growable: false)
|
? (json[r'id'] as Iterable).cast<String>().toList(growable: false)
|
||||||
: const [],
|
: const [],
|
||||||
@@ -154,8 +178,8 @@ class TimeBucketAssetResponseDto {
|
|||||||
livePhotoVideoId: json[r'livePhotoVideoId'] is Iterable
|
livePhotoVideoId: json[r'livePhotoVideoId'] is Iterable
|
||||||
? (json[r'livePhotoVideoId'] as Iterable).cast<String>().toList(growable: false)
|
? (json[r'livePhotoVideoId'] as Iterable).cast<String>().toList(growable: false)
|
||||||
: const [],
|
: const [],
|
||||||
localDateTime: json[r'localDateTime'] is Iterable
|
localOffsetHours: json[r'localOffsetHours'] is Iterable
|
||||||
? (json[r'localDateTime'] as Iterable).cast<String>().toList(growable: false)
|
? (json[r'localOffsetHours'] as Iterable).cast<num>().toList(growable: false)
|
||||||
: const [],
|
: const [],
|
||||||
ownerId: json[r'ownerId'] is Iterable
|
ownerId: json[r'ownerId'] is Iterable
|
||||||
? (json[r'ownerId'] as Iterable).cast<String>().toList(growable: false)
|
? (json[r'ownerId'] as Iterable).cast<String>().toList(growable: false)
|
||||||
@@ -225,12 +249,13 @@ class TimeBucketAssetResponseDto {
|
|||||||
'city',
|
'city',
|
||||||
'country',
|
'country',
|
||||||
'duration',
|
'duration',
|
||||||
|
'fileCreatedAt',
|
||||||
'id',
|
'id',
|
||||||
'isFavorite',
|
'isFavorite',
|
||||||
'isImage',
|
'isImage',
|
||||||
'isTrashed',
|
'isTrashed',
|
||||||
'livePhotoVideoId',
|
'livePhotoVideoId',
|
||||||
'localDateTime',
|
'localOffsetHours',
|
||||||
'ownerId',
|
'ownerId',
|
||||||
'projectionType',
|
'projectionType',
|
||||||
'ratio',
|
'ratio',
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ class TimeBucketsResponseDto {
|
|||||||
required this.timeBucket,
|
required this.timeBucket,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Number of assets in this time bucket
|
||||||
int count;
|
int count;
|
||||||
|
|
||||||
|
/// Time bucket identifier in YYYY-MM-DD format representing the start of the time period
|
||||||
String timeBucket;
|
String timeBucket;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
+9
-1
@@ -13,6 +13,7 @@ part of openapi.api;
|
|||||||
class UserPreferencesResponseDto {
|
class UserPreferencesResponseDto {
|
||||||
/// Returns a new [UserPreferencesResponseDto] instance.
|
/// Returns a new [UserPreferencesResponseDto] instance.
|
||||||
UserPreferencesResponseDto({
|
UserPreferencesResponseDto({
|
||||||
|
required this.albums,
|
||||||
required this.cast,
|
required this.cast,
|
||||||
required this.download,
|
required this.download,
|
||||||
required this.emailNotifications,
|
required this.emailNotifications,
|
||||||
@@ -25,6 +26,8 @@ class UserPreferencesResponseDto {
|
|||||||
required this.tags,
|
required this.tags,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
AlbumsResponse albums;
|
||||||
|
|
||||||
CastResponse cast;
|
CastResponse cast;
|
||||||
|
|
||||||
DownloadResponse download;
|
DownloadResponse download;
|
||||||
@@ -47,6 +50,7 @@ class UserPreferencesResponseDto {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is UserPreferencesResponseDto &&
|
bool operator ==(Object other) => identical(this, other) || other is UserPreferencesResponseDto &&
|
||||||
|
other.albums == albums &&
|
||||||
other.cast == cast &&
|
other.cast == cast &&
|
||||||
other.download == download &&
|
other.download == download &&
|
||||||
other.emailNotifications == emailNotifications &&
|
other.emailNotifications == emailNotifications &&
|
||||||
@@ -61,6 +65,7 @@ class UserPreferencesResponseDto {
|
|||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
|
(albums.hashCode) +
|
||||||
(cast.hashCode) +
|
(cast.hashCode) +
|
||||||
(download.hashCode) +
|
(download.hashCode) +
|
||||||
(emailNotifications.hashCode) +
|
(emailNotifications.hashCode) +
|
||||||
@@ -73,10 +78,11 @@ class UserPreferencesResponseDto {
|
|||||||
(tags.hashCode);
|
(tags.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'UserPreferencesResponseDto[cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]';
|
String toString() => 'UserPreferencesResponseDto[albums=$albums, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
|
json[r'albums'] = this.albums;
|
||||||
json[r'cast'] = this.cast;
|
json[r'cast'] = this.cast;
|
||||||
json[r'download'] = this.download;
|
json[r'download'] = this.download;
|
||||||
json[r'emailNotifications'] = this.emailNotifications;
|
json[r'emailNotifications'] = this.emailNotifications;
|
||||||
@@ -99,6 +105,7 @@ class UserPreferencesResponseDto {
|
|||||||
final json = value.cast<String, dynamic>();
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
return UserPreferencesResponseDto(
|
return UserPreferencesResponseDto(
|
||||||
|
albums: AlbumsResponse.fromJson(json[r'albums'])!,
|
||||||
cast: CastResponse.fromJson(json[r'cast'])!,
|
cast: CastResponse.fromJson(json[r'cast'])!,
|
||||||
download: DownloadResponse.fromJson(json[r'download'])!,
|
download: DownloadResponse.fromJson(json[r'download'])!,
|
||||||
emailNotifications: EmailNotificationsResponse.fromJson(json[r'emailNotifications'])!,
|
emailNotifications: EmailNotificationsResponse.fromJson(json[r'emailNotifications'])!,
|
||||||
@@ -156,6 +163,7 @@ class UserPreferencesResponseDto {
|
|||||||
|
|
||||||
/// The list of required keys that must be present in a JSON.
|
/// The list of required keys that must be present in a JSON.
|
||||||
static const requiredKeys = <String>{
|
static const requiredKeys = <String>{
|
||||||
|
'albums',
|
||||||
'cast',
|
'cast',
|
||||||
'download',
|
'download',
|
||||||
'emailNotifications',
|
'emailNotifications',
|
||||||
|
|||||||
+18
-1
@@ -13,6 +13,7 @@ part of openapi.api;
|
|||||||
class UserPreferencesUpdateDto {
|
class UserPreferencesUpdateDto {
|
||||||
/// Returns a new [UserPreferencesUpdateDto] instance.
|
/// Returns a new [UserPreferencesUpdateDto] instance.
|
||||||
UserPreferencesUpdateDto({
|
UserPreferencesUpdateDto({
|
||||||
|
this.albums,
|
||||||
this.avatar,
|
this.avatar,
|
||||||
this.cast,
|
this.cast,
|
||||||
this.download,
|
this.download,
|
||||||
@@ -26,6 +27,14 @@ class UserPreferencesUpdateDto {
|
|||||||
this.tags,
|
this.tags,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
///
|
||||||
|
/// Please note: This property should have been non-nullable! Since the specification file
|
||||||
|
/// does not include a default value (using the "default:" property), however, the generated
|
||||||
|
/// source code must fall back to having a nullable type.
|
||||||
|
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||||
|
///
|
||||||
|
AlbumsUpdate? albums;
|
||||||
|
|
||||||
///
|
///
|
||||||
/// Please note: This property should have been non-nullable! Since the specification file
|
/// Please note: This property should have been non-nullable! Since the specification file
|
||||||
/// does not include a default value (using the "default:" property), however, the generated
|
/// does not include a default value (using the "default:" property), however, the generated
|
||||||
@@ -116,6 +125,7 @@ class UserPreferencesUpdateDto {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is UserPreferencesUpdateDto &&
|
bool operator ==(Object other) => identical(this, other) || other is UserPreferencesUpdateDto &&
|
||||||
|
other.albums == albums &&
|
||||||
other.avatar == avatar &&
|
other.avatar == avatar &&
|
||||||
other.cast == cast &&
|
other.cast == cast &&
|
||||||
other.download == download &&
|
other.download == download &&
|
||||||
@@ -131,6 +141,7 @@ class UserPreferencesUpdateDto {
|
|||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
|
(albums == null ? 0 : albums!.hashCode) +
|
||||||
(avatar == null ? 0 : avatar!.hashCode) +
|
(avatar == null ? 0 : avatar!.hashCode) +
|
||||||
(cast == null ? 0 : cast!.hashCode) +
|
(cast == null ? 0 : cast!.hashCode) +
|
||||||
(download == null ? 0 : download!.hashCode) +
|
(download == null ? 0 : download!.hashCode) +
|
||||||
@@ -144,10 +155,15 @@ class UserPreferencesUpdateDto {
|
|||||||
(tags == null ? 0 : tags!.hashCode);
|
(tags == null ? 0 : tags!.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'UserPreferencesUpdateDto[avatar=$avatar, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]';
|
String toString() => 'UserPreferencesUpdateDto[albums=$albums, avatar=$avatar, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
|
if (this.albums != null) {
|
||||||
|
json[r'albums'] = this.albums;
|
||||||
|
} else {
|
||||||
|
// json[r'albums'] = null;
|
||||||
|
}
|
||||||
if (this.avatar != null) {
|
if (this.avatar != null) {
|
||||||
json[r'avatar'] = this.avatar;
|
json[r'avatar'] = this.avatar;
|
||||||
} else {
|
} else {
|
||||||
@@ -215,6 +231,7 @@ class UserPreferencesUpdateDto {
|
|||||||
final json = value.cast<String, dynamic>();
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
return UserPreferencesUpdateDto(
|
return UserPreferencesUpdateDto(
|
||||||
|
albums: AlbumsUpdate.fromJson(json[r'albums']),
|
||||||
avatar: AvatarUpdate.fromJson(json[r'avatar']),
|
avatar: AvatarUpdate.fromJson(json[r'avatar']),
|
||||||
cast: CastUpdate.fromJson(json[r'cast']),
|
cast: CastUpdate.fromJson(json[r'cast']),
|
||||||
download: DownloadUpdate.fromJson(json[r'download']),
|
download: DownloadUpdate.fromJson(json[r'download']),
|
||||||
|
|||||||
@@ -86,4 +86,7 @@ abstract class NativeSyncApi {
|
|||||||
|
|
||||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
List<PlatformAsset> getAssetsForAlbum(String albumId, {int? updatedTimeCond});
|
List<PlatformAsset> getAssetsForAlbum(String albumId, {int? updatedTimeCond});
|
||||||
|
|
||||||
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
|
List<Uint8List?> hashPaths(List<String> paths);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||||
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||||
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
class MockStoreService extends Mock implements StoreService {}
|
class MockStoreService extends Mock implements StoreService {}
|
||||||
@@ -8,3 +9,5 @@ class MockStoreService extends Mock implements StoreService {}
|
|||||||
class MockUserService extends Mock implements UserService {}
|
class MockUserService extends Mock implements UserService {}
|
||||||
|
|
||||||
class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
|
class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
|
||||||
|
|
||||||
|
class MockNativeSyncApi extends Mock implements NativeSyncApi {}
|
||||||
|
|||||||
@@ -1,425 +1,292 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:file/memory.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/device_asset.model.dart';
|
import 'package:immich_mobile/domain/models/local_album.model.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/domain/services/hash.service.dart';
|
||||||
import 'package:immich_mobile/services/background.service.dart';
|
|
||||||
import 'package:immich_mobile/services/hash.service.dart';
|
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
|
||||||
|
|
||||||
|
import '../../fixtures/album.stub.dart';
|
||||||
import '../../fixtures/asset.stub.dart';
|
import '../../fixtures/asset.stub.dart';
|
||||||
import '../../infrastructure/repository.mock.dart';
|
import '../../infrastructure/repository.mock.dart';
|
||||||
import '../../service.mocks.dart';
|
import '../service.mock.dart';
|
||||||
|
|
||||||
class MockAsset extends Mock implements Asset {}
|
class MockFile extends Mock implements File {}
|
||||||
|
|
||||||
class MockAssetEntity extends Mock implements AssetEntity {}
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late HashService sut;
|
late HashService sut;
|
||||||
late BackgroundService mockBackgroundService;
|
late MockLocalAlbumRepository mockAlbumRepo;
|
||||||
late IDeviceAssetRepository mockDeviceAssetRepository;
|
late MockLocalAssetRepository mockAssetRepo;
|
||||||
|
late MockStorageRepository mockStorageRepo;
|
||||||
|
late MockNativeSyncApi mockNativeApi;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
mockBackgroundService = MockBackgroundService();
|
mockAlbumRepo = MockLocalAlbumRepository();
|
||||||
mockDeviceAssetRepository = MockDeviceAssetRepository();
|
mockAssetRepo = MockLocalAssetRepository();
|
||||||
|
mockStorageRepo = MockStorageRepository();
|
||||||
|
mockNativeApi = MockNativeSyncApi();
|
||||||
|
|
||||||
sut = HashService(
|
sut = HashService(
|
||||||
deviceAssetRepository: mockDeviceAssetRepository,
|
localAlbumRepository: mockAlbumRepo,
|
||||||
backgroundService: mockBackgroundService,
|
localAssetRepository: mockAssetRepo,
|
||||||
|
storageRepository: mockStorageRepo,
|
||||||
|
nativeSyncApi: mockNativeApi,
|
||||||
);
|
);
|
||||||
|
|
||||||
when(() => mockDeviceAssetRepository.transaction<Null>(any()))
|
registerFallbackValue(LocalAlbumStub.recent);
|
||||||
.thenAnswer((_) async {
|
registerFallbackValue(LocalAssetStub.image1);
|
||||||
final capturedCallback = verify(
|
|
||||||
() => mockDeviceAssetRepository.transaction<Null>(captureAny()),
|
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
||||||
).captured;
|
|
||||||
// Invoke the transaction callback
|
|
||||||
await (capturedCallback.firstOrNull as Future<Null> Function()?)?.call();
|
|
||||||
});
|
|
||||||
when(() => mockDeviceAssetRepository.updateAll(any()))
|
|
||||||
.thenAnswer((_) async => true);
|
|
||||||
when(() => mockDeviceAssetRepository.deleteIds(any()))
|
|
||||||
.thenAnswer((_) async => true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group("HashService: No DeviceAsset entry", () {
|
group('HashService hashAssets', () {
|
||||||
test("hash successfully", () async {
|
test('processes albums in correct order', () async {
|
||||||
final (mockAsset, file, deviceAsset, hash) =
|
final album1 = LocalAlbumStub.recent
|
||||||
await _createAssetMock(AssetStub.image1);
|
.copyWith(id: "1", backupSelection: BackupSelection.none);
|
||||||
|
final album2 = LocalAlbumStub.recent
|
||||||
when(() => mockBackgroundService.digestFiles([file.path]))
|
.copyWith(id: "2", backupSelection: BackupSelection.excluded);
|
||||||
.thenAnswer((_) async => [hash]);
|
final album3 = LocalAlbumStub.recent
|
||||||
// No DB entries for this asset
|
.copyWith(id: "3", backupSelection: BackupSelection.selected);
|
||||||
when(
|
final album4 = LocalAlbumStub.recent.copyWith(
|
||||||
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
|
id: "4",
|
||||||
).thenAnswer((_) async => []);
|
backupSelection: BackupSelection.selected,
|
||||||
|
isIosSharedAlbum: true,
|
||||||
final result = await sut.hashAssets([mockAsset]);
|
|
||||||
|
|
||||||
// Verify we stored the new hash in DB
|
|
||||||
when(() => mockDeviceAssetRepository.transaction<Null>(any()))
|
|
||||||
.thenAnswer((_) async {
|
|
||||||
final capturedCallback = verify(
|
|
||||||
() => mockDeviceAssetRepository.transaction<Null>(captureAny()),
|
|
||||||
).captured;
|
|
||||||
// Invoke the transaction callback
|
|
||||||
await (capturedCallback.firstOrNull as Future<Null> Function()?)
|
|
||||||
?.call();
|
|
||||||
verify(
|
|
||||||
() => mockDeviceAssetRepository.updateAll([
|
|
||||||
deviceAsset.copyWith(modifiedTime: AssetStub.image1.fileModifiedAt),
|
|
||||||
]),
|
|
||||||
).called(1);
|
|
||||||
verify(() => mockDeviceAssetRepository.deleteIds([])).called(1);
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
result,
|
|
||||||
[AssetStub.image1.copyWith(checksum: base64.encode(hash))],
|
|
||||||
);
|
);
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group("HashService: Has DeviceAsset entry", () {
|
when(() => mockAlbumRepo.getAll())
|
||||||
test("when the asset is not modified", () async {
|
.thenAnswer((_) async => [album1, album2, album4, album3]);
|
||||||
final hash = utf8.encode("image1-hash");
|
when(() => mockAlbumRepo.getAssetsToHash(any()))
|
||||||
|
.thenAnswer((_) async => []);
|
||||||
|
|
||||||
when(
|
await sut.hashAssets();
|
||||||
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
|
|
||||||
).thenAnswer(
|
|
||||||
(_) async => [
|
|
||||||
DeviceAsset(
|
|
||||||
assetId: AssetStub.image1.localId!,
|
|
||||||
hash: hash,
|
|
||||||
modifiedTime: AssetStub.image1.fileModifiedAt,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
final result = await sut.hashAssets([AssetStub.image1]);
|
|
||||||
|
|
||||||
verifyNever(() => mockBackgroundService.digestFiles(any()));
|
verifyInOrder([
|
||||||
verifyNever(() => mockBackgroundService.digestFile(any()));
|
() => mockAlbumRepo.getAll(),
|
||||||
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
|
() => mockAlbumRepo.getAssetsToHash(album3.id),
|
||||||
verifyNever(() => mockDeviceAssetRepository.deleteIds(any()));
|
() => mockAlbumRepo.getAssetsToHash(album4.id),
|
||||||
|
() => mockAlbumRepo.getAssetsToHash(album1.id),
|
||||||
expect(result, [
|
() => mockAlbumRepo.getAssetsToHash(album2.id),
|
||||||
AssetStub.image1.copyWith(checksum: base64.encode(hash)),
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("hashed successful when asset is modified", () async {
|
test('skips albums with no assets to hash', () async {
|
||||||
final (mockAsset, file, deviceAsset, hash) =
|
when(() => mockAlbumRepo.getAll()).thenAnswer(
|
||||||
await _createAssetMock(AssetStub.image1);
|
(_) async => [LocalAlbumStub.recent.copyWith(assetCount: 0)],
|
||||||
|
);
|
||||||
|
when(() => mockAlbumRepo.getAssetsToHash(LocalAlbumStub.recent.id))
|
||||||
|
.thenAnswer((_) async => []);
|
||||||
|
|
||||||
when(() => mockBackgroundService.digestFiles([file.path]))
|
await sut.hashAssets();
|
||||||
.thenAnswer((_) async => [hash]);
|
|
||||||
when(
|
|
||||||
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
|
|
||||||
).thenAnswer((_) async => [deviceAsset]);
|
|
||||||
|
|
||||||
final result = await sut.hashAssets([mockAsset]);
|
verifyNever(() => mockStorageRepo.getFileForAsset(any()));
|
||||||
|
verifyNever(() => mockNativeApi.hashPaths(any()));
|
||||||
when(() => mockDeviceAssetRepository.transaction<Null>(any()))
|
|
||||||
.thenAnswer((_) async {
|
|
||||||
final capturedCallback = verify(
|
|
||||||
() => mockDeviceAssetRepository.transaction<Null>(captureAny()),
|
|
||||||
).captured;
|
|
||||||
// Invoke the transaction callback
|
|
||||||
await (capturedCallback.firstOrNull as Future<Null> Function()?)
|
|
||||||
?.call();
|
|
||||||
verify(
|
|
||||||
() => mockDeviceAssetRepository.updateAll([
|
|
||||||
deviceAsset.copyWith(modifiedTime: AssetStub.image1.fileModifiedAt),
|
|
||||||
]),
|
|
||||||
).called(1);
|
|
||||||
verify(() => mockDeviceAssetRepository.deleteIds([])).called(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
verify(() => mockBackgroundService.digestFiles([file.path])).called(1);
|
|
||||||
|
|
||||||
expect(result, [
|
|
||||||
AssetStub.image1.copyWith(checksum: base64.encode(hash)),
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group("HashService: Cleanup", () {
|
group('HashService _hashAssets', () {
|
||||||
late Asset mockAsset;
|
test('skips assets without files', () async {
|
||||||
late Uint8List hash;
|
final album = LocalAlbumStub.recent;
|
||||||
late DeviceAsset deviceAsset;
|
final asset = LocalAssetStub.image1;
|
||||||
late File file;
|
when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]);
|
||||||
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
||||||
|
.thenAnswer((_) async => [asset]);
|
||||||
|
when(() => mockStorageRepo.getFileForAsset(asset))
|
||||||
|
.thenAnswer((_) async => null);
|
||||||
|
|
||||||
setUp(() async {
|
await sut.hashAssets();
|
||||||
(mockAsset, file, deviceAsset, hash) =
|
|
||||||
await _createAssetMock(AssetStub.image1);
|
|
||||||
|
|
||||||
when(() => mockBackgroundService.digestFiles([file.path]))
|
verifyNever(() => mockNativeApi.hashPaths(any()));
|
||||||
.thenAnswer((_) async => [hash]);
|
|
||||||
when(
|
|
||||||
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
|
|
||||||
).thenAnswer((_) async => [deviceAsset]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("cleanups DeviceAsset when local file cannot be obtained", () async {
|
test('processes assets when available', () async {
|
||||||
when(() => mockAsset.local).thenThrow(Exception("File not found"));
|
final album = LocalAlbumStub.recent;
|
||||||
final result = await sut.hashAssets([mockAsset]);
|
final asset = LocalAssetStub.image1;
|
||||||
|
final mockFile = MockFile();
|
||||||
|
final hash = Uint8List.fromList(List.generate(20, (i) => i));
|
||||||
|
|
||||||
verifyNever(() => mockBackgroundService.digestFiles(any()));
|
when(() => mockFile.length()).thenAnswer((_) async => 1000);
|
||||||
verifyNever(() => mockBackgroundService.digestFile(any()));
|
when(() => mockFile.path).thenReturn('image-path');
|
||||||
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
|
|
||||||
verify(
|
|
||||||
() => mockDeviceAssetRepository.deleteIds([AssetStub.image1.localId!]),
|
|
||||||
).called(1);
|
|
||||||
|
|
||||||
expect(result, isEmpty);
|
when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]);
|
||||||
});
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
||||||
|
.thenAnswer((_) async => [asset]);
|
||||||
test("cleanups DeviceAsset when hashing failed", () async {
|
when(() => mockStorageRepo.getFileForAsset(asset))
|
||||||
when(() => mockDeviceAssetRepository.transaction<Null>(any()))
|
.thenAnswer((_) async => mockFile);
|
||||||
.thenAnswer((_) async {
|
when(() => mockNativeApi.hashPaths(['image-path'])).thenAnswer(
|
||||||
final capturedCallback = verify(
|
(_) async => [hash],
|
||||||
() => mockDeviceAssetRepository.transaction<Null>(captureAny()),
|
|
||||||
).captured;
|
|
||||||
// Invoke the transaction callback
|
|
||||||
await (capturedCallback.firstOrNull as Future<Null> Function()?)
|
|
||||||
?.call();
|
|
||||||
|
|
||||||
// Verify the callback inside the transaction because, doing it outside results
|
|
||||||
// in a small delay before the callback is invoked, resulting in other LOCs getting executed
|
|
||||||
// resulting in an incorrect state
|
|
||||||
//
|
|
||||||
// i.e, consider the following piece of code
|
|
||||||
// await _deviceAssetRepository.transaction(() async {
|
|
||||||
// await _deviceAssetRepository.updateAll(toBeAdded);
|
|
||||||
// await _deviceAssetRepository.deleteIds(toBeDeleted);
|
|
||||||
// });
|
|
||||||
// toBeDeleted.clear();
|
|
||||||
// since the transaction method is mocked, the callback is not invoked until it is captured
|
|
||||||
// and executed manually in the next event loop. However, the toBeDeleted.clear() is executed
|
|
||||||
// immediately once the transaction stub is executed, resulting in the deleteIds method being
|
|
||||||
// called with an empty list.
|
|
||||||
//
|
|
||||||
// To avoid this, we capture the callback and execute it within the transaction stub itself
|
|
||||||
// and verify the results inside the transaction stub
|
|
||||||
verify(() => mockDeviceAssetRepository.updateAll([])).called(1);
|
|
||||||
verify(
|
|
||||||
() =>
|
|
||||||
mockDeviceAssetRepository.deleteIds([AssetStub.image1.localId!]),
|
|
||||||
).called(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
when(() => mockBackgroundService.digestFiles([file.path])).thenAnswer(
|
|
||||||
// Invalid hash, length != 20
|
|
||||||
(_) async => [Uint8List.fromList(hash.slice(2).toList())],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final result = await sut.hashAssets([mockAsset]);
|
await sut.hashAssets();
|
||||||
|
|
||||||
verify(() => mockBackgroundService.digestFiles([file.path])).called(1);
|
verify(() => mockNativeApi.hashPaths(['image-path'])).called(1);
|
||||||
expect(result, isEmpty);
|
final captured = verify(() => mockAssetRepo.updateHashes(captureAny()))
|
||||||
});
|
.captured
|
||||||
});
|
.first as List<LocalAsset>;
|
||||||
|
expect(captured.length, 1);
|
||||||
group("HashService: Batch processing", () {
|
expect(captured[0].checksum, base64.encode(hash));
|
||||||
test("processes assets in batches when size limit is reached", () async {
|
|
||||||
// Setup multiple assets with large file sizes
|
|
||||||
final (mock1, mock2, mock3) = await (
|
|
||||||
_createAssetMock(AssetStub.image1),
|
|
||||||
_createAssetMock(AssetStub.image2),
|
|
||||||
_createAssetMock(AssetStub.image3),
|
|
||||||
).wait;
|
|
||||||
|
|
||||||
final (asset1, file1, deviceAsset1, hash1) = mock1;
|
|
||||||
final (asset2, file2, deviceAsset2, hash2) = mock2;
|
|
||||||
final (asset3, file3, deviceAsset3, hash3) = mock3;
|
|
||||||
|
|
||||||
when(() => mockDeviceAssetRepository.getByIds(any()))
|
|
||||||
.thenAnswer((_) async => []);
|
|
||||||
|
|
||||||
// Setup for multiple batch processing calls
|
|
||||||
when(() => mockBackgroundService.digestFiles([file1.path, file2.path]))
|
|
||||||
.thenAnswer((_) async => [hash1, hash2]);
|
|
||||||
when(() => mockBackgroundService.digestFiles([file3.path]))
|
|
||||||
.thenAnswer((_) async => [hash3]);
|
|
||||||
|
|
||||||
final size = await file1.length() + await file2.length();
|
|
||||||
|
|
||||||
sut = HashService(
|
|
||||||
deviceAssetRepository: mockDeviceAssetRepository,
|
|
||||||
backgroundService: mockBackgroundService,
|
|
||||||
batchSizeLimit: size,
|
|
||||||
);
|
|
||||||
final result = await sut.hashAssets([asset1, asset2, asset3]);
|
|
||||||
|
|
||||||
// Verify multiple batch process calls
|
|
||||||
verify(() => mockBackgroundService.digestFiles([file1.path, file2.path]))
|
|
||||||
.called(1);
|
|
||||||
verify(() => mockBackgroundService.digestFiles([file3.path])).called(1);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
result,
|
|
||||||
[
|
|
||||||
AssetStub.image1.copyWith(checksum: base64.encode(hash1)),
|
|
||||||
AssetStub.image2.copyWith(checksum: base64.encode(hash2)),
|
|
||||||
AssetStub.image3.copyWith(checksum: base64.encode(hash3)),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("processes assets in batches when file limit is reached", () async {
|
test('handles failed hashes', () async {
|
||||||
// Setup multiple assets with large file sizes
|
final album = LocalAlbumStub.recent;
|
||||||
final (mock1, mock2, mock3) = await (
|
final asset = LocalAssetStub.image1;
|
||||||
_createAssetMock(AssetStub.image1),
|
final mockFile = MockFile();
|
||||||
_createAssetMock(AssetStub.image2),
|
when(() => mockFile.length()).thenAnswer((_) async => 1000);
|
||||||
_createAssetMock(AssetStub.image3),
|
when(() => mockFile.path).thenReturn('image-path');
|
||||||
).wait;
|
|
||||||
|
|
||||||
final (asset1, file1, deviceAsset1, hash1) = mock1;
|
when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]);
|
||||||
final (asset2, file2, deviceAsset2, hash2) = mock2;
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
||||||
final (asset3, file3, deviceAsset3, hash3) = mock3;
|
.thenAnswer((_) async => [asset]);
|
||||||
|
when(() => mockStorageRepo.getFileForAsset(asset))
|
||||||
|
.thenAnswer((_) async => mockFile);
|
||||||
|
when(() => mockNativeApi.hashPaths(['image-path']))
|
||||||
|
.thenAnswer((_) async => [null]);
|
||||||
|
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
||||||
|
|
||||||
when(() => mockDeviceAssetRepository.getByIds(any()))
|
await sut.hashAssets();
|
||||||
.thenAnswer((_) async => []);
|
|
||||||
|
|
||||||
when(() => mockBackgroundService.digestFiles([file1.path]))
|
final captured = verify(() => mockAssetRepo.updateHashes(captureAny()))
|
||||||
.thenAnswer((_) async => [hash1]);
|
.captured
|
||||||
when(() => mockBackgroundService.digestFiles([file2.path]))
|
.first as List<LocalAsset>;
|
||||||
.thenAnswer((_) async => [hash2]);
|
expect(captured.length, 0);
|
||||||
when(() => mockBackgroundService.digestFiles([file3.path]))
|
});
|
||||||
.thenAnswer((_) async => [hash3]);
|
|
||||||
|
|
||||||
sut = HashService(
|
test('handles invalid hash length', () async {
|
||||||
deviceAssetRepository: mockDeviceAssetRepository,
|
final album = LocalAlbumStub.recent;
|
||||||
backgroundService: mockBackgroundService,
|
final asset = LocalAssetStub.image1;
|
||||||
|
final mockFile = MockFile();
|
||||||
|
when(() => mockFile.length()).thenAnswer((_) async => 1000);
|
||||||
|
when(() => mockFile.path).thenReturn('image-path');
|
||||||
|
|
||||||
|
when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]);
|
||||||
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
||||||
|
.thenAnswer((_) async => [asset]);
|
||||||
|
when(() => mockStorageRepo.getFileForAsset(asset))
|
||||||
|
.thenAnswer((_) async => mockFile);
|
||||||
|
|
||||||
|
final invalidHash = Uint8List.fromList([1, 2, 3]);
|
||||||
|
when(() => mockNativeApi.hashPaths(['image-path']))
|
||||||
|
.thenAnswer((_) async => [invalidHash]);
|
||||||
|
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
||||||
|
|
||||||
|
await sut.hashAssets();
|
||||||
|
|
||||||
|
final captured = verify(() => mockAssetRepo.updateHashes(captureAny()))
|
||||||
|
.captured
|
||||||
|
.first as List<LocalAsset>;
|
||||||
|
expect(captured.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('batches by file count limit', () async {
|
||||||
|
final sut = HashService(
|
||||||
|
localAlbumRepository: mockAlbumRepo,
|
||||||
|
localAssetRepository: mockAssetRepo,
|
||||||
|
storageRepository: mockStorageRepo,
|
||||||
|
nativeSyncApi: mockNativeApi,
|
||||||
batchFileLimit: 1,
|
batchFileLimit: 1,
|
||||||
);
|
);
|
||||||
final result = await sut.hashAssets([asset1, asset2, asset3]);
|
|
||||||
|
|
||||||
// Verify multiple batch process calls
|
final album = LocalAlbumStub.recent;
|
||||||
verify(() => mockBackgroundService.digestFiles([file1.path])).called(1);
|
final asset1 = LocalAssetStub.image1;
|
||||||
verify(() => mockBackgroundService.digestFiles([file2.path])).called(1);
|
final asset2 = LocalAssetStub.image2;
|
||||||
verify(() => mockBackgroundService.digestFiles([file3.path])).called(1);
|
final mockFile1 = MockFile();
|
||||||
|
final mockFile2 = MockFile();
|
||||||
|
when(() => mockFile1.length()).thenAnswer((_) async => 100);
|
||||||
|
when(() => mockFile1.path).thenReturn('path-1');
|
||||||
|
when(() => mockFile2.length()).thenAnswer((_) async => 100);
|
||||||
|
when(() => mockFile2.path).thenReturn('path-2');
|
||||||
|
|
||||||
expect(
|
when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]);
|
||||||
result,
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
||||||
[
|
.thenAnswer((_) async => [asset1, asset2]);
|
||||||
AssetStub.image1.copyWith(checksum: base64.encode(hash1)),
|
when(() => mockStorageRepo.getFileForAsset(asset1))
|
||||||
AssetStub.image2.copyWith(checksum: base64.encode(hash2)),
|
.thenAnswer((_) async => mockFile1);
|
||||||
AssetStub.image3.copyWith(checksum: base64.encode(hash3)),
|
when(() => mockStorageRepo.getFileForAsset(asset2))
|
||||||
],
|
.thenAnswer((_) async => mockFile2);
|
||||||
);
|
|
||||||
|
final hash = Uint8List.fromList(List.generate(20, (i) => i));
|
||||||
|
when(() => mockNativeApi.hashPaths(any()))
|
||||||
|
.thenAnswer((_) async => [hash]);
|
||||||
|
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
||||||
|
|
||||||
|
await sut.hashAssets();
|
||||||
|
|
||||||
|
verify(() => mockNativeApi.hashPaths(['path-1'])).called(1);
|
||||||
|
verify(() => mockNativeApi.hashPaths(['path-2'])).called(1);
|
||||||
|
verify(() => mockAssetRepo.updateHashes(any())).called(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("HashService: Sort & Process different states", () async {
|
test('batches by size limit', () async {
|
||||||
final (asset1, file1, deviceAsset1, hash1) =
|
final sut = HashService(
|
||||||
await _createAssetMock(AssetStub.image1); // Will need rehashing
|
localAlbumRepository: mockAlbumRepo,
|
||||||
final (asset2, file2, deviceAsset2, hash2) =
|
localAssetRepository: mockAssetRepo,
|
||||||
await _createAssetMock(AssetStub.image2); // Will have matching hash
|
storageRepository: mockStorageRepo,
|
||||||
final (asset3, file3, deviceAsset3, hash3) =
|
nativeSyncApi: mockNativeApi,
|
||||||
await _createAssetMock(AssetStub.image3); // No DB entry
|
batchSizeLimit: 80,
|
||||||
final asset4 =
|
|
||||||
AssetStub.image3.copyWith(localId: "image4"); // Cannot be hashed
|
|
||||||
|
|
||||||
when(() => mockBackgroundService.digestFiles([file1.path, file3.path]))
|
|
||||||
.thenAnswer((_) async => [hash1, hash3]);
|
|
||||||
// DB entries are not sorted and a dummy entry added
|
|
||||||
when(
|
|
||||||
() => mockDeviceAssetRepository.getByIds([
|
|
||||||
AssetStub.image1.localId!,
|
|
||||||
AssetStub.image2.localId!,
|
|
||||||
AssetStub.image3.localId!,
|
|
||||||
asset4.localId!,
|
|
||||||
]),
|
|
||||||
).thenAnswer(
|
|
||||||
(_) async => [
|
|
||||||
// Same timestamp to reuse deviceAsset
|
|
||||||
deviceAsset2.copyWith(modifiedTime: asset2.fileModifiedAt),
|
|
||||||
deviceAsset1,
|
|
||||||
deviceAsset3.copyWith(assetId: asset4.localId!),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final result = await sut.hashAssets([asset1, asset2, asset3, asset4]);
|
final album = LocalAlbumStub.recent;
|
||||||
|
final asset1 = LocalAssetStub.image1;
|
||||||
|
final asset2 = LocalAssetStub.image2;
|
||||||
|
final mockFile1 = MockFile();
|
||||||
|
final mockFile2 = MockFile();
|
||||||
|
when(() => mockFile1.length()).thenAnswer((_) async => 100);
|
||||||
|
when(() => mockFile1.path).thenReturn('path-1');
|
||||||
|
when(() => mockFile2.length()).thenAnswer((_) async => 100);
|
||||||
|
when(() => mockFile2.path).thenReturn('path-2');
|
||||||
|
|
||||||
// Verify correct processing of all assets
|
when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]);
|
||||||
verify(() => mockBackgroundService.digestFiles([file1.path, file3.path]))
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
||||||
.called(1);
|
.thenAnswer((_) async => [asset1, asset2]);
|
||||||
expect(result.length, 3);
|
when(() => mockStorageRepo.getFileForAsset(asset1))
|
||||||
expect(result, [
|
.thenAnswer((_) async => mockFile1);
|
||||||
AssetStub.image2.copyWith(checksum: base64.encode(hash2)),
|
when(() => mockStorageRepo.getFileForAsset(asset2))
|
||||||
AssetStub.image1.copyWith(checksum: base64.encode(hash1)),
|
.thenAnswer((_) async => mockFile2);
|
||||||
AssetStub.image3.copyWith(checksum: base64.encode(hash3)),
|
|
||||||
]);
|
final hash = Uint8List.fromList(List.generate(20, (i) => i));
|
||||||
|
when(() => mockNativeApi.hashPaths(any()))
|
||||||
|
.thenAnswer((_) async => [hash]);
|
||||||
|
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
||||||
|
|
||||||
|
await sut.hashAssets();
|
||||||
|
|
||||||
|
verify(() => mockNativeApi.hashPaths(['path-1'])).called(1);
|
||||||
|
verify(() => mockNativeApi.hashPaths(['path-2'])).called(1);
|
||||||
|
verify(() => mockAssetRepo.updateHashes(any())).called(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
group("HashService: Edge cases", () {
|
test('handles mixed success and failure in batch', () async {
|
||||||
test("handles empty list of assets", () async {
|
final album = LocalAlbumStub.recent;
|
||||||
when(() => mockDeviceAssetRepository.getByIds(any()))
|
final asset1 = LocalAssetStub.image1;
|
||||||
.thenAnswer((_) async => []);
|
final asset2 = LocalAssetStub.image2;
|
||||||
|
final mockFile1 = MockFile();
|
||||||
|
final mockFile2 = MockFile();
|
||||||
|
when(() => mockFile1.length()).thenAnswer((_) async => 100);
|
||||||
|
when(() => mockFile1.path).thenReturn('path-1');
|
||||||
|
when(() => mockFile2.length()).thenAnswer((_) async => 100);
|
||||||
|
when(() => mockFile2.path).thenReturn('path-2');
|
||||||
|
|
||||||
final result = await sut.hashAssets([]);
|
when(() => mockAlbumRepo.getAll()).thenAnswer((_) async => [album]);
|
||||||
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
||||||
|
.thenAnswer((_) async => [asset1, asset2]);
|
||||||
|
when(() => mockStorageRepo.getFileForAsset(asset1))
|
||||||
|
.thenAnswer((_) async => mockFile1);
|
||||||
|
when(() => mockStorageRepo.getFileForAsset(asset2))
|
||||||
|
.thenAnswer((_) async => mockFile2);
|
||||||
|
|
||||||
verifyNever(() => mockBackgroundService.digestFiles(any()));
|
final validHash = Uint8List.fromList(List.generate(20, (i) => i));
|
||||||
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
|
when(() => mockNativeApi.hashPaths(['path-1', 'path-2']))
|
||||||
verifyNever(() => mockDeviceAssetRepository.deleteIds(any()));
|
.thenAnswer((_) async => [validHash, null]);
|
||||||
|
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
||||||
|
|
||||||
expect(result, isEmpty);
|
await sut.hashAssets();
|
||||||
});
|
|
||||||
|
|
||||||
test("handles all file access failures", () async {
|
final captured = verify(() => mockAssetRepo.updateHashes(captureAny()))
|
||||||
// No DB entries
|
.captured
|
||||||
when(
|
.first as List<LocalAsset>;
|
||||||
() => mockDeviceAssetRepository.getByIds(
|
expect(captured.length, 1);
|
||||||
[AssetStub.image1.localId!, AssetStub.image2.localId!],
|
expect(captured.first.id, asset1.id);
|
||||||
),
|
|
||||||
).thenAnswer((_) async => []);
|
|
||||||
|
|
||||||
final result = await sut.hashAssets([
|
|
||||||
AssetStub.image1,
|
|
||||||
AssetStub.image2,
|
|
||||||
]);
|
|
||||||
|
|
||||||
verifyNever(() => mockBackgroundService.digestFiles(any()));
|
|
||||||
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
|
|
||||||
expect(result, isEmpty);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<(Asset, File, DeviceAsset, Uint8List)> _createAssetMock(
|
|
||||||
Asset asset,
|
|
||||||
) async {
|
|
||||||
final random = Random();
|
|
||||||
final hash =
|
|
||||||
Uint8List.fromList(List.generate(20, (i) => random.nextInt(255)));
|
|
||||||
final mockAsset = MockAsset();
|
|
||||||
final mockAssetEntity = MockAssetEntity();
|
|
||||||
final fs = MemoryFileSystem();
|
|
||||||
final deviceAsset = DeviceAsset(
|
|
||||||
assetId: asset.localId!,
|
|
||||||
hash: Uint8List.fromList(hash),
|
|
||||||
modifiedTime: DateTime.now(),
|
|
||||||
);
|
|
||||||
final tmp = await fs.systemTempDirectory.createTemp();
|
|
||||||
final file = tmp.childFile("${asset.fileName}-path");
|
|
||||||
await file.writeAsString("${asset.fileName}-content");
|
|
||||||
|
|
||||||
when(() => mockAsset.localId).thenReturn(asset.localId);
|
|
||||||
when(() => mockAsset.fileName).thenReturn(asset.fileName);
|
|
||||||
when(() => mockAsset.fileCreatedAt).thenReturn(asset.fileCreatedAt);
|
|
||||||
when(() => mockAsset.fileModifiedAt).thenReturn(asset.fileModifiedAt);
|
|
||||||
when(() => mockAsset.copyWith(checksum: any(named: "checksum")))
|
|
||||||
.thenReturn(asset.copyWith(checksum: base64.encode(hash)));
|
|
||||||
when(() => mockAsset.local).thenAnswer((_) => mockAssetEntity);
|
|
||||||
when(() => mockAssetEntity.originFile).thenAnswer((_) async => file);
|
|
||||||
|
|
||||||
return (mockAsset, file, deviceAsset, hash);
|
|
||||||
}
|
|
||||||
|
|||||||
Vendored
+14
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:immich_mobile/domain/models/local_album.model.dart';
|
||||||
import 'package:immich_mobile/entities/album.entity.dart';
|
import 'package:immich_mobile/entities/album.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
||||||
|
|
||||||
@@ -101,3 +102,16 @@ final class AlbumStub {
|
|||||||
endDate: DateTime(2026),
|
endDate: DateTime(2026),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abstract final class LocalAlbumStub {
|
||||||
|
const LocalAlbumStub._();
|
||||||
|
|
||||||
|
static final recent = LocalAlbum(
|
||||||
|
id: "recent-local-id",
|
||||||
|
name: "Recent",
|
||||||
|
updatedAt: DateTime(2023),
|
||||||
|
assetCount: 1000,
|
||||||
|
backupSelection: BackupSelection.none,
|
||||||
|
isIosSharedAlbum: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+28
-7
@@ -1,10 +1,11 @@
|
|||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart' as old;
|
||||||
|
|
||||||
final class AssetStub {
|
final class AssetStub {
|
||||||
const AssetStub._();
|
const AssetStub._();
|
||||||
|
|
||||||
static final image1 = Asset(
|
static final image1 = old.Asset(
|
||||||
checksum: "image1-checksum",
|
checksum: "image1-checksum",
|
||||||
localId: "image1",
|
localId: "image1",
|
||||||
remoteId: 'image1-remote',
|
remoteId: 'image1-remote',
|
||||||
@@ -13,7 +14,7 @@ final class AssetStub {
|
|||||||
fileModifiedAt: DateTime(2020),
|
fileModifiedAt: DateTime(2020),
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
durationInSeconds: 0,
|
durationInSeconds: 0,
|
||||||
type: AssetType.image,
|
type: old.AssetType.image,
|
||||||
fileName: "image1.jpg",
|
fileName: "image1.jpg",
|
||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
@@ -21,7 +22,7 @@ final class AssetStub {
|
|||||||
exifInfo: const ExifInfo(isFlipped: false),
|
exifInfo: const ExifInfo(isFlipped: false),
|
||||||
);
|
);
|
||||||
|
|
||||||
static final image2 = Asset(
|
static final image2 = old.Asset(
|
||||||
checksum: "image2-checksum",
|
checksum: "image2-checksum",
|
||||||
localId: "image2",
|
localId: "image2",
|
||||||
remoteId: 'image2-remote',
|
remoteId: 'image2-remote',
|
||||||
@@ -30,7 +31,7 @@ final class AssetStub {
|
|||||||
fileModifiedAt: DateTime(2010),
|
fileModifiedAt: DateTime(2010),
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
durationInSeconds: 60,
|
durationInSeconds: 60,
|
||||||
type: AssetType.video,
|
type: old.AssetType.video,
|
||||||
fileName: "image2.jpg",
|
fileName: "image2.jpg",
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
@@ -38,7 +39,7 @@ final class AssetStub {
|
|||||||
exifInfo: const ExifInfo(isFlipped: true),
|
exifInfo: const ExifInfo(isFlipped: true),
|
||||||
);
|
);
|
||||||
|
|
||||||
static final image3 = Asset(
|
static final image3 = old.Asset(
|
||||||
checksum: "image3-checksum",
|
checksum: "image3-checksum",
|
||||||
localId: "image3",
|
localId: "image3",
|
||||||
ownerId: 1,
|
ownerId: 1,
|
||||||
@@ -46,10 +47,30 @@ final class AssetStub {
|
|||||||
fileModifiedAt: DateTime(2025),
|
fileModifiedAt: DateTime(2025),
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
durationInSeconds: 60,
|
durationInSeconds: 60,
|
||||||
type: AssetType.image,
|
type: old.AssetType.image,
|
||||||
fileName: "image3.jpg",
|
fileName: "image3.jpg",
|
||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
isTrashed: false,
|
isTrashed: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abstract final class LocalAssetStub {
|
||||||
|
const LocalAssetStub._();
|
||||||
|
|
||||||
|
static final image1 = LocalAsset(
|
||||||
|
id: "image1",
|
||||||
|
name: "image1.jpg",
|
||||||
|
type: AssetType.image,
|
||||||
|
createdAt: DateTime(2025),
|
||||||
|
updatedAt: DateTime(2025, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
static final image2 = LocalAsset(
|
||||||
|
id: "image2",
|
||||||
|
name: "image2.jpg",
|
||||||
|
type: AssetType.image,
|
||||||
|
createdAt: DateTime(2000),
|
||||||
|
updatedAt: DateTime(20021),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/local_album.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/local_asset.interface.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/log.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/log.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/storage.interface.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/store.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/store.interface.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/sync_api.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/sync_api.interface.dart';
|
||||||
import 'package:immich_mobile/domain/interfaces/sync_stream.interface.dart';
|
import 'package:immich_mobile/domain/interfaces/sync_stream.interface.dart';
|
||||||
@@ -18,6 +21,12 @@ class MockDeviceAssetRepository extends Mock
|
|||||||
|
|
||||||
class MockSyncStreamRepository extends Mock implements ISyncStreamRepository {}
|
class MockSyncStreamRepository extends Mock implements ISyncStreamRepository {}
|
||||||
|
|
||||||
|
class MockLocalAlbumRepository extends Mock implements ILocalAlbumRepository {}
|
||||||
|
|
||||||
|
class MockLocalAssetRepository extends Mock implements ILocalAssetRepository {}
|
||||||
|
|
||||||
|
class MockStorageRepository extends Mock implements IStorageRepository {}
|
||||||
|
|
||||||
// API Repos
|
// API Repos
|
||||||
class MockUserApiRepository extends Mock implements IUserApiRepository {}
|
class MockUserApiRepository extends Mock implements IUserApiRepository {}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,425 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:file/memory.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/device_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/services/background.service.dart';
|
||||||
|
import 'package:immich_mobile/services/hash.service.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
|
import '../fixtures/asset.stub.dart';
|
||||||
|
import '../infrastructure/repository.mock.dart';
|
||||||
|
import '../service.mocks.dart';
|
||||||
|
|
||||||
|
class MockAsset extends Mock implements Asset {}
|
||||||
|
|
||||||
|
class MockAssetEntity extends Mock implements AssetEntity {}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late HashService sut;
|
||||||
|
late BackgroundService mockBackgroundService;
|
||||||
|
late IDeviceAssetRepository mockDeviceAssetRepository;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockBackgroundService = MockBackgroundService();
|
||||||
|
mockDeviceAssetRepository = MockDeviceAssetRepository();
|
||||||
|
|
||||||
|
sut = HashService(
|
||||||
|
deviceAssetRepository: mockDeviceAssetRepository,
|
||||||
|
backgroundService: mockBackgroundService,
|
||||||
|
);
|
||||||
|
|
||||||
|
when(() => mockDeviceAssetRepository.transaction<Null>(any()))
|
||||||
|
.thenAnswer((_) async {
|
||||||
|
final capturedCallback = verify(
|
||||||
|
() => mockDeviceAssetRepository.transaction<Null>(captureAny()),
|
||||||
|
).captured;
|
||||||
|
// Invoke the transaction callback
|
||||||
|
await (capturedCallback.firstOrNull as Future<Null> Function()?)?.call();
|
||||||
|
});
|
||||||
|
when(() => mockDeviceAssetRepository.updateAll(any()))
|
||||||
|
.thenAnswer((_) async => true);
|
||||||
|
when(() => mockDeviceAssetRepository.deleteIds(any()))
|
||||||
|
.thenAnswer((_) async => true);
|
||||||
|
});
|
||||||
|
|
||||||
|
group("HashService: No DeviceAsset entry", () {
|
||||||
|
test("hash successfully", () async {
|
||||||
|
final (mockAsset, file, deviceAsset, hash) =
|
||||||
|
await _createAssetMock(AssetStub.image1);
|
||||||
|
|
||||||
|
when(() => mockBackgroundService.digestFiles([file.path]))
|
||||||
|
.thenAnswer((_) async => [hash]);
|
||||||
|
// No DB entries for this asset
|
||||||
|
when(
|
||||||
|
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
|
||||||
|
).thenAnswer((_) async => []);
|
||||||
|
|
||||||
|
final result = await sut.hashAssets([mockAsset]);
|
||||||
|
|
||||||
|
// Verify we stored the new hash in DB
|
||||||
|
when(() => mockDeviceAssetRepository.transaction<Null>(any()))
|
||||||
|
.thenAnswer((_) async {
|
||||||
|
final capturedCallback = verify(
|
||||||
|
() => mockDeviceAssetRepository.transaction<Null>(captureAny()),
|
||||||
|
).captured;
|
||||||
|
// Invoke the transaction callback
|
||||||
|
await (capturedCallback.firstOrNull as Future<Null> Function()?)
|
||||||
|
?.call();
|
||||||
|
verify(
|
||||||
|
() => mockDeviceAssetRepository.updateAll([
|
||||||
|
deviceAsset.copyWith(modifiedTime: AssetStub.image1.fileModifiedAt),
|
||||||
|
]),
|
||||||
|
).called(1);
|
||||||
|
verify(() => mockDeviceAssetRepository.deleteIds([])).called(1);
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
result,
|
||||||
|
[AssetStub.image1.copyWith(checksum: base64.encode(hash))],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group("HashService: Has DeviceAsset entry", () {
|
||||||
|
test("when the asset is not modified", () async {
|
||||||
|
final hash = utf8.encode("image1-hash");
|
||||||
|
|
||||||
|
when(
|
||||||
|
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
|
||||||
|
).thenAnswer(
|
||||||
|
(_) async => [
|
||||||
|
DeviceAsset(
|
||||||
|
assetId: AssetStub.image1.localId!,
|
||||||
|
hash: hash,
|
||||||
|
modifiedTime: AssetStub.image1.fileModifiedAt,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final result = await sut.hashAssets([AssetStub.image1]);
|
||||||
|
|
||||||
|
verifyNever(() => mockBackgroundService.digestFiles(any()));
|
||||||
|
verifyNever(() => mockBackgroundService.digestFile(any()));
|
||||||
|
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
|
||||||
|
verifyNever(() => mockDeviceAssetRepository.deleteIds(any()));
|
||||||
|
|
||||||
|
expect(result, [
|
||||||
|
AssetStub.image1.copyWith(checksum: base64.encode(hash)),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hashed successful when asset is modified", () async {
|
||||||
|
final (mockAsset, file, deviceAsset, hash) =
|
||||||
|
await _createAssetMock(AssetStub.image1);
|
||||||
|
|
||||||
|
when(() => mockBackgroundService.digestFiles([file.path]))
|
||||||
|
.thenAnswer((_) async => [hash]);
|
||||||
|
when(
|
||||||
|
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
|
||||||
|
).thenAnswer((_) async => [deviceAsset]);
|
||||||
|
|
||||||
|
final result = await sut.hashAssets([mockAsset]);
|
||||||
|
|
||||||
|
when(() => mockDeviceAssetRepository.transaction<Null>(any()))
|
||||||
|
.thenAnswer((_) async {
|
||||||
|
final capturedCallback = verify(
|
||||||
|
() => mockDeviceAssetRepository.transaction<Null>(captureAny()),
|
||||||
|
).captured;
|
||||||
|
// Invoke the transaction callback
|
||||||
|
await (capturedCallback.firstOrNull as Future<Null> Function()?)
|
||||||
|
?.call();
|
||||||
|
verify(
|
||||||
|
() => mockDeviceAssetRepository.updateAll([
|
||||||
|
deviceAsset.copyWith(modifiedTime: AssetStub.image1.fileModifiedAt),
|
||||||
|
]),
|
||||||
|
).called(1);
|
||||||
|
verify(() => mockDeviceAssetRepository.deleteIds([])).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
verify(() => mockBackgroundService.digestFiles([file.path])).called(1);
|
||||||
|
|
||||||
|
expect(result, [
|
||||||
|
AssetStub.image1.copyWith(checksum: base64.encode(hash)),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group("HashService: Cleanup", () {
|
||||||
|
late Asset mockAsset;
|
||||||
|
late Uint8List hash;
|
||||||
|
late DeviceAsset deviceAsset;
|
||||||
|
late File file;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
(mockAsset, file, deviceAsset, hash) =
|
||||||
|
await _createAssetMock(AssetStub.image1);
|
||||||
|
|
||||||
|
when(() => mockBackgroundService.digestFiles([file.path]))
|
||||||
|
.thenAnswer((_) async => [hash]);
|
||||||
|
when(
|
||||||
|
() => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]),
|
||||||
|
).thenAnswer((_) async => [deviceAsset]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cleanups DeviceAsset when local file cannot be obtained", () async {
|
||||||
|
when(() => mockAsset.local).thenThrow(Exception("File not found"));
|
||||||
|
final result = await sut.hashAssets([mockAsset]);
|
||||||
|
|
||||||
|
verifyNever(() => mockBackgroundService.digestFiles(any()));
|
||||||
|
verifyNever(() => mockBackgroundService.digestFile(any()));
|
||||||
|
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
|
||||||
|
verify(
|
||||||
|
() => mockDeviceAssetRepository.deleteIds([AssetStub.image1.localId!]),
|
||||||
|
).called(1);
|
||||||
|
|
||||||
|
expect(result, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cleanups DeviceAsset when hashing failed", () async {
|
||||||
|
when(() => mockDeviceAssetRepository.transaction<Null>(any()))
|
||||||
|
.thenAnswer((_) async {
|
||||||
|
final capturedCallback = verify(
|
||||||
|
() => mockDeviceAssetRepository.transaction<Null>(captureAny()),
|
||||||
|
).captured;
|
||||||
|
// Invoke the transaction callback
|
||||||
|
await (capturedCallback.firstOrNull as Future<Null> Function()?)
|
||||||
|
?.call();
|
||||||
|
|
||||||
|
// Verify the callback inside the transaction because, doing it outside results
|
||||||
|
// in a small delay before the callback is invoked, resulting in other LOCs getting executed
|
||||||
|
// resulting in an incorrect state
|
||||||
|
//
|
||||||
|
// i.e, consider the following piece of code
|
||||||
|
// await _deviceAssetRepository.transaction(() async {
|
||||||
|
// await _deviceAssetRepository.updateAll(toBeAdded);
|
||||||
|
// await _deviceAssetRepository.deleteIds(toBeDeleted);
|
||||||
|
// });
|
||||||
|
// toBeDeleted.clear();
|
||||||
|
// since the transaction method is mocked, the callback is not invoked until it is captured
|
||||||
|
// and executed manually in the next event loop. However, the toBeDeleted.clear() is executed
|
||||||
|
// immediately once the transaction stub is executed, resulting in the deleteIds method being
|
||||||
|
// called with an empty list.
|
||||||
|
//
|
||||||
|
// To avoid this, we capture the callback and execute it within the transaction stub itself
|
||||||
|
// and verify the results inside the transaction stub
|
||||||
|
verify(() => mockDeviceAssetRepository.updateAll([])).called(1);
|
||||||
|
verify(
|
||||||
|
() =>
|
||||||
|
mockDeviceAssetRepository.deleteIds([AssetStub.image1.localId!]),
|
||||||
|
).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
when(() => mockBackgroundService.digestFiles([file.path])).thenAnswer(
|
||||||
|
// Invalid hash, length != 20
|
||||||
|
(_) async => [Uint8List.fromList(hash.slice(2).toList())],
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await sut.hashAssets([mockAsset]);
|
||||||
|
|
||||||
|
verify(() => mockBackgroundService.digestFiles([file.path])).called(1);
|
||||||
|
expect(result, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group("HashService: Batch processing", () {
|
||||||
|
test("processes assets in batches when size limit is reached", () async {
|
||||||
|
// Setup multiple assets with large file sizes
|
||||||
|
final (mock1, mock2, mock3) = await (
|
||||||
|
_createAssetMock(AssetStub.image1),
|
||||||
|
_createAssetMock(AssetStub.image2),
|
||||||
|
_createAssetMock(AssetStub.image3),
|
||||||
|
).wait;
|
||||||
|
|
||||||
|
final (asset1, file1, deviceAsset1, hash1) = mock1;
|
||||||
|
final (asset2, file2, deviceAsset2, hash2) = mock2;
|
||||||
|
final (asset3, file3, deviceAsset3, hash3) = mock3;
|
||||||
|
|
||||||
|
when(() => mockDeviceAssetRepository.getByIds(any()))
|
||||||
|
.thenAnswer((_) async => []);
|
||||||
|
|
||||||
|
// Setup for multiple batch processing calls
|
||||||
|
when(() => mockBackgroundService.digestFiles([file1.path, file2.path]))
|
||||||
|
.thenAnswer((_) async => [hash1, hash2]);
|
||||||
|
when(() => mockBackgroundService.digestFiles([file3.path]))
|
||||||
|
.thenAnswer((_) async => [hash3]);
|
||||||
|
|
||||||
|
final size = await file1.length() + await file2.length();
|
||||||
|
|
||||||
|
sut = HashService(
|
||||||
|
deviceAssetRepository: mockDeviceAssetRepository,
|
||||||
|
backgroundService: mockBackgroundService,
|
||||||
|
batchSizeLimit: size,
|
||||||
|
);
|
||||||
|
final result = await sut.hashAssets([asset1, asset2, asset3]);
|
||||||
|
|
||||||
|
// Verify multiple batch process calls
|
||||||
|
verify(() => mockBackgroundService.digestFiles([file1.path, file2.path]))
|
||||||
|
.called(1);
|
||||||
|
verify(() => mockBackgroundService.digestFiles([file3.path])).called(1);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result,
|
||||||
|
[
|
||||||
|
AssetStub.image1.copyWith(checksum: base64.encode(hash1)),
|
||||||
|
AssetStub.image2.copyWith(checksum: base64.encode(hash2)),
|
||||||
|
AssetStub.image3.copyWith(checksum: base64.encode(hash3)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("processes assets in batches when file limit is reached", () async {
|
||||||
|
// Setup multiple assets with large file sizes
|
||||||
|
final (mock1, mock2, mock3) = await (
|
||||||
|
_createAssetMock(AssetStub.image1),
|
||||||
|
_createAssetMock(AssetStub.image2),
|
||||||
|
_createAssetMock(AssetStub.image3),
|
||||||
|
).wait;
|
||||||
|
|
||||||
|
final (asset1, file1, deviceAsset1, hash1) = mock1;
|
||||||
|
final (asset2, file2, deviceAsset2, hash2) = mock2;
|
||||||
|
final (asset3, file3, deviceAsset3, hash3) = mock3;
|
||||||
|
|
||||||
|
when(() => mockDeviceAssetRepository.getByIds(any()))
|
||||||
|
.thenAnswer((_) async => []);
|
||||||
|
|
||||||
|
when(() => mockBackgroundService.digestFiles([file1.path]))
|
||||||
|
.thenAnswer((_) async => [hash1]);
|
||||||
|
when(() => mockBackgroundService.digestFiles([file2.path]))
|
||||||
|
.thenAnswer((_) async => [hash2]);
|
||||||
|
when(() => mockBackgroundService.digestFiles([file3.path]))
|
||||||
|
.thenAnswer((_) async => [hash3]);
|
||||||
|
|
||||||
|
sut = HashService(
|
||||||
|
deviceAssetRepository: mockDeviceAssetRepository,
|
||||||
|
backgroundService: mockBackgroundService,
|
||||||
|
batchFileLimit: 1,
|
||||||
|
);
|
||||||
|
final result = await sut.hashAssets([asset1, asset2, asset3]);
|
||||||
|
|
||||||
|
// Verify multiple batch process calls
|
||||||
|
verify(() => mockBackgroundService.digestFiles([file1.path])).called(1);
|
||||||
|
verify(() => mockBackgroundService.digestFiles([file2.path])).called(1);
|
||||||
|
verify(() => mockBackgroundService.digestFiles([file3.path])).called(1);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result,
|
||||||
|
[
|
||||||
|
AssetStub.image1.copyWith(checksum: base64.encode(hash1)),
|
||||||
|
AssetStub.image2.copyWith(checksum: base64.encode(hash2)),
|
||||||
|
AssetStub.image3.copyWith(checksum: base64.encode(hash3)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("HashService: Sort & Process different states", () async {
|
||||||
|
final (asset1, file1, deviceAsset1, hash1) =
|
||||||
|
await _createAssetMock(AssetStub.image1); // Will need rehashing
|
||||||
|
final (asset2, file2, deviceAsset2, hash2) =
|
||||||
|
await _createAssetMock(AssetStub.image2); // Will have matching hash
|
||||||
|
final (asset3, file3, deviceAsset3, hash3) =
|
||||||
|
await _createAssetMock(AssetStub.image3); // No DB entry
|
||||||
|
final asset4 =
|
||||||
|
AssetStub.image3.copyWith(localId: "image4"); // Cannot be hashed
|
||||||
|
|
||||||
|
when(() => mockBackgroundService.digestFiles([file1.path, file3.path]))
|
||||||
|
.thenAnswer((_) async => [hash1, hash3]);
|
||||||
|
// DB entries are not sorted and a dummy entry added
|
||||||
|
when(
|
||||||
|
() => mockDeviceAssetRepository.getByIds([
|
||||||
|
AssetStub.image1.localId!,
|
||||||
|
AssetStub.image2.localId!,
|
||||||
|
AssetStub.image3.localId!,
|
||||||
|
asset4.localId!,
|
||||||
|
]),
|
||||||
|
).thenAnswer(
|
||||||
|
(_) async => [
|
||||||
|
// Same timestamp to reuse deviceAsset
|
||||||
|
deviceAsset2.copyWith(modifiedTime: asset2.fileModifiedAt),
|
||||||
|
deviceAsset1,
|
||||||
|
deviceAsset3.copyWith(assetId: asset4.localId!),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await sut.hashAssets([asset1, asset2, asset3, asset4]);
|
||||||
|
|
||||||
|
// Verify correct processing of all assets
|
||||||
|
verify(() => mockBackgroundService.digestFiles([file1.path, file3.path]))
|
||||||
|
.called(1);
|
||||||
|
expect(result.length, 3);
|
||||||
|
expect(result, [
|
||||||
|
AssetStub.image2.copyWith(checksum: base64.encode(hash2)),
|
||||||
|
AssetStub.image1.copyWith(checksum: base64.encode(hash1)),
|
||||||
|
AssetStub.image3.copyWith(checksum: base64.encode(hash3)),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
group("HashService: Edge cases", () {
|
||||||
|
test("handles empty list of assets", () async {
|
||||||
|
when(() => mockDeviceAssetRepository.getByIds(any()))
|
||||||
|
.thenAnswer((_) async => []);
|
||||||
|
|
||||||
|
final result = await sut.hashAssets([]);
|
||||||
|
|
||||||
|
verifyNever(() => mockBackgroundService.digestFiles(any()));
|
||||||
|
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
|
||||||
|
verifyNever(() => mockDeviceAssetRepository.deleteIds(any()));
|
||||||
|
|
||||||
|
expect(result, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles all file access failures", () async {
|
||||||
|
// No DB entries
|
||||||
|
when(
|
||||||
|
() => mockDeviceAssetRepository.getByIds(
|
||||||
|
[AssetStub.image1.localId!, AssetStub.image2.localId!],
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async => []);
|
||||||
|
|
||||||
|
final result = await sut.hashAssets([
|
||||||
|
AssetStub.image1,
|
||||||
|
AssetStub.image2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
verifyNever(() => mockBackgroundService.digestFiles(any()));
|
||||||
|
verifyNever(() => mockDeviceAssetRepository.updateAll(any()));
|
||||||
|
expect(result, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(Asset, File, DeviceAsset, Uint8List)> _createAssetMock(
|
||||||
|
Asset asset,
|
||||||
|
) async {
|
||||||
|
final random = Random();
|
||||||
|
final hash =
|
||||||
|
Uint8List.fromList(List.generate(20, (i) => random.nextInt(255)));
|
||||||
|
final mockAsset = MockAsset();
|
||||||
|
final mockAssetEntity = MockAssetEntity();
|
||||||
|
final fs = MemoryFileSystem();
|
||||||
|
final deviceAsset = DeviceAsset(
|
||||||
|
assetId: asset.localId!,
|
||||||
|
hash: Uint8List.fromList(hash),
|
||||||
|
modifiedTime: DateTime.now(),
|
||||||
|
);
|
||||||
|
final tmp = await fs.systemTempDirectory.createTemp();
|
||||||
|
final file = tmp.childFile("${asset.fileName}-path");
|
||||||
|
await file.writeAsString("${asset.fileName}-content");
|
||||||
|
|
||||||
|
when(() => mockAsset.localId).thenReturn(asset.localId);
|
||||||
|
when(() => mockAsset.fileName).thenReturn(asset.fileName);
|
||||||
|
when(() => mockAsset.fileCreatedAt).thenReturn(asset.fileCreatedAt);
|
||||||
|
when(() => mockAsset.fileModifiedAt).thenReturn(asset.fileModifiedAt);
|
||||||
|
when(() => mockAsset.copyWith(checksum: any(named: "checksum")))
|
||||||
|
.thenReturn(asset.copyWith(checksum: base64.encode(hash)));
|
||||||
|
when(() => mockAsset.local).thenAnswer((_) => mockAssetEntity);
|
||||||
|
when(() => mockAssetEntity.originFile).thenAnswer((_) async => file);
|
||||||
|
|
||||||
|
return (mockAsset, file, deviceAsset, hash);
|
||||||
|
}
|
||||||
@@ -7343,6 +7343,7 @@
|
|||||||
"name": "albumId",
|
"name": "albumId",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Filter assets belonging to a specific album",
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -7352,6 +7353,7 @@
|
|||||||
"name": "isFavorite",
|
"name": "isFavorite",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Filter by favorite status (true for favorites only, false for non-favorites only)",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
@@ -7360,6 +7362,7 @@
|
|||||||
"name": "isTrashed",
|
"name": "isTrashed",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Filter by trash status (true for trashed assets only, false for non-trashed only)",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
@@ -7376,6 +7379,7 @@
|
|||||||
"name": "order",
|
"name": "order",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/AssetOrder"
|
"$ref": "#/components/schemas/AssetOrder"
|
||||||
}
|
}
|
||||||
@@ -7384,6 +7388,7 @@
|
|||||||
"name": "personId",
|
"name": "personId",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Filter assets containing a specific person (face recognition)",
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -7393,6 +7398,7 @@
|
|||||||
"name": "tagId",
|
"name": "tagId",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Filter assets with a specific tag",
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -7402,7 +7408,9 @@
|
|||||||
"name": "timeBucket",
|
"name": "timeBucket",
|
||||||
"required": true,
|
"required": true,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Time bucket identifier in YYYY-MM-DD format (e.g., \"2024-01-01\" for January 2024)",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
"example": "2024-01-01",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -7410,6 +7418,7 @@
|
|||||||
"name": "userId",
|
"name": "userId",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Filter assets by specific user ID",
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -7419,6 +7428,7 @@
|
|||||||
"name": "visibility",
|
"name": "visibility",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/AssetVisibility"
|
"$ref": "#/components/schemas/AssetVisibility"
|
||||||
}
|
}
|
||||||
@@ -7427,6 +7437,7 @@
|
|||||||
"name": "withPartners",
|
"name": "withPartners",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Include assets shared by partners",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
@@ -7435,6 +7446,7 @@
|
|||||||
"name": "withStacked",
|
"name": "withStacked",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Include stacked assets in the response. When true, only primary assets from stacks are returned.",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
@@ -7476,6 +7488,7 @@
|
|||||||
"name": "albumId",
|
"name": "albumId",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Filter assets belonging to a specific album",
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -7485,6 +7498,7 @@
|
|||||||
"name": "isFavorite",
|
"name": "isFavorite",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Filter by favorite status (true for favorites only, false for non-favorites only)",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
@@ -7493,6 +7507,7 @@
|
|||||||
"name": "isTrashed",
|
"name": "isTrashed",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Filter by trash status (true for trashed assets only, false for non-trashed only)",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
@@ -7509,6 +7524,7 @@
|
|||||||
"name": "order",
|
"name": "order",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/AssetOrder"
|
"$ref": "#/components/schemas/AssetOrder"
|
||||||
}
|
}
|
||||||
@@ -7517,6 +7533,7 @@
|
|||||||
"name": "personId",
|
"name": "personId",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Filter assets containing a specific person (face recognition)",
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -7526,6 +7543,7 @@
|
|||||||
"name": "tagId",
|
"name": "tagId",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Filter assets with a specific tag",
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -7535,6 +7553,7 @@
|
|||||||
"name": "userId",
|
"name": "userId",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Filter assets by specific user ID",
|
||||||
"schema": {
|
"schema": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -7544,6 +7563,7 @@
|
|||||||
"name": "visibility",
|
"name": "visibility",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/AssetVisibility"
|
"$ref": "#/components/schemas/AssetVisibility"
|
||||||
}
|
}
|
||||||
@@ -7552,6 +7572,7 @@
|
|||||||
"name": "withPartners",
|
"name": "withPartners",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Include assets shared by partners",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
@@ -7560,6 +7581,7 @@
|
|||||||
"name": "withStacked",
|
"name": "withStacked",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "Include stacked assets in the response. When true, only primary assets from stacks are returned.",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
@@ -8695,6 +8717,34 @@
|
|||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"AlbumsResponse": {
|
||||||
|
"properties": {
|
||||||
|
"defaultAssetOrder": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/AssetOrder"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": "desc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"defaultAssetOrder"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"AlbumsUpdate": {
|
||||||
|
"properties": {
|
||||||
|
"defaultAssetOrder": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/AssetOrder"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"AllJobStatusResponseDto": {
|
"AllJobStatusResponseDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"backgroundTask": {
|
"backgroundTask": {
|
||||||
@@ -9369,10 +9419,14 @@
|
|||||||
"$ref": "#/components/schemas/ExifResponseDto"
|
"$ref": "#/components/schemas/ExifResponseDto"
|
||||||
},
|
},
|
||||||
"fileCreatedAt": {
|
"fileCreatedAt": {
|
||||||
|
"description": "The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken.",
|
||||||
|
"example": "2024-01-15T19:30:00.000Z",
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"fileModifiedAt": {
|
"fileModifiedAt": {
|
||||||
|
"description": "The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken.",
|
||||||
|
"example": "2024-01-16T10:15:00.000Z",
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -9405,6 +9459,8 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"localDateTime": {
|
"localDateTime": {
|
||||||
|
"description": "The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer's local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by \"local\" days and months.",
|
||||||
|
"example": "2024-01-15T14:30:00.000Z",
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -9466,6 +9522,8 @@
|
|||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
|
"description": "The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified.",
|
||||||
|
"example": "2024-01-16T12:45:30.000Z",
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -14424,6 +14482,7 @@
|
|||||||
"TimeBucketAssetResponseDto": {
|
"TimeBucketAssetResponseDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"city": {
|
"city": {
|
||||||
|
"description": "Array of city names extracted from EXIF GPS data",
|
||||||
"items": {
|
"items": {
|
||||||
"nullable": true,
|
"nullable": true,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -14431,6 +14490,7 @@
|
|||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
"country": {
|
"country": {
|
||||||
|
"description": "Array of country names extracted from EXIF GPS data",
|
||||||
"items": {
|
"items": {
|
||||||
"nullable": true,
|
"nullable": true,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -14438,56 +14498,72 @@
|
|||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
"duration": {
|
"duration": {
|
||||||
|
"description": "Array of video durations in HH:MM:SS format (null for images)",
|
||||||
"items": {
|
"items": {
|
||||||
"nullable": true,
|
"nullable": true,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"fileCreatedAt": {
|
||||||
|
"description": "Array of file creation timestamps in UTC (ISO 8601 format, without timezone)",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"id": {
|
"id": {
|
||||||
|
"description": "Array of asset IDs in the time bucket",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
"isFavorite": {
|
"isFavorite": {
|
||||||
|
"description": "Array indicating whether each asset is favorited",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
"isImage": {
|
"isImage": {
|
||||||
|
"description": "Array indicating whether each asset is an image (false for videos)",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
"isTrashed": {
|
"isTrashed": {
|
||||||
|
"description": "Array indicating whether each asset is in the trash",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
"livePhotoVideoId": {
|
"livePhotoVideoId": {
|
||||||
|
"description": "Array of live photo video asset IDs (null for non-live photos)",
|
||||||
"items": {
|
"items": {
|
||||||
"nullable": true,
|
"nullable": true,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
"localDateTime": {
|
"localOffsetHours": {
|
||||||
|
"description": "Array of UTC offset hours at the time each photo was taken. Positive values are east of UTC, negative values are west of UTC. Values may be fractional (e.g., 5.5 for +05:30, -9.75 for -09:45). Applying this offset to 'fileCreatedAt' will give you the time the photo was taken from the photographer's perspective.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"type": "number"
|
||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
"ownerId": {
|
"ownerId": {
|
||||||
|
"description": "Array of owner IDs for each asset",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
"projectionType": {
|
"projectionType": {
|
||||||
|
"description": "Array of projection types for 360° content (e.g., \"EQUIRECTANGULAR\", \"CUBEFACE\", \"CYLINDRICAL\")",
|
||||||
"items": {
|
"items": {
|
||||||
"nullable": true,
|
"nullable": true,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -14495,13 +14571,14 @@
|
|||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
"ratio": {
|
"ratio": {
|
||||||
|
"description": "Array of aspect ratios (width/height) for each asset",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
"stack": {
|
"stack": {
|
||||||
"description": "(stack ID, stack asset count) tuple",
|
"description": "Array of stack information as [stackId, assetCount] tuples (null for non-stacked assets)",
|
||||||
"items": {
|
"items": {
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -14514,6 +14591,7 @@
|
|||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
"thumbhash": {
|
"thumbhash": {
|
||||||
|
"description": "Array of BlurHash strings for generating asset previews (base64 encoded)",
|
||||||
"items": {
|
"items": {
|
||||||
"nullable": true,
|
"nullable": true,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -14521,6 +14599,7 @@
|
|||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
"visibility": {
|
"visibility": {
|
||||||
|
"description": "Array of visibility statuses for each asset (e.g., ARCHIVE, TIMELINE, HIDDEN, LOCKED)",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/AssetVisibility"
|
"$ref": "#/components/schemas/AssetVisibility"
|
||||||
},
|
},
|
||||||
@@ -14531,12 +14610,13 @@
|
|||||||
"city",
|
"city",
|
||||||
"country",
|
"country",
|
||||||
"duration",
|
"duration",
|
||||||
|
"fileCreatedAt",
|
||||||
"id",
|
"id",
|
||||||
"isFavorite",
|
"isFavorite",
|
||||||
"isImage",
|
"isImage",
|
||||||
"isTrashed",
|
"isTrashed",
|
||||||
"livePhotoVideoId",
|
"livePhotoVideoId",
|
||||||
"localDateTime",
|
"localOffsetHours",
|
||||||
"ownerId",
|
"ownerId",
|
||||||
"projectionType",
|
"projectionType",
|
||||||
"ratio",
|
"ratio",
|
||||||
@@ -14548,9 +14628,13 @@
|
|||||||
"TimeBucketsResponseDto": {
|
"TimeBucketsResponseDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"count": {
|
"count": {
|
||||||
|
"description": "Number of assets in this time bucket",
|
||||||
|
"example": 42,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"timeBucket": {
|
"timeBucket": {
|
||||||
|
"description": "Time bucket identifier in YYYY-MM-DD format representing the start of the time period",
|
||||||
|
"example": "2024-01-01",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -14984,6 +15068,9 @@
|
|||||||
},
|
},
|
||||||
"UserPreferencesResponseDto": {
|
"UserPreferencesResponseDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"albums": {
|
||||||
|
"$ref": "#/components/schemas/AlbumsResponse"
|
||||||
|
},
|
||||||
"cast": {
|
"cast": {
|
||||||
"$ref": "#/components/schemas/CastResponse"
|
"$ref": "#/components/schemas/CastResponse"
|
||||||
},
|
},
|
||||||
@@ -15016,6 +15103,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
"albums",
|
||||||
"cast",
|
"cast",
|
||||||
"download",
|
"download",
|
||||||
"emailNotifications",
|
"emailNotifications",
|
||||||
@@ -15031,6 +15119,9 @@
|
|||||||
},
|
},
|
||||||
"UserPreferencesUpdateDto": {
|
"UserPreferencesUpdateDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"albums": {
|
||||||
|
"$ref": "#/components/schemas/AlbumsUpdate"
|
||||||
|
},
|
||||||
"avatar": {
|
"avatar": {
|
||||||
"$ref": "#/components/schemas/AvatarUpdate"
|
"$ref": "#/components/schemas/AvatarUpdate"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ export type UserAdminUpdateDto = {
|
|||||||
shouldChangePassword?: boolean;
|
shouldChangePassword?: boolean;
|
||||||
storageLabel?: string | null;
|
storageLabel?: string | null;
|
||||||
};
|
};
|
||||||
|
export type AlbumsResponse = {
|
||||||
|
defaultAssetOrder: AssetOrder;
|
||||||
|
};
|
||||||
export type CastResponse = {
|
export type CastResponse = {
|
||||||
gCastEnabled: boolean;
|
gCastEnabled: boolean;
|
||||||
};
|
};
|
||||||
@@ -168,6 +171,7 @@ export type TagsResponse = {
|
|||||||
sidebarWeb: boolean;
|
sidebarWeb: boolean;
|
||||||
};
|
};
|
||||||
export type UserPreferencesResponseDto = {
|
export type UserPreferencesResponseDto = {
|
||||||
|
albums: AlbumsResponse;
|
||||||
cast: CastResponse;
|
cast: CastResponse;
|
||||||
download: DownloadResponse;
|
download: DownloadResponse;
|
||||||
emailNotifications: EmailNotificationsResponse;
|
emailNotifications: EmailNotificationsResponse;
|
||||||
@@ -179,6 +183,9 @@ export type UserPreferencesResponseDto = {
|
|||||||
sharedLinks: SharedLinksResponse;
|
sharedLinks: SharedLinksResponse;
|
||||||
tags: TagsResponse;
|
tags: TagsResponse;
|
||||||
};
|
};
|
||||||
|
export type AlbumsUpdate = {
|
||||||
|
defaultAssetOrder?: AssetOrder;
|
||||||
|
};
|
||||||
export type AvatarUpdate = {
|
export type AvatarUpdate = {
|
||||||
color?: UserAvatarColor;
|
color?: UserAvatarColor;
|
||||||
};
|
};
|
||||||
@@ -221,6 +228,7 @@ export type TagsUpdate = {
|
|||||||
sidebarWeb?: boolean;
|
sidebarWeb?: boolean;
|
||||||
};
|
};
|
||||||
export type UserPreferencesUpdateDto = {
|
export type UserPreferencesUpdateDto = {
|
||||||
|
albums?: AlbumsUpdate;
|
||||||
avatar?: AvatarUpdate;
|
avatar?: AvatarUpdate;
|
||||||
cast?: CastUpdate;
|
cast?: CastUpdate;
|
||||||
download?: DownloadUpdate;
|
download?: DownloadUpdate;
|
||||||
@@ -312,7 +320,9 @@ export type AssetResponseDto = {
|
|||||||
duplicateId?: string | null;
|
duplicateId?: string | null;
|
||||||
duration: string;
|
duration: string;
|
||||||
exifInfo?: ExifResponseDto;
|
exifInfo?: ExifResponseDto;
|
||||||
|
/** The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken. */
|
||||||
fileCreatedAt: string;
|
fileCreatedAt: string;
|
||||||
|
/** The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken. */
|
||||||
fileModifiedAt: string;
|
fileModifiedAt: string;
|
||||||
hasMetadata: boolean;
|
hasMetadata: boolean;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -323,6 +333,7 @@ export type AssetResponseDto = {
|
|||||||
/** This property was deprecated in v1.106.0 */
|
/** This property was deprecated in v1.106.0 */
|
||||||
libraryId?: string | null;
|
libraryId?: string | null;
|
||||||
livePhotoVideoId?: string | null;
|
livePhotoVideoId?: string | null;
|
||||||
|
/** The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer's local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by "local" days and months. */
|
||||||
localDateTime: string;
|
localDateTime: string;
|
||||||
originalFileName: string;
|
originalFileName: string;
|
||||||
originalMimeType?: string;
|
originalMimeType?: string;
|
||||||
@@ -337,6 +348,7 @@ export type AssetResponseDto = {
|
|||||||
thumbhash: string | null;
|
thumbhash: string | null;
|
||||||
"type": AssetTypeEnum;
|
"type": AssetTypeEnum;
|
||||||
unassignedFaces?: AssetFaceWithoutPersonResponseDto[];
|
unassignedFaces?: AssetFaceWithoutPersonResponseDto[];
|
||||||
|
/** The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified. */
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
visibility: AssetVisibility;
|
visibility: AssetVisibility;
|
||||||
};
|
};
|
||||||
@@ -1442,25 +1454,43 @@ export type TagUpdateDto = {
|
|||||||
color?: string | null;
|
color?: string | null;
|
||||||
};
|
};
|
||||||
export type TimeBucketAssetResponseDto = {
|
export type TimeBucketAssetResponseDto = {
|
||||||
|
/** Array of city names extracted from EXIF GPS data */
|
||||||
city: (string | null)[];
|
city: (string | null)[];
|
||||||
|
/** Array of country names extracted from EXIF GPS data */
|
||||||
country: (string | null)[];
|
country: (string | null)[];
|
||||||
|
/** Array of video durations in HH:MM:SS format (null for images) */
|
||||||
duration: (string | null)[];
|
duration: (string | null)[];
|
||||||
|
/** Array of file creation timestamps in UTC (ISO 8601 format, without timezone) */
|
||||||
|
fileCreatedAt: string[];
|
||||||
|
/** Array of asset IDs in the time bucket */
|
||||||
id: string[];
|
id: string[];
|
||||||
|
/** Array indicating whether each asset is favorited */
|
||||||
isFavorite: boolean[];
|
isFavorite: boolean[];
|
||||||
|
/** Array indicating whether each asset is an image (false for videos) */
|
||||||
isImage: boolean[];
|
isImage: boolean[];
|
||||||
|
/** Array indicating whether each asset is in the trash */
|
||||||
isTrashed: boolean[];
|
isTrashed: boolean[];
|
||||||
|
/** Array of live photo video asset IDs (null for non-live photos) */
|
||||||
livePhotoVideoId: (string | null)[];
|
livePhotoVideoId: (string | null)[];
|
||||||
localDateTime: string[];
|
/** Array of UTC offset hours at the time each photo was taken. Positive values are east of UTC, negative values are west of UTC. Values may be fractional (e.g., 5.5 for +05:30, -9.75 for -09:45). Applying this offset to 'fileCreatedAt' will give you the time the photo was taken from the photographer's perspective. */
|
||||||
|
localOffsetHours: number[];
|
||||||
|
/** Array of owner IDs for each asset */
|
||||||
ownerId: string[];
|
ownerId: string[];
|
||||||
|
/** Array of projection types for 360° content (e.g., "EQUIRECTANGULAR", "CUBEFACE", "CYLINDRICAL") */
|
||||||
projectionType: (string | null)[];
|
projectionType: (string | null)[];
|
||||||
|
/** Array of aspect ratios (width/height) for each asset */
|
||||||
ratio: number[];
|
ratio: number[];
|
||||||
/** (stack ID, stack asset count) tuple */
|
/** Array of stack information as [stackId, assetCount] tuples (null for non-stacked assets) */
|
||||||
stack?: (string[] | null)[];
|
stack?: (string[] | null)[];
|
||||||
|
/** Array of BlurHash strings for generating asset previews (base64 encoded) */
|
||||||
thumbhash: (string | null)[];
|
thumbhash: (string | null)[];
|
||||||
|
/** Array of visibility statuses for each asset (e.g., ARCHIVE, TIMELINE, HIDDEN, LOCKED) */
|
||||||
visibility: AssetVisibility[];
|
visibility: AssetVisibility[];
|
||||||
};
|
};
|
||||||
export type TimeBucketsResponseDto = {
|
export type TimeBucketsResponseDto = {
|
||||||
|
/** Number of assets in this time bucket */
|
||||||
count: number;
|
count: number;
|
||||||
|
/** Time bucket identifier in YYYY-MM-DD format representing the start of the time period */
|
||||||
timeBucket: string;
|
timeBucket: string;
|
||||||
};
|
};
|
||||||
export type TrashResponseDto = {
|
export type TrashResponseDto = {
|
||||||
@@ -3727,6 +3757,10 @@ export enum UserStatus {
|
|||||||
Removing = "removing",
|
Removing = "removing",
|
||||||
Deleted = "deleted"
|
Deleted = "deleted"
|
||||||
}
|
}
|
||||||
|
export enum AssetOrder {
|
||||||
|
Asc = "asc",
|
||||||
|
Desc = "desc"
|
||||||
|
}
|
||||||
export enum AssetVisibility {
|
export enum AssetVisibility {
|
||||||
Archive = "archive",
|
Archive = "archive",
|
||||||
Timeline = "timeline",
|
Timeline = "timeline",
|
||||||
@@ -3748,10 +3782,6 @@ export enum AssetTypeEnum {
|
|||||||
Audio = "AUDIO",
|
Audio = "AUDIO",
|
||||||
Other = "OTHER"
|
Other = "OTHER"
|
||||||
}
|
}
|
||||||
export enum AssetOrder {
|
|
||||||
Asc = "asc",
|
|
||||||
Desc = "desc"
|
|
||||||
}
|
|
||||||
export enum Error {
|
export enum Error {
|
||||||
Duplicate = "duplicate",
|
Duplicate = "duplicate",
|
||||||
NoPermission = "no_permission",
|
NoPermission = "no_permission",
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ export class SanitizedAssetResponseDto {
|
|||||||
type!: AssetType;
|
type!: AssetType;
|
||||||
thumbhash!: string | null;
|
thumbhash!: string | null;
|
||||||
originalMimeType?: string;
|
originalMimeType?: string;
|
||||||
|
@ApiProperty({
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time',
|
||||||
|
description:
|
||||||
|
'The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer\'s local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by "local" days and months.',
|
||||||
|
example: '2024-01-15T14:30:00.000Z',
|
||||||
|
})
|
||||||
localDateTime!: Date;
|
localDateTime!: Date;
|
||||||
duration!: string;
|
duration!: string;
|
||||||
livePhotoVideoId?: string | null;
|
livePhotoVideoId?: string | null;
|
||||||
@@ -37,8 +44,29 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
|
|||||||
libraryId?: string | null;
|
libraryId?: string | null;
|
||||||
originalPath!: string;
|
originalPath!: string;
|
||||||
originalFileName!: string;
|
originalFileName!: string;
|
||||||
|
@ApiProperty({
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time',
|
||||||
|
description:
|
||||||
|
'The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken.',
|
||||||
|
example: '2024-01-15T19:30:00.000Z',
|
||||||
|
})
|
||||||
fileCreatedAt!: Date;
|
fileCreatedAt!: Date;
|
||||||
|
@ApiProperty({
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time',
|
||||||
|
description:
|
||||||
|
'The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken.',
|
||||||
|
example: '2024-01-16T10:15:00.000Z',
|
||||||
|
})
|
||||||
fileModifiedAt!: Date;
|
fileModifiedAt!: Date;
|
||||||
|
@ApiProperty({
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time',
|
||||||
|
description:
|
||||||
|
'The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified.',
|
||||||
|
example: '2024-01-16T12:45:30.000Z',
|
||||||
|
})
|
||||||
updatedAt!: Date;
|
updatedAt!: Date;
|
||||||
isFavorite!: boolean;
|
isFavorite!: boolean;
|
||||||
isArchived!: boolean;
|
isArchived!: boolean;
|
||||||
|
|||||||
@@ -5,72 +5,143 @@ import { AssetOrder, AssetVisibility } from 'src/enum';
|
|||||||
import { Optional, ValidateAssetVisibility, ValidateBoolean, ValidateUUID } from 'src/validation';
|
import { Optional, ValidateAssetVisibility, ValidateBoolean, ValidateUUID } from 'src/validation';
|
||||||
|
|
||||||
export class TimeBucketDto {
|
export class TimeBucketDto {
|
||||||
@ValidateUUID({ optional: true })
|
@ValidateUUID({ optional: true, description: 'Filter assets by specific user ID' })
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
|
||||||
@ValidateUUID({ optional: true })
|
@ValidateUUID({ optional: true, description: 'Filter assets belonging to a specific album' })
|
||||||
albumId?: string;
|
albumId?: string;
|
||||||
|
|
||||||
@ValidateUUID({ optional: true })
|
@ValidateUUID({ optional: true, description: 'Filter assets containing a specific person (face recognition)' })
|
||||||
personId?: string;
|
personId?: string;
|
||||||
|
|
||||||
@ValidateUUID({ optional: true })
|
@ValidateUUID({ optional: true, description: 'Filter assets with a specific tag' })
|
||||||
tagId?: string;
|
tagId?: string;
|
||||||
|
|
||||||
@ValidateBoolean({ optional: true })
|
@ValidateBoolean({
|
||||||
|
optional: true,
|
||||||
|
description: 'Filter by favorite status (true for favorites only, false for non-favorites only)',
|
||||||
|
})
|
||||||
isFavorite?: boolean;
|
isFavorite?: boolean;
|
||||||
|
|
||||||
@ValidateBoolean({ optional: true })
|
@ValidateBoolean({
|
||||||
|
optional: true,
|
||||||
|
description: 'Filter by trash status (true for trashed assets only, false for non-trashed only)',
|
||||||
|
})
|
||||||
isTrashed?: boolean;
|
isTrashed?: boolean;
|
||||||
|
|
||||||
@ValidateBoolean({ optional: true })
|
@ValidateBoolean({
|
||||||
|
optional: true,
|
||||||
|
description: 'Include stacked assets in the response. When true, only primary assets from stacks are returned.',
|
||||||
|
})
|
||||||
withStacked?: boolean;
|
withStacked?: boolean;
|
||||||
|
|
||||||
@ValidateBoolean({ optional: true })
|
@ValidateBoolean({ optional: true, description: 'Include assets shared by partners' })
|
||||||
withPartners?: boolean;
|
withPartners?: boolean;
|
||||||
|
|
||||||
@IsEnum(AssetOrder)
|
@IsEnum(AssetOrder)
|
||||||
@Optional()
|
@Optional()
|
||||||
@ApiProperty({ enum: AssetOrder, enumName: 'AssetOrder' })
|
@ApiProperty({
|
||||||
|
enum: AssetOrder,
|
||||||
|
enumName: 'AssetOrder',
|
||||||
|
description: 'Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)',
|
||||||
|
})
|
||||||
order?: AssetOrder;
|
order?: AssetOrder;
|
||||||
|
|
||||||
@ValidateAssetVisibility({ optional: true })
|
@ValidateAssetVisibility({
|
||||||
|
optional: true,
|
||||||
|
description: 'Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)',
|
||||||
|
})
|
||||||
visibility?: AssetVisibility;
|
visibility?: AssetVisibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TimeBucketAssetDto extends TimeBucketDto {
|
export class TimeBucketAssetDto extends TimeBucketDto {
|
||||||
|
@ApiProperty({
|
||||||
|
type: 'string',
|
||||||
|
description: 'Time bucket identifier in YYYY-MM-DD format (e.g., "2024-01-01" for January 2024)',
|
||||||
|
example: '2024-01-01',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
timeBucket!: string;
|
timeBucket!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TimelineStackResponseDto {
|
|
||||||
id!: string;
|
|
||||||
primaryAssetId!: string;
|
|
||||||
assetCount!: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TimeBucketAssetResponseDto {
|
export class TimeBucketAssetResponseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
description: 'Array of asset IDs in the time bucket',
|
||||||
|
})
|
||||||
id!: string[];
|
id!: string[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
description: 'Array of owner IDs for each asset',
|
||||||
|
})
|
||||||
ownerId!: string[];
|
ownerId!: string[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'number' },
|
||||||
|
description: 'Array of aspect ratios (width/height) for each asset',
|
||||||
|
})
|
||||||
ratio!: number[];
|
ratio!: number[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'boolean' },
|
||||||
|
description: 'Array indicating whether each asset is favorited',
|
||||||
|
})
|
||||||
isFavorite!: boolean[];
|
isFavorite!: boolean[];
|
||||||
|
|
||||||
@ApiProperty({ enum: AssetVisibility, enumName: 'AssetVisibility', isArray: true })
|
@ApiProperty({
|
||||||
|
enum: AssetVisibility,
|
||||||
|
enumName: 'AssetVisibility',
|
||||||
|
isArray: true,
|
||||||
|
description: 'Array of visibility statuses for each asset (e.g., ARCHIVE, TIMELINE, HIDDEN, LOCKED)',
|
||||||
|
})
|
||||||
visibility!: AssetVisibility[];
|
visibility!: AssetVisibility[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'boolean' },
|
||||||
|
description: 'Array indicating whether each asset is in the trash',
|
||||||
|
})
|
||||||
isTrashed!: boolean[];
|
isTrashed!: boolean[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'boolean' },
|
||||||
|
description: 'Array indicating whether each asset is an image (false for videos)',
|
||||||
|
})
|
||||||
isImage!: boolean[];
|
isImage!: boolean[];
|
||||||
|
|
||||||
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
|
@ApiProperty({
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string', nullable: true },
|
||||||
|
description: 'Array of BlurHash strings for generating asset previews (base64 encoded)',
|
||||||
|
})
|
||||||
thumbhash!: (string | null)[];
|
thumbhash!: (string | null)[];
|
||||||
|
|
||||||
localDateTime!: string[];
|
@ApiProperty({
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
description: 'Array of file creation timestamps in UTC (ISO 8601 format, without timezone)',
|
||||||
|
})
|
||||||
|
fileCreatedAt!: string[];
|
||||||
|
|
||||||
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
|
@ApiProperty({
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'number' },
|
||||||
|
description:
|
||||||
|
"Array of UTC offset hours at the time each photo was taken. Positive values are east of UTC, negative values are west of UTC. Values may be fractional (e.g., 5.5 for +05:30, -9.75 for -09:45). Applying this offset to 'fileCreatedAt' will give you the time the photo was taken from the photographer's perspective.",
|
||||||
|
})
|
||||||
|
localOffsetHours!: number[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string', nullable: true },
|
||||||
|
description: 'Array of video durations in HH:MM:SS format (null for images)',
|
||||||
|
})
|
||||||
duration!: (string | null)[];
|
duration!: (string | null)[];
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
@@ -82,27 +153,51 @@ export class TimeBucketAssetResponseDto {
|
|||||||
maxItems: 2,
|
maxItems: 2,
|
||||||
nullable: true,
|
nullable: true,
|
||||||
},
|
},
|
||||||
description: '(stack ID, stack asset count) tuple',
|
description: 'Array of stack information as [stackId, assetCount] tuples (null for non-stacked assets)',
|
||||||
})
|
})
|
||||||
stack?: ([string, string] | null)[];
|
stack?: ([string, string] | null)[];
|
||||||
|
|
||||||
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
|
@ApiProperty({
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string', nullable: true },
|
||||||
|
description: 'Array of projection types for 360° content (e.g., "EQUIRECTANGULAR", "CUBEFACE", "CYLINDRICAL")',
|
||||||
|
})
|
||||||
projectionType!: (string | null)[];
|
projectionType!: (string | null)[];
|
||||||
|
|
||||||
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
|
@ApiProperty({
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string', nullable: true },
|
||||||
|
description: 'Array of live photo video asset IDs (null for non-live photos)',
|
||||||
|
})
|
||||||
livePhotoVideoId!: (string | null)[];
|
livePhotoVideoId!: (string | null)[];
|
||||||
|
|
||||||
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
|
@ApiProperty({
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string', nullable: true },
|
||||||
|
description: 'Array of city names extracted from EXIF GPS data',
|
||||||
|
})
|
||||||
city!: (string | null)[];
|
city!: (string | null)[];
|
||||||
|
|
||||||
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
|
@ApiProperty({
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string', nullable: true },
|
||||||
|
description: 'Array of country names extracted from EXIF GPS data',
|
||||||
|
})
|
||||||
country!: (string | null)[];
|
country!: (string | null)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TimeBucketsResponseDto {
|
export class TimeBucketsResponseDto {
|
||||||
@ApiProperty({ type: 'string' })
|
@ApiProperty({
|
||||||
|
type: 'string',
|
||||||
|
description: 'Time bucket identifier in YYYY-MM-DD format representing the start of the time period',
|
||||||
|
example: '2024-01-01',
|
||||||
|
})
|
||||||
timeBucket!: string;
|
timeBucket!: string;
|
||||||
|
|
||||||
@ApiProperty({ type: 'integer' })
|
@ApiProperty({
|
||||||
|
type: 'integer',
|
||||||
|
description: 'Number of assets in this time bucket',
|
||||||
|
example: 42,
|
||||||
|
})
|
||||||
count!: number;
|
count!: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsDateString, IsEnum, IsInt, IsPositive, ValidateNested } from 'class-validator';
|
import { IsDateString, IsEnum, IsInt, IsPositive, ValidateNested } from 'class-validator';
|
||||||
import { UserAvatarColor } from 'src/enum';
|
import { AssetOrder, UserAvatarColor } from 'src/enum';
|
||||||
import { UserPreferences } from 'src/types';
|
import { UserPreferences } from 'src/types';
|
||||||
import { Optional, ValidateBoolean } from 'src/validation';
|
import { Optional, ValidateBoolean } from 'src/validation';
|
||||||
|
|
||||||
@@ -22,6 +22,12 @@ class RatingsUpdate {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class AlbumsUpdate {
|
||||||
|
@IsEnum(AssetOrder)
|
||||||
|
@ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder })
|
||||||
|
defaultAssetOrder?: AssetOrder;
|
||||||
|
}
|
||||||
|
|
||||||
class FoldersUpdate {
|
class FoldersUpdate {
|
||||||
@ValidateBoolean({ optional: true })
|
@ValidateBoolean({ optional: true })
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@@ -91,6 +97,11 @@ class CastUpdate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UserPreferencesUpdateDto {
|
export class UserPreferencesUpdateDto {
|
||||||
|
@Optional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => AlbumsUpdate)
|
||||||
|
albums?: AlbumsUpdate;
|
||||||
|
|
||||||
@Optional()
|
@Optional()
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => FoldersUpdate)
|
@Type(() => FoldersUpdate)
|
||||||
@@ -147,6 +158,12 @@ export class UserPreferencesUpdateDto {
|
|||||||
cast?: CastUpdate;
|
cast?: CastUpdate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class AlbumsResponse {
|
||||||
|
@IsEnum(AssetOrder)
|
||||||
|
@ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder })
|
||||||
|
defaultAssetOrder: AssetOrder = AssetOrder.DESC;
|
||||||
|
}
|
||||||
|
|
||||||
class RatingsResponse {
|
class RatingsResponse {
|
||||||
enabled: boolean = false;
|
enabled: boolean = false;
|
||||||
}
|
}
|
||||||
@@ -198,6 +215,7 @@ class CastResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UserPreferencesResponseDto implements UserPreferences {
|
export class UserPreferencesResponseDto implements UserPreferences {
|
||||||
|
albums!: AlbumsResponse;
|
||||||
folders!: FoldersResponse;
|
folders!: FoldersResponse;
|
||||||
memories!: MemoriesResponse;
|
memories!: MemoriesResponse;
|
||||||
people!: PeopleResponse;
|
people!: PeopleResponse;
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ with
|
|||||||
and "assets"."visibility" in ('archive', 'timeline')
|
and "assets"."visibility" in ('archive', 'timeline')
|
||||||
)
|
)
|
||||||
select
|
select
|
||||||
"timeBucket",
|
"timeBucket"::date::text as "timeBucket",
|
||||||
count(*) as "count"
|
count(*) as "count"
|
||||||
from
|
from
|
||||||
"assets"
|
"assets"
|
||||||
@@ -262,9 +262,16 @@ with
|
|||||||
assets.type = 'IMAGE' as "isImage",
|
assets.type = 'IMAGE' as "isImage",
|
||||||
assets."deletedAt" is not null as "isTrashed",
|
assets."deletedAt" is not null as "isTrashed",
|
||||||
"assets"."livePhotoVideoId",
|
"assets"."livePhotoVideoId",
|
||||||
"assets"."localDateTime",
|
extract(
|
||||||
|
epoch
|
||||||
|
from
|
||||||
|
(
|
||||||
|
assets."localDateTime" - assets."fileCreatedAt" at time zone 'UTC'
|
||||||
|
)
|
||||||
|
)::real / 3600 as "localOffsetHours",
|
||||||
"assets"."ownerId",
|
"assets"."ownerId",
|
||||||
"assets"."status",
|
"assets"."status",
|
||||||
|
assets."fileCreatedAt" at time zone 'utc' as "fileCreatedAt",
|
||||||
encode("assets"."thumbhash", 'base64') as "thumbhash",
|
encode("assets"."thumbhash", 'base64') as "thumbhash",
|
||||||
"exif"."city",
|
"exif"."city",
|
||||||
"exif"."country",
|
"exif"."country",
|
||||||
@@ -313,7 +320,7 @@ with
|
|||||||
and "asset_stack"."primaryAssetId" != "assets"."id"
|
and "asset_stack"."primaryAssetId" != "assets"."id"
|
||||||
)
|
)
|
||||||
order by
|
order by
|
||||||
"assets"."localDateTime" desc
|
"assets"."fileCreatedAt" desc
|
||||||
),
|
),
|
||||||
"agg" as (
|
"agg" as (
|
||||||
select
|
select
|
||||||
@@ -326,7 +333,8 @@ with
|
|||||||
coalesce(array_agg("isImage"), '{}') as "isImage",
|
coalesce(array_agg("isImage"), '{}') as "isImage",
|
||||||
coalesce(array_agg("isTrashed"), '{}') as "isTrashed",
|
coalesce(array_agg("isTrashed"), '{}') as "isTrashed",
|
||||||
coalesce(array_agg("livePhotoVideoId"), '{}') as "livePhotoVideoId",
|
coalesce(array_agg("livePhotoVideoId"), '{}') as "livePhotoVideoId",
|
||||||
coalesce(array_agg("localDateTime"), '{}') as "localDateTime",
|
coalesce(array_agg("fileCreatedAt"), '{}') as "fileCreatedAt",
|
||||||
|
coalesce(array_agg("localOffsetHours"), '{}') as "localOffsetHours",
|
||||||
coalesce(array_agg("ownerId"), '{}') as "ownerId",
|
coalesce(array_agg("ownerId"), '{}') as "ownerId",
|
||||||
coalesce(array_agg("projectionType"), '{}') as "projectionType",
|
coalesce(array_agg("projectionType"), '{}') as "projectionType",
|
||||||
coalesce(array_agg("ratio"), '{}') as "ratio",
|
coalesce(array_agg("ratio"), '{}') as "ratio",
|
||||||
|
|||||||
@@ -532,51 +532,44 @@ export class AssetRepository {
|
|||||||
|
|
||||||
@GenerateSql({ params: [{ size: TimeBucketSize.MONTH }] })
|
@GenerateSql({ params: [{ size: TimeBucketSize.MONTH }] })
|
||||||
async getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]> {
|
async getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]> {
|
||||||
return (
|
return this.db
|
||||||
this.db
|
.with('assets', (qb) =>
|
||||||
.with('assets', (qb) =>
|
qb
|
||||||
qb
|
.selectFrom('assets')
|
||||||
.selectFrom('assets')
|
.select(truncatedDate<Date>(TimeBucketSize.MONTH).as('timeBucket'))
|
||||||
.select(truncatedDate<Date>(TimeBucketSize.MONTH).as('timeBucket'))
|
.$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
|
||||||
.$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
|
.where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null)
|
||||||
.where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null)
|
.$if(options.visibility === undefined, withDefaultVisibility)
|
||||||
.$if(options.visibility === undefined, withDefaultVisibility)
|
.$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!))
|
||||||
.$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!))
|
.$if(!!options.albumId, (qb) =>
|
||||||
.$if(!!options.albumId, (qb) =>
|
qb
|
||||||
qb
|
.innerJoin('albums_assets_assets', 'assets.id', 'albums_assets_assets.assetsId')
|
||||||
.innerJoin('albums_assets_assets', 'assets.id', 'albums_assets_assets.assetsId')
|
.where('albums_assets_assets.albumsId', '=', asUuid(options.albumId!)),
|
||||||
.where('albums_assets_assets.albumsId', '=', asUuid(options.albumId!)),
|
)
|
||||||
)
|
.$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
|
||||||
.$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
|
.$if(!!options.withStacked, (qb) =>
|
||||||
.$if(!!options.withStacked, (qb) =>
|
qb
|
||||||
qb
|
.leftJoin('asset_stack', (join) =>
|
||||||
.leftJoin('asset_stack', (join) =>
|
join
|
||||||
join
|
.onRef('asset_stack.id', '=', 'assets.stackId')
|
||||||
.onRef('asset_stack.id', '=', 'assets.stackId')
|
.onRef('asset_stack.primaryAssetId', '=', 'assets.id'),
|
||||||
.onRef('asset_stack.primaryAssetId', '=', 'assets.id'),
|
)
|
||||||
)
|
.where((eb) => eb.or([eb('assets.stackId', 'is', null), eb(eb.table('asset_stack'), 'is not', null)])),
|
||||||
.where((eb) => eb.or([eb('assets.stackId', 'is', null), eb(eb.table('asset_stack'), 'is not', null)])),
|
)
|
||||||
)
|
.$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!)))
|
||||||
.$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!)))
|
.$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!))
|
||||||
.$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!))
|
.$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!))
|
||||||
.$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!))
|
.$if(options.isDuplicate !== undefined, (qb) =>
|
||||||
.$if(options.isDuplicate !== undefined, (qb) =>
|
qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null),
|
||||||
qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null),
|
)
|
||||||
)
|
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)),
|
||||||
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)),
|
)
|
||||||
)
|
.selectFrom('assets')
|
||||||
.selectFrom('assets')
|
.select(sql<string>`"timeBucket"::date::text`.as('timeBucket'))
|
||||||
.select('timeBucket')
|
.select((eb) => eb.fn.countAll<number>().as('count'))
|
||||||
/*
|
.groupBy('timeBucket')
|
||||||
TODO: the above line outputs in ISO format, which bloats the response.
|
.orderBy('timeBucket', options.order ?? 'desc')
|
||||||
The line below outputs in YYYY-MM-DD format, but needs a change in the web app to work.
|
.execute() as any as Promise<TimeBucketItem[]>;
|
||||||
.select(sql<string>`"timeBucket"::date::text`.as('timeBucket'))
|
|
||||||
*/
|
|
||||||
.select((eb) => eb.fn.countAll<number>().as('count'))
|
|
||||||
.groupBy('timeBucket')
|
|
||||||
.orderBy('timeBucket', options.order ?? 'desc')
|
|
||||||
.execute() as any as Promise<TimeBucketItem[]>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({
|
@GenerateSql({
|
||||||
@@ -596,9 +589,12 @@ export class AssetRepository {
|
|||||||
sql`assets.type = 'IMAGE'`.as('isImage'),
|
sql`assets.type = 'IMAGE'`.as('isImage'),
|
||||||
sql`assets."deletedAt" is not null`.as('isTrashed'),
|
sql`assets."deletedAt" is not null`.as('isTrashed'),
|
||||||
'assets.livePhotoVideoId',
|
'assets.livePhotoVideoId',
|
||||||
'assets.localDateTime',
|
sql`extract(epoch from (assets."localDateTime" - assets."fileCreatedAt" at time zone 'UTC'))::real / 3600`.as(
|
||||||
|
'localOffsetHours',
|
||||||
|
),
|
||||||
'assets.ownerId',
|
'assets.ownerId',
|
||||||
'assets.status',
|
'assets.status',
|
||||||
|
sql`assets."fileCreatedAt" at time zone 'utc'`.as('fileCreatedAt'),
|
||||||
eb.fn('encode', ['assets.thumbhash', sql.lit('base64')]).as('thumbhash'),
|
eb.fn('encode', ['assets.thumbhash', sql.lit('base64')]).as('thumbhash'),
|
||||||
'exif.city',
|
'exif.city',
|
||||||
'exif.country',
|
'exif.country',
|
||||||
@@ -666,7 +662,7 @@ export class AssetRepository {
|
|||||||
)
|
)
|
||||||
.$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
|
.$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
|
||||||
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
|
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
|
||||||
.orderBy('assets.localDateTime', options.order ?? 'desc'),
|
.orderBy('assets.fileCreatedAt', options.order ?? 'desc'),
|
||||||
)
|
)
|
||||||
.with('agg', (qb) =>
|
.with('agg', (qb) =>
|
||||||
qb
|
qb
|
||||||
@@ -682,7 +678,8 @@ export class AssetRepository {
|
|||||||
// TODO: isTrashed is redundant as it will always be all true or false depending on the options
|
// TODO: isTrashed is redundant as it will always be all true or false depending on the options
|
||||||
eb.fn.coalesce(eb.fn('array_agg', ['isTrashed']), sql.lit('{}')).as('isTrashed'),
|
eb.fn.coalesce(eb.fn('array_agg', ['isTrashed']), sql.lit('{}')).as('isTrashed'),
|
||||||
eb.fn.coalesce(eb.fn('array_agg', ['livePhotoVideoId']), sql.lit('{}')).as('livePhotoVideoId'),
|
eb.fn.coalesce(eb.fn('array_agg', ['livePhotoVideoId']), sql.lit('{}')).as('livePhotoVideoId'),
|
||||||
eb.fn.coalesce(eb.fn('array_agg', ['localDateTime']), sql.lit('{}')).as('localDateTime'),
|
eb.fn.coalesce(eb.fn('array_agg', ['fileCreatedAt']), sql.lit('{}')).as('fileCreatedAt'),
|
||||||
|
eb.fn.coalesce(eb.fn('array_agg', ['localOffsetHours']), sql.lit('{}')).as('localOffsetHours'),
|
||||||
eb.fn.coalesce(eb.fn('array_agg', ['ownerId']), sql.lit('{}')).as('ownerId'),
|
eb.fn.coalesce(eb.fn('array_agg', ['ownerId']), sql.lit('{}')).as('ownerId'),
|
||||||
eb.fn.coalesce(eb.fn('array_agg', ['projectionType']), sql.lit('{}')).as('projectionType'),
|
eb.fn.coalesce(eb.fn('array_agg', ['projectionType']), sql.lit('{}')).as('projectionType'),
|
||||||
eb.fn.coalesce(eb.fn('array_agg', ['ratio']), sql.lit('{}')).as('ratio'),
|
eb.fn.coalesce(eb.fn('array_agg', ['ratio']), sql.lit('{}')).as('ratio'),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||||
import { AlbumUserRole } from 'src/enum';
|
import { AlbumUserRole, AssetOrder, UserMetadataKey } from 'src/enum';
|
||||||
import { AlbumService } from 'src/services/album.service';
|
import { AlbumService } from 'src/services/album.service';
|
||||||
import { albumStub } from 'test/fixtures/album.stub';
|
import { albumStub } from 'test/fixtures/album.stub';
|
||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
@@ -141,6 +141,7 @@ describe(AlbumService.name, () => {
|
|||||||
it('creates album', async () => {
|
it('creates album', async () => {
|
||||||
mocks.album.create.mockResolvedValue(albumStub.empty);
|
mocks.album.create.mockResolvedValue(albumStub.empty);
|
||||||
mocks.user.get.mockResolvedValue(userStub.user1);
|
mocks.user.get.mockResolvedValue(userStub.user1);
|
||||||
|
mocks.user.getMetadata.mockResolvedValue([]);
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['123']));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['123']));
|
||||||
|
|
||||||
await sut.create(authStub.admin, {
|
await sut.create(authStub.admin, {
|
||||||
@@ -155,7 +156,7 @@ describe(AlbumService.name, () => {
|
|||||||
ownerId: authStub.admin.user.id,
|
ownerId: authStub.admin.user.id,
|
||||||
albumName: albumStub.empty.albumName,
|
albumName: albumStub.empty.albumName,
|
||||||
description: albumStub.empty.description,
|
description: albumStub.empty.description,
|
||||||
|
order: 'desc',
|
||||||
albumThumbnailAssetId: '123',
|
albumThumbnailAssetId: '123',
|
||||||
},
|
},
|
||||||
['123'],
|
['123'],
|
||||||
@@ -163,6 +164,50 @@ describe(AlbumService.name, () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.user.get).toHaveBeenCalledWith('user-id', {});
|
expect(mocks.user.get).toHaveBeenCalledWith('user-id', {});
|
||||||
|
expect(mocks.user.getMetadata).toHaveBeenCalledWith(authStub.admin.user.id);
|
||||||
|
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']), false);
|
||||||
|
expect(mocks.event.emit).toHaveBeenCalledWith('album.invite', {
|
||||||
|
id: albumStub.empty.id,
|
||||||
|
userId: 'user-id',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates album with assetOrder from user preferences', async () => {
|
||||||
|
mocks.album.create.mockResolvedValue(albumStub.empty);
|
||||||
|
mocks.user.get.mockResolvedValue(userStub.user1);
|
||||||
|
mocks.user.getMetadata.mockResolvedValue([
|
||||||
|
{
|
||||||
|
key: UserMetadataKey.PREFERENCES,
|
||||||
|
value: {
|
||||||
|
albums: {
|
||||||
|
defaultAssetOrder: AssetOrder.ASC,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['123']));
|
||||||
|
|
||||||
|
await sut.create(authStub.admin, {
|
||||||
|
albumName: 'Empty album',
|
||||||
|
albumUsers: [{ userId: 'user-id', role: AlbumUserRole.EDITOR }],
|
||||||
|
description: '',
|
||||||
|
assetIds: ['123'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.album.create).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
ownerId: authStub.admin.user.id,
|
||||||
|
albumName: albumStub.empty.albumName,
|
||||||
|
description: albumStub.empty.description,
|
||||||
|
order: 'asc',
|
||||||
|
albumThumbnailAssetId: '123',
|
||||||
|
},
|
||||||
|
['123'],
|
||||||
|
[{ userId: 'user-id', role: AlbumUserRole.EDITOR }],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.user.get).toHaveBeenCalledWith('user-id', {});
|
||||||
|
expect(mocks.user.getMetadata).toHaveBeenCalledWith(authStub.admin.user.id);
|
||||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']), false);
|
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']), false);
|
||||||
expect(mocks.event.emit).toHaveBeenCalledWith('album.invite', {
|
expect(mocks.event.emit).toHaveBeenCalledWith('album.invite', {
|
||||||
id: albumStub.empty.id,
|
id: albumStub.empty.id,
|
||||||
@@ -185,6 +230,7 @@ describe(AlbumService.name, () => {
|
|||||||
it('should only add assets the user is allowed to access', async () => {
|
it('should only add assets the user is allowed to access', async () => {
|
||||||
mocks.user.get.mockResolvedValue(userStub.user1);
|
mocks.user.get.mockResolvedValue(userStub.user1);
|
||||||
mocks.album.create.mockResolvedValue(albumStub.oneAsset);
|
mocks.album.create.mockResolvedValue(albumStub.oneAsset);
|
||||||
|
mocks.user.getMetadata.mockResolvedValue([]);
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||||
|
|
||||||
await sut.create(authStub.admin, {
|
await sut.create(authStub.admin, {
|
||||||
@@ -198,7 +244,7 @@ describe(AlbumService.name, () => {
|
|||||||
ownerId: authStub.admin.user.id,
|
ownerId: authStub.admin.user.id,
|
||||||
albumName: 'Test album',
|
albumName: 'Test album',
|
||||||
description: '',
|
description: '',
|
||||||
|
order: 'desc',
|
||||||
albumThumbnailAssetId: 'asset-1',
|
albumThumbnailAssetId: 'asset-1',
|
||||||
},
|
},
|
||||||
['asset-1'],
|
['asset-1'],
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { Permission } from 'src/enum';
|
|||||||
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
|
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { addAssets, removeAssets } from 'src/utils/asset.util';
|
import { addAssets, removeAssets } from 'src/utils/asset.util';
|
||||||
|
import { getPreferences } from 'src/utils/preferences';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AlbumService extends BaseService {
|
export class AlbumService extends BaseService {
|
||||||
@@ -106,12 +107,15 @@ export class AlbumService extends BaseService {
|
|||||||
});
|
});
|
||||||
const assetIds = [...allowedAssetIdsSet].map((id) => id);
|
const assetIds = [...allowedAssetIdsSet].map((id) => id);
|
||||||
|
|
||||||
|
const userMetadata = await this.userRepository.getMetadata(auth.user.id);
|
||||||
|
|
||||||
const album = await this.albumRepository.create(
|
const album = await this.albumRepository.create(
|
||||||
{
|
{
|
||||||
ownerId: auth.user.id,
|
ownerId: auth.user.id,
|
||||||
albumName: dto.albumName,
|
albumName: dto.albumName,
|
||||||
description: dto.description,
|
description: dto.description,
|
||||||
albumThumbnailAssetId: assetIds[0] || null,
|
albumThumbnailAssetId: assetIds[0] || null,
|
||||||
|
order: getPreferences(userMetadata).albums.defaultAssetOrder,
|
||||||
},
|
},
|
||||||
assetIds,
|
assetIds,
|
||||||
albumUsers,
|
albumUsers,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { SystemConfig } from 'src/config';
|
import { SystemConfig } from 'src/config';
|
||||||
import { VECTOR_EXTENSIONS } from 'src/constants';
|
import { VECTOR_EXTENSIONS } from 'src/constants';
|
||||||
import {
|
import {
|
||||||
|
AssetOrder,
|
||||||
AssetType,
|
AssetType,
|
||||||
DatabaseSslMode,
|
DatabaseSslMode,
|
||||||
ExifOrientation,
|
ExifOrientation,
|
||||||
@@ -467,6 +468,9 @@ export type UserMetadataItem<T extends keyof UserMetadata = UserMetadataKey> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface UserPreferences {
|
export interface UserPreferences {
|
||||||
|
albums: {
|
||||||
|
defaultAssetOrder: AssetOrder;
|
||||||
|
};
|
||||||
folders: {
|
folders: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
sidebarWeb: boolean;
|
sidebarWeb: boolean;
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
|
import { UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
|
||||||
import { UserMetadataKey } from 'src/enum';
|
import { AssetOrder, UserMetadataKey } from 'src/enum';
|
||||||
import { DeepPartial, UserMetadataItem, UserPreferences } from 'src/types';
|
import { DeepPartial, UserMetadataItem, UserPreferences } from 'src/types';
|
||||||
import { HumanReadableSize } from 'src/utils/bytes';
|
import { HumanReadableSize } from 'src/utils/bytes';
|
||||||
import { getKeysDeep } from 'src/utils/misc';
|
import { getKeysDeep } from 'src/utils/misc';
|
||||||
|
|
||||||
const getDefaultPreferences = (): UserPreferences => {
|
const getDefaultPreferences = (): UserPreferences => {
|
||||||
return {
|
return {
|
||||||
|
albums: {
|
||||||
|
defaultAssetOrder: AssetOrder.DESC,
|
||||||
|
},
|
||||||
folders: {
|
folders: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
sidebarWeb: false,
|
sidebarWeb: false,
|
||||||
|
|||||||
+36
-17
@@ -6,7 +6,7 @@ import {
|
|||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
applyDecorators,
|
applyDecorators,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptions } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
@@ -72,22 +72,28 @@ export class UUIDParamDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PinCodeOptions = { optional?: boolean } & OptionalOptions;
|
type PinCodeOptions = { optional?: boolean } & OptionalOptions;
|
||||||
export const PinCode = ({ optional, ...options }: PinCodeOptions = {}) => {
|
export const PinCode = (options?: PinCodeOptions & ApiPropertyOptions) => {
|
||||||
|
const { optional, nullable, emptyToNull, ...apiPropertyOptions } = {
|
||||||
|
optional: false,
|
||||||
|
nullable: false,
|
||||||
|
emptyToNull: false,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
const decorators = [
|
const decorators = [
|
||||||
IsString(),
|
IsString(),
|
||||||
IsNotEmpty(),
|
IsNotEmpty(),
|
||||||
Matches(/^\d{6}$/, { message: ({ property }) => `${property} must be a 6-digit numeric string` }),
|
Matches(/^\d{6}$/, { message: ({ property }) => `${property} must be a 6-digit numeric string` }),
|
||||||
ApiProperty({ example: '123456' }),
|
ApiProperty({ example: '123456', ...apiPropertyOptions }),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (optional) {
|
if (optional) {
|
||||||
decorators.push(Optional(options));
|
decorators.push(Optional({ nullable, emptyToNull }));
|
||||||
}
|
}
|
||||||
|
|
||||||
return applyDecorators(...decorators);
|
return applyDecorators(...decorators);
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface OptionalOptions extends ValidationOptions {
|
export interface OptionalOptions {
|
||||||
nullable?: boolean;
|
nullable?: boolean;
|
||||||
/** convert empty strings to null */
|
/** convert empty strings to null */
|
||||||
emptyToNull?: boolean;
|
emptyToNull?: boolean;
|
||||||
@@ -127,22 +133,32 @@ export const ValidateHexColor = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type UUIDOptions = { optional?: boolean; each?: boolean; nullable?: boolean };
|
type UUIDOptions = { optional?: boolean; each?: boolean; nullable?: boolean };
|
||||||
export const ValidateUUID = (options?: UUIDOptions) => {
|
export const ValidateUUID = (options?: UUIDOptions & ApiPropertyOptions) => {
|
||||||
const { optional, each, nullable } = { optional: false, each: false, nullable: false, ...options };
|
const { optional, each, nullable, ...apiPropertyOptions } = {
|
||||||
|
optional: false,
|
||||||
|
each: false,
|
||||||
|
nullable: false,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
return applyDecorators(
|
return applyDecorators(
|
||||||
IsUUID('4', { each }),
|
IsUUID('4', { each }),
|
||||||
ApiProperty({ format: 'uuid' }),
|
ApiProperty({ format: 'uuid', ...apiPropertyOptions }),
|
||||||
optional ? Optional({ nullable }) : IsNotEmpty(),
|
optional ? Optional({ nullable }) : IsNotEmpty(),
|
||||||
each ? IsArray() : IsString(),
|
each ? IsArray() : IsString(),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type DateOptions = { optional?: boolean; nullable?: boolean; format?: 'date' | 'date-time' };
|
type DateOptions = { optional?: boolean; nullable?: boolean; format?: 'date' | 'date-time' };
|
||||||
export const ValidateDate = (options?: DateOptions) => {
|
export const ValidateDate = (options?: DateOptions & ApiPropertyOptions) => {
|
||||||
const { optional, nullable, format } = { optional: false, nullable: false, format: 'date-time', ...options };
|
const { optional, nullable, format, ...apiPropertyOptions } = {
|
||||||
|
optional: false,
|
||||||
|
nullable: false,
|
||||||
|
format: 'date-time',
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
const decorators = [
|
const decorators = [
|
||||||
ApiProperty({ format }),
|
ApiProperty({ format, ...apiPropertyOptions }),
|
||||||
IsDate(),
|
IsDate(),
|
||||||
optional ? Optional({ nullable: true }) : IsNotEmpty(),
|
optional ? Optional({ nullable: true }) : IsNotEmpty(),
|
||||||
Transform(({ key, value }) => {
|
Transform(({ key, value }) => {
|
||||||
@@ -166,9 +182,12 @@ export const ValidateDate = (options?: DateOptions) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type AssetVisibilityOptions = { optional?: boolean };
|
type AssetVisibilityOptions = { optional?: boolean };
|
||||||
export const ValidateAssetVisibility = (options?: AssetVisibilityOptions) => {
|
export const ValidateAssetVisibility = (options?: AssetVisibilityOptions & ApiPropertyOptions) => {
|
||||||
const { optional } = { optional: false, ...options };
|
const { optional, ...apiPropertyOptions } = { optional: false, ...options };
|
||||||
const decorators = [IsEnum(AssetVisibility), ApiProperty({ enumName: 'AssetVisibility', enum: AssetVisibility })];
|
const decorators = [
|
||||||
|
IsEnum(AssetVisibility),
|
||||||
|
ApiProperty({ enumName: 'AssetVisibility', enum: AssetVisibility, ...apiPropertyOptions }),
|
||||||
|
];
|
||||||
|
|
||||||
if (optional) {
|
if (optional) {
|
||||||
decorators.push(Optional());
|
decorators.push(Optional());
|
||||||
@@ -177,10 +196,10 @@ export const ValidateAssetVisibility = (options?: AssetVisibilityOptions) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type BooleanOptions = { optional?: boolean };
|
type BooleanOptions = { optional?: boolean };
|
||||||
export const ValidateBoolean = (options?: BooleanOptions) => {
|
export const ValidateBoolean = (options?: BooleanOptions & ApiPropertyOptions) => {
|
||||||
const { optional } = { optional: false, ...options };
|
const { optional, ...apiPropertyOptions } = { optional: false, ...options };
|
||||||
const decorators = [
|
const decorators = [
|
||||||
// ApiProperty(),
|
ApiProperty(apiPropertyOptions),
|
||||||
IsBoolean(),
|
IsBoolean(),
|
||||||
Transform(({ value }) => {
|
Transform(({ value }) => {
|
||||||
if (value == 'true') {
|
if (value == 'true') {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
||||||
import { fromDateTimeOriginal, fromLocalDateTime } from '$lib/utils/timeline-util';
|
import { fromISODateTime, fromISODateTimeUTC } from '$lib/utils/timeline-util';
|
||||||
import {
|
import {
|
||||||
AssetMediaSize,
|
AssetMediaSize,
|
||||||
getAssetInfo,
|
getAssetInfo,
|
||||||
@@ -112,8 +112,8 @@
|
|||||||
let timeZone = $derived(asset.exifInfo?.timeZone);
|
let timeZone = $derived(asset.exifInfo?.timeZone);
|
||||||
let dateTime = $derived(
|
let dateTime = $derived(
|
||||||
timeZone && asset.exifInfo?.dateTimeOriginal
|
timeZone && asset.exifInfo?.dateTimeOriginal
|
||||||
? fromDateTimeOriginal(asset.exifInfo.dateTimeOriginal, timeZone)
|
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
|
||||||
: fromLocalDateTime(asset.localDateTime),
|
: fromISODateTimeUTC(asset.localDateTime),
|
||||||
);
|
);
|
||||||
|
|
||||||
const getMegapixel = (width: number, height: number): number | undefined => {
|
const getMegapixel = (width: number, height: number): number | undefined => {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
import { getAssetPlaybackUrl, getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
import { getAssetPlaybackUrl, getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
||||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||||
import { fromLocalDateTime, toTimelineAsset } from '$lib/utils/timeline-util';
|
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { AssetMediaSize, getAssetInfo } from '@immich/sdk';
|
import { AssetMediaSize, getAssetInfo } from '@immich/sdk';
|
||||||
import { IconButton } from '@immich/ui';
|
import { IconButton } from '@immich/ui';
|
||||||
import {
|
import {
|
||||||
@@ -576,7 +576,7 @@
|
|||||||
|
|
||||||
<div class="absolute start-8 top-4 text-sm font-medium text-white">
|
<div class="absolute start-8 top-4 text-sm font-medium text-white">
|
||||||
<p>
|
<p>
|
||||||
{fromLocalDateTime(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, {
|
{fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, {
|
||||||
locale: $locale,
|
locale: $locale,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -4,14 +4,18 @@
|
|||||||
NotificationType,
|
NotificationType,
|
||||||
} from '$lib/components/shared-components/notification/notification';
|
} from '$lib/components/shared-components/notification/notification';
|
||||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||||
|
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||||
import { preferences } from '$lib/stores/user.store';
|
import { preferences } from '$lib/stores/user.store';
|
||||||
import { updateMyPreferences } from '@immich/sdk';
|
import { AssetOrder, updateMyPreferences } from '@immich/sdk';
|
||||||
import { Button } from '@immich/ui';
|
import { Button } from '@immich/ui';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import { handleError } from '../../utils/handle-error';
|
import { handleError } from '../../utils/handle-error';
|
||||||
|
|
||||||
|
// Albums
|
||||||
|
let defaultAssetOrder = $state($preferences?.albums?.defaultAssetOrder ?? AssetOrder.Desc);
|
||||||
|
|
||||||
// Folders
|
// Folders
|
||||||
let foldersEnabled = $state($preferences?.folders?.enabled ?? false);
|
let foldersEnabled = $state($preferences?.folders?.enabled ?? false);
|
||||||
let foldersSidebar = $state($preferences?.folders?.sidebarWeb ?? false);
|
let foldersSidebar = $state($preferences?.folders?.sidebarWeb ?? false);
|
||||||
@@ -41,6 +45,7 @@
|
|||||||
try {
|
try {
|
||||||
const data = await updateMyPreferences({
|
const data = await updateMyPreferences({
|
||||||
userPreferencesUpdateDto: {
|
userPreferencesUpdateDto: {
|
||||||
|
albums: { defaultAssetOrder },
|
||||||
folders: { enabled: foldersEnabled, sidebarWeb: foldersSidebar },
|
folders: { enabled: foldersEnabled, sidebarWeb: foldersSidebar },
|
||||||
memories: { enabled: memoriesEnabled },
|
memories: { enabled: memoriesEnabled },
|
||||||
people: { enabled: peopleEnabled, sidebarWeb: peopleSidebar },
|
people: { enabled: peopleEnabled, sidebarWeb: peopleSidebar },
|
||||||
@@ -68,6 +73,20 @@
|
|||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" {onsubmit}>
|
<form autocomplete="off" {onsubmit}>
|
||||||
<div class="ms-4 mt-4 flex flex-col">
|
<div class="ms-4 mt-4 flex flex-col">
|
||||||
|
<SettingAccordion key="albums" title={$t('albums')} subtitle={$t('albums_feature_description')}>
|
||||||
|
<div class="ms-4 mt-6">
|
||||||
|
<SettingSelect
|
||||||
|
label={$t('albums_default_sort_order')}
|
||||||
|
desc={$t('albums_default_sort_order_description')}
|
||||||
|
options={[
|
||||||
|
{ value: AssetOrder.Asc, text: $t('oldest_first') },
|
||||||
|
{ value: AssetOrder.Desc, text: $t('newest_first') },
|
||||||
|
]}
|
||||||
|
bind:value={defaultAssetOrder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingAccordion>
|
||||||
|
|
||||||
<SettingAccordion key="folders" title={$t('folders')} subtitle={$t('folders_feature_description')}>
|
<SettingAccordion key="folders" title={$t('folders')} subtitle={$t('folders_feature_description')}>
|
||||||
<div class="ms-4 mt-6">
|
<div class="ms-4 mt-6">
|
||||||
<SettingSwitch title={$t('enable')} bind:checked={foldersEnabled} />
|
<SettingSwitch title={$t('enable')} bind:checked={foldersEnabled} />
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { handleError } from '$lib/utils/handle-error';
|
|||||||
import {
|
import {
|
||||||
formatBucketTitle,
|
formatBucketTitle,
|
||||||
formatGroupTitle,
|
formatGroupTitle,
|
||||||
fromLocalDateTimeToObject,
|
|
||||||
fromTimelinePlainDate,
|
fromTimelinePlainDate,
|
||||||
fromTimelinePlainDateTime,
|
fromTimelinePlainDateTime,
|
||||||
fromTimelinePlainYearMonth,
|
fromTimelinePlainYearMonth,
|
||||||
|
getTimes,
|
||||||
type TimelinePlainDateTime,
|
type TimelinePlainDateTime,
|
||||||
type TimelinePlainYearMonth,
|
type TimelinePlainYearMonth,
|
||||||
} from '$lib/utils/timeline-util';
|
} from '$lib/utils/timeline-util';
|
||||||
@@ -153,8 +153,12 @@ export class AssetBucket {
|
|||||||
|
|
||||||
addAssets(bucketAssets: TimeBucketAssetResponseDto) {
|
addAssets(bucketAssets: TimeBucketAssetResponseDto) {
|
||||||
const addContext = new AddContext();
|
const addContext = new AddContext();
|
||||||
const people: string[] = [];
|
|
||||||
for (let i = 0; i < bucketAssets.id.length; i++) {
|
for (let i = 0; i < bucketAssets.id.length; i++) {
|
||||||
|
const { localDateTime, fileCreatedAt } = getTimes(
|
||||||
|
bucketAssets.fileCreatedAt[i],
|
||||||
|
bucketAssets.localOffsetHours[i],
|
||||||
|
);
|
||||||
|
|
||||||
const timelineAsset: TimelineAsset = {
|
const timelineAsset: TimelineAsset = {
|
||||||
city: bucketAssets.city[i],
|
city: bucketAssets.city[i],
|
||||||
country: bucketAssets.country[i],
|
country: bucketAssets.country[i],
|
||||||
@@ -166,9 +170,9 @@ export class AssetBucket {
|
|||||||
isTrashed: bucketAssets.isTrashed[i],
|
isTrashed: bucketAssets.isTrashed[i],
|
||||||
isVideo: !bucketAssets.isImage[i],
|
isVideo: !bucketAssets.isImage[i],
|
||||||
livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
|
livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
|
||||||
localDateTime: fromLocalDateTimeToObject(bucketAssets.localDateTime[i]),
|
localDateTime,
|
||||||
|
fileCreatedAt,
|
||||||
ownerId: bucketAssets.ownerId[i],
|
ownerId: bucketAssets.ownerId[i],
|
||||||
people,
|
|
||||||
projectionType: bucketAssets.projectionType[i],
|
projectionType: bucketAssets.projectionType[i],
|
||||||
ratio: bucketAssets.ratio[i],
|
ratio: bucketAssets.ratio[i],
|
||||||
stack: bucketAssets.stack?.[i]
|
stack: bucketAssets.stack?.[i]
|
||||||
@@ -179,6 +183,7 @@ export class AssetBucket {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
thumbhash: bucketAssets.thumbhash[i],
|
thumbhash: bucketAssets.thumbhash[i],
|
||||||
|
people: null, // People are not included in the bucket assets
|
||||||
};
|
};
|
||||||
this.addTimelineAsset(timelineAsset, addContext);
|
this.addTimelineAsset(timelineAsset, addContext);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export class AssetDateGroup {
|
|||||||
|
|
||||||
sortAssets(sortOrder: AssetOrder = AssetOrder.Desc) {
|
sortAssets(sortOrder: AssetOrder = AssetOrder.Desc) {
|
||||||
const sortFn = plainDateTimeCompare.bind(undefined, sortOrder === AssetOrder.Asc);
|
const sortFn = plainDateTimeCompare.bind(undefined, sortOrder === AssetOrder.Asc);
|
||||||
this.intersectingAssets.sort((a, b) => sortFn(a.asset.localDateTime, b.asset.localDateTime));
|
this.intersectingAssets.sort((a, b) => sortFn(a.asset.fileCreatedAt, b.asset.fileCreatedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
getFirstAsset() {
|
getFirstAsset() {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { websocketEvents } from '$lib/stores/websocket';
|
|||||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||||
import {
|
import {
|
||||||
plainDateTimeCompare,
|
plainDateTimeCompare,
|
||||||
toISOLocalDateTime,
|
toISOYearMonthUTC,
|
||||||
toTimelineAsset,
|
toTimelineAsset,
|
||||||
type TimelinePlainDate,
|
type TimelinePlainDate,
|
||||||
type TimelinePlainDateTime,
|
type TimelinePlainDateTime,
|
||||||
@@ -573,7 +573,7 @@ export class AssetStore {
|
|||||||
if (bucket.getFirstAsset()) {
|
if (bucket.getFirstAsset()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const timeBucket = toISOLocalDateTime(bucket.yearMonth);
|
const timeBucket = toISOYearMonthUTC(bucket.yearMonth);
|
||||||
const key = authManager.key;
|
const key = authManager.key;
|
||||||
const bucketResponse = await getTimeBucket(
|
const bucketResponse = await getTimeBucket(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type TimelineAsset = {
|
|||||||
ratio: number;
|
ratio: number;
|
||||||
thumbhash: string | null;
|
thumbhash: string | null;
|
||||||
localDateTime: TimelinePlainDateTime;
|
localDateTime: TimelinePlainDateTime;
|
||||||
|
fileCreatedAt: TimelinePlainDateTime;
|
||||||
visibility: AssetVisibility;
|
visibility: AssetVisibility;
|
||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
isTrashed: boolean;
|
isTrashed: boolean;
|
||||||
@@ -29,7 +30,7 @@ export type TimelineAsset = {
|
|||||||
livePhotoVideoId: string | null;
|
livePhotoVideoId: string | null;
|
||||||
city: string | null;
|
city: string | null;
|
||||||
country: string | null;
|
country: string | null;
|
||||||
people: string[];
|
people: string[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AssetOperation = (asset: TimelineAsset) => { remove: boolean };
|
export type AssetOperation = (asset: TimelineAsset) => { remove: boolean };
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
|||||||
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import { AbortError } from '$lib/utils';
|
import { AbortError } from '$lib/utils';
|
||||||
import { fromLocalDateTimeToObject } from '$lib/utils/timeline-util';
|
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
|
||||||
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||||
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
|
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
|
||||||
|
|
||||||
@@ -14,6 +14,13 @@ async function getAssets(store: AssetStore) {
|
|||||||
return assets;
|
return assets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deriveLocalDateTimeFromFileCreatedAt(arg: TimelineAsset): TimelineAsset {
|
||||||
|
return {
|
||||||
|
...arg,
|
||||||
|
localDateTime: arg.fileCreatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('AssetStore', () => {
|
describe('AssetStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
@@ -22,15 +29,24 @@ describe('AssetStore', () => {
|
|||||||
describe('init', () => {
|
describe('init', () => {
|
||||||
let assetStore: AssetStore;
|
let assetStore: AssetStore;
|
||||||
const bucketAssets: Record<string, TimelineAsset[]> = {
|
const bucketAssets: Record<string, TimelineAsset[]> = {
|
||||||
'2024-03-01T00:00:00.000Z': timelineAssetFactory
|
'2024-03-01T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
|
||||||
.buildList(1)
|
deriveLocalDateTimeFromFileCreatedAt({
|
||||||
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-03-01T00:00:00.000Z') })),
|
...asset,
|
||||||
'2024-02-01T00:00:00.000Z': timelineAssetFactory
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'),
|
||||||
.buildList(100)
|
}),
|
||||||
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-02-01T00:00:00.000Z') })),
|
),
|
||||||
'2024-01-01T00:00:00.000Z': timelineAssetFactory
|
'2024-02-01T00:00:00.000Z': timelineAssetFactory.buildList(100).map((asset) =>
|
||||||
.buildList(3)
|
deriveLocalDateTimeFromFileCreatedAt({
|
||||||
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-01-01T00:00:00.000Z') })),
|
...asset,
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-01T00:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
'2024-01-01T00:00:00.000Z': timelineAssetFactory.buildList(3).map((asset) =>
|
||||||
|
deriveLocalDateTimeFromFileCreatedAt({
|
||||||
|
...asset,
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-01T00:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
||||||
@@ -40,9 +56,9 @@ describe('AssetStore', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
assetStore = new AssetStore();
|
assetStore = new AssetStore();
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([
|
sdkMock.getTimeBuckets.mockResolvedValue([
|
||||||
{ count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
|
{ count: 1, timeBucket: '2024-03-01' },
|
||||||
{ count: 100, timeBucket: '2024-02-01T00:00:00.000Z' },
|
{ count: 100, timeBucket: '2024-02-01' },
|
||||||
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
|
{ count: 3, timeBucket: '2024-01-01' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
||||||
@@ -78,12 +94,18 @@ describe('AssetStore', () => {
|
|||||||
describe('loadBucket', () => {
|
describe('loadBucket', () => {
|
||||||
let assetStore: AssetStore;
|
let assetStore: AssetStore;
|
||||||
const bucketAssets: Record<string, TimelineAsset[]> = {
|
const bucketAssets: Record<string, TimelineAsset[]> = {
|
||||||
'2024-01-03T00:00:00.000Z': timelineAssetFactory
|
'2024-01-03T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
|
||||||
.buildList(1)
|
deriveLocalDateTimeFromFileCreatedAt({
|
||||||
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-03-01T00:00:00.000Z') })),
|
...asset,
|
||||||
'2024-01-01T00:00:00.000Z': timelineAssetFactory
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'),
|
||||||
.buildList(3)
|
}),
|
||||||
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-01-01T00:00:00.000Z') })),
|
),
|
||||||
|
'2024-01-01T00:00:00.000Z': timelineAssetFactory.buildList(3).map((asset) =>
|
||||||
|
deriveLocalDateTimeFromFileCreatedAt({
|
||||||
|
...asset,
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-01T00:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
||||||
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
|
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
|
||||||
@@ -166,9 +188,11 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('adds assets to new bucket', () => {
|
it('adds assets to new bucket', () => {
|
||||||
const asset = timelineAssetFactory.build({
|
const asset = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
|
timelineAssetFactory.build({
|
||||||
});
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
assetStore.addAssets([asset]);
|
assetStore.addAssets([asset]);
|
||||||
|
|
||||||
expect(assetStore.buckets.length).toEqual(1);
|
expect(assetStore.buckets.length).toEqual(1);
|
||||||
@@ -180,9 +204,11 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('adds assets to existing bucket', () => {
|
it('adds assets to existing bucket', () => {
|
||||||
const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, {
|
const [assetOne, assetTwo] = timelineAssetFactory
|
||||||
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
|
.buildList(2, {
|
||||||
});
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
|
})
|
||||||
|
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||||
assetStore.addAssets([assetOne]);
|
assetStore.addAssets([assetOne]);
|
||||||
assetStore.addAssets([assetTwo]);
|
assetStore.addAssets([assetTwo]);
|
||||||
|
|
||||||
@@ -194,15 +220,21 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('orders assets in buckets by descending date', () => {
|
it('orders assets in buckets by descending date', () => {
|
||||||
const assetOne = timelineAssetFactory.build({
|
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
|
timelineAssetFactory.build({
|
||||||
});
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
const assetTwo = timelineAssetFactory.build({
|
}),
|
||||||
localDateTime: fromLocalDateTimeToObject('2024-01-15T12:00:00.000Z'),
|
);
|
||||||
});
|
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
const assetThree = timelineAssetFactory.build({
|
timelineAssetFactory.build({
|
||||||
localDateTime: fromLocalDateTimeToObject('2024-01-16T12:00:00.000Z'),
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
const assetThree = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
|
timelineAssetFactory.build({
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-16T12:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
assetStore.addAssets([assetOne, assetTwo, assetThree]);
|
assetStore.addAssets([assetOne, assetTwo, assetThree]);
|
||||||
|
|
||||||
const bucket = assetStore.getBucketByDate({ year: 2024, month: 1 });
|
const bucket = assetStore.getBucketByDate({ year: 2024, month: 1 });
|
||||||
@@ -214,15 +246,21 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('orders buckets by descending date', () => {
|
it('orders buckets by descending date', () => {
|
||||||
const assetOne = timelineAssetFactory.build({
|
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
|
timelineAssetFactory.build({
|
||||||
});
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
const assetTwo = timelineAssetFactory.build({
|
}),
|
||||||
localDateTime: fromLocalDateTimeToObject('2024-04-20T12:00:00.000Z'),
|
);
|
||||||
});
|
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
const assetThree = timelineAssetFactory.build({
|
timelineAssetFactory.build({
|
||||||
localDateTime: fromLocalDateTimeToObject('2023-01-20T12:00:00.000Z'),
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-04-20T12:00:00.000Z'),
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
const assetThree = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
|
timelineAssetFactory.build({
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2023-01-20T12:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
assetStore.addAssets([assetOne, assetTwo, assetThree]);
|
assetStore.addAssets([assetOne, assetTwo, assetThree]);
|
||||||
|
|
||||||
expect(assetStore.buckets.length).toEqual(3);
|
expect(assetStore.buckets.length).toEqual(3);
|
||||||
@@ -238,7 +276,7 @@ describe('AssetStore', () => {
|
|||||||
|
|
||||||
it('updates existing asset', () => {
|
it('updates existing asset', () => {
|
||||||
const updateAssetsSpy = vi.spyOn(assetStore, 'updateAssets');
|
const updateAssetsSpy = vi.spyOn(assetStore, 'updateAssets');
|
||||||
const asset = timelineAssetFactory.build();
|
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
|
||||||
assetStore.addAssets([asset]);
|
assetStore.addAssets([asset]);
|
||||||
|
|
||||||
assetStore.addAssets([asset]);
|
assetStore.addAssets([asset]);
|
||||||
@@ -248,8 +286,8 @@ describe('AssetStore', () => {
|
|||||||
|
|
||||||
// disabled due to the wasm Justified Layout import
|
// disabled due to the wasm Justified Layout import
|
||||||
it('ignores trashed assets when isTrashed is true', async () => {
|
it('ignores trashed assets when isTrashed is true', async () => {
|
||||||
const asset = timelineAssetFactory.build({ isTrashed: false });
|
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isTrashed: false }));
|
||||||
const trashedAsset = timelineAssetFactory.build({ isTrashed: true });
|
const trashedAsset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isTrashed: true }));
|
||||||
|
|
||||||
const assetStore = new AssetStore();
|
const assetStore = new AssetStore();
|
||||||
await assetStore.updateOptions({ isTrashed: true });
|
await assetStore.updateOptions({ isTrashed: true });
|
||||||
@@ -269,14 +307,14 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('ignores non-existing assets', () => {
|
it('ignores non-existing assets', () => {
|
||||||
assetStore.updateAssets([timelineAssetFactory.build()]);
|
assetStore.updateAssets([deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build())]);
|
||||||
|
|
||||||
expect(assetStore.buckets.length).toEqual(0);
|
expect(assetStore.buckets.length).toEqual(0);
|
||||||
expect(assetStore.count).toEqual(0);
|
expect(assetStore.count).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates an asset', () => {
|
it('updates an asset', () => {
|
||||||
const asset = timelineAssetFactory.build({ isFavorite: false });
|
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isFavorite: false }));
|
||||||
const updatedAsset = { ...asset, isFavorite: true };
|
const updatedAsset = { ...asset, isFavorite: true };
|
||||||
|
|
||||||
assetStore.addAssets([asset]);
|
assetStore.addAssets([asset]);
|
||||||
@@ -289,10 +327,15 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('asset moves buckets when asset date changes', () => {
|
it('asset moves buckets when asset date changes', () => {
|
||||||
const asset = timelineAssetFactory.build({
|
const asset = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
|
timelineAssetFactory.build({
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const updatedAsset = deriveLocalDateTimeFromFileCreatedAt({
|
||||||
|
...asset,
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-20T12:00:00.000Z'),
|
||||||
});
|
});
|
||||||
const updatedAsset = { ...asset, localDateTime: fromLocalDateTimeToObject('2024-03-20T12:00:00.000Z') };
|
|
||||||
|
|
||||||
assetStore.addAssets([asset]);
|
assetStore.addAssets([asset]);
|
||||||
expect(assetStore.buckets.length).toEqual(1);
|
expect(assetStore.buckets.length).toEqual(1);
|
||||||
@@ -320,7 +363,11 @@ describe('AssetStore', () => {
|
|||||||
|
|
||||||
it('ignores invalid IDs', () => {
|
it('ignores invalid IDs', () => {
|
||||||
assetStore.addAssets(
|
assetStore.addAssets(
|
||||||
timelineAssetFactory.buildList(2, { localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z') }),
|
timelineAssetFactory
|
||||||
|
.buildList(2, {
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
|
})
|
||||||
|
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset)),
|
||||||
);
|
);
|
||||||
assetStore.removeAssets(['', 'invalid', '4c7d9acc']);
|
assetStore.removeAssets(['', 'invalid', '4c7d9acc']);
|
||||||
|
|
||||||
@@ -330,9 +377,11 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('removes asset from bucket', () => {
|
it('removes asset from bucket', () => {
|
||||||
const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, {
|
const [assetOne, assetTwo] = timelineAssetFactory
|
||||||
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
|
.buildList(2, {
|
||||||
});
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
|
})
|
||||||
|
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||||
assetStore.addAssets([assetOne, assetTwo]);
|
assetStore.addAssets([assetOne, assetTwo]);
|
||||||
assetStore.removeAssets([assetOne.id]);
|
assetStore.removeAssets([assetOne.id]);
|
||||||
|
|
||||||
@@ -342,9 +391,11 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not remove bucket when empty', () => {
|
it('does not remove bucket when empty', () => {
|
||||||
const assets = timelineAssetFactory.buildList(2, {
|
const assets = timelineAssetFactory
|
||||||
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
|
.buildList(2, {
|
||||||
});
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
|
})
|
||||||
|
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||||
assetStore.addAssets(assets);
|
assetStore.addAssets(assets);
|
||||||
assetStore.removeAssets(assets.map((asset) => asset.id));
|
assetStore.removeAssets(assets.map((asset) => asset.id));
|
||||||
|
|
||||||
@@ -367,12 +418,16 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('populated store returns first asset', () => {
|
it('populated store returns first asset', () => {
|
||||||
const assetOne = timelineAssetFactory.build({
|
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
|
timelineAssetFactory.build({
|
||||||
});
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
const assetTwo = timelineAssetFactory.build({
|
}),
|
||||||
localDateTime: fromLocalDateTimeToObject('2024-01-15T12:00:00.000Z'),
|
);
|
||||||
});
|
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
|
timelineAssetFactory.build({
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
assetStore.addAssets([assetOne, assetTwo]);
|
assetStore.addAssets([assetOne, assetTwo]);
|
||||||
expect(assetStore.getFirstAsset()).toEqual(assetOne);
|
expect(assetStore.getFirstAsset()).toEqual(assetOne);
|
||||||
});
|
});
|
||||||
@@ -381,15 +436,24 @@ describe('AssetStore', () => {
|
|||||||
describe('getLaterAsset', () => {
|
describe('getLaterAsset', () => {
|
||||||
let assetStore: AssetStore;
|
let assetStore: AssetStore;
|
||||||
const bucketAssets: Record<string, TimelineAsset[]> = {
|
const bucketAssets: Record<string, TimelineAsset[]> = {
|
||||||
'2024-03-01T00:00:00.000Z': timelineAssetFactory
|
'2024-03-01T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
|
||||||
.buildList(1)
|
deriveLocalDateTimeFromFileCreatedAt({
|
||||||
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-03-01T00:00:00.000Z') })),
|
...asset,
|
||||||
'2024-02-01T00:00:00.000Z': timelineAssetFactory
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'),
|
||||||
.buildList(6)
|
}),
|
||||||
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-02-01T00:00:00.000Z') })),
|
),
|
||||||
'2024-01-01T00:00:00.000Z': timelineAssetFactory
|
'2024-02-01T00:00:00.000Z': timelineAssetFactory.buildList(6).map((asset) =>
|
||||||
.buildList(3)
|
deriveLocalDateTimeFromFileCreatedAt({
|
||||||
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-01-01T00:00:00.000Z') })),
|
...asset,
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-01T00:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
'2024-01-01T00:00:00.000Z': timelineAssetFactory.buildList(3).map((asset) =>
|
||||||
|
deriveLocalDateTimeFromFileCreatedAt({
|
||||||
|
...asset,
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-01T00:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
||||||
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
|
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
|
||||||
@@ -479,12 +543,16 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns the bucket index', () => {
|
it('returns the bucket index', () => {
|
||||||
const assetOne = timelineAssetFactory.build({
|
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
|
timelineAssetFactory.build({
|
||||||
});
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
const assetTwo = timelineAssetFactory.build({
|
}),
|
||||||
localDateTime: fromLocalDateTimeToObject('2024-02-15T12:00:00.000Z'),
|
);
|
||||||
});
|
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
|
timelineAssetFactory.build({
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
assetStore.addAssets([assetOne, assetTwo]);
|
assetStore.addAssets([assetOne, assetTwo]);
|
||||||
|
|
||||||
expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
|
expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
|
||||||
@@ -494,12 +562,16 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('ignores removed buckets', () => {
|
it('ignores removed buckets', () => {
|
||||||
const assetOne = timelineAssetFactory.build({
|
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
|
timelineAssetFactory.build({
|
||||||
});
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
const assetTwo = timelineAssetFactory.build({
|
}),
|
||||||
localDateTime: fromLocalDateTimeToObject('2024-02-15T12:00:00.000Z'),
|
);
|
||||||
});
|
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
||||||
|
timelineAssetFactory.build({
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
assetStore.addAssets([assetOne, assetTwo]);
|
assetStore.addAssets([assetOne, assetTwo]);
|
||||||
|
|
||||||
assetStore.removeAssets([assetTwo.id]);
|
assetStore.removeAssets([assetTwo.id]);
|
||||||
|
|||||||
@@ -62,6 +62,15 @@ describe('getAltText', () => {
|
|||||||
ownerId: 'test-owner',
|
ownerId: 'test-owner',
|
||||||
ratio: 1,
|
ratio: 1,
|
||||||
thumbhash: null,
|
thumbhash: null,
|
||||||
|
fileCreatedAt: {
|
||||||
|
year: testDate.getUTCFullYear(),
|
||||||
|
month: testDate.getUTCMonth() + 1, // Note: getMonth() is 0-based
|
||||||
|
day: testDate.getUTCDate(),
|
||||||
|
hour: testDate.getUTCHours(),
|
||||||
|
minute: testDate.getUTCMinutes(),
|
||||||
|
second: testDate.getUTCSeconds(),
|
||||||
|
millisecond: testDate.getUTCMilliseconds(),
|
||||||
|
},
|
||||||
localDateTime: {
|
localDateTime: {
|
||||||
year: testDate.getUTCFullYear(),
|
year: testDate.getUTCFullYear(),
|
||||||
month: testDate.getUTCMonth() + 1, // Note: getMonth() is 0-based
|
month: testDate.getUTCMonth() + 1, // Note: getMonth() is 0-based
|
||||||
@@ -71,6 +80,7 @@ describe('getAltText', () => {
|
|||||||
second: testDate.getUTCSeconds(),
|
second: testDate.getUTCSeconds(),
|
||||||
millisecond: testDate.getUTCMilliseconds(),
|
millisecond: testDate.getUTCMilliseconds(),
|
||||||
},
|
},
|
||||||
|
|
||||||
visibility: AssetVisibility.Timeline,
|
visibility: AssetVisibility.Timeline,
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
isTrashed: false,
|
isTrashed: false,
|
||||||
|
|||||||
@@ -46,16 +46,16 @@ export const getAltText = derived(t, ($t) => {
|
|||||||
});
|
});
|
||||||
const hasPlace = asset.city && asset.country;
|
const hasPlace = asset.city && asset.country;
|
||||||
|
|
||||||
const peopleCount = asset.people.length;
|
const peopleCount = asset.people?.length ?? 0;
|
||||||
const isVideo = asset.isVideo;
|
const isVideo = asset.isVideo;
|
||||||
|
|
||||||
const values = {
|
const values = {
|
||||||
date,
|
date,
|
||||||
city: asset.city,
|
city: asset.city,
|
||||||
country: asset.country,
|
country: asset.country,
|
||||||
person1: asset.people[0],
|
person1: asset.people?.[0],
|
||||||
person2: asset.people[1],
|
person2: asset.people?.[1],
|
||||||
person3: asset.people[2],
|
person3: asset.people?.[2],
|
||||||
isVideo,
|
isVideo,
|
||||||
additionalCount: peopleCount > 3 ? peopleCount - 2 : 0,
|
additionalCount: peopleCount > 3 ? peopleCount - 2 : 0,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,17 +5,81 @@ import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
|||||||
import { DateTime, type LocaleOptions } from 'luxon';
|
import { DateTime, type LocaleOptions } from 'luxon';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
// Move type definitions to the top
|
||||||
|
export type TimelinePlainYearMonth = {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimelinePlainDate = TimelinePlainYearMonth & {
|
||||||
|
day: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimelinePlainDateTime = TimelinePlainDate & {
|
||||||
|
hour: number;
|
||||||
|
minute: number;
|
||||||
|
second: number;
|
||||||
|
millisecond: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type ScrubberListener = (
|
export type ScrubberListener = (
|
||||||
bucketDate: { year: number; month: number },
|
bucketDate: { year: number; month: number },
|
||||||
overallScrollPercent: number,
|
overallScrollPercent: number,
|
||||||
bucketScrollPercent: number,
|
bucketScrollPercent: number,
|
||||||
) => void | Promise<void>;
|
) => void | Promise<void>;
|
||||||
|
|
||||||
export const fromLocalDateTime = (localDateTime: string) =>
|
// used for AssetResponseDto.dateTimeOriginal, amongst others
|
||||||
DateTime.fromISO(localDateTime, { zone: 'UTC', locale: get(locale) });
|
export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> =>
|
||||||
|
DateTime.fromISO(isoDateTime, { zone: timeZone, locale: get(locale) }) as DateTime<true>;
|
||||||
|
|
||||||
export const fromLocalDateTimeToObject = (localDateTime: string): TimelinePlainDateTime =>
|
export const fromISODateTimeToObject = (isoDateTime: string, timeZone: string): TimelinePlainDateTime =>
|
||||||
(fromLocalDateTime(localDateTime) as DateTime<true>).toObject();
|
(fromISODateTime(isoDateTime, timeZone) as DateTime<true>).toObject();
|
||||||
|
|
||||||
|
// used for AssetResponseDto.localDateTime, amongst others
|
||||||
|
export const fromISODateTimeUTC = (isoDateTimeUtc: string) => fromISODateTime(isoDateTimeUtc, 'UTC');
|
||||||
|
|
||||||
|
export const fromISODateTimeUTCToObject = (isoDateTimeUtc: string): TimelinePlainDateTime =>
|
||||||
|
(fromISODateTimeUTC(isoDateTimeUtc) as DateTime<true>).toObject();
|
||||||
|
|
||||||
|
// used to create equivalent of AssetResponseDto.localDateTime in UTC, but without timezone information
|
||||||
|
export const fromISODateTimeTruncateTZToObject = (
|
||||||
|
isoDateTimeUtc: string,
|
||||||
|
timeZone: string | undefined,
|
||||||
|
): TimelinePlainDateTime =>
|
||||||
|
(
|
||||||
|
fromISODateTime(isoDateTimeUtc, timeZone ?? 'UTC').setZone('UTC', { keepLocalTime: true }) as DateTime<true>
|
||||||
|
).toObject();
|
||||||
|
|
||||||
|
// Used to derive a local date time from an ISO string and a UTC offset in hours
|
||||||
|
export const fromISODateTimeWithOffsetToObject = (
|
||||||
|
isoDateTimeUtc: string,
|
||||||
|
utcOffsetHours: number,
|
||||||
|
): TimelinePlainDateTime => {
|
||||||
|
const utcDateTime = fromISODateTimeUTC(isoDateTimeUtc);
|
||||||
|
|
||||||
|
// Apply the offset to get the local time
|
||||||
|
// Note: offset is in hours (may be fractional), positive for east of UTC, negative for west
|
||||||
|
const localDateTime = utcDateTime.plus({ hours: utcOffsetHours });
|
||||||
|
|
||||||
|
// Return as plain object (keeping the local time but in UTC zone context)
|
||||||
|
return (localDateTime.setZone('UTC', { keepLocalTime: true }) as DateTime<true>).toObject();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTimes = (isoDateTimeUtc: string, localUtcOffsetHours: number) => {
|
||||||
|
const utcDateTime = fromISODateTimeUTC(isoDateTimeUtc);
|
||||||
|
const fileCreatedAt = (utcDateTime as DateTime<true>).toObject();
|
||||||
|
|
||||||
|
// Apply the offset to get the local time
|
||||||
|
// Note: offset is in hours (may be fractional), positive for east of UTC, negative for west
|
||||||
|
const luxonLocalDateTime = utcDateTime.plus({ hours: localUtcOffsetHours });
|
||||||
|
// Return as plain object (keeping the local time but in UTC zone context)
|
||||||
|
const localDateTime = (luxonLocalDateTime.setZone('UTC', { keepLocalTime: true }) as DateTime<true>).toObject();
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileCreatedAt,
|
||||||
|
localDateTime,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const fromTimelinePlainDateTime = (timelineDateTime: TimelinePlainDateTime): DateTime<true> =>
|
export const fromTimelinePlainDateTime = (timelineDateTime: TimelinePlainDateTime): DateTime<true> =>
|
||||||
DateTime.fromObject(timelineDateTime, { zone: 'local', locale: get(locale) }) as DateTime<true>;
|
DateTime.fromObject(timelineDateTime, { zone: 'local', locale: get(locale) }) as DateTime<true>;
|
||||||
@@ -32,10 +96,7 @@ export const fromTimelinePlainYearMonth = (timelineYearMonth: TimelinePlainYearM
|
|||||||
{ zone: 'local', locale: get(locale) },
|
{ zone: 'local', locale: get(locale) },
|
||||||
) as DateTime<true>;
|
) as DateTime<true>;
|
||||||
|
|
||||||
export const fromDateTimeOriginal = (dateTimeOriginal: string, timeZone: string) =>
|
export const toISOYearMonthUTC = (timelineYearMonth: TimelinePlainYearMonth): string =>
|
||||||
DateTime.fromISO(dateTimeOriginal, { zone: timeZone, locale: get(locale) });
|
|
||||||
|
|
||||||
export const toISOLocalDateTime = (timelineYearMonth: TimelinePlainYearMonth): string =>
|
|
||||||
(fromTimelinePlainYearMonth(timelineYearMonth).setZone('UTC', { keepLocalTime: true }) as DateTime<true>).toISO();
|
(fromTimelinePlainYearMonth(timelineYearMonth).setZone('UTC', { keepLocalTime: true }) as DateTime<true>).toISO();
|
||||||
|
|
||||||
export function formatBucketTitle(_date: DateTime): string {
|
export function formatBucketTitle(_date: DateTime): string {
|
||||||
@@ -104,12 +165,16 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
|
|||||||
const country = assetResponse.exifInfo?.country;
|
const country = assetResponse.exifInfo?.country;
|
||||||
const people = assetResponse.people?.map((person) => person.name) || [];
|
const people = assetResponse.people?.map((person) => person.name) || [];
|
||||||
|
|
||||||
|
const localDateTime = fromISODateTimeUTCToObject(assetResponse.localDateTime);
|
||||||
|
const fileCreatedAt = fromISODateTimeToObject(assetResponse.fileCreatedAt, assetResponse.exifInfo?.timeZone ?? 'UTC');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: assetResponse.id,
|
id: assetResponse.id,
|
||||||
ownerId: assetResponse.ownerId,
|
ownerId: assetResponse.ownerId,
|
||||||
ratio,
|
ratio,
|
||||||
thumbhash: assetResponse.thumbhash,
|
thumbhash: assetResponse.thumbhash,
|
||||||
localDateTime: fromLocalDateTimeToObject(assetResponse.localDateTime),
|
localDateTime,
|
||||||
|
fileCreatedAt,
|
||||||
isFavorite: assetResponse.isFavorite,
|
isFavorite: assetResponse.isFavorite,
|
||||||
visibility: assetResponse.visibility,
|
visibility: assetResponse.visibility,
|
||||||
isTrashed: assetResponse.isTrashed,
|
isTrashed: assetResponse.isTrashed,
|
||||||
@@ -151,19 +216,3 @@ export const plainDateTimeCompare = (ascending: boolean, a: TimelinePlainDateTim
|
|||||||
}
|
}
|
||||||
return aDateTime.millisecond - bDateTime.millisecond;
|
return aDateTime.millisecond - bDateTime.millisecond;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TimelinePlainDateTime = TimelinePlainDate & {
|
|
||||||
hour: number;
|
|
||||||
minute: number;
|
|
||||||
second: number;
|
|
||||||
millisecond: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TimelinePlainDate = TimelinePlainYearMonth & {
|
|
||||||
day: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TimelinePlainYearMonth = {
|
|
||||||
year: number;
|
|
||||||
month: number;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import { fromLocalDateTimeToObject, fromTimelinePlainDateTime } from '$lib/utils/timeline-util';
|
import { fromISODateTimeUTCToObject, fromTimelinePlainDateTime } from '$lib/utils/timeline-util';
|
||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
import { AssetTypeEnum, AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
import { AssetTypeEnum, AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||||
import { Sync } from 'factory.ts';
|
import { Sync } from 'factory.ts';
|
||||||
@@ -34,7 +34,8 @@ export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({
|
|||||||
ratio: Sync.each(() => faker.number.int()),
|
ratio: Sync.each(() => faker.number.int()),
|
||||||
ownerId: Sync.each(() => faker.string.uuid()),
|
ownerId: Sync.each(() => faker.string.uuid()),
|
||||||
thumbhash: Sync.each(() => faker.string.alphanumeric(28)),
|
thumbhash: Sync.each(() => faker.string.alphanumeric(28)),
|
||||||
localDateTime: Sync.each(() => fromLocalDateTimeToObject(faker.date.past().toISOString())),
|
localDateTime: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())),
|
||||||
|
fileCreatedAt: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())),
|
||||||
isFavorite: Sync.each(() => faker.datatype.boolean()),
|
isFavorite: Sync.each(() => faker.datatype.boolean()),
|
||||||
visibility: AssetVisibility.Timeline,
|
visibility: AssetVisibility.Timeline,
|
||||||
isTrashed: false,
|
isTrashed: false,
|
||||||
@@ -60,7 +61,8 @@ export const toResponseDto = (...timelineAsset: TimelineAsset[]) => {
|
|||||||
isImage: [],
|
isImage: [],
|
||||||
isTrashed: [],
|
isTrashed: [],
|
||||||
livePhotoVideoId: [],
|
livePhotoVideoId: [],
|
||||||
localDateTime: [],
|
fileCreatedAt: [],
|
||||||
|
localOffsetHours: [],
|
||||||
ownerId: [],
|
ownerId: [],
|
||||||
projectionType: [],
|
projectionType: [],
|
||||||
ratio: [],
|
ratio: [],
|
||||||
@@ -68,6 +70,7 @@ export const toResponseDto = (...timelineAsset: TimelineAsset[]) => {
|
|||||||
thumbhash: [],
|
thumbhash: [],
|
||||||
};
|
};
|
||||||
for (const asset of timelineAsset) {
|
for (const asset of timelineAsset) {
|
||||||
|
const fileCreatedAt = fromTimelinePlainDateTime(asset.fileCreatedAt).toISO();
|
||||||
bucketAssets.city.push(asset.city);
|
bucketAssets.city.push(asset.city);
|
||||||
bucketAssets.country.push(asset.country);
|
bucketAssets.country.push(asset.country);
|
||||||
bucketAssets.duration.push(asset.duration!);
|
bucketAssets.duration.push(asset.duration!);
|
||||||
@@ -77,7 +80,7 @@ export const toResponseDto = (...timelineAsset: TimelineAsset[]) => {
|
|||||||
bucketAssets.isImage.push(asset.isImage);
|
bucketAssets.isImage.push(asset.isImage);
|
||||||
bucketAssets.isTrashed.push(asset.isTrashed);
|
bucketAssets.isTrashed.push(asset.isTrashed);
|
||||||
bucketAssets.livePhotoVideoId.push(asset.livePhotoVideoId!);
|
bucketAssets.livePhotoVideoId.push(asset.livePhotoVideoId!);
|
||||||
bucketAssets.localDateTime.push(fromTimelinePlainDateTime(asset.localDateTime).toISO());
|
bucketAssets.fileCreatedAt.push(fileCreatedAt);
|
||||||
bucketAssets.ownerId.push(asset.ownerId);
|
bucketAssets.ownerId.push(asset.ownerId);
|
||||||
bucketAssets.projectionType.push(asset.projectionType!);
|
bucketAssets.projectionType.push(asset.projectionType!);
|
||||||
bucketAssets.ratio.push(asset.ratio);
|
bucketAssets.ratio.push(asset.ratio);
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { UserPreferencesResponseDto } from '@immich/sdk';
|
import { AssetOrder, type UserPreferencesResponseDto } from '@immich/sdk';
|
||||||
import { Sync } from 'factory.ts';
|
import { Sync } from 'factory.ts';
|
||||||
|
|
||||||
export const preferencesFactory = Sync.makeFactory<UserPreferencesResponseDto>({
|
export const preferencesFactory = Sync.makeFactory<UserPreferencesResponseDto>({
|
||||||
|
albums: {
|
||||||
|
defaultAssetOrder: AssetOrder.Desc,
|
||||||
|
},
|
||||||
cast: {
|
cast: {
|
||||||
gCastEnabled: false,
|
gCastEnabled: false,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user