Merge branch 'main' into mobile/implement-locate

This commit is contained in:
Thien Dang
2025-06-06 14:00:33 +07:00
committed by GitHub
73 changed files with 2338 additions and 688 deletions
+6 -4
View File
@@ -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: [],
+3
View File
@@ -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",
+1
View File
@@ -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
}
}
}
} }
File diff suppressed because one or more lines are too long
+18
View File
@@ -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)
}
} }
} }
+23
View File
@@ -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;
}
}
+1 -1
View 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
View File
@@ -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),
),
);
+37 -2
View File
@@ -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;
+1
View File
@@ -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':
+2
View File
@@ -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)
+2
View File
@@ -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';
+42
View File
@@ -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) {
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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>{
};
}
+4
View File
@@ -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
View File
@@ -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',
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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']),
+3
View File
@@ -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);
} }
+3
View File
@@ -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 {}
+227 -360
View File
@@ -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);
}
+14
View File
@@ -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,
);
}
+28 -7
View File
@@ -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 {}
+425
View File
@@ -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);
}
+95 -4
View File
@@ -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"
}, },
+36 -6
View File
@@ -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",
+28
View File
@@ -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;
+122 -27
View File
@@ -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;
} }
+19 -1
View File
@@ -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;
+12 -4
View File
@@ -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",
+45 -48
View File
@@ -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'),
+49 -3
View File
@@ -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'],
+4
View File
@@ -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,
+4
View File
@@ -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;
+4 -1
View File
@@ -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
View File
@@ -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 };
+157 -85
View File
@@ -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]);
+10
View File
@@ -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,
+4 -4
View File
@@ -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,
}; };
+74 -25
View File
@@ -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;
};
+7 -4
View File
@@ -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,
}, },