refactor: asset media endpoints (#9831)

* refactor: asset media endpoints

* refactor: mobile upload livePhoto as separate request

* refactor: change mobile backup flow to use new asset upload endpoints

* chore: format and analyze dart code

* feat: mark motion as hidden when linked

* feat: upload video portion of live photo before image portion

* fix: incorrect assetApi calls in mobile code

* fix: download asset

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Zack Pollard <zackpollard@ymail.com>
This commit is contained in:
Jason Rasmussen
2024-05-31 13:44:04 -04:00
committed by GitHub
parent 66fced40e7
commit 69d2fcb43e
91 changed files with 1932 additions and 2456 deletions

View File

@@ -30,7 +30,6 @@ import 'package:immich_mobile/widgets/photo_view/src/photo_view_computed_scale.d
import 'package:immich_mobile/widgets/photo_view/src/photo_view_scale_state.dart';
import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attributes.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart' show ThumbnailFormat;
@RoutePage()
// ignore: must_be_immutable
@@ -52,9 +51,6 @@ class GalleryViewerPage extends HookConsumerWidget {
final PageController controller;
static const jpeg = ThumbnailFormat.JPEG;
static const webp = ThumbnailFormat.WEBP;
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(appSettingsServiceProvider);

View File

@@ -22,8 +22,8 @@ Future<VideoPlayerController> videoPlayerController(
// Use a network URL for the video player controller
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final String videoUrl = asset.livePhotoVideoId != null
? '$serverEndpoint/asset/file/${asset.livePhotoVideoId}'
: '$serverEndpoint/asset/file/${asset.remoteId}';
? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback'
: '$serverEndpoint/assets/${asset.remoteId}/video/playback';
final url = Uri.parse(videoUrl);
final accessToken = Store.get(StoreKey.accessToken);

View File

@@ -74,7 +74,7 @@ class ImmichRemoteImageProvider
if (_loadPreview) {
final preview = getThumbnailUrlForRemoteId(
key.assetId,
type: api.ThumbnailFormat.WEBP,
type: api.AssetMediaSize.thumbnail,
);
yield await ImageLoader.loadImageFromCache(
@@ -88,7 +88,7 @@ class ImmichRemoteImageProvider
// Load the higher resolution version of the image
final url = getThumbnailUrlForRemoteId(
key.assetId,
type: api.ThumbnailFormat.JPEG,
type: api.AssetMediaSize.preview,
);
final codec = await ImageLoader.loadImageFromCache(
url,

View File

@@ -61,7 +61,7 @@ class ImmichRemoteThumbnailProvider
// Load a preview to the chunk events
final preview = getThumbnailUrlForRemoteId(
key.assetId,
type: api.ThumbnailFormat.WEBP,
type: api.AssetMediaSize.thumbnail,
);
yield await ImageLoader.loadImageFromCache(

View File

@@ -2,26 +2,26 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:cancellation_token_http/http.dart' as http;
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:path/path.dart' as p;
import 'package:permission_handler/permission_handler.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:cancellation_token_http/http.dart' as http;
import 'package:path/path.dart' as p;
final backupServiceProvider = Provider(
(ref) => BackupService(
@@ -270,10 +270,12 @@ class BackupService {
);
file = await entity.loadFile(progressHandler: pmProgressHandler);
livePhotoFile = await entity.loadFile(
withSubtype: true,
progressHandler: pmProgressHandler,
);
if (entity.isLivePhoto) {
livePhotoFile = await entity.loadFile(
withSubtype: true,
progressHandler: pmProgressHandler,
);
}
} else {
if (entity.type == AssetType.video) {
file = await entity.originFile;
@@ -288,6 +290,15 @@ class BackupService {
if (file != null) {
String originalFileName = await entity.titleAsync;
if (entity.isLivePhoto) {
if (livePhotoFile == null) {
_log.warning(
"Failed to obtain motion part of the livePhoto - $originalFileName",
);
}
}
var fileStream = file.openRead();
var assetRawUploadData = http.MultipartFile(
"assetData",
@@ -296,50 +307,29 @@ class BackupService {
filename: originalFileName,
);
var req = MultipartRequest(
var baseRequest = MultipartRequest(
'POST',
Uri.parse('$savedEndpoint/asset/upload'),
Uri.parse('$savedEndpoint/assets'),
onProgress: ((bytes, totalBytes) =>
uploadProgressCb(bytes, totalBytes)),
);
req.headers["x-immich-user-token"] = Store.get(StoreKey.accessToken);
req.headers["Transfer-Encoding"] = "chunked";
baseRequest.headers["x-immich-user-token"] =
Store.get(StoreKey.accessToken);
baseRequest.headers["Transfer-Encoding"] = "chunked";
req.fields['deviceAssetId'] = entity.id;
req.fields['deviceId'] = deviceId;
req.fields['fileCreatedAt'] =
baseRequest.fields['deviceAssetId'] = entity.id;
baseRequest.fields['deviceId'] = deviceId;
baseRequest.fields['fileCreatedAt'] =
entity.createDateTime.toUtc().toIso8601String();
req.fields['fileModifiedAt'] =
baseRequest.fields['fileModifiedAt'] =
entity.modifiedDateTime.toUtc().toIso8601String();
req.fields['isFavorite'] = entity.isFavorite.toString();
req.fields['duration'] = entity.videoDuration.toString();
baseRequest.fields['isFavorite'] = entity.isFavorite.toString();
baseRequest.fields['duration'] = entity.videoDuration.toString();
req.files.add(assetRawUploadData);
baseRequest.files.add(assetRawUploadData);
var fileSize = file.lengthSync();
if (entity.isLivePhoto) {
if (livePhotoFile != null) {
final livePhotoTitle = p.setExtension(
originalFileName,
p.extension(livePhotoFile.path),
);
final fileStream = livePhotoFile.openRead();
final livePhotoRawUploadData = http.MultipartFile(
"livePhotoData",
fileStream,
livePhotoFile.lengthSync(),
filename: livePhotoTitle,
);
req.files.add(livePhotoRawUploadData);
fileSize += livePhotoFile.lengthSync();
} else {
_log.warning(
"Failed to obtain motion part of the livePhoto - $originalFileName",
);
}
}
setCurrentUploadAssetCb(
CurrentUploadAsset(
id: entity.id,
@@ -353,19 +343,29 @@ class BackupService {
),
);
var response =
await httpClient.send(req, cancellationToken: cancelToken);
String? livePhotoVideoId;
if (entity.isLivePhoto && livePhotoFile != null) {
livePhotoVideoId = await uploadLivePhotoVideo(
originalFileName,
livePhotoFile,
baseRequest,
cancelToken,
);
}
if (response.statusCode == 200) {
// asset is a duplicate (already exists on the server)
duplicatedAssetIds.add(entity.id);
uploadSuccessCb(entity.id, deviceId, true);
} else if (response.statusCode == 201) {
// stored a new asset on the server
uploadSuccessCb(entity.id, deviceId, false);
} else {
var data = await response.stream.bytesToString();
var error = jsonDecode(data);
if (livePhotoVideoId != null) {
baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId;
}
var response = await httpClient.send(
baseRequest,
cancellationToken: cancelToken,
);
var responseBody = jsonDecode(await response.stream.bytesToString());
if (![200, 201].contains(response.statusCode)) {
var error = responseBody;
var errorMessage = error['message'] ?? error['error'];
debugPrint(
@@ -389,6 +389,14 @@ class BackupService {
}
continue;
}
var isDuplicate = false;
if (response.statusCode == 200) {
isDuplicate = true;
duplicatedAssetIds.add(entity.id);
}
uploadSuccessCb(entity.id, deviceId, isDuplicate);
}
} on http.CancelledException {
debugPrint("Backup was cancelled by the user");
@@ -415,6 +423,54 @@ class BackupService {
return !anyErrors;
}
Future<String?> uploadLivePhotoVideo(
String originalFileName,
File? livePhotoVideoFile,
MultipartRequest baseRequest,
http.CancellationToken cancelToken,
) async {
if (livePhotoVideoFile == null) {
return null;
}
final livePhotoTitle = p.setExtension(
originalFileName,
p.extension(livePhotoVideoFile.path),
);
final fileStream = livePhotoVideoFile.openRead();
final livePhotoRawUploadData = http.MultipartFile(
"assetData",
fileStream,
livePhotoVideoFile.lengthSync(),
filename: livePhotoTitle,
);
final livePhotoReq = MultipartRequest(
baseRequest.method,
baseRequest.url,
onProgress: baseRequest.onProgress,
)
..headers.addAll(baseRequest.headers)
..fields.addAll(baseRequest.fields);
livePhotoReq.files.add(livePhotoRawUploadData);
var response = await httpClient.send(
livePhotoReq,
cancellationToken: cancelToken,
);
var responseBody = jsonDecode(await response.stream.bytesToString());
if (![200, 201].contains(response.statusCode)) {
var error = responseBody;
debugPrint(
"Error(${error['statusCode']}) uploading livePhoto for assetId | $livePhotoTitle | ${error['error']}",
);
}
return responseBody.containsKey('id') ? responseBody['id'] : null;
}
String _getAssetType(AssetType assetType) {
switch (assetType) {
case AssetType.audio:

View File

@@ -165,8 +165,8 @@ class BackupVerificationService {
// (skip first few KBs containing metadata)
final Uint64List localImage =
_fakeDecodeImg(local, await file.readAsBytes());
final res = await apiService.downloadApi
.downloadFileWithHttpInfo(remote.remoteId!);
final res = await apiService.assetsApi
.downloadAssetWithHttpInfo(remote.remoteId!);
final Uint64List remoteImage = _fakeDecodeImg(remote, res.bodyBytes);
final eq = const ListEquality().equals(remoteImage, localImage);

View File

@@ -26,19 +26,19 @@ class ImageViewerService {
// Download LivePhotos image and motion part
if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) {
var imageResponse =
await _apiService.downloadApi.downloadFileWithHttpInfo(
await _apiService.assetsApi.downloadAssetWithHttpInfo(
asset.remoteId!,
);
var motionReponse =
await _apiService.downloadApi.downloadFileWithHttpInfo(
var motionResponse =
await _apiService.assetsApi.downloadAssetWithHttpInfo(
asset.livePhotoVideoId!,
);
if (imageResponse.statusCode != 200 ||
motionReponse.statusCode != 200) {
motionResponse.statusCode != 200) {
final failedResponse =
imageResponse.statusCode != 200 ? imageResponse : motionReponse;
imageResponse.statusCode != 200 ? imageResponse : motionResponse;
_log.severe(
"Motion asset download failed",
failedResponse.toLoggerString(),
@@ -51,7 +51,7 @@ class ImageViewerService {
final tempDir = await getTemporaryDirectory();
videoFile = await File('${tempDir.path}/livephoto.mov').create();
imageFile = await File('${tempDir.path}/livephoto.heic').create();
videoFile.writeAsBytesSync(motionReponse.bodyBytes);
videoFile.writeAsBytesSync(motionResponse.bodyBytes);
imageFile.writeAsBytesSync(imageResponse.bodyBytes);
entity = await PhotoManager.editor.darwin.saveLivePhoto(
@@ -73,8 +73,8 @@ class ImageViewerService {
return entity != null;
} else {
var res = await _apiService.downloadApi
.downloadFileWithHttpInfo(asset.remoteId!);
var res = await _apiService.assetsApi
.downloadAssetWithHttpInfo(asset.remoteId!);
if (res.statusCode != 200) {
_log.severe("Asset download failed", res.toLoggerString());

View File

@@ -37,8 +37,8 @@ class ShareService {
final tempDir = await getTemporaryDirectory();
final fileName = asset.fileName;
final tempFile = await File('${tempDir.path}/$fileName').create();
final res = await _apiService.downloadApi
.downloadFileWithHttpInfo(asset.remoteId!);
final res = await _apiService.assetsApi
.downloadAssetWithHttpInfo(asset.remoteId!);
if (res.statusCode != 200) {
_log.severe(

View File

@@ -129,8 +129,8 @@ class _ChewieControllerHookState
// Use a network URL for the video player controller
final serverEndpoint = store.Store.get(store.StoreKey.serverEndpoint);
final String videoUrl = hook.asset.livePhotoVideoId != null
? '$serverEndpoint/asset/file/${hook.asset.livePhotoVideoId}'
: '$serverEndpoint/asset/file/${hook.asset.remoteId}';
? '$serverEndpoint/assets/${hook.asset.livePhotoVideoId}/video/playback'
: '$serverEndpoint/assets/${hook.asset.remoteId}/video/playback';
final url = Uri.parse(videoUrl);
final accessToken = store.Store.get(StoreKey.accessToken);

View File

@@ -6,23 +6,23 @@ import 'package:openapi/api.dart';
String getThumbnailUrl(
final Asset asset, {
ThumbnailFormat type = ThumbnailFormat.WEBP,
AssetMediaSize type = AssetMediaSize.thumbnail,
}) {
return getThumbnailUrlForRemoteId(asset.remoteId!, type: type);
}
String getThumbnailCacheKey(
final Asset asset, {
ThumbnailFormat type = ThumbnailFormat.WEBP,
AssetMediaSize type = AssetMediaSize.thumbnail,
}) {
return getThumbnailCacheKeyForRemoteId(asset.remoteId!, type: type);
}
String getThumbnailCacheKeyForRemoteId(
final String id, {
ThumbnailFormat type = ThumbnailFormat.WEBP,
AssetMediaSize type = AssetMediaSize.thumbnail,
}) {
if (type == ThumbnailFormat.WEBP) {
if (type == AssetMediaSize.thumbnail) {
return 'thumbnail-image-$id';
} else {
return '${id}_previewStage';
@@ -31,7 +31,7 @@ String getThumbnailCacheKeyForRemoteId(
String getAlbumThumbnailUrl(
final Album album, {
ThumbnailFormat type = ThumbnailFormat.WEBP,
AssetMediaSize type = AssetMediaSize.thumbnail,
}) {
if (album.thumbnail.value?.remoteId == null) {
return '';
@@ -44,7 +44,7 @@ String getAlbumThumbnailUrl(
String getAlbumThumbNailCacheKey(
final Album album, {
ThumbnailFormat type = ThumbnailFormat.WEBP,
AssetMediaSize type = AssetMediaSize.thumbnail,
}) {
if (album.thumbnail.value?.remoteId == null) {
return '';
@@ -60,7 +60,7 @@ String getImageUrl(final Asset asset) {
}
String getImageUrlFromId(final String id) {
return '${Store.get(StoreKey.serverEndpoint)}/asset/file/$id?isThumb=false';
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=preview';
}
String getImageCacheKey(final Asset asset) {
@@ -71,9 +71,9 @@ String getImageCacheKey(final Asset asset) {
String getThumbnailUrlForRemoteId(
final String id, {
ThumbnailFormat type = ThumbnailFormat.WEBP,
AssetMediaSize type = AssetMediaSize.thumbnail,
}) {
return '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/$id?format=${type.value}';
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?format=${type.value}';
}
String getFaceThumbnailUrl(final String personId) {

View File

@@ -46,12 +46,13 @@ class AlbumThumbnailListTile extends StatelessWidget {
fadeInDuration: const Duration(milliseconds: 200),
imageUrl: getAlbumThumbnailUrl(
album,
type: ThumbnailFormat.WEBP,
type: AssetMediaSize.thumbnail,
),
httpHeaders: {
"x-immich-user-token": Store.get(StoreKey.accessToken),
},
cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.WEBP),
cacheKey:
getAlbumThumbNailCacheKey(album, type: AssetMediaSize.thumbnail),
errorWidget: (context, url, error) =>
const Icon(Icons.image_not_supported_outlined),
);

View File

@@ -115,7 +115,7 @@ class CuratedPlacesRow extends CuratedRow {
final actualIndex = index - actualContentIndex;
final object = content[actualIndex];
final thumbnailRequestUrl =
'${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${object.id}';
'${Store.get(StoreKey.serverEndpoint)}/assets/${object.id}/thumbnail';
return SizedBox(
width: imageSize,
height: imageSize,

View File

@@ -46,7 +46,7 @@ class CuratedRow extends StatelessWidget {
itemBuilder: (context, index) {
final object = content[index];
final thumbnailRequestUrl =
'${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${object.id}';
'${Store.get(StoreKey.serverEndpoint)}/assets/${object.id}/thumbnail';
return SizedBox(
width: imageSize,
height: imageSize,

View File

@@ -44,7 +44,7 @@ class ExploreGrid extends StatelessWidget {
final content = curatedContent[index];
final thumbnailRequestUrl = isPeople
? getFaceThumbnailUrl(content.id)
: '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${content.id}';
: '${Store.get(StoreKey.serverEndpoint)}/assets/${content.id}/thumbnail';
return ThumbnailWithInfo(
imageUrl: thumbnailRequestUrl,