mirror of
https://github.com/immich-app/immich.git
synced 2026-05-28 02:22:34 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4423a8f8a4 | |||
| 77fd2ba919 | |||
| 1318dafdc4 | |||
| 26714f6bfe |
@@ -234,24 +234,13 @@ class RemoteAlbumService {
|
||||
final pendingAdds = <Future<void>>[];
|
||||
final localById = {for (final a in localAssets) a.id: a};
|
||||
|
||||
final UploadCallbacks(:onProgress, :onSuccess, :onError, :onICloudProgress) = userCallbacks;
|
||||
final wrappedCallbacks = UploadCallbacks(
|
||||
onProgress: (localId, filename, bytes, totalBytes) => _runUploadCallback(
|
||||
'Upload progress callback failed for $localId',
|
||||
() => userCallbacks.onProgress?.call(localId, filename, bytes, totalBytes),
|
||||
),
|
||||
onICloudProgress: (localId, progress) => _runUploadCallback(
|
||||
'iCloud progress callback failed for $localId',
|
||||
() => userCallbacks.onICloudProgress?.call(localId, progress),
|
||||
),
|
||||
onError: (localId, errorMessage) => _runUploadCallback(
|
||||
'Upload error callback failed for $localId',
|
||||
() => userCallbacks.onError?.call(localId, errorMessage),
|
||||
),
|
||||
onProgress: onProgress,
|
||||
onICloudProgress: onICloudProgress,
|
||||
onError: onError,
|
||||
onSuccess: (localId, remoteId) {
|
||||
_runUploadCallback(
|
||||
'Upload success callback failed for $localId',
|
||||
() => userCallbacks.onSuccess?.call(localId, remoteId),
|
||||
);
|
||||
onSuccess?.call(localId, remoteId);
|
||||
final source = localById[localId];
|
||||
if (source == null) {
|
||||
_logger.warning('Upload success for $localId but source LocalAsset missing; skipping album link');
|
||||
@@ -259,29 +248,22 @@ class RemoteAlbumService {
|
||||
}
|
||||
pendingAdds.add(
|
||||
_linkUploadedAssetToAlbum(albumId, remoteId, uploader, source)
|
||||
.then<void>((added) {
|
||||
addedCount += added;
|
||||
})
|
||||
.catchError((Object error, StackTrace stack) {
|
||||
_logger.warning('Failed to add uploaded asset $remoteId to album $albumId', error, stack);
|
||||
}),
|
||||
.then<void>((added) => addedCount += added)
|
||||
.onError(
|
||||
(error, stack) =>
|
||||
_logger.warning('Failed to add uploaded asset $remoteId to album $albumId', error, stack),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await _uploadService.uploadManual(localAssets, callbacks: wrappedCallbacks);
|
||||
await _uploadService.uploadManual(localAssets, cancelToken: null, callbacks: wrappedCallbacks);
|
||||
await Future.wait(pendingAdds);
|
||||
return addedCount;
|
||||
}
|
||||
|
||||
void _runUploadCallback(String message, void Function() callback) {
|
||||
try {
|
||||
callback();
|
||||
} catch (error, stack) {
|
||||
_logger.warning(message, error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: this is a poorly designed flow; adding a "stub" just to satisfy FK constraints is hacky,
|
||||
// it goes out of its way to insert one at a time, and it swallows errors that should be surfaced to the user.
|
||||
/// Links a freshly-uploaded asset to an album, ensuring the local DB
|
||||
/// reflects the change without waiting for the next sync. We call the API
|
||||
/// (server is the source of truth), then upsert a placeholder
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
@@ -5,66 +6,54 @@ import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
typedef OnProgress = void Function(String id, double progress);
|
||||
|
||||
class StorageRepository {
|
||||
final log = Logger('StorageRepository');
|
||||
static final log = Logger('StorageRepository');
|
||||
|
||||
StorageRepository();
|
||||
const StorageRepository();
|
||||
|
||||
Future<File?> getFileForAsset(String assetId) async {
|
||||
File? file;
|
||||
final log = Logger('StorageRepository');
|
||||
|
||||
try {
|
||||
final entity = await AssetEntity.fromId(assetId);
|
||||
file = await entity?.originFile;
|
||||
if (file == null) {
|
||||
log.warning("Cannot get file for asset $assetId");
|
||||
return null;
|
||||
}
|
||||
|
||||
final exists = await file.exists();
|
||||
if (!exists) {
|
||||
log.warning("File for asset $assetId does not exist");
|
||||
return null;
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
log.warning("Error getting file for asset $assetId", error, stackTrace);
|
||||
}
|
||||
return file;
|
||||
Future<File?> getAssetFile(String assetId, {OnProgress? onProgress, Completer<void>? cancelToken}) {
|
||||
return _getFileForAsset(assetId, isMotion: false, onProgress: onProgress, cancelToken: cancelToken);
|
||||
}
|
||||
|
||||
Future<File?> getMotionFileForAsset(LocalAsset asset) async {
|
||||
File? file;
|
||||
final log = Logger('StorageRepository');
|
||||
Future<File?> getMotionFile(String assetId, {OnProgress? onProgress, Completer<void>? cancelToken}) {
|
||||
return _getFileForAsset(assetId, isMotion: true, onProgress: onProgress, cancelToken: cancelToken);
|
||||
}
|
||||
|
||||
Future<File?> _getFileForAsset(
|
||||
String assetId, {
|
||||
bool isMotion = false,
|
||||
OnProgress? onProgress,
|
||||
Completer<void>? cancelToken,
|
||||
}) async {
|
||||
final entity = await AssetEntity.fromId(assetId);
|
||||
if (entity == null) {
|
||||
log.warning("Cannot get AssetEntity for asset $assetId");
|
||||
return null;
|
||||
}
|
||||
|
||||
PMProgressHandler? progressHandler;
|
||||
StreamSubscription<PMProgressState>? progressSubscription;
|
||||
PMCancelToken? pmCancelToken;
|
||||
if (cancelToken != null) {
|
||||
progressHandler = PMProgressHandler();
|
||||
progressSubscription = progressHandler.stream.listen((event) => onProgress?.call(assetId, event.progress));
|
||||
pmCancelToken = PMCancelToken();
|
||||
unawaited(cancelToken.future.then((_) => pmCancelToken!.cancelRequest()));
|
||||
}
|
||||
|
||||
try {
|
||||
final entity = await AssetEntity.fromId(asset.id);
|
||||
file = await entity?.originFileWithSubtype;
|
||||
if (file == null) {
|
||||
log.warning(
|
||||
"Cannot get motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
final exists = await file.exists();
|
||||
if (!exists) {
|
||||
log.warning("Motion file for asset ${asset.id} does not exist");
|
||||
return null;
|
||||
}
|
||||
return await entity.loadFile(withSubtype: isMotion, progressHandler: progressHandler, cancelToken: pmCancelToken);
|
||||
} catch (error, stackTrace) {
|
||||
log.warning(
|
||||
"Error getting motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
|
||||
error,
|
||||
stackTrace,
|
||||
);
|
||||
log.warning("Error loading file for asset $assetId", error, stackTrace);
|
||||
return null;
|
||||
} finally {
|
||||
unawaited(progressSubscription?.cancel());
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
Future<AssetEntity?> getAssetEntityForAsset(LocalAsset asset) async {
|
||||
final log = Logger('StorageRepository');
|
||||
|
||||
AssetEntity? entity;
|
||||
|
||||
try {
|
||||
@@ -99,39 +88,7 @@ class StorageRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<File?> loadFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async {
|
||||
try {
|
||||
final entity = await AssetEntity.fromId(assetId);
|
||||
if (entity == null) {
|
||||
log.warning("Cannot get AssetEntity for asset $assetId");
|
||||
return null;
|
||||
}
|
||||
|
||||
return await entity.loadFile(progressHandler: progressHandler);
|
||||
} catch (error, stackTrace) {
|
||||
log.warning("Error loading file from cloud for asset $assetId", error, stackTrace);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<File?> loadMotionFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async {
|
||||
try {
|
||||
final entity = await AssetEntity.fromId(assetId);
|
||||
if (entity == null) {
|
||||
log.warning("Cannot get AssetEntity for asset $assetId");
|
||||
return null;
|
||||
}
|
||||
|
||||
return await entity.loadFile(withSubtype: true, progressHandler: progressHandler);
|
||||
} catch (error, stackTrace) {
|
||||
log.warning("Error loading motion file from cloud for asset $assetId", error, stackTrace);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearCache() async {
|
||||
final log = Logger('StorageRepository');
|
||||
|
||||
try {
|
||||
await PhotoManager.clearFileCache();
|
||||
} catch (error, stackTrace) {
|
||||
|
||||
@@ -6,13 +6,13 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:native_video_player/native_video_player.dart';
|
||||
@@ -108,7 +108,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||
try {
|
||||
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
|
||||
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
|
||||
final file = await StorageRepository().getFileForAsset(id);
|
||||
final file = await ref.read(storageRepositoryProvider).getAssetFile(id);
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
@@ -273,7 +274,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
onProgress: _handleForegroundBackupProgress,
|
||||
onSuccess: _handleForegroundBackupSuccess,
|
||||
onError: _handleForegroundBackupError,
|
||||
onICloudProgress: _handleICloudProgress,
|
||||
onICloudProgress: CurrentPlatform.isIOS ? _handleICloudProgress : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -282,7 +283,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
_cancelToken?.complete();
|
||||
_cancelToken = null;
|
||||
_uploadSpeedManager.clear();
|
||||
state = state.copyWith(uploadItems: {}, iCloudDownloadProgress: {});
|
||||
state = state.copyWith(uploadItems: const {}, iCloudDownloadProgress: const {});
|
||||
}
|
||||
|
||||
void _handleICloudProgress(String localAssetId, double progress) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
|
||||
final storageRepositoryProvider = Provider<StorageRepository>((ref) => StorageRepository());
|
||||
final storageRepositoryProvider = Provider<StorageRepository>((ref) => const StorageRepository());
|
||||
|
||||
@@ -10,7 +10,6 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
|
||||
final uploadRepositoryProvider = Provider((ref) => UploadRepository());
|
||||
|
||||
@@ -20,21 +19,14 @@ class UploadRepository {
|
||||
void Function(TaskProgressUpdate)? onTaskProgress;
|
||||
|
||||
UploadRepository() {
|
||||
FileDownloader().registerCallbacks(
|
||||
group: kBackupGroup,
|
||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||
taskProgressCallback: (update) => onTaskProgress?.call(update),
|
||||
);
|
||||
FileDownloader().registerCallbacks(
|
||||
group: kBackupLivePhotoGroup,
|
||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||
taskProgressCallback: (update) => onTaskProgress?.call(update),
|
||||
);
|
||||
FileDownloader().registerCallbacks(
|
||||
group: kManualUploadGroup,
|
||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||
taskProgressCallback: (update) => onTaskProgress?.call(update),
|
||||
);
|
||||
final downloader = FileDownloader();
|
||||
for (final group in const [kBackupGroup, kBackupLivePhotoGroup, kManualUploadGroup]) {
|
||||
downloader.registerCallbacks(
|
||||
group: group,
|
||||
taskStatusCallback: onUploadStatus,
|
||||
taskProgressCallback: onTaskProgress,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> enqueueBackground(UploadTask task) {
|
||||
@@ -66,28 +58,6 @@ class UploadRepository {
|
||||
return FileDownloader().start();
|
||||
}
|
||||
|
||||
Future<void> getUploadInfo() async {
|
||||
final [enqueuedTasks, runningTasks, canceledTasks, waitingTasks, pausedTasks] = await Future.wait([
|
||||
FileDownloader().database.allRecordsWithStatus(TaskStatus.enqueued, group: kBackupGroup),
|
||||
FileDownloader().database.allRecordsWithStatus(TaskStatus.running, group: kBackupGroup),
|
||||
FileDownloader().database.allRecordsWithStatus(TaskStatus.canceled, group: kBackupGroup),
|
||||
FileDownloader().database.allRecordsWithStatus(TaskStatus.waitingToRetry, group: kBackupGroup),
|
||||
FileDownloader().database.allRecordsWithStatus(TaskStatus.paused, group: kBackupGroup),
|
||||
]);
|
||||
|
||||
dPrint(
|
||||
() =>
|
||||
"""
|
||||
Upload Info:
|
||||
Enqueued: ${enqueuedTasks.length}
|
||||
Running: ${runningTasks.length}
|
||||
Canceled: ${canceledTasks.length}
|
||||
Waiting: ${waitingTasks.length}
|
||||
Paused: ${pausedTasks.length}
|
||||
""",
|
||||
);
|
||||
}
|
||||
|
||||
Future<UploadResult> uploadFile({
|
||||
required File file,
|
||||
required String originalFileName,
|
||||
@@ -111,41 +81,30 @@ class UploadRepository {
|
||||
baseRequest.fields.addAll(fields);
|
||||
baseRequest.files.add(assetRawUploadData);
|
||||
|
||||
final response = await NetworkRepository.client.send(baseRequest);
|
||||
final responseBodyString = await response.stream.bytesToString();
|
||||
final StreamedResponse(:statusCode, :stream) = await NetworkRepository.client.send(baseRequest);
|
||||
final responseBodyString = await stream.bytesToString();
|
||||
|
||||
if (![200, 201].contains(response.statusCode)) {
|
||||
String? errorMessage;
|
||||
|
||||
if (response.statusCode == 413) {
|
||||
errorMessage = 'Error(413) File is too large to upload';
|
||||
return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage);
|
||||
}
|
||||
|
||||
try {
|
||||
final error = jsonDecode(responseBodyString);
|
||||
errorMessage = error['message'] ?? error['error'];
|
||||
} catch (_) {
|
||||
errorMessage = responseBodyString.isNotEmpty
|
||||
? responseBodyString
|
||||
: 'Upload failed with status ${response.statusCode}';
|
||||
}
|
||||
|
||||
return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage);
|
||||
}
|
||||
|
||||
try {
|
||||
final responseBody = jsonDecode(responseBodyString);
|
||||
return UploadResult.success(remoteAssetId: responseBody['id'] as String);
|
||||
} catch (e) {
|
||||
return UploadResult.error(errorMessage: 'Failed to parse server response');
|
||||
}
|
||||
return switch ((statusCode, _tryJsonDecode(responseBodyString))) {
|
||||
(200 || 201, {'id': String id}) => UploadSuccess(remoteAssetId: id),
|
||||
(413, _) => const UploadError(statusCode: 413, message: 'File is too large to upload'),
|
||||
(_, {'message': String message}) => UploadError(statusCode: statusCode, message: message),
|
||||
_ => UploadError(statusCode: statusCode, message: 'Upload failed with status $statusCode'),
|
||||
};
|
||||
} on RequestAbortedException {
|
||||
logger.warning("Upload $logContext was cancelled");
|
||||
return UploadResult.cancelled();
|
||||
return const UploadCancelled();
|
||||
} catch (error, stackTrace) {
|
||||
logger.warning("Error uploading $logContext: ${error.toString()}: $stackTrace");
|
||||
return UploadResult.error(errorMessage: error.toString());
|
||||
return UploadError(message: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
Map? _tryJsonDecode(String s) {
|
||||
try {
|
||||
return (jsonDecode(s) as Map);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,30 +139,23 @@ class ProgressMultipartRequest extends MultipartRequest with Abortable {
|
||||
}
|
||||
}
|
||||
|
||||
class UploadResult {
|
||||
final bool isSuccess;
|
||||
final bool isCancelled;
|
||||
final String? remoteAssetId;
|
||||
final String? errorMessage;
|
||||
sealed class UploadResult {
|
||||
const UploadResult();
|
||||
}
|
||||
|
||||
final class UploadSuccess extends UploadResult {
|
||||
final String remoteAssetId;
|
||||
|
||||
const UploadSuccess({required this.remoteAssetId});
|
||||
}
|
||||
|
||||
final class UploadError extends UploadResult {
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
|
||||
const UploadResult({
|
||||
required this.isSuccess,
|
||||
required this.isCancelled,
|
||||
this.remoteAssetId,
|
||||
this.errorMessage,
|
||||
this.statusCode,
|
||||
});
|
||||
|
||||
factory UploadResult.success({required String remoteAssetId}) {
|
||||
return UploadResult(isSuccess: true, isCancelled: false, remoteAssetId: remoteAssetId);
|
||||
}
|
||||
|
||||
factory UploadResult.error({String? errorMessage, int? statusCode}) {
|
||||
return UploadResult(isSuccess: false, isCancelled: false, errorMessage: errorMessage, statusCode: statusCode);
|
||||
}
|
||||
|
||||
factory UploadResult.cancelled() {
|
||||
return const UploadResult(isSuccess: false, isCancelled: true);
|
||||
}
|
||||
const UploadError({required this.message, this.statusCode});
|
||||
}
|
||||
|
||||
final class UploadCancelled extends UploadResult {
|
||||
const UploadCancelled();
|
||||
}
|
||||
|
||||
@@ -266,8 +266,6 @@ class BackgroundUploadService {
|
||||
return null;
|
||||
}
|
||||
|
||||
File? file;
|
||||
|
||||
/// iOS LivePhoto has two files: a photo and a video.
|
||||
/// They are uploaded separately, with video file being upload first, then returned with the assetId
|
||||
/// The assetId is then used as a metadata for the photo file upload task.
|
||||
@@ -278,11 +276,9 @@ class BackgroundUploadService {
|
||||
/// The cancel operation will only cancel the video group (normal group), the photo group will not
|
||||
/// be touched, as the video file is already uploaded.
|
||||
|
||||
if (entity.isLivePhoto) {
|
||||
file = await _storageRepository.getMotionFileForAsset(asset);
|
||||
} else {
|
||||
file = await _storageRepository.getFileForAsset(asset.id);
|
||||
}
|
||||
final file = await (entity.isLivePhoto
|
||||
? _storageRepository.getMotionFile(asset.id)
|
||||
: _storageRepository.getAssetFile(asset.id));
|
||||
|
||||
if (file == null) {
|
||||
_logger.warning("Failed to get file for asset ${asset.id} - ${asset.name}");
|
||||
@@ -330,7 +326,7 @@ class BackgroundUploadService {
|
||||
return null;
|
||||
}
|
||||
|
||||
final file = await _storageRepository.getFileForAsset(asset.id);
|
||||
final file = await _storageRepository.getAssetFile(asset.id);
|
||||
if (file == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||
|
||||
/// Callbacks for upload progress and status updates
|
||||
class UploadCallbacks {
|
||||
@@ -99,7 +98,7 @@ class ForegroundUploadService {
|
||||
final requireWifi = _shouldRequireWiFi(asset);
|
||||
return requireWifi && !hasWifi;
|
||||
},
|
||||
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
|
||||
processItem: (asset) => _uploadSingleAsset(asset, cancelToken: cancelToken, callbacks: callbacks),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -125,14 +124,14 @@ class ForegroundUploadService {
|
||||
continue;
|
||||
}
|
||||
|
||||
await _uploadSingleAsset(asset, cancelToken, callbacks: callbacks);
|
||||
await _uploadSingleAsset(asset, cancelToken: cancelToken, callbacks: callbacks);
|
||||
}
|
||||
}
|
||||
|
||||
/// Manually upload picked local assets
|
||||
Future<void> uploadManual(
|
||||
List<LocalAsset> localAssets, {
|
||||
Completer<void>? cancelToken,
|
||||
required Completer<void>? cancelToken,
|
||||
UploadCallbacks callbacks = const UploadCallbacks(),
|
||||
}) async {
|
||||
if (localAssets.isEmpty) {
|
||||
@@ -142,7 +141,7 @@ class ForegroundUploadService {
|
||||
await _executeWithWorkerPool<LocalAsset>(
|
||||
items: localAssets,
|
||||
cancelToken: cancelToken,
|
||||
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
|
||||
processItem: (asset) => _uploadSingleAsset(asset, cancelToken: cancelToken, callbacks: callbacks),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -170,11 +169,11 @@ class ForegroundUploadService {
|
||||
onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes),
|
||||
);
|
||||
|
||||
if (result.isSuccess) {
|
||||
onSuccess?.call(fileId);
|
||||
} else if (!result.isCancelled && result.errorMessage != null) {
|
||||
onError?.call(fileId, result.errorMessage!);
|
||||
}
|
||||
return switch (result) {
|
||||
UploadSuccess() => onSuccess?.call(fileId),
|
||||
UploadError(:final message) => onError?.call(fileId, message),
|
||||
UploadCancelled() => null,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -216,7 +215,7 @@ class ForegroundUploadService {
|
||||
|
||||
final item = items[index];
|
||||
|
||||
if (shouldSkip?.call(item) ?? false) {
|
||||
if (shouldSkip != null && shouldSkip(item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -233,78 +232,48 @@ class ForegroundUploadService {
|
||||
}
|
||||
|
||||
Future<void> _uploadSingleAsset(
|
||||
LocalAsset asset,
|
||||
Completer<void>? cancelToken, {
|
||||
LocalAsset asset, {
|
||||
required Completer<void>? cancelToken,
|
||||
required UploadCallbacks callbacks,
|
||||
}) async {
|
||||
File? file;
|
||||
final UploadCallbacks(:onProgress, :onSuccess, :onError, :onICloudProgress) = callbacks;
|
||||
File? assetFile;
|
||||
File? livePhotoFile;
|
||||
|
||||
try {
|
||||
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
||||
if (entity == null) {
|
||||
callbacks.onError?.call(
|
||||
onError?.call(
|
||||
asset.localId!,
|
||||
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id);
|
||||
|
||||
if (!isAvailableLocally && CurrentPlatform.isIOS) {
|
||||
_logger.info("Loading iCloud asset ${asset.id} - ${asset.name}");
|
||||
|
||||
// Create progress handler for iCloud download
|
||||
PMProgressHandler? progressHandler;
|
||||
StreamSubscription? progressSubscription;
|
||||
|
||||
progressHandler = PMProgressHandler();
|
||||
progressSubscription = progressHandler.stream.listen((event) {
|
||||
callbacks.onICloudProgress?.call(asset.localId!, event.progress);
|
||||
});
|
||||
|
||||
try {
|
||||
file = await _storageRepository.loadFileFromCloud(asset.id, progressHandler: progressHandler);
|
||||
if (entity.isLivePhoto) {
|
||||
livePhotoFile = await _storageRepository.loadMotionFileFromCloud(
|
||||
asset.id,
|
||||
progressHandler: progressHandler,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await progressSubscription.cancel();
|
||||
}
|
||||
} else {
|
||||
// Get files locally
|
||||
file = await _storageRepository.getFileForAsset(asset.id);
|
||||
File? file;
|
||||
if (entity.isLivePhoto) {
|
||||
file = await _storageRepository.getMotionFile(asset.id, cancelToken: cancelToken, onProgress: onICloudProgress);
|
||||
if (file == null) {
|
||||
_logger.warning("Failed to get file ${asset.id} - ${asset.name}");
|
||||
callbacks.onError?.call(
|
||||
_logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}");
|
||||
onError?.call(
|
||||
asset.localId!,
|
||||
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// For live photos, get the motion video file
|
||||
if (entity.isLivePhoto) {
|
||||
livePhotoFile = await _storageRepository.getMotionFileForAsset(asset);
|
||||
if (livePhotoFile == null) {
|
||||
_logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}");
|
||||
callbacks.onError?.call(
|
||||
asset.localId!,
|
||||
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
|
||||
);
|
||||
}
|
||||
}
|
||||
livePhotoFile = file;
|
||||
}
|
||||
|
||||
file = await _storageRepository.getAssetFile(asset.id, cancelToken: cancelToken, onProgress: onICloudProgress);
|
||||
if (file == null) {
|
||||
_logger.warning("Failed to obtain file from iCloud for asset ${asset.id} - ${asset.name}");
|
||||
callbacks.onError?.call(asset.localId!, "asset_not_found_on_icloud".t());
|
||||
_logger.warning("Failed to get file ${asset.id} - ${asset.name}");
|
||||
onError?.call(
|
||||
asset.localId!,
|
||||
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
assetFile = file;
|
||||
|
||||
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
||||
|
||||
@@ -330,11 +299,9 @@ class ForegroundUploadService {
|
||||
};
|
||||
|
||||
// Upload live photo video first if available
|
||||
String? livePhotoVideoId;
|
||||
if (entity.isLivePhoto && livePhotoFile != null) {
|
||||
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path));
|
||||
|
||||
final onProgress = callbacks.onProgress;
|
||||
final livePhotoResult = await _uploadRepository.uploadFile(
|
||||
file: livePhotoFile,
|
||||
originalFileName: livePhotoTitle,
|
||||
@@ -346,15 +313,16 @@ class ForegroundUploadService {
|
||||
logContext: 'livePhotoVideo[${asset.localId}]',
|
||||
);
|
||||
|
||||
if (livePhotoResult.isSuccess && livePhotoResult.remoteAssetId != null) {
|
||||
livePhotoVideoId = livePhotoResult.remoteAssetId;
|
||||
switch (livePhotoResult) {
|
||||
case UploadSuccess(:final remoteAssetId):
|
||||
fields['livePhotoVideoId'] = remoteAssetId;
|
||||
case UploadError(:final message):
|
||||
onError?.call(asset.localId!, "Failed to upload live photo video: $message");
|
||||
return;
|
||||
case UploadCancelled():
|
||||
}
|
||||
}
|
||||
|
||||
if (livePhotoVideoId != null) {
|
||||
fields['livePhotoVideoId'] = livePhotoVideoId;
|
||||
}
|
||||
|
||||
// Add cloudId metadata only to the still image, not the motion video, becasue when the sync id happens, the motion video can get associated with the wrong still image.
|
||||
if (CurrentPlatform.isIOS && asset.cloudId != null) {
|
||||
fields['metadata'] = jsonEncode([
|
||||
@@ -371,7 +339,6 @@ class ForegroundUploadService {
|
||||
]);
|
||||
}
|
||||
|
||||
final onProgress = callbacks.onProgress;
|
||||
final result = await _uploadRepository.uploadFile(
|
||||
file: file,
|
||||
originalFileName: originalFileName,
|
||||
@@ -383,34 +350,33 @@ class ForegroundUploadService {
|
||||
logContext: 'asset[${asset.localId}]',
|
||||
);
|
||||
|
||||
if (result.isSuccess && result.remoteAssetId != null) {
|
||||
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
|
||||
} else if (result.isCancelled) {
|
||||
_logger.warning(() => "Backup was cancelled by the user");
|
||||
shouldAbortUpload = true;
|
||||
} else if (result.errorMessage != null) {
|
||||
_logger.severe(
|
||||
() =>
|
||||
"Error(${result.statusCode}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt} | ${result.errorMessage}",
|
||||
);
|
||||
|
||||
callbacks.onError?.call(asset.localId!, result.errorMessage!);
|
||||
|
||||
if (result.errorMessage == "Quota has been exceeded!") {
|
||||
switch (result) {
|
||||
case UploadSuccess(:final remoteAssetId):
|
||||
onSuccess?.call(asset.localId!, remoteAssetId);
|
||||
case UploadCancelled():
|
||||
shouldAbortUpload = true;
|
||||
}
|
||||
_logger.warning("Upload was cancelled by the user for asset ${asset.localId}");
|
||||
case UploadError(:final message, :final statusCode):
|
||||
shouldAbortUpload |= message == "Quota has been exceeded!";
|
||||
_logger.severe(
|
||||
"Error(${statusCode ?? 'unknown'}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt} | $message",
|
||||
);
|
||||
onError?.call(asset.localId!, message);
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
_logger.severe(() => "Error backup asset: ${error.toString()}", stackTrace);
|
||||
callbacks.onError?.call(asset.localId!, error.toString());
|
||||
_logger.severe("Asset backup failed", error, stackTrace);
|
||||
onError?.call(asset.localId!, error.toString());
|
||||
} finally {
|
||||
if (Platform.isIOS) {
|
||||
try {
|
||||
await file?.delete();
|
||||
await livePhotoFile?.delete();
|
||||
} catch (error, stackTrace) {
|
||||
_logger.severe(() => "ERROR deleting file: ${error.toString()}", stackTrace);
|
||||
}
|
||||
unawaited(
|
||||
Future.wait([
|
||||
if (assetFile != null) assetFile.delete(),
|
||||
if (livePhotoFile != null) livePhotoFile.delete(),
|
||||
]).onError((error, stackTrace) {
|
||||
_logger.severe("Post-upload file cleanup failed", error, stackTrace);
|
||||
return const [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -446,7 +412,7 @@ class ForegroundUploadService {
|
||||
logContext: 'shareIntent[$deviceAssetId]',
|
||||
);
|
||||
} catch (e) {
|
||||
return UploadResult.error(errorMessage: e.toString());
|
||||
return UploadError(message: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ void main() {
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'OriginalPhoto.jpg');
|
||||
|
||||
final task = await sut.getUploadTask(asset);
|
||||
@@ -92,7 +92,7 @@ void main() {
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null);
|
||||
|
||||
final task = await sut.getUploadTask(asset);
|
||||
@@ -109,7 +109,7 @@ void main() {
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(true);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getMotionFileForAsset(asset)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockStorageRepository.getMotionFile(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(
|
||||
() => mockAssetMediaRepository.getOriginalFilename(asset.id),
|
||||
).thenAnswer((_) async => 'OriginalLivePhoto.HEIC');
|
||||
@@ -130,7 +130,7 @@ void main() {
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(true);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(
|
||||
() => mockAssetMediaRepository.getOriginalFilename(asset.id),
|
||||
).thenAnswer((_) async => 'OriginalLivePhoto.HEIC');
|
||||
@@ -150,7 +150,7 @@ void main() {
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(true);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null);
|
||||
|
||||
final task = await sut.getLivePhotoUploadTask(asset, 'video-id-456');
|
||||
@@ -194,7 +194,7 @@ void main() {
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockStorageRepository.getAssetFile(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
|
||||
|
||||
final task = await sutWithV24.getUploadTask(assetWithCloudId);
|
||||
@@ -243,7 +243,7 @@ void main() {
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockStorageRepository.getAssetFile(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
|
||||
|
||||
final task = await sutAndroid.getUploadTask(assetWithCloudId);
|
||||
@@ -281,7 +281,7 @@ void main() {
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithoutCloudId)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(assetWithoutCloudId.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockStorageRepository.getAssetFile(assetWithoutCloudId.id)).thenAnswer((_) async => mockFile);
|
||||
when(
|
||||
() => mockAssetMediaRepository.getOriginalFilename(assetWithoutCloudId.id),
|
||||
).thenAnswer((_) async => 'test.jpg');
|
||||
@@ -323,7 +323,7 @@ void main() {
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(true);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockStorageRepository.getAssetFile(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
|
||||
when(
|
||||
() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id),
|
||||
).thenAnswer((_) async => 'livephoto.heic');
|
||||
|
||||
@@ -75,7 +75,7 @@ export class SearchService extends BaseService {
|
||||
|
||||
const page = dto.page ?? 1;
|
||||
const size = dto.size || 250;
|
||||
const userIds = await this.getUserIdsToSearch(auth);
|
||||
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
|
||||
const { hasNextPage, items } = await this.searchRepository.searchMetadata(
|
||||
{ page, size },
|
||||
{
|
||||
@@ -103,7 +103,7 @@ export class SearchService extends BaseService {
|
||||
requireElevatedPermission(auth);
|
||||
}
|
||||
|
||||
const userIds = await this.getUserIdsToSearch(auth);
|
||||
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
|
||||
const items = await this.searchRepository.searchRandom(dto.size || 250, { ...dto, userIds });
|
||||
return items.map((item) => mapAsset(item, { auth }));
|
||||
}
|
||||
@@ -113,7 +113,7 @@ export class SearchService extends BaseService {
|
||||
requireElevatedPermission(auth);
|
||||
}
|
||||
|
||||
const userIds = await this.getUserIdsToSearch(auth);
|
||||
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
|
||||
const items = await this.searchRepository.searchLargeAssets(dto.size || 250, { ...dto, userIds });
|
||||
return items.map((item) => mapAsset(item, { auth }));
|
||||
}
|
||||
@@ -128,7 +128,7 @@ export class SearchService extends BaseService {
|
||||
throw new BadRequestException('Smart search is not enabled');
|
||||
}
|
||||
|
||||
const userIds = this.getUserIdsToSearch(auth);
|
||||
const userIds = this.getUserIdsToSearch(auth, dto.visibility);
|
||||
let embedding;
|
||||
if (dto.query) {
|
||||
const key = machineLearning.clip.modelName + dto.query + dto.language;
|
||||
@@ -202,7 +202,11 @@ export class SearchService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private async getUserIdsToSearch(auth: AuthDto): Promise<string[]> {
|
||||
private async getUserIdsToSearch(auth: AuthDto, visibility?: AssetVisibility): Promise<string[]> {
|
||||
// Locked assets are personal. Never include partner IDs, regardless of A's elevated session.
|
||||
if (visibility === AssetVisibility.Locked) {
|
||||
return [auth.user.id];
|
||||
}
|
||||
const partnerIds = await getMyPartnerIds({
|
||||
userId: auth.user.id,
|
||||
repository: this.partnerRepository,
|
||||
|
||||
@@ -204,5 +204,16 @@ describe(TimelineService.name, () => {
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw an error if withPartners is true and visibility is locked', async () => {
|
||||
await expect(
|
||||
sut.getTimeBucket(authStub.adminWithElevatedPermission, {
|
||||
timeBucket: 'bucket',
|
||||
visibility: AssetVisibility.Locked,
|
||||
withPartners: true,
|
||||
userId: authStub.adminWithElevatedPermission.user.id,
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,13 +71,14 @@ export class TimelineService extends BaseService {
|
||||
}
|
||||
|
||||
if (dto.withPartners) {
|
||||
const requestedLocked = dto.visibility === AssetVisibility.Locked;
|
||||
const requestedArchived = dto.visibility === AssetVisibility.Archive || dto.visibility === undefined;
|
||||
const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false;
|
||||
const requestedTrash = dto.isTrashed === true;
|
||||
|
||||
if (requestedArchived || requestedFavorite || requestedTrash) {
|
||||
if (requestedLocked || requestedArchived || requestedFavorite || requestedTrash) {
|
||||
throw new BadRequestException(
|
||||
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
|
||||
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+7
@@ -41,6 +41,13 @@ export const authStub = {
|
||||
id: 'token-id',
|
||||
} as AuthSession,
|
||||
}),
|
||||
adminWithElevatedPermission: Object.freeze<AuthDto>({
|
||||
user: authUser.admin,
|
||||
session: {
|
||||
id: 'token-id-elevated',
|
||||
hasElevatedPermission: true,
|
||||
} as AuthSession,
|
||||
}),
|
||||
adminSharedLink: Object.freeze({
|
||||
user: authUser.admin,
|
||||
sharedLink: {
|
||||
|
||||
@@ -51,13 +51,13 @@ describe(TimelineService.name, () => {
|
||||
const response1 = sut.getTimeBuckets(auth, { withPartners: true, visibility: AssetVisibility.Archive });
|
||||
await expect(response1).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(response1).rejects.toThrow(
|
||||
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
|
||||
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
|
||||
);
|
||||
|
||||
const response2 = sut.getTimeBuckets(auth, { withPartners: true });
|
||||
await expect(response2).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(response2).rejects.toThrow(
|
||||
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
|
||||
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -67,13 +67,13 @@ describe(TimelineService.name, () => {
|
||||
const response1 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: false });
|
||||
await expect(response1).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(response1).rejects.toThrow(
|
||||
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
|
||||
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
|
||||
);
|
||||
|
||||
const response2 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: true });
|
||||
await expect(response2).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(response2).rejects.toThrow(
|
||||
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
|
||||
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ describe(TimelineService.name, () => {
|
||||
const response = sut.getTimeBuckets(auth, { withPartners: true, isTrashed: true });
|
||||
await expect(response).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(response).rejects.toThrow(
|
||||
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
|
||||
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -67,11 +67,3 @@ export function calculateViewerAssetViewportProximity(
|
||||
timelineManager.visibleWindow.bottom + headerHeight,
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateViewerAssetIsInOrNearViewport(
|
||||
timelineManager: TimelineManager,
|
||||
positionTop: number,
|
||||
positionHeight: number,
|
||||
) {
|
||||
return isInOrNearViewport(calculateViewerAssetViewportProximity(timelineManager, positionTop, positionHeight));
|
||||
}
|
||||
|
||||
@@ -29,8 +29,7 @@ import type { AssetDescriptor, Direction, MoveAsset, TimelineAsset } from './typ
|
||||
import { ViewerAsset } from './viewer-asset.svelte';
|
||||
|
||||
export class TimelineMonth {
|
||||
#isInOrNearViewport = $state(false);
|
||||
#isInViewport = $state(false);
|
||||
#viewportProximity: ViewportProximity = $state(ViewportProximity.FarFromViewport);
|
||||
isLoaded: boolean = $state(false);
|
||||
timelineDays: TimelineDay[] = $state([]);
|
||||
readonly timelineManager: TimelineManager;
|
||||
@@ -86,28 +85,24 @@ export class TimelineMonth {
|
||||
}
|
||||
|
||||
set viewportProximity(newValue: ViewportProximity) {
|
||||
const isInOrNearViewport = isInOrNearViewportUtil(newValue);
|
||||
if (this.#isInOrNearViewport !== isInOrNearViewport) {
|
||||
this.#isInOrNearViewport = isInOrNearViewport;
|
||||
if (isInOrNearViewport) {
|
||||
void this.timelineManager.loadTimelineMonth(this.yearMonth);
|
||||
} else {
|
||||
this.cancel();
|
||||
}
|
||||
const old = this.#viewportProximity;
|
||||
if (old === newValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isInViewport = isInViewportUtil(newValue);
|
||||
if (this.#isInViewport !== isInViewport) {
|
||||
this.#isInViewport = isInViewport;
|
||||
this.#viewportProximity = newValue;
|
||||
if (isInOrNearViewportUtil(newValue)) {
|
||||
void this.timelineManager.loadTimelineMonth(this.yearMonth);
|
||||
} else {
|
||||
this.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
get isInOrNearViewport() {
|
||||
return this.#isInOrNearViewport;
|
||||
return isInOrNearViewportUtil(this.#viewportProximity);
|
||||
}
|
||||
|
||||
get isInViewport() {
|
||||
return this.#isInViewport;
|
||||
return isInViewportUtil(this.#viewportProximity);
|
||||
}
|
||||
|
||||
get lastTimelineDay() {
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||
import { calculateViewerAssetIsInOrNearViewport } from './internal/intersection-support.svelte';
|
||||
import {
|
||||
ViewportProximity,
|
||||
calculateViewerAssetViewportProximity,
|
||||
isInOrNearViewport,
|
||||
} from './internal/intersection-support.svelte';
|
||||
import type { TimelineDay } from './timeline-day.svelte';
|
||||
import type { TimelineAsset } from './types';
|
||||
|
||||
export class ViewerAsset {
|
||||
readonly #group: TimelineDay;
|
||||
|
||||
#isInOrNearViewport = $derived.by(() => {
|
||||
#viewportProximity = $derived.by(() => {
|
||||
if (!this.position) {
|
||||
return false;
|
||||
return ViewportProximity.FarFromViewport;
|
||||
}
|
||||
|
||||
const store = this.#group.timelineMonth.timelineManager;
|
||||
const positionTop = this.#group.absoluteTimelineDayTop + this.position.top;
|
||||
|
||||
return calculateViewerAssetIsInOrNearViewport(store, positionTop, this.position.height);
|
||||
return calculateViewerAssetViewportProximity(store, positionTop, this.position.height);
|
||||
});
|
||||
|
||||
get isInOrNearViewport() {
|
||||
return this.#isInOrNearViewport;
|
||||
return isInOrNearViewport(this.#viewportProximity);
|
||||
}
|
||||
|
||||
position: CommonPosition | undefined = $state.raw();
|
||||
|
||||
Reference in New Issue
Block a user