Merge branch 'main' of github.com:immich-app/immich into new-upload

This commit is contained in:
Alex 2025-06-06 08:43:06 -05:00
commit b9a3b45d88
No known key found for this signature in database
GPG Key ID: 53CD082B3A5E1082
122 changed files with 4204 additions and 2446 deletions

View File

@ -116,7 +116,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:ff21bc0f8194dc9c105b769aeabf9585fea6a8ed649c0781caeac5cb3c247884 image: docker.io/valkey/valkey:8-bookworm@sha256:a19bebed6a91bd5e6e2106fef015f9602a3392deeb7c9ed47548378dcee3dfc2
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1

View File

@ -56,7 +56,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:ff21bc0f8194dc9c105b769aeabf9585fea6a8ed649c0781caeac5cb3c247884 image: docker.io/valkey/valkey:8-bookworm@sha256:a19bebed6a91bd5e6e2106fef015f9602a3392deeb7c9ed47548378dcee3dfc2
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always

View File

@ -49,7 +49,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:ff21bc0f8194dc9c105b769aeabf9585fea6a8ed649c0781caeac5cb3c247884 image: docker.io/valkey/valkey:8-bookworm@sha256:a19bebed6a91bd5e6e2106fef015f9602a3392deeb7c9ed47548378dcee3dfc2
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always

View File

@ -13,6 +13,9 @@ import {
mdiTrashCan, mdiTrashCan,
mdiWeb, mdiWeb,
mdiWrap, mdiWrap,
mdiCloudKeyOutline,
mdiRegex,
mdiCodeJson,
} from '@mdi/js'; } from '@mdi/js';
import Layout from '@theme/Layout'; import Layout from '@theme/Layout';
import React from 'react'; import React from 'react';
@ -23,6 +26,30 @@ const withLanguage = (date: Date) => (language: string) => date.toLocaleDateStri
type Item = Omit<TimelineItem, 'done' | 'getDateLabel'> & { date: Date }; type Item = Omit<TimelineItem, 'done' | 'getDateLabel'> & { date: Date };
const items: Item[] = [ const items: Item[] = [
{
icon: mdiRegex,
iconColor: 'purple',
title: 'Zitadel Actions are cursed',
description:
"Zitadel is cursed because its custom scripting feature is executed with a JS engine that doesn't support regex named capture groups.",
link: {
url: 'https://github.com/dop251/goja',
text: 'Go JS engine',
},
date: new Date(2025, 5, 4),
},
{
icon: mdiCloudKeyOutline,
iconColor: '#0078d4',
title: 'Entra is cursed',
description:
"Microsoft Entra supports PKCE, but doesn't include it in its OpenID discovery document. This leads to clients thinking PKCE isn't available.",
link: {
url: 'https://github.com/immich-app/immich/pull/18725',
text: '#18725',
},
date: new Date(2025, 4, 30),
},
{ {
icon: mdiCrop, icon: mdiCrop,
iconColor: 'tomato', iconColor: 'tomato',
@ -35,6 +62,17 @@ const items: Item[] = [
}, },
date: new Date(2025, 4, 5), date: new Date(2025, 4, 5),
}, },
{
icon: mdiCodeJson,
iconColor: 'yellow',
title: 'YAML whitespace is cursed',
description: 'YAML whitespaces are often handled in unintuitive ways.',
link: {
url: 'https://github.com/immich-app/immich/pull/17309',
text: '#17309',
},
date: new Date(2025, 3, 1),
},
{ {
icon: mdiMicrosoftWindows, icon: mdiMicrosoftWindows,
iconColor: '#357EC7', iconColor: '#357EC7',

View File

@ -28,8 +28,10 @@ services:
extra_hosts: extra_hosts:
- 'auth-server:host-gateway' - 'auth-server:host-gateway'
depends_on: depends_on:
- redis redis:
- database condition: service_started
database:
condition: service_healthy
ports: ports:
- 2285:2285 - 2285:2285
@ -37,7 +39,7 @@ services:
image: redis:6.2-alpine@sha256:3211c33a618c457e5d241922c975dbc4f446d0bdb2dc75694f5573ef8e2d01fa image: redis:6.2-alpine@sha256:3211c33a618c457e5d241922c975dbc4f446d0bdb2dc75694f5573ef8e2d01fa
database: database:
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:e6d1209c1c13791c6f9fbf726c41865e3320dfe2445a6b4ffb03e25f904b3b37 image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:9c704fb49ce27549df00f1b096cc93f8b0c959ef087507704d74954808f78a82
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
environment: environment:
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
@ -45,3 +47,9 @@ services:
POSTGRES_DB: immich POSTGRES_DB: immich
ports: ports:
- 5435:5432 - 5435:5432
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres -d immich']
interval: 1s
timeout: 5s
retries: 30
start_period: 10s

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: [],

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",
@ -460,6 +463,7 @@
"assets_added_count": "Added {count, plural, one {# asset} other {# assets}}", "assets_added_count": "Added {count, plural, one {# asset} other {# assets}}",
"assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album", "assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album",
"assets_added_to_name_count": "Added {count, plural, one {# asset} other {# assets}} to {hasName, select, true {<b>{name}</b>} other {new album}}", "assets_added_to_name_count": "Added {count, plural, one {# asset} other {# assets}} to {hasName, select, true {<b>{name}</b>} other {new album}}",
"assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album",
"assets_count": "{count, plural, one {# asset} other {# assets}}", "assets_count": "{count, plural, one {# asset} other {# assets}}",
"assets_deleted_permanently": "{count} asset(s) deleted permanently", "assets_deleted_permanently": "{count} asset(s) deleted permanently",
"assets_deleted_permanently_from_server": "{count} asset(s) deleted permanently from the Immich server", "assets_deleted_permanently_from_server": "{count} asset(s) deleted permanently from the Immich server",

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

View File

@ -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)
}
}
} }
} }
} }

View File

@ -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

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)
}
} }
} }

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))
}
}
} }

View File

@ -30,6 +30,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 }

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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
}'''; }''';
} }
} }

View File

@ -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});
}

View File

@ -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()

View File

@ -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;

View File

@ -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()();

View File

@ -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();

View File

@ -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),
),
);
} }
@override @override

View File

@ -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),
);
}
});
}
}

View File

@ -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;
}
}

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';

View File

@ -114,9 +114,9 @@ class AlbumViewer extends HookConsumerWidget {
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [
context.primaryColor.withValues(alpha: 0.06),
context.primaryColor.withValues(alpha: 0.04), context.primaryColor.withValues(alpha: 0.04),
context.primaryColor.withValues(alpha: 0.02), Colors.indigo.withValues(alpha: 0.02),
Colors.orange.withValues(alpha: 0.02),
Colors.transparent, Colors.transparent,
], ],
stops: const [0.0, 0.3, 0.7, 1.0], stops: const [0.0, 0.3, 0.7, 1.0],

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?>();
}
}
} }

View File

@ -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;
} }

View File

@ -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,

View File

@ -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());
} }

View File

@ -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)),
);

View File

@ -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(),
);

View File

@ -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),
),
);

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;

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':
@ -42,6 +43,11 @@ dynamic upgradeDto(dynamic value, String targetType) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
} }
break; break;
case 'LoginResponseDto':
if (value is Map) {
addDefault(value, 'isOnboarded', false);
}
break;
} }
} }

View File

@ -17,11 +17,15 @@ class AlbumActionFilledButton extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.only(right: 8.0), padding: const EdgeInsets.only(right: 8.0),
child: FilledButton.icon( child: OutlinedButton.icon(
style: FilledButton.styleFrom( style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 16), padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 16),
shape: RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25), borderRadius: BorderRadius.all(Radius.circular(20)),
),
side: BorderSide(
color: context.colorScheme.surfaceContainerHighest,
width: 1,
), ),
backgroundColor: context.colorScheme.surfaceContainerHigh, backgroundColor: context.colorScheme.surfaceContainerHigh,
), ),

View File

