mirror of
https://github.com/immich-app/immich.git
synced 2026-06-13 03:21:45 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4423a8f8a4 | |||
| 77fd2ba919 | |||
| 1318dafdc4 |
@@ -1,39 +1,39 @@
|
||||
dev:
|
||||
@printf "This command has been removed. Please use:\n\n mise dev # or mise //:dev from another directory\n\n"\n\n >&2 && exit 1
|
||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
|
||||
|
||||
dev-down:
|
||||
@printf "This command has been removed. Please use:\n\n mise dev-down # or mise //:dev-down from another directory\n\n"\n\n >&2 && exit 1
|
||||
docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans
|
||||
|
||||
dev-update:
|
||||
@printf "This command has been removed. Please use:\n\n mise dev-update # or mise //:dev-update from another directory\n\n"\n\n >&2 && exit 1
|
||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
|
||||
|
||||
dev-scale:
|
||||
@printf "This command has been removed. Please use:\n\n mise dev-scale # or mise //:dev-scale from another directory\n\n"\n\n >&2 && exit 1
|
||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
|
||||
|
||||
dev-docs:
|
||||
npm --prefix docs run start
|
||||
|
||||
.PHONY: e2e
|
||||
e2e:
|
||||
@printf "This command has been removed. Please use:\n\n mise e2e # or mise //:e2e from another directory\n\n"\n\n >&2 && exit 1
|
||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
|
||||
|
||||
e2e-dev:
|
||||
@printf "This command has been removed. Please use:\n\n mise e2e-dev # or mise //:e2e-dev from another directory\n\n"\n\n >&2 && exit 1
|
||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.dev.yml up --remove-orphans
|
||||
|
||||
e2e-update:
|
||||
@printf "This command has been removed. Please use:\n\n mise e2e-update # or mise //:e2e-update from another directory\n\n"\n\n >&2 && exit 1
|
||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
|
||||
|
||||
e2e-down:
|
||||
@printf "This command has been removed. Please use:\n\n mise e2e-down # or mise //:e2e-down from another directory\n\n"\n\n >&2 && exit 1
|
||||
docker compose -f ./e2e/docker-compose.yml down --remove-orphans
|
||||
|
||||
prod:
|
||||
@printf "This command has been removed. Please use:\n\n mise prod # or mise //:prod from another directory\n\n"\n\n >&2 && exit 1
|
||||
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
|
||||
|
||||
prod-down:
|
||||
@printf "This command has been removed. Please use:\n\n mise prod-down # or mise //:prod-down from another directory\n\n"\n\n >&2 && exit 1
|
||||
docker compose -f ./docker/docker-compose.prod.yml down --remove-orphans
|
||||
|
||||
prod-scale:
|
||||
@printf "This command has been removed. Please use:\n\n mise prod-scale # or mise //:prod-scale from another directory\n\n"\n\n >&2 && exit 1
|
||||
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
|
||||
|
||||
.PHONY: open-api
|
||||
open-api:
|
||||
|
||||
@@ -55,8 +55,8 @@ export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetRe
|
||||
result.duration.push(asset.duration);
|
||||
result.projectionType.push(asset.projectionType);
|
||||
result.livePhotoVideoId.push(asset.livePhotoVideoId);
|
||||
result.city?.push(asset.city);
|
||||
result.country?.push(asset.country);
|
||||
result.city.push(asset.city);
|
||||
result.country.push(asset.country);
|
||||
result.visibility.push(asset.visibility);
|
||||
}
|
||||
|
||||
|
||||
@@ -84,72 +84,6 @@ run = [
|
||||
dir = "server"
|
||||
run = "node ./dist/bin/sync-sql.js"
|
||||
|
||||
# TODO dev, prod, and e2e should be de-duplicated by using env but for some reason I ran into issues
|
||||
[tasks.dev]
|
||||
depends = "//:plugins"
|
||||
dir = "docker"
|
||||
interactive = true
|
||||
env = { COMPOSE_BAKE = true }
|
||||
run = "docker compose -f ./docker-compose.dev.yml up --remove-orphans"
|
||||
depends_post = "//:dev-down"
|
||||
|
||||
[tasks.dev-update]
|
||||
run = { task = "//:dev", args = ["--build", "-V"] }
|
||||
|
||||
[tasks.dev-scale]
|
||||
run = { task = "//:dev", args = ["--build", "-V", "--scale immich-server=3"] }
|
||||
|
||||
[tasks.dev-down]
|
||||
dir = "docker"
|
||||
run = "docker compose -f ./docker-compose.dev.yml down --remove-orphans"
|
||||
|
||||
[tasks.prod]
|
||||
depends = "//:plugins"
|
||||
dir = "docker"
|
||||
interactive = true
|
||||
env = { COMPOSE_BAKE = true }
|
||||
run = "docker compose -f ./docker-compose.prod.yml up --remove-orphans"
|
||||
depends_post = "//:prod-down"
|
||||
|
||||
[tasks.prod-scale]
|
||||
run = { task = "//:prod", args = [
|
||||
"--build",
|
||||
"-V",
|
||||
"--scale immich-server=3",
|
||||
"--scale immich-microservices",
|
||||
] }
|
||||
|
||||
[tasks.prod-down]
|
||||
dir = "docker"
|
||||
run = "docker compose -f ./docker-compose.prod.yml down --remove-orphans"
|
||||
|
||||
[tasks.e2e]
|
||||
depends = "//:plugins"
|
||||
dir = "e2e"
|
||||
interactive = true
|
||||
env = { COMPOSE_BAKE = true }
|
||||
run = "docker compose -f ./docker-compose.yml up --remove-orphans"
|
||||
depends_post = "//:e2e-down"
|
||||
|
||||
[tasks.e2e-dev]
|
||||
depends = "//:plugins"
|
||||
dir = "e2e"
|
||||
interactive = true
|
||||
env = { COMPOSE_BAKE = true }
|
||||
run = "docker compose -f ./docker-compose.dev.yml up --remove-orphans"
|
||||
depends_post = "//:e2e-dev-down"
|
||||
|
||||
[tasks.e2e-update]
|
||||
run = { task = "//:e2e", args = ["--build", '-V'] }
|
||||
|
||||
[tasks.e2e-down]
|
||||
dir = "e2e"
|
||||
run = "docker compose -f ./docker-compose.yml down --remove-orphans"
|
||||
|
||||
[tasks.e2e-dev-down]
|
||||
dir = "e2e"
|
||||
run = "docker compose -f ./docker-compose.dev.yml down --remove-orphans"
|
||||
|
||||
# SDK tasks
|
||||
[tasks."sdk:install"]
|
||||
dir = "packages/sdk"
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -30,11 +30,14 @@
|
||||
|
||||
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
|
||||
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||
|
||||
const firstInOrNearViewport = $derived(viewerAssets.findIndex((a) => a.isInOrNearViewport));
|
||||
const lastInOrNearViewport = $derived(viewerAssets.findLastIndex((a) => a.isInOrNearViewport));
|
||||
</script>
|
||||
|
||||
<!-- Image grid -->
|
||||
<div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}>
|
||||
{#each viewerAssets as viewerAsset (viewerAsset.id)}
|
||||
{#each viewerAssets.slice(firstInOrNearViewport, lastInOrNearViewport + 1) as viewerAsset (viewerAsset.id)}
|
||||
{@const position = viewerAsset.position!}
|
||||
{@const asset = viewerAsset.asset!}
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
|
||||
<AssetLayout
|
||||
{manager}
|
||||
viewerAssets={timelineDay.activeViewerAssets}
|
||||
viewerAssets={timelineDay.viewerAssets}
|
||||
height={timelineDay.height}
|
||||
width={timelineDay.width}
|
||||
{customThumbnailLayout}
|
||||
|
||||
@@ -53,3 +53,17 @@ export function updateTimelineMonthViewportProximity(timelineManager: TimelineMa
|
||||
timelineManager.clearDeferredLayout(month);
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateViewerAssetViewportProximity(
|
||||
timelineManager: TimelineManager,
|
||||
positionTop: number,
|
||||
positionHeight: number,
|
||||
) {
|
||||
const headerHeight = timelineManager.headerHeight;
|
||||
return calculateViewportProximity(
|
||||
positionTop,
|
||||
positionTop + positionHeight,
|
||||
timelineManager.visibleWindow.top - headerHeight,
|
||||
timelineManager.visibleWindow.bottom + headerHeight,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,12 @@
|
||||
import { AssetOrder, AssetOrderBy } from '@immich/sdk';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { CommonLayoutOptions, CommonPosition } from '$lib/utils/layout-utils';
|
||||
import type { CommonLayoutOptions } from '$lib/utils/layout-utils';
|
||||
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
|
||||
import { getOrderingDate, plainDateTimeCompare } from '$lib/utils/timeline-util';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import type { TimelineMonth } from './timeline-month.svelte';
|
||||
import type { Direction, MoveAsset, TimelineAsset } from './types';
|
||||
import { ViewerAsset } from './viewer-asset.svelte';
|
||||
|
||||
const {
|
||||
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
||||
} = TUNABLES;
|
||||
|
||||
function lowerBound(assets: ViewerAsset[], target: number, key: (pos: CommonPosition) => number): number {
|
||||
let lo = 0;
|
||||
let hi = assets.length;
|
||||
while (lo < hi) {
|
||||
const mid = Math.floor((lo + hi) / 2);
|
||||
if (key(assets[mid].position!) < target) {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
export class TimelineDay {
|
||||
readonly timelineMonth: TimelineMonth;
|
||||
readonly index: number;
|
||||
@@ -37,15 +18,12 @@ export class TimelineDay {
|
||||
height = $state(0);
|
||||
width = $state(0);
|
||||
|
||||
// Assets in or near the viewport; active assets should be added to the DOM.
|
||||
activeViewerAssets: ViewerAsset[] = $state([]);
|
||||
isInOrNearViewport = $state(false);
|
||||
|
||||
#top: number = $state(0);
|
||||
#start: number = $state(0);
|
||||
#row = $state(0);
|
||||
#col = $state(0);
|
||||
#deferredLayout = false;
|
||||
#lastInOrNearViewport = -1;
|
||||
|
||||
constructor(timelineMonth: TimelineMonth, index: number, day: number, groupTitle: string, orderBy: AssetOrderBy) {
|
||||
this.index = index;
|
||||
@@ -171,32 +149,18 @@ export class TimelineDay {
|
||||
for (let i = 0; i < this.viewerAssets.length; i++) {
|
||||
this.viewerAssets[i].position = geometry.getPosition(i);
|
||||
}
|
||||
this.updateAssetBoundaries();
|
||||
}
|
||||
|
||||
updateAssetBoundaries() {
|
||||
const manager = this.timelineMonth.timelineManager;
|
||||
const visibleWindow = manager.visibleWindow;
|
||||
if (this.viewerAssets.length === 0 || !this.viewerAssets[0].position) {
|
||||
this.activeViewerAssets = [];
|
||||
this.isInOrNearViewport = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const dayOffset = this.absoluteTimelineDayTop;
|
||||
const headerHeight = manager.headerHeight;
|
||||
const expandedTop = visibleWindow.top - headerHeight - INTERSECTION_EXPAND_TOP - dayOffset;
|
||||
const expandedBottom = visibleWindow.bottom + headerHeight + INTERSECTION_EXPAND_BOTTOM - dayOffset;
|
||||
|
||||
const first = lowerBound(this.viewerAssets, expandedTop, (p) => p.top + p.height);
|
||||
const last = lowerBound(this.viewerAssets, expandedBottom, (p) => p.top) - 1;
|
||||
|
||||
const hasActive = last >= first && first < this.viewerAssets.length;
|
||||
this.activeViewerAssets = hasActive ? this.viewerAssets.slice(first, last + 1) : [];
|
||||
this.isInOrNearViewport = hasActive;
|
||||
}
|
||||
|
||||
get absoluteTimelineDayTop() {
|
||||
return this.timelineMonth.top + this.#top;
|
||||
}
|
||||
|
||||
get isInOrNearViewport() {
|
||||
if (this.#lastInOrNearViewport !== -1 && this.viewerAssets[this.#lastInOrNearViewport].isInOrNearViewport) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.#lastInOrNearViewport = this.viewerAssets.findIndex((viewAsset) => viewAsset.isInOrNearViewport);
|
||||
return this.#lastInOrNearViewport !== -1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,11 +214,6 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
|
||||
for (const month of this.months) {
|
||||
updateTimelineMonthViewportProximity(this, month);
|
||||
if (month.isInOrNearViewport && month.isLoaded) {
|
||||
for (const day of month.timelineDays) {
|
||||
day.updateAssetBoundaries();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const month = this.months.find((month) => month.isInViewport);
|
||||
|
||||
@@ -254,7 +254,7 @@ export class TimelineMonth {
|
||||
addContext.newTimelineDays.add(timelineDay);
|
||||
}
|
||||
|
||||
const viewerAsset = new ViewerAsset(timelineAsset);
|
||||
const viewerAsset = new ViewerAsset(timelineDay, timelineAsset);
|
||||
timelineDay.viewerAssets.push(viewerAsset);
|
||||
addContext.changedTimelineDays.add(timelineDay);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,36 @@
|
||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||
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;
|
||||
|
||||
#viewportProximity = $derived.by(() => {
|
||||
if (!this.position) {
|
||||
return ViewportProximity.FarFromViewport;
|
||||
}
|
||||
|
||||
const store = this.#group.timelineMonth.timelineManager;
|
||||
const positionTop = this.#group.absoluteTimelineDayTop + this.position.top;
|
||||
|
||||
return calculateViewerAssetViewportProximity(store, positionTop, this.position.height);
|
||||
});
|
||||
|
||||
get isInOrNearViewport() {
|
||||
return isInOrNearViewport(this.#viewportProximity);
|
||||
}
|
||||
|
||||
position: CommonPosition | undefined = $state.raw();
|
||||
asset: TimelineAsset = $state() as TimelineAsset;
|
||||
id: string = $derived(this.asset.id);
|
||||
|
||||
constructor(asset: TimelineAsset) {
|
||||
constructor(group: TimelineDay, asset: TimelineAsset) {
|
||||
this.#group = group;
|
||||
this.asset = asset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,9 +45,9 @@
|
||||
import { cloneDeep, isEqual } from 'lodash-es';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
import WorkflowStepCard from '$lib/components/workflows/WorkflowStepCard.svelte';
|
||||
import WorkflowJsonEditor from '$lib/components/workflows/WorkflowJsonEditor.svelte';
|
||||
import WorkflowSummary from '$lib/components/workflows/WorkflowSummary.svelte';
|
||||
import WorkflowJsonEditor from './WorkflowJsonEditor.svelte';
|
||||
import WorkflowStepCard from './WorkflowStepCard.svelte';
|
||||
import WorkflowSummary from './WorkflowSummary.svelte';
|
||||
|
||||
type WorkflowJsonContent = Required<
|
||||
Pick<WorkflowUpdateDto, 'description' | 'enabled' | 'name' | 'steps' | 'trigger'>
|
||||
|
||||
Reference in New Issue
Block a user