@ -326,6 +326,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
return AppBar( return AppBar(
elevation: 0, elevation: 0,
backgroundColor: context.scaffoldBackgroundColor,
leading: buildLeadingButton(), leading: buildLeadingButton(),
centerTitle: false, centerTitle: false,
actions: [ actions: [

View File

@ -55,7 +55,7 @@ class AlbumViewerEditableDescription extends HookConsumerWidget {
} }
}, },
focusNode: descriptionFocusNode, focusNode: descriptionFocusNode,
style: context.textTheme.bodyMedium, style: context.textTheme.bodyLarge,
maxLines: 3, maxLines: 3,
minLines: 1, minLines: 1,
controller: descriptionTextEditController, controller: descriptionTextEditController,

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)

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';

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) {

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':

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',
};
}

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>{
};
}

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;

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',

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

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',

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']),

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);
} }

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 {}

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);
}

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,
);
}

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),
);
}

View File

@ -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 {}

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);
}

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"
}, },

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",

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;

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;
} }

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;

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",

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'),

View File

@ -0,0 +1,12 @@
import { Kysely, sql } from 'kysely';
import { UserMetadataKey } from 'src/enum';
export async function up(db: Kysely<any>): Promise<void> {
await sql`INSERT INTO user_metadata SELECT id, ${UserMetadataKey.ONBOARDING}, '{"isOnboarded": true}' FROM users
ON CONFLICT ("userId", key) DO NOTHING
`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DELETE FROM user_metadata WHERE key = ${UserMetadataKey.ONBOARDING}`.execute(db);
}

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'],

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,

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;

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,

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') {

View File

@ -6,7 +6,7 @@
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetStore } from '$lib/stores/assets-store.svelte'; import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store'; import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';

View File

@ -1,5 +1,5 @@
import type { AssetAction } from '$lib/constants'; import type { AssetAction } from '$lib/constants';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { AlbumResponseDto } from '@immich/sdk'; import type { AlbumResponseDto } from '@immich/sdk';
type ActionMap = { type ActionMap = {

View File

@ -2,7 +2,7 @@
import { shortcut } from '$lib/actions/shortcut'; import { shortcut } from '$lib/actions/shortcut';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { downloadFile } from '$lib/utils/asset-utils'; import { downloadFile } from '$lib/utils/asset-utils';
import { getAssetInfo } from '@immich/sdk'; import { getAssetInfo } from '@immich/sdk';
import { IconButton } from '@immich/ui'; import { IconButton } from '@immich/ui';

View File

@ -12,8 +12,9 @@
</script> </script>
<IconButton <IconButton
color="secondary"
variant="ghost"
shape="round" shape="round"
color="primary"
icon={isPlaying ? mdiMotionPauseOutline : mdiPlaySpeed} icon={isPlaying ? mdiMotionPauseOutline : mdiPlaySpeed}
aria-label={isPlaying ? $t('stop_motion_photo') : $t('play_motion_photo')} aria-label={isPlaying ? $t('stop_motion_photo') : $t('play_motion_photo')}
onclick={() => onClick(!isPlaying)} onclick={() => onClick(!isPlaying)}

View File

@ -3,7 +3,7 @@
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte'; import { modalManager } from '$lib/managers/modal-manager.svelte';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { AssetVisibility, updateAssets } from '@immich/sdk'; import { AssetVisibility, updateAssets } from '@immich/sdk';
import { mdiLockOpenVariantOutline, mdiLockOutline } from '@mdi/js'; import { mdiLockOpenVariantOutline, mdiLockOutline } from '@mdi/js';

View File

@ -10,7 +10,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { closeEditorCofirm } from '$lib/stores/asset-editor.store'; import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { isShowDetail } from '$lib/stores/preferences.store'; import { isShowDetail } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';

View File

@ -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 => {

View File

@ -4,7 +4,8 @@
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte'; import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte'; import { castManager } from '$lib/managers/cast-manager.svelte';
import { photoViewerImgElement, type TimelineAsset } from '$lib/stores/assets-store.svelte'; import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store'; import { boundingBoxesArray } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';

View File

@ -18,7 +18,7 @@
import { thumbhash } from '$lib/actions/thumbhash'; import { thumbhash } from '$lib/actions/thumbhash';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { moveFocus } from '$lib/utils/focus-util'; import { moveFocus } from '$lib/utils/focus-util';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation'; import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
@ -231,7 +231,7 @@
{#if (!loaded || thumbError) && asset.thumbhash} {#if (!loaded || thumbError) && asset.thumbhash}
<canvas <canvas
use:thumbhash={{ base64ThumbHash: asset.thumbhash }} use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
class="absolute object-cover" class="absolute object-cover z-1"
style:width="{width}px" style:width="{width}px"
style:height="{height}px" style:height="{height}px"
out:fade={{ duration: THUMBHASH_FADE_DURATION }} out:fade={{ duration: THUMBHASH_FADE_DURATION }}

View File

@ -27,14 +27,15 @@
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { type TimelineAsset, type Viewport } from '$lib/stores/assets-store.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { type MemoryAsset, memoryStore } from '$lib/stores/memory.store.svelte'; import { type MemoryAsset, memoryStore } from '$lib/stores/memory.store.svelte';
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store'; import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store'; import { preferences } from '$lib/stores/user.store';
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 {
@ -575,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>

View File

@ -1,4 +1,5 @@
import type { AssetStore, TimelineAsset } from '$lib/stores/assets-store.svelte'; import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { moveFocus } from '$lib/utils/focus-util'; import { moveFocus } from '$lib/utils/focus-util';
import { InvocationTracker } from '$lib/utils/invocationTracker'; import { InvocationTracker } from '$lib/utils/invocationTracker';
import { tick } from 'svelte'; import { tick } from 'svelte';

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte'; import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import type { OnLink, OnUnlink } from '$lib/utils/actions'; import type { OnLink, OnUnlink } from '$lib/utils/actions';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';

View File

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { type AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { cancelMultiselect, selectAllAssets } from '$lib/utils/asset-utils'; import { cancelMultiselect, selectAllAssets } from '$lib/utils/asset-utils';
import { Button, IconButton } from '@immich/ui'; import { Button, IconButton } from '@immich/ui';
import { mdiSelectAll, mdiSelectRemove } from '@mdi/js'; import { mdiSelectAll, mdiSelectRemove } from '@mdi/js';

View File

@ -1,20 +1,17 @@
<script lang="ts"> <script lang="ts">
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
type AssetBucket, import type { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
assetSnapshot, import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
assetsSnapshot, import { assetSnapshot, assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
type AssetStore,
isSelectingAllAssets,
type TimelineAsset,
} from '$lib/stores/assets-store.svelte';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js'; import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import { fly, scale } from 'svelte/transition'; import { fly, scale } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import type { AssetBucket } from '$lib/managers/timeline-manager/asset-bucket.svelte';
import { uploadAssetsStore } from '$lib/stores/upload'; import { uploadAssetsStore } from '$lib/stores/upload';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';

View File

@ -18,17 +18,15 @@
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte'; import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
AssetBucket, import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
assetsSnapshot, import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
AssetStore, import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
isSelectingAllAssets,
type TimelineAsset,
} from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store'; import { showDeleteModal } from '$lib/stores/preferences.store';
import { searchStore } from '$lib/stores/search.svelte'; import { searchStore } from '$lib/stores/search.svelte';
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
import type { AssetBucket } from '$lib/managers/timeline-manager/asset-bucket.svelte';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions'; import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils'; import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';

View File

@ -14,7 +14,7 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { mdiClose } from '@mdi/js'; import { mdiClose } from '@mdi/js';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte'; import ControlAppBar from '../shared-components/control-app-bar.svelte';

View File

@ -5,7 +5,7 @@
import { AppRoute, AssetAction } from '$lib/constants'; import { AppRoute, AssetAction } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import type { Viewport } from '$lib/stores/assets-store.svelte'; import type { Viewport } from '$lib/managers/timeline-manager/types';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store'; import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect, downloadArchive } from '$lib/utils/asset-utils'; import { cancelMultiselect, downloadArchive } from '$lib/utils/asset-utils';

View File

@ -8,7 +8,8 @@
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte'; import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { TimelineAsset, Viewport } from '$lib/stores/assets-store.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { showDeleteModal } from '$lib/stores/preferences.store'; import { showDeleteModal } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';

View File

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import type { AssetStore, LiteBucket } from '$lib/stores/assets-store.svelte'; import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
import type { LiteBucket } from '$lib/managers/timeline-manager/types';
import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { getTabbable } from '$lib/utils/focus-util'; import { getTabbable } from '$lib/utils/focus-util';
import { type ScrubberListener } from '$lib/utils/timeline-util'; import { type ScrubberListener } from '$lib/utils/timeline-util';

View File

@ -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} />

View File

@ -0,0 +1,60 @@
import type { TimelinePlainDate } from '$lib/utils/timeline-util';
import { AssetOrder } from '@immich/sdk';
import type { AssetBucket } from './asset-bucket.svelte';
import type { AssetDateGroup } from './asset-date-group.svelte';
import type { TimelineAsset } from './types';
export class AddContext {
#lookupCache: {
[year: number]: { [month: number]: { [day: number]: AssetDateGroup } };
} = {};
unprocessedAssets: TimelineAsset[] = [];
changedDateGroups = new Set<AssetDateGroup>();
newDateGroups = new Set<AssetDateGroup>();
getDateGroup({ year, month, day }: TimelinePlainDate): AssetDateGroup | undefined {
return this.#lookupCache[year]?.[month]?.[day];
}
setDateGroup(dateGroup: AssetDateGroup, { year, month, day }: TimelinePlainDate) {
if (!this.#lookupCache[year]) {
this.#lookupCache[year] = {};
}
if (!this.#lookupCache[year][month]) {
this.#lookupCache[year][month] = {};
}
this.#lookupCache[year][month][day] = dateGroup;
}
get existingDateGroups() {
return this.changedDateGroups.difference(this.newDateGroups);
}
get updatedBuckets() {
const updated = new Set<AssetBucket>();
for (const group of this.changedDateGroups) {
updated.add(group.bucket);
}
return updated;
}
get bucketsWithNewDateGroups() {
const updated = new Set<AssetBucket>();
for (const group of this.newDateGroups) {
updated.add(group.bucket);
}
return updated;
}
sort(bucket: AssetBucket, sortOrder: AssetOrder = AssetOrder.Desc) {
for (const group of this.changedDateGroups) {
group.sortAssets(sortOrder);
}
for (const group of this.newDateGroups) {
group.sortAssets(sortOrder);
}
if (this.newDateGroups.size > 0) {
bucket.sortDateGroups();
}
}
}

View File

@ -0,0 +1,366 @@
import { CancellableTask } from '$lib/utils/cancellable-task';
import { handleError } from '$lib/utils/handle-error';
import {
formatBucketTitle,
formatGroupTitle,
fromTimelinePlainDate,
fromTimelinePlainDateTime,
fromTimelinePlainYearMonth,
getTimes,
type TimelinePlainDateTime,
type TimelinePlainYearMonth,
} from '$lib/utils/timeline-util';
import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import { AddContext } from './add-context.svelte';
import { AssetDateGroup } from './asset-date-group.svelte';
import type { AssetStore } from './asset-store.svelte';
import { IntersectingAsset } from './intersecting-asset.svelte';
import type { AssetDescriptor, AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
export class AssetBucket {
#intersecting: boolean = $state(false);
actuallyIntersecting: boolean = $state(false);
isLoaded: boolean = $state(false);
dateGroups: AssetDateGroup[] = $state([]);
readonly store: AssetStore;
#bucketHeight: number = $state(0);
#top: number = $state(0);
#initialCount: number = 0;
#sortOrder: AssetOrder = AssetOrder.Desc;
percent: number = $state(0);
bucketCount: number = $derived(
this.isLoaded
? this.dateGroups.reduce((accumulator, g) => accumulator + g.intersectingAssets.length, 0)
: this.#initialCount,
);
loader: CancellableTask | undefined;
isBucketHeightActual: boolean = $state(false);
readonly bucketDateFormatted: string;
readonly yearMonth: TimelinePlainYearMonth;
constructor(
store: AssetStore,
yearMonth: TimelinePlainYearMonth,
initialCount: number,
order: AssetOrder = AssetOrder.Desc,
) {
this.store = store;
this.#initialCount = initialCount;
this.#sortOrder = order;
this.yearMonth = yearMonth;
this.bucketDateFormatted = formatBucketTitle(fromTimelinePlainYearMonth(yearMonth));
this.loader = new CancellableTask(
() => {
this.isLoaded = true;
},
() => {
this.dateGroups = [];
this.isLoaded = false;
},
this.#handleLoadError,
);
}
set intersecting(newValue: boolean) {
const old = this.#intersecting;
if (old === newValue) {
return;
}
this.#intersecting = newValue;
if (newValue) {
void this.store.loadBucket(this.yearMonth);
} else {
this.cancel();
}
}
get intersecting() {
return this.#intersecting;
}
get lastDateGroup() {
return this.dateGroups.at(-1);
}
getFirstAsset() {
return this.dateGroups[0]?.getFirstAsset();
}
getAssets() {
// eslint-disable-next-line unicorn/no-array-reduce
return this.dateGroups.reduce(
(accumulator: TimelineAsset[], g: AssetDateGroup) => accumulator.concat(g.getAssets()),
[],
);
}
sortDateGroups() {
if (this.#sortOrder === AssetOrder.Asc) {
return this.dateGroups.sort((a, b) => a.day - b.day);
}
return this.dateGroups.sort((a, b) => b.day - a.day);
}
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
if (ids.size === 0) {
return {
moveAssets: [] as MoveAsset[],
processedIds: new Set<string>(),
unprocessedIds: ids,
changedGeometry: false,
};
}
const { dateGroups } = this;
let combinedChangedGeometry = false;
let idsToProcess = new Set(ids);
const idsProcessed = new Set<string>();
const combinedMoveAssets: MoveAsset[][] = [];
let index = dateGroups.length;
while (index--) {
if (idsToProcess.size > 0) {
const group = dateGroups[index];
const { moveAssets, processedIds, changedGeometry } = group.runAssetOperation(ids, operation);
if (moveAssets.length > 0) {
combinedMoveAssets.push(moveAssets);
}
idsToProcess = idsToProcess.difference(processedIds);
for (const id of processedIds) {
idsProcessed.add(id);
}
combinedChangedGeometry = combinedChangedGeometry || changedGeometry;
if (group.intersectingAssets.length === 0) {
dateGroups.splice(index, 1);
combinedChangedGeometry = true;
}
}
}
return {
moveAssets: combinedMoveAssets.flat(),
unprocessedIds: idsToProcess,
processedIds: idsProcessed,
changedGeometry: combinedChangedGeometry,
};
}
addAssets(bucketAssets: TimeBucketAssetResponseDto) {
const addContext = new AddContext();
for (let i = 0; i < bucketAssets.id.length; i++) {
const { localDateTime, fileCreatedAt } = getTimes(
bucketAssets.fileCreatedAt[i],
bucketAssets.localOffsetHours[i],
);
const timelineAsset: TimelineAsset = {
city: bucketAssets.city[i],
country: bucketAssets.country[i],
duration: bucketAssets.duration[i],
id: bucketAssets.id[i],
visibility: bucketAssets.visibility[i],
isFavorite: bucketAssets.isFavorite[i],
isImage: bucketAssets.isImage[i],
isTrashed: bucketAssets.isTrashed[i],
isVideo: !bucketAssets.isImage[i],
livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
localDateTime,
fileCreatedAt,
ownerId: bucketAssets.ownerId[i],
projectionType: bucketAssets.projectionType[i],
ratio: bucketAssets.ratio[i],
stack: bucketAssets.stack?.[i]
? {
id: bucketAssets.stack[i]![0],
primaryAssetId: bucketAssets.id[i],
assetCount: Number.parseInt(bucketAssets.stack[i]![1]),
}
: null,
thumbhash: bucketAssets.thumbhash[i],
people: null, // People are not included in the bucket assets
};
this.addTimelineAsset(timelineAsset, addContext);
}
for (const group of addContext.existingDateGroups) {
group.sortAssets(this.#sortOrder);
}
if (addContext.newDateGroups.size > 0) {
this.sortDateGroups();
}
addContext.sort(this, this.#sortOrder);
return addContext.unprocessedAssets;
}
addTimelineAsset(timelineAsset: TimelineAsset, addContext: AddContext) {
const { localDateTime } = timelineAsset;
const { year, month } = this.yearMonth;
if (month !== localDateTime.month || year !== localDateTime.year) {
addContext.unprocessedAssets.push(timelineAsset);
return;
}
let dateGroup = addContext.getDateGroup(localDateTime) || this.findDateGroupByDay(localDateTime.day);
if (dateGroup) {
addContext.setDateGroup(dateGroup, localDateTime);
} else {
const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime));
dateGroup = new AssetDateGroup(this, this.dateGroups.length, localDateTime.day, groupTitle);
this.dateGroups.push(dateGroup);
addContext.setDateGroup(dateGroup, localDateTime);
addContext.newDateGroups.add(dateGroup);
}
const intersectingAsset = new IntersectingAsset(dateGroup, timelineAsset);
dateGroup.intersectingAssets.push(intersectingAsset);
addContext.changedDateGroups.add(dateGroup);
}
getRandomDateGroup() {
const random = Math.floor(Math.random() * this.dateGroups.length);
return this.dateGroups[random];
}
getRandomAsset() {
return this.getRandomDateGroup()?.getRandomAsset()?.asset;
}
get viewId() {
const { year, month } = this.yearMonth;
return year + '-' + month;
}
set bucketHeight(height: number) {
if (this.#bucketHeight === height) {
return;
}
const { store, percent } = this;
const index = store.buckets.indexOf(this);
const bucketHeightDelta = height - this.#bucketHeight;
this.#bucketHeight = height;
const prevBucket = store.buckets[index - 1];
if (prevBucket) {
const newTop = prevBucket.#top + prevBucket.#bucketHeight;
if (this.#top !== newTop) {
this.#top = newTop;
}
}
for (let cursor = index + 1; cursor < store.buckets.length; cursor++) {
const bucket = this.store.buckets[cursor];
const newTop = bucket.#top + bucketHeightDelta;
if (bucket.#top !== newTop) {
bucket.#top = newTop;
}
}
if (store.topIntersectingBucket) {
const currentIndex = store.buckets.indexOf(store.topIntersectingBucket);
if (currentIndex > 0) {
if (index < currentIndex) {
store.scrollCompensation = {
heightDelta: bucketHeightDelta,
scrollTop: undefined,
bucket: this,
};
} else if (percent > 0) {
const top = this.top + height * percent;
store.scrollCompensation = {
heightDelta: undefined,
scrollTop: top,
bucket: this,
};
}
}
}
}
get bucketHeight() {
return this.#bucketHeight;
}
get top(): number {
return this.#top + this.store.topSectionHeight;
}
#handleLoadError(error: unknown) {
const _$t = get(t);
handleError(error, _$t('errors.failed_to_load_assets'));
}
findDateGroupForAsset(asset: TimelineAsset) {
for (const group of this.dateGroups) {
if (group.intersectingAssets.some((IntersectingAsset) => IntersectingAsset.id === asset.id)) {
return group;
}
}
}
findDateGroupByDay(day: number) {
return this.dateGroups.find((group) => group.day === day);
}
findAssetAbsolutePosition(assetId: string) {
this.store.clearDeferredLayout(this);
for (const group of this.dateGroups) {
const intersectingAsset = group.intersectingAssets.find((asset) => asset.id === assetId);
if (intersectingAsset) {
if (!intersectingAsset.position) {
console.warn('No position for asset');
break;
}
return this.top + group.top + intersectingAsset.position.top + this.store.headerHeight;
}
}
return -1;
}
*assetsIterator(options?: { startDateGroup?: AssetDateGroup; startAsset?: TimelineAsset; direction?: Direction }) {
const direction = options?.direction ?? 'earlier';
let { startAsset } = options ?? {};
const isEarlier = direction === 'earlier';
let groupIndex = options?.startDateGroup
? this.dateGroups.indexOf(options.startDateGroup)
: isEarlier
? 0
: this.dateGroups.length - 1;
while (groupIndex >= 0 && groupIndex < this.dateGroups.length) {
const group = this.dateGroups[groupIndex];
yield* group.assetsIterator({ startAsset, direction });
startAsset = undefined;
groupIndex += isEarlier ? 1 : -1;
}
}
findAssetById(assetDescriptor: AssetDescriptor) {
return this.assetsIterator().find((asset) => asset.id === assetDescriptor.id);
}
findClosest(target: TimelinePlainDateTime) {
const targetDate = fromTimelinePlainDateTime(target);
let closest = undefined;
let smallestDiff = Infinity;
for (const current of this.assetsIterator()) {
const currentAssetDate = fromTimelinePlainDateTime(current.localDateTime);
const diff = Math.abs(targetDate.diff(currentAssetDate).as('milliseconds'));
if (diff < smallestDiff) {
smallestDiff = diff;
closest = current;
}
}
return closest;
}
cancel() {
this.loader?.cancel();
}
}

View File

@ -0,0 +1,162 @@
import type { CommonLayoutOptions } from '$lib/utils/layout-utils';
import { getJustifiedLayoutFromAssets, getPosition } from '$lib/utils/layout-utils';
import { plainDateTimeCompare } from '$lib/utils/timeline-util';
import { AssetOrder } from '@immich/sdk';
import type { AssetBucket } from './asset-bucket.svelte';
import { IntersectingAsset } from './intersecting-asset.svelte';
import type { AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
export class AssetDateGroup {
readonly bucket: AssetBucket;
readonly index: number;
readonly groupTitle: string;
readonly day: number;
intersectingAssets: IntersectingAsset[] = $state([]);
height = $state(0);
width = $state(0);
intersecting = $derived.by(() => this.intersectingAssets.some((asset) => asset.intersecting));
#top: number = $state(0);
#left: number = $state(0);
#row = $state(0);
#col = $state(0);
#deferredLayout = false;
constructor(bucket: AssetBucket, index: number, day: number, groupTitle: string) {
this.index = index;
this.bucket = bucket;
this.day = day;
this.groupTitle = groupTitle;
}
get top() {
return this.#top;
}
set top(value: number) {
this.#top = value;
}
get left() {
return this.#left;
}
set left(value: number) {
this.#left = value;
}
get row() {
return this.#row;
}
set row(value: number) {
this.#row = value;
}
get col() {
return this.#col;
}
set col(value: number) {
this.#col = value;
}
get deferredLayout() {
return this.#deferredLayout;
}
set deferredLayout(value: boolean) {
this.#deferredLayout = value;
}
sortAssets(sortOrder: AssetOrder = AssetOrder.Desc) {
const sortFn = plainDateTimeCompare.bind(undefined, sortOrder === AssetOrder.Asc);
this.intersectingAssets.sort((a, b) => sortFn(a.asset.fileCreatedAt, b.asset.fileCreatedAt));
}
getFirstAsset() {
return this.intersectingAssets[0]?.asset;
}
getRandomAsset() {
const random = Math.floor(Math.random() * this.intersectingAssets.length);
return this.intersectingAssets[random];
}
*assetsIterator(options: { startAsset?: TimelineAsset; direction?: Direction } = {}) {
const isEarlier = (options?.direction ?? 'earlier') === 'earlier';
let assetIndex = options?.startAsset
? this.intersectingAssets.findIndex((intersectingAsset) => intersectingAsset.asset.id === options.startAsset!.id)
: isEarlier
? 0
: this.intersectingAssets.length - 1;
while (assetIndex >= 0 && assetIndex < this.intersectingAssets.length) {
const intersectingAsset = this.intersectingAssets[assetIndex];
yield intersectingAsset.asset;
assetIndex += isEarlier ? 1 : -1;
}
}
getAssets() {
return this.intersectingAssets.map((intersectingasset) => intersectingasset.asset);
}
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
if (ids.size === 0) {
return {
moveAssets: [] as MoveAsset[],
processedIds: new Set<string>(),
unprocessedIds: ids,
changedGeometry: false,
};
}
const unprocessedIds = new Set<string>(ids);
const processedIds = new Set<string>();
const moveAssets: MoveAsset[] = [];
let changedGeometry = false;
for (const assetId of unprocessedIds) {
const index = this.intersectingAssets.findIndex((ia) => ia.id == assetId);
if (index === -1) {
continue;
}
const asset = this.intersectingAssets[index].asset!;
const oldTime = { ...asset.localDateTime };
let { remove } = operation(asset);
const newTime = asset.localDateTime;
if (oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day) {
const { year, month, day } = newTime;
remove = true;
moveAssets.push({ asset, date: { year, month, day } });
}
unprocessedIds.delete(assetId);
processedIds.add(assetId);
if (remove || this.bucket.store.isExcluded(asset)) {
this.intersectingAssets.splice(index, 1);
changedGeometry = true;
}
}
return { moveAssets, processedIds, unprocessedIds, changedGeometry };
}
layout(options: CommonLayoutOptions, noDefer: boolean) {
if (!noDefer && !this.bucket.intersecting) {
this.#deferredLayout = true;
return;
}
const assets = this.intersectingAssets.map((intersetingAsset) => intersetingAsset.asset!);
const geometry = getJustifiedLayoutFromAssets(assets, options);
this.width = geometry.containerWidth;
this.height = assets.length === 0 ? 0 : geometry.containerHeight;
for (let i = 0; i < this.intersectingAssets.length; i++) {
const position = getPosition(geometry, i);
this.intersectingAssets[i].position = position;
}
}
get absoluteDateGroupTop() {
return this.bucket.top + this.#top;
}
}

View File

@ -0,0 +1,934 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { websocketEvents } from '$lib/stores/websocket';
import { CancellableTask } from '$lib/utils/cancellable-task';
import {
plainDateTimeCompare,
toISOYearMonthUTC,
toTimelineAsset,
type TimelinePlainDate,
type TimelinePlainDateTime,
type TimelinePlainYearMonth,
} from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
import { getAssetInfo, getTimeBucket, getTimeBuckets } from '@immich/sdk';
import { clamp, debounce, isEqual, throttle } from 'lodash-es';
import { SvelteSet } from 'svelte/reactivity';
import type { Unsubscriber } from 'svelte/store';
import { AddContext } from './add-context.svelte';
import { AssetBucket } from './asset-bucket.svelte';
import { AssetDateGroup } from './asset-date-group.svelte';
import type {
AssetDescriptor,
AssetOperation,
AssetStoreLayoutOptions,
AssetStoreOptions,
Direction,
LiteBucket,
PendingChange,
TimelineAsset,
UpdateGeometryOptions,
Viewport,
} from './types';
import { isMismatched, updateObject } from './utils.svelte';
const {
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
} = TUNABLES;
export class AssetStore {
isInitialized = $state(false);
buckets: AssetBucket[] = $state([]);
topSectionHeight = $state(0);
timelineHeight = $derived(
this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0) + this.topSectionHeight,
);
count = $derived(this.buckets.reduce((accumulator, b) => accumulator + b.bucketCount, 0));
albumAssets: Set<string> = new SvelteSet();
scrubberBuckets: LiteBucket[] = $state([]);
scrubberTimelineHeight: number = $state(0);
topIntersectingBucket: AssetBucket | undefined = $state();
visibleWindow = $derived.by(() => ({
top: this.#scrollTop,
bottom: this.#scrollTop + this.viewportHeight,
}));
initTask = new CancellableTask(
() => {
this.isInitialized = true;
if (this.#options.albumId || this.#options.personId) {
return;
}
this.connect();
},
() => {
this.disconnect();
this.isInitialized = false;
},
() => void 0,
);
static #INIT_OPTIONS = {};
#viewportHeight = $state(0);
#viewportWidth = $state(0);
#scrollTop = $state(0);
#pendingChanges: PendingChange[] = [];
#unsubscribers: Unsubscriber[] = [];
#rowHeight = $state(235);
#headerHeight = $state(48);
#gap = $state(12);
#options: AssetStoreOptions = AssetStore.#INIT_OPTIONS;
#scrolling = $state(false);
#suspendTransitions = $state(false);
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
scrollCompensation: {
heightDelta: number | undefined;
scrollTop: number | undefined;
bucket: AssetBucket | undefined;
} = $state({
heightDelta: 0,
scrollTop: 0,
bucket: undefined,
});
constructor() {}
setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: AssetStoreLayoutOptions) {
let changed = false;
changed ||= this.#setHeaderHeight(headerHeight);
changed ||= this.#setGap(gap);
changed ||= this.#setRowHeight(rowHeight);
if (changed) {
this.refreshLayout();
}
}
#setHeaderHeight(value: number) {
if (this.#headerHeight == value) {
return false;
}
this.#headerHeight = value;
return true;
}
get headerHeight() {
return this.#headerHeight;
}
#setGap(value: number) {
if (this.#gap == value) {
return false;
}
this.#gap = value;
return true;
}
get gap() {
return this.#gap;
}
#setRowHeight(value: number) {
if (this.#rowHeight == value) {
return false;
}
this.#rowHeight = value;
return true;
}
get rowHeight() {
return this.#rowHeight;
}
set scrolling(value: boolean) {
this.#scrolling = value;
if (value) {
this.suspendTransitions = true;
this.#resetScrolling();
}
}
get scrolling() {
return this.#scrolling;
}
set suspendTransitions(value: boolean) {
this.#suspendTransitions = value;
if (value) {
this.#resetSuspendTransitions();
}
}
get suspendTransitions() {
return this.#suspendTransitions;
}
set viewportWidth(value: number) {
const changed = value !== this.#viewportWidth;
this.#viewportWidth = value;
this.suspendTransitions = true;
void this.#updateViewportGeometry(changed);
}
get viewportWidth() {
return this.#viewportWidth;
}
set viewportHeight(value: number) {
this.#viewportHeight = value;
this.#suspendTransitions = true;
void this.#updateViewportGeometry(false);
}
get viewportHeight() {
return this.#viewportHeight;
}
async *assetsIterator(options?: {
startBucket?: AssetBucket;
startDateGroup?: AssetDateGroup;
startAsset?: TimelineAsset;
direction?: Direction;
}) {
const direction = options?.direction ?? 'earlier';
let { startDateGroup, startAsset } = options ?? {};
for (const bucket of this.bucketsIterator({ direction, startBucket: options?.startBucket })) {
await this.loadBucket(bucket.yearMonth, { cancelable: false });
yield* bucket.assetsIterator({ startDateGroup, startAsset, direction });
startDateGroup = startAsset = undefined;
}
}
*bucketsIterator(options?: { direction?: Direction; startBucket?: AssetBucket }) {
const isEarlier = options?.direction === 'earlier';
let startIndex = options?.startBucket
? this.buckets.indexOf(options.startBucket)
: isEarlier
? 0
: this.buckets.length - 1;
while (startIndex >= 0 && startIndex < this.buckets.length) {
yield this.buckets[startIndex];
startIndex += isEarlier ? 1 : -1;
}
}
#addPendingChanges(...changes: PendingChange[]) {
this.#pendingChanges.push(...changes);
this.#processPendingChanges();
}
connect() {
this.#unsubscribers.push(
websocketEvents.on('on_upload_success', (asset) =>
this.#addPendingChanges({ type: 'add', values: [toTimelineAsset(asset)] }),
),
websocketEvents.on('on_asset_trash', (ids) => this.#addPendingChanges({ type: 'trash', values: ids })),
websocketEvents.on('on_asset_update', (asset) =>
this.#addPendingChanges({ type: 'update', values: [toTimelineAsset(asset)] }),
),
websocketEvents.on('on_asset_delete', (id: string) => this.#addPendingChanges({ type: 'delete', values: [id] })),
);
}
disconnect() {
for (const unsubscribe of this.#unsubscribers) {
unsubscribe();
}
this.#unsubscribers = [];
}
#getPendingChangeBatches() {
const batch: {
add: TimelineAsset[];
update: TimelineAsset[];
remove: string[];
} = {
add: [],
update: [],
remove: [],
};
for (const { type, values } of this.#pendingChanges) {
switch (type) {
case 'add': {
batch.add.push(...values);
break;
}
case 'update': {
batch.update.push(...values);
break;
}
case 'delete':
case 'trash': {
batch.remove.push(...values);
break;
}
}
}
return batch;
}
#findBucketForAsset(id: string) {
for (const bucket of this.buckets) {
const asset = bucket.findAssetById({ id });
if (asset) {
return { bucket, asset };
}
}
}
#findBucketForDate(targetYearMonth: TimelinePlainYearMonth) {
for (const bucket of this.buckets) {
const { year, month } = bucket.yearMonth;
if (month === targetYearMonth.month && year === targetYearMonth.year) {
return bucket;
}
}
}
updateSlidingWindow(scrollTop: number) {
if (this.#scrollTop !== scrollTop) {
this.#scrollTop = scrollTop;
this.updateIntersections();
}
}
clearScrollCompensation() {
this.scrollCompensation = {
heightDelta: undefined,
scrollTop: undefined,
bucket: undefined,
};
}
updateIntersections() {
if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
return;
}
let topIntersectingBucket = undefined;
for (const bucket of this.buckets) {
this.#updateIntersection(bucket);
if (!topIntersectingBucket && bucket.actuallyIntersecting) {
topIntersectingBucket = bucket;
}
}
if (topIntersectingBucket !== undefined && this.topIntersectingBucket !== topIntersectingBucket) {
this.topIntersectingBucket = topIntersectingBucket;
}
for (const bucket of this.buckets) {
if (bucket === this.topIntersectingBucket) {
this.topIntersectingBucket.percent = clamp(
(this.visibleWindow.top - this.topIntersectingBucket.top) / this.topIntersectingBucket.bucketHeight,
0,
1,
);
} else {
bucket.percent = 0;
}
}
}
#calculateIntersecting(bucket: AssetBucket, expandTop: number, expandBottom: number) {
const bucketTop = bucket.top;
const bucketBottom = bucketTop + bucket.bucketHeight;
const topWindow = this.visibleWindow.top - expandTop;
const bottomWindow = this.visibleWindow.bottom + expandBottom;
return (
(bucketTop >= topWindow && bucketTop < bottomWindow) ||
(bucketBottom >= topWindow && bucketBottom < bottomWindow) ||
(bucketTop < topWindow && bucketBottom >= bottomWindow)
);
}
clearDeferredLayout(bucket: AssetBucket) {
const hasDeferred = bucket.dateGroups.some((group) => group.deferredLayout);
if (hasDeferred) {
this.#updateGeometry(bucket, { invalidateHeight: true, noDefer: true });
for (const group of bucket.dateGroups) {
group.deferredLayout = false;
}
}
}
#updateIntersection(bucket: AssetBucket) {
const actuallyIntersecting = this.#calculateIntersecting(bucket, 0, 0);
let preIntersecting = false;
if (!actuallyIntersecting) {
preIntersecting = this.#calculateIntersecting(bucket, INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM);
}
bucket.intersecting = actuallyIntersecting || preIntersecting;
bucket.actuallyIntersecting = actuallyIntersecting;
if (preIntersecting || actuallyIntersecting) {
this.clearDeferredLayout(bucket);
}
}
#processPendingChanges = throttle(() => {
const { add, update, remove } = this.#getPendingChangeBatches();
if (add.length > 0) {
this.addAssets(add);
}
if (update.length > 0) {
this.updateAssets(update);
}
if (remove.length > 0) {
this.removeAssets(remove);
}
this.#pendingChanges = [];
}, 2500);
async #initializeTimeBuckets() {
const timebuckets = await getTimeBuckets({
...this.#options,
key: authManager.key,
});
this.buckets = timebuckets.map((bucket) => {
const date = new Date(bucket.timeBucket);
return new AssetBucket(
this,
{ year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
bucket.count,
this.#options.order,
);
});
this.albumAssets.clear();
this.#updateViewportGeometry(false);
}
async updateOptions(options: AssetStoreOptions) {
if (options.deferInit) {
return;
}
if (this.#options !== AssetStore.#INIT_OPTIONS && isEqual(this.#options, options)) {
return;
}
await this.initTask.reset();
await this.#init(options);
this.#updateViewportGeometry(false);
}
async #init(options: AssetStoreOptions) {
this.isInitialized = false;
this.buckets = [];
this.albumAssets.clear();
await this.initTask.execute(async () => {
this.#options = options;
await this.#initializeTimeBuckets();
}, true);
}
public destroy() {
this.disconnect();
this.isInitialized = false;
}
async updateViewport(viewport: Viewport) {
if (viewport.height === 0 && viewport.width === 0) {
return;
}
if (this.viewportHeight === viewport.height && this.viewportWidth === viewport.width) {
return;
}
if (!this.initTask.executed) {
await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.#init(this.#options));
}
const changedWidth = viewport.width !== this.viewportWidth;
this.viewportHeight = viewport.height;
this.viewportWidth = viewport.width;
this.#updateViewportGeometry(changedWidth);
}
#updateViewportGeometry(changedWidth: boolean) {
if (!this.isInitialized) {
return;
}
if (this.viewportWidth === 0 || this.viewportHeight === 0) {
return;
}
for (const bucket of this.buckets) {
this.#updateGeometry(bucket, { invalidateHeight: changedWidth });
}
this.updateIntersections();
this.#createScrubBuckets();
}
#createScrubBuckets() {
this.scrubberBuckets = this.buckets.map((bucket) => ({
assetCount: bucket.bucketCount,
year: bucket.yearMonth.year,
month: bucket.yearMonth.month,
bucketDateFormattted: bucket.bucketDateFormatted,
bucketHeight: bucket.bucketHeight,
}));
this.scrubberTimelineHeight = this.timelineHeight;
}
createLayoutOptions() {
const viewportWidth = this.viewportWidth;
return {
spacing: 2,
heightTolerance: 0.15,
rowHeight: this.#rowHeight,
rowWidth: Math.floor(viewportWidth),
};
}
#updateGeometry(bucket: AssetBucket, options: UpdateGeometryOptions) {
const { invalidateHeight, noDefer = false } = options;
if (invalidateHeight) {
bucket.isBucketHeightActual = false;
}
if (!bucket.isLoaded) {
const viewportWidth = this.viewportWidth;
if (!bucket.isBucketHeightActual) {
const unwrappedWidth = (3 / 2) * bucket.bucketCount * this.#rowHeight * (7 / 10);
const rows = Math.ceil(unwrappedWidth / viewportWidth);
const height = 51 + Math.max(1, rows) * this.#rowHeight;
bucket.bucketHeight = height;
}
return;
}
this.#layoutBucket(bucket, noDefer);
}
#layoutBucket(bucket: AssetBucket, noDefer: boolean = false) {
let cummulativeHeight = 0;
let cummulativeWidth = 0;
let lastRowHeight = 0;
let lastRow = 0;
let dateGroupRow = 0;
let dateGroupCol = 0;
const rowSpaceRemaining: number[] = Array.from({ length: bucket.dateGroups.length });
rowSpaceRemaining.fill(this.viewportWidth, 0, bucket.dateGroups.length);
const options = this.createLayoutOptions();
for (const assetGroup of bucket.dateGroups) {
assetGroup.layout(options, noDefer);
rowSpaceRemaining[dateGroupRow] -= assetGroup.width - 1;
if (dateGroupCol > 0) {
rowSpaceRemaining[dateGroupRow] -= this.gap;
}
if (rowSpaceRemaining[dateGroupRow] >= 0) {
assetGroup.row = dateGroupRow;
assetGroup.col = dateGroupCol;
assetGroup.left = cummulativeWidth;
assetGroup.top = cummulativeHeight;
dateGroupCol++;
cummulativeWidth += assetGroup.width + this.gap;
} else {
cummulativeWidth = 0;
dateGroupRow++;
dateGroupCol = 0;
assetGroup.row = dateGroupRow;
assetGroup.col = dateGroupCol;
assetGroup.left = cummulativeWidth;
rowSpaceRemaining[dateGroupRow] -= assetGroup.width;
dateGroupCol++;
cummulativeHeight += lastRowHeight;
assetGroup.top = cummulativeHeight;
cummulativeWidth += assetGroup.width + this.gap;
lastRow = assetGroup.row - 1;
}
lastRowHeight = assetGroup.height + this.headerHeight;
}
if (lastRow === 0 || lastRow !== bucket.lastDateGroup?.row) {
cummulativeHeight += lastRowHeight;
}
bucket.bucketHeight = cummulativeHeight;
bucket.isBucketHeightActual = true;
}
async loadBucket(yearMonth: TimelinePlainYearMonth, options?: { cancelable: boolean }): Promise<void> {
let cancelable = true;
if (options) {
cancelable = options.cancelable;
}
const bucket = this.getBucketByDate(yearMonth);
if (!bucket) {
return;
}
if (bucket.loader?.executed) {
return;
}
const result = await bucket.loader?.execute(async (signal: AbortSignal) => {
if (bucket.getFirstAsset()) {
return;
}
const timeBucket = toISOYearMonthUTC(bucket.yearMonth);
const key = authManager.key;
const bucketResponse = await getTimeBucket(
{
...this.#options,
timeBucket,
key,
},
{ signal },
);
if (bucketResponse) {
if (this.#options.timelineAlbumId) {
const albumAssets = await getTimeBucket(
{
albumId: this.#options.timelineAlbumId,
timeBucket,
key,
},
{ signal },
);
for (const id of albumAssets.id) {
this.albumAssets.add(id);
}
}
const unprocessedAssets = bucket.addAssets(bucketResponse);
if (unprocessedAssets.length > 0) {
console.error(
`Warning: getTimeBucket API returning assets not in requested month: ${bucket.yearMonth.month}, ${JSON.stringify(
unprocessedAssets.map((unprocessed) => ({
id: unprocessed.id,
localDateTime: unprocessed.localDateTime,
})),
)}`,
);
}
this.#layoutBucket(bucket);
}
}, cancelable);
if (result === 'LOADED') {
this.#updateIntersection(bucket);
}
}
addAssets(assets: TimelineAsset[]) {
const assetsToUpdate: TimelineAsset[] = [];
for (const asset of assets) {
if (this.isExcluded(asset)) {
continue;
}
assetsToUpdate.push(asset);
}
const notUpdated = this.updateAssets(assetsToUpdate);
this.#addAssetsToBuckets([...notUpdated]);
}
#addAssetsToBuckets(assets: TimelineAsset[]) {
if (assets.length === 0) {
return;
}
const addContext = new AddContext();
const updatedBuckets = new Set<AssetBucket>();
const bucketCount = this.buckets.length;
for (const asset of assets) {
let bucket = this.getBucketByDate(asset.localDateTime);
if (!bucket) {
bucket = new AssetBucket(this, asset.localDateTime, 1, this.#options.order);
bucket.isLoaded = true;
this.buckets.push(bucket);
}
bucket.addTimelineAsset(asset, addContext);
updatedBuckets.add(bucket);
}
if (this.buckets.length !== bucketCount) {
this.buckets.sort((a, b) => {
return a.yearMonth.year === b.yearMonth.year
? b.yearMonth.month - a.yearMonth.month
: b.yearMonth.year - a.yearMonth.year;
});
}
for (const group of addContext.existingDateGroups) {
group.sortAssets(this.#options.order);
}
for (const bucket of addContext.bucketsWithNewDateGroups) {
bucket.sortDateGroups();
}
for (const bucket of addContext.updatedBuckets) {
bucket.sortDateGroups();
this.#updateGeometry(bucket, { invalidateHeight: true });
}
this.updateIntersections();
}
getBucketByDate(targetYearMonth: TimelinePlainYearMonth): AssetBucket | undefined {
return this.buckets.find(
(bucket) => bucket.yearMonth.year === targetYearMonth.year && bucket.yearMonth.month === targetYearMonth.month,
);
}
async findBucketForAsset(id: string) {
if (!this.isInitialized) {
await this.initTask.waitUntilCompletion();
}
let { bucket } = this.#findBucketForAsset(id) ?? {};
if (bucket) {
return bucket;
}
const asset = toTimelineAsset(await getAssetInfo({ id, key: authManager.key }));
if (!asset || this.isExcluded(asset)) {
return;
}
bucket = await this.#loadBucketAtTime(asset.localDateTime, { cancelable: false });
if (bucket?.findAssetById({ id })) {
return bucket;
}
}
async #loadBucketAtTime(yearMonth: TimelinePlainYearMonth, options?: { cancelable: boolean }) {
await this.loadBucket(yearMonth, options);
return this.getBucketByDate(yearMonth);
}
getBucketIndexByAssetId(assetId: string) {
const bucketInfo = this.#findBucketForAsset(assetId);
return bucketInfo?.bucket;
}
async getRandomBucket() {
const random = Math.floor(Math.random() * this.buckets.length);
const bucket = this.buckets[random];
await this.loadBucket(bucket.yearMonth, { cancelable: false });
return bucket;
}
async getRandomAsset() {
const bucket = await this.getRandomBucket();
return bucket?.getRandomAsset();
}
#runAssetOperation(ids: Set<string>, operation: AssetOperation) {
if (ids.size === 0) {
return { processedIds: new Set(), unprocessedIds: ids, changedGeometry: false };
}
const changedBuckets = new Set<AssetBucket>();
let idsToProcess = new Set(ids);
const idsProcessed = new Set<string>();
const combinedMoveAssets: { asset: TimelineAsset; date: TimelinePlainDate }[][] = [];
for (const bucket of this.buckets) {
if (idsToProcess.size > 0) {
const { moveAssets, processedIds, changedGeometry } = bucket.runAssetOperation(idsToProcess, operation);
if (moveAssets.length > 0) {
combinedMoveAssets.push(moveAssets);
}
idsToProcess = idsToProcess.difference(processedIds);
for (const id of processedIds) {
idsProcessed.add(id);
}
if (changedGeometry) {
changedBuckets.add(bucket);
}
}
}
if (combinedMoveAssets.length > 0) {
this.#addAssetsToBuckets(combinedMoveAssets.flat().map((a) => a.asset));
}
const changedGeometry = changedBuckets.size > 0;
for (const bucket of changedBuckets) {
this.#updateGeometry(bucket, { invalidateHeight: true });
}
if (changedGeometry) {
this.updateIntersections();
}
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
}
updateAssetOperation(ids: string[], operation: AssetOperation) {
this.#runAssetOperation(new Set(ids), operation);
}
updateAssets(assets: TimelineAsset[]) {
const lookup = new Map<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
const { unprocessedIds } = this.#runAssetOperation(new Set(lookup.keys()), (asset) => {
updateObject(asset, lookup.get(asset.id));
return { remove: false };
});
return unprocessedIds.values().map((id) => lookup.get(id)!);
}
removeAssets(ids: string[]) {
const { unprocessedIds } = this.#runAssetOperation(new Set(ids), () => {
return { remove: true };
});
return [...unprocessedIds];
}
refreshLayout() {
for (const bucket of this.buckets) {
this.#updateGeometry(bucket, { invalidateHeight: true });
}
this.updateIntersections();
}
getFirstAsset(): TimelineAsset | undefined {
return this.buckets[0]?.getFirstAsset();
}
async getLaterAsset(
assetDescriptor: AssetDescriptor,
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
): Promise<TimelineAsset | undefined> {
return await this.#getAssetWithOffset(assetDescriptor, interval, 'later');
}
async getEarlierAsset(
assetDescriptor: AssetDescriptor,
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
): Promise<TimelineAsset | undefined> {
return await this.#getAssetWithOffset(assetDescriptor, interval, 'earlier');
}
async getClosestAssetToDate(dateTime: TimelinePlainDateTime) {
const bucket = this.#findBucketForDate(dateTime);
if (!bucket) {
return;
}
await this.loadBucket(dateTime, { cancelable: false });
const asset = bucket.findClosest(dateTime);
if (asset) {
return asset;
}
for await (const asset of this.assetsIterator({ startBucket: bucket })) {
return asset;
}
}
async retrieveRange(start: AssetDescriptor, end: AssetDescriptor) {
let { asset: startAsset, bucket: startBucket } = this.#findBucketForAsset(start.id) ?? {};
if (!startBucket || !startAsset) {
return [];
}
let { asset: endAsset, bucket: endBucket } = this.#findBucketForAsset(end.id) ?? {};
if (!endBucket || !endAsset) {
return [];
}
let direction: Direction = 'earlier';
if (plainDateTimeCompare(true, startAsset.localDateTime, endAsset.localDateTime) < 0) {
[startAsset, endAsset] = [endAsset, startAsset];
[startBucket, endBucket] = [endBucket, startBucket];
direction = 'earlier';
}
const range: TimelineAsset[] = [];
const startDateGroup = startBucket.findDateGroupForAsset(startAsset);
for await (const targetAsset of this.assetsIterator({
startBucket,
startDateGroup,
startAsset,
direction,
})) {
range.push(targetAsset);
if (targetAsset.id === endAsset.id) {
break;
}
}
return range;
}
async #getAssetWithOffset(
assetDescriptor: AssetDescriptor,
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
direction: Direction,
): Promise<TimelineAsset | undefined> {
const { asset, bucket } = this.#findBucketForAsset(assetDescriptor.id) ?? {};
if (!bucket || !asset) {
return;
}
switch (interval) {
case 'asset': {
return this.#getAssetByAssetOffset(asset, bucket, direction);
}
case 'day': {
return this.#getAssetByDayOffset(asset, bucket, direction);
}
case 'month': {
return this.#getAssetByMonthOffset(bucket, direction);
}
case 'year': {
return this.#getAssetByYearOffset(bucket, direction);
}
}
}
async #getAssetByAssetOffset(asset: TimelineAsset, bucket: AssetBucket, direction: Direction) {
const dateGroup = bucket.findDateGroupForAsset(asset);
for await (const targetAsset of this.assetsIterator({
startBucket: bucket,
startDateGroup: dateGroup,
startAsset: asset,
direction,
})) {
if (asset.id === targetAsset.id) {
continue;
}
return targetAsset;
}
}
async #getAssetByDayOffset(asset: TimelineAsset, bucket: AssetBucket, direction: Direction) {
const dateGroup = bucket.findDateGroupForAsset(asset);
for await (const targetAsset of this.assetsIterator({
startBucket: bucket,
startDateGroup: dateGroup,
startAsset: asset,
direction,
})) {
if (targetAsset.localDateTime.day !== asset.localDateTime.day) {
return targetAsset;
}
}
}
async #getAssetByMonthOffset(bucket: AssetBucket, direction: Direction) {
for (const targetBucket of this.bucketsIterator({ startBucket: bucket, direction })) {
if (targetBucket.yearMonth.month !== bucket.yearMonth.month) {
for await (const targetAsset of this.assetsIterator({ startBucket: targetBucket, direction })) {
return targetAsset;
}
}
}
}
async #getAssetByYearOffset(bucket: AssetBucket, direction: Direction) {
for (const targetBucket of this.bucketsIterator({ startBucket: bucket, direction })) {
if (targetBucket.yearMonth.year !== bucket.yearMonth.year) {
for await (const targetAsset of this.assetsIterator({ startBucket: targetBucket, direction })) {
return targetAsset;
}
}
}
}
isExcluded(asset: TimelineAsset) {
return (
isMismatched(this.#options.visibility, asset.visibility) ||
isMismatched(this.#options.isFavorite, asset.isFavorite) ||
isMismatched(this.#options.isTrashed, asset.isTrashed)
);
}
}

View File

@ -0,0 +1,45 @@
import type { CommonPosition } from '$lib/utils/layout-utils';
import { TUNABLES } from '$lib/utils/tunables';
import type { AssetDateGroup } from './asset-date-group.svelte';
import type { TimelineAsset } from './types';
const {
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
} = TUNABLES;
export class IntersectingAsset {
readonly #group: AssetDateGroup;
intersecting = $derived.by(() => {
if (!this.position) {
return false;
}
const store = this.#group.bucket.store;
const scrollCompensation = store.scrollCompensation;
const scrollCompensationHeightDelta = scrollCompensation?.heightDelta ?? 0;
const topWindow =
store.visibleWindow.top - store.headerHeight - INTERSECTION_EXPAND_TOP + scrollCompensationHeightDelta;
const bottomWindow =
store.visibleWindow.bottom + store.headerHeight + INTERSECTION_EXPAND_BOTTOM + scrollCompensationHeightDelta;
const positionTop = this.#group.absoluteDateGroupTop + this.position.top;
const positionBottom = positionTop + this.position.height;
const intersecting =
(positionTop >= topWindow && positionTop < bottomWindow) ||
(positionBottom >= topWindow && positionBottom < bottomWindow) ||
(positionTop < topWindow && positionBottom >= bottomWindow);
return intersecting;
});
position: CommonPosition | undefined = $state();
asset: TimelineAsset = <TimelineAsset>$state();
id: string | undefined = $derived(this.asset?.id);
constructor(group: AssetDateGroup, asset: TimelineAsset) {
this.#group = group;
this.asset = asset;
}
}

View File

@ -0,0 +1,94 @@
import type { TimelinePlainDate, TimelinePlainDateTime } from '$lib/utils/timeline-util';
import type { AssetStackResponseDto, AssetVisibility } from '@immich/sdk';
export type AssetApiGetTimeBucketsRequest = Parameters<typeof import('@immich/sdk').getTimeBuckets>[0];
export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & {
timelineAlbumId?: string;
deferInit?: boolean;
};
export type AssetDescriptor = { id: string };
export type Direction = 'earlier' | 'later';
export type TimelineAsset = {
id: string;
ownerId: string;
ratio: number;
thumbhash: string | null;
localDateTime: TimelinePlainDateTime;
fileCreatedAt: TimelinePlainDateTime;
visibility: AssetVisibility;
isFavorite: boolean;
isTrashed: boolean;
isVideo: boolean;
isImage: boolean;
stack: AssetStackResponseDto | null;
duration: string | null;
projectionType: string | null;
livePhotoVideoId: string | null;
city: string | null;
country: string | null;
people: string[] | null;
};
export type AssetOperation = (asset: TimelineAsset) => { remove: boolean };
export type MoveAsset = { asset: TimelineAsset; date: TimelinePlainDate };
export interface Viewport {
width: number;
height: number;
}
export type ViewportXY = Viewport & {
x: number;
y: number;
};
export interface AddAsset {
type: 'add';
values: TimelineAsset[];
}
export interface UpdateAsset {
type: 'update';
values: TimelineAsset[];
}
export interface DeleteAsset {
type: 'delete';
values: string[];
}
export interface TrashAssets {
type: 'trash';
values: string[];
}
export interface UpdateStackAssets {
type: 'update_stack_assets';
values: string[];
}
export type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets | UpdateStackAssets;
export type LiteBucket = {
bucketHeight: number;
assetCount: number;
year: number;
month: number;
bucketDateFormattted: string;
};
export type AssetStoreLayoutOptions = {
rowHeight?: number;
headerHeight?: number;
gap?: number;
};
export interface UpdateGeometryOptions {
invalidateHeight: boolean;
noDefer?: boolean;
}

View File

@ -0,0 +1,34 @@
import type { TimelineAsset } from './types';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function updateObject(target: any, source: any): boolean {
if (!target) {
return false;
}
let updated = false;
for (const key in source) {
if (!Object.prototype.hasOwnProperty.call(source, key)) {
continue;
}
if (key === '__proto__' || key === 'constructor') {
continue;
}
const isDate = target[key] instanceof Date;
if (typeof target[key] === 'object' && !isDate) {
updated = updated || updateObject(target[key], source[key]);
} else {
if (target[key] !== source[key]) {
target[key] = source[key];
updated = true;
}
}
}
return updated;
}
export function isMismatched<T>(option: T | undefined, value: T): boolean {
return option === undefined ? false : option !== value;
}
export const assetSnapshot = (asset: TimelineAsset): TimelineAsset => $state.snapshot(asset);
export const assetsSnapshot = (assets: TimelineAsset[]) => assets.map((asset) => $state.snapshot(asset));

View File

@ -1,4 +1,4 @@
import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { AssetVisibility, type UserAdminResponseDto } from '@immich/sdk'; import { AssetVisibility, type UserAdminResponseDto } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';

View File

@ -1,5 +1,5 @@
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { type AssetGridRouteSearchParams } from '$lib/utils/navigation'; import { type AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { readonly, writable } from 'svelte/store'; import { readonly, writable } from 'svelte/store';

View File

@ -1,9 +1,10 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock'; import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
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';
import { AssetStore, type TimelineAsset } from './assets-store.svelte';
async function getAssets(store: AssetStore) { async function getAssets(store: AssetStore) {
const assets = []; const assets = [];
@ -13,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();
@ -21,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(
@ -39,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]));
@ -77,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)]),
@ -165,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);
@ -179,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]);
@ -193,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 });
@ -213,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);
@ -237,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]);
@ -247,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 });
@ -268,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]);
@ -288,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);
@ -319,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']);
@ -329,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]);
@ -341,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));
@ -366,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);
}); });
@ -380,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)]),
@ -478,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);
@ -493,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]);

Some files were not shown because too many files have changed in this diff Show More