Merge branch 'immich-app:main' into feat/samsung-raw-and-fujifilm-raf

This commit is contained in:
Skyler Mäntysaari
2023-02-05 04:29:22 +02:00
committed by GitHub
83 changed files with 2092 additions and 1711 deletions

View File

@@ -3,6 +3,10 @@ name: Build Mobile
on:
workflow_dispatch:
workflow_call:
inputs:
ref:
required: false
type: string
pull_request:
push:
branches: [main]
@@ -13,7 +17,17 @@ jobs:
runs-on: macos-12
steps:
- name: Determine ref
id: get-ref
run: |
input_ref="${{ inputs.ref }}"
github_ref="${{ github.sha }}"
ref="${input_ref:-$github_ref}"
echo "ref=$ref" >> $GITHUB_OUTPUT
- uses: actions/checkout@v3
with:
ref: ${{ steps.get-ref.outputs.ref }}
- uses: actions/setup-java@v3
with:

View File

@@ -98,3 +98,4 @@ jobs:
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{matrix.image}}
cache-to: ${{ steps.cache-target.outputs.cache-to }}
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}

View File

@@ -18,14 +18,12 @@ on:
type: boolean
jobs:
build_mobile:
uses: ./.github/workflows/build-mobile.yml
secrets: inherit
tag_release:
bump_version:
runs-on: ubuntu-latest
needs: build_mobile
outputs:
ref: ${{ steps.push-tag.outputs.commit_long_sha }}
steps:
- name: Checkout
uses: actions/checkout@v3
@@ -36,6 +34,7 @@ jobs:
run: misc/release/pump-version.sh -s "${{ inputs.serverBump }}" -m "${{ inputs.mobileBump }}"
- name: Commit and tag
id: push-tag
uses: EndBug/add-and-commit@v9
with:
author_name: Immich Release Bot
@@ -43,7 +42,19 @@ jobs:
message: "Version ${{ env.IMMICH_VERSION }}"
tag: ${{ env.IMMICH_VERSION }}
push: true
build_mobile:
uses: ./.github/workflows/build-mobile.yml
needs: bump_version
secrets: inherit
with:
ref: ${{ needs.bump_version.outputs.ref }}
prepare_release:
runs-on: ubuntu-latest
needs: build_mobile
steps:
- name: Download APK
uses: actions/download-artifact@v3
with:

View File

@@ -1,4 +1,3 @@
FROM node:16-bullseye-slim as builder
ARG DEBIAN_FRONTEND=noninteractive
@@ -6,7 +5,7 @@ ARG DEBIAN_FRONTEND=noninteractive
WORKDIR /usr/src/app
RUN apt-get update
RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y
RUN apt-get install gcc g++ make cmake python3 python3-pip -y
COPY package.json package-lock.json ./
@@ -15,24 +14,17 @@ RUN npm rebuild @tensorflow/tfjs-node --build-from-source
COPY . .
FROM builder as prod
RUN npm run build
RUN npm prune --omit=dev
FROM node:16-bullseye-slim
ARG DEBIAN_FRONTEND=noninteractive
WORKDIR /usr/src/app
RUN apt-get update \
&& apt-get install -y ffmpeg \
&& rm -rf /var/cache/apt/lists
COPY --from=prod /usr/src/app/node_modules ./node_modules
COPY --from=prod /usr/src/app/dist ./dist

File diff suppressed because it is too large Load Diff

View File

@@ -23,19 +23,9 @@
"dependencies": {
"@nestjs/common": "^8.0.0",
"@nestjs/core": "^8.0.0",
"@nestjs/mapped-types": "^1.0.1",
"@nestjs/platform-express": "^8.0.0",
"@tensorflow-models/coco-ssd": "^2.2.2",
"@tensorflow-models/mobilenet": "^2.1.0",
"@tensorflow/tfjs": "^3.19.0",
"@tensorflow/tfjs-converter": "^3.19.0",
"@tensorflow/tfjs-core": "^3.19.0",
"@tensorflow/tfjs-node": "^3.19.0",
"@tensorflow/tfjs-node-gpu": "^3.19.0",
"@trpc/server": "^9.20.3",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0"
"@tensorflow/tfjs-node": "^3.19.0"
},
"devDependencies": {
"@nestjs/cli": "^8.2.4",
@@ -52,6 +42,7 @@
"eslint-plugin-prettier": "^4.0.0",
"jest": "^27.2.5",
"prettier": "^2.3.2",
"rimraf": "^3.0.2",
"source-map-support": "^0.5.20",
"supertest": "^6.1.3",
"ts-jest": "^27.0.3",

View File

@@ -65,6 +65,7 @@ if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
sed -i "s/^ \"version\": \"$CURRENT_SERVER\",$/ \"version\": \"$NEXT_SERVER\",/" server/package.json
sed -i "s/^ \"version\": \"$CURRENT_SERVER\",$/ \"version\": \"$NEXT_SERVER\",/" server/package-lock.json
sed -i "s/\"version\": \"$CURRENT_SERVER\",$/\"version\": \"$NEXT_SERVER\",/" server/immich-openapi-specs.json
sed -i "s/\"android\.injected\.version\.name\" => \"$CURRENT_SERVER\",/\"android\.injected\.version\.name\" => \"$NEXT_SERVER\",/" mobile/android/fastlane/Fastfile
sed -i "s/version_number: \"$CURRENT_SERVER\"$/version_number: \"$NEXT_SERVER\"/" mobile/ios/fastlane/Fastfile
fi

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 67,
"android.injected.version.name" => "1.44.0",
"android.injected.version.code" => 68,
"android.injected.version.name" => "1.45.0",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -0,0 +1,7 @@
* reused HTTP client for uploading assets
* top padding missing from drag handle in add to album bottom sheet
* Improve the gallery to improve scale, double tap, and swipe gesture detection
* Uses clamping scroll physics on android
* Fix back button closing the app from multi selection in Android
* add and delete operation asset in album viewer doesn't update count in album list
* Update share_file to latest version and migrate to using cross platform shareXFile

View File

@@ -0,0 +1,4 @@
fixes back button while multiselecting showing the last selected image
fixes dark mode color for invite to album app bar
fixed local asset thumbnail size and eliminated fade in
Fullscreen image effects

View File

@@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000213">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000307">
</testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="61.218233">
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="36.955598">
</testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="41.974053">
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="75.445063">
</testcase>

View File

@@ -360,7 +360,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 82;
CURRENT_PROJECT_VERSION = 84;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -495,7 +495,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 82;
CURRENT_PROJECT_VERSION = 84;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -522,7 +522,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 82;
CURRENT_PROJECT_VERSION = 84;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -17,11 +17,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.43.0</string>
<string>1.45.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>82</string>
<string>84</string>
<key>LSRequiresIPhoneOS</key>
<true />
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.44.0"
version_number: "1.45.0"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -5,32 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000396">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000222">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.478301">
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.33276">
</testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="3.846552">
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="5.370222">
</testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="2.367554">
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="1.977555">
</testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="75.618447">
<testcase classname="fastlane.lanes" name="4: build_app" time="94.172587">
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="47.502114">
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="82.579096">
</testcase>

View File

@@ -5,6 +5,7 @@ const String deviceIdKey = 'immichBoxDeviceIdKey'; // Key 2
const String isLoggedInKey = 'immichIsLoggedInKey'; // Key 3
const String serverEndpointKey = 'immichBoxServerEndpoint'; // Key 4
const String assetEtagKey = 'immichAssetEtagKey'; // Key 5
const String userIdKey = 'immichUserIdKey'; // Key 6
// Login Info
const String hiveLoginInfoBox = "immichLoginInfoBox"; // Box

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -155,6 +156,8 @@ class ImmichAppState extends ConsumerState<ImmichApp>
var router = ref.watch(appRouterProvider);
ref.watch(releaseInfoProvider.notifier).checkGithubReleaseInfo();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
return MaterialApp(
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,

View File

@@ -85,9 +85,11 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
right: 10,
bottom: 5,
child: Icon(
(deviceId != asset.deviceId)
? Icons.cloud_done_outlined
: Icons.photo_library_rounded,
asset.isRemote
? (deviceId == asset.deviceId
? Icons.cloud_done_outlined
: Icons.cloud_outlined)
: Icons.cloud_off_outlined,
color: Colors.white,
size: 18,
),

View File

@@ -121,7 +121,7 @@ class SelectionThumbnailImage extends HookConsumerWidget {
child: Row(
children: [
Text(
asset.duration.substring(0, 7),
asset.duration.toString().substring(0, 7),
style: const TextStyle(
color: Colors.white,
fontSize: 10,

View File

@@ -118,7 +118,6 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
appBar: AppBar(
title: const Text(
'share_invite',
style: TextStyle(color: Colors.black),
).tr(),
elevation: 0,
centerTitle: false,

View File

@@ -7,7 +7,6 @@ import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/services/share.service.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/ui/share_dialog.dart';
import 'package:openapi/api.dart';
class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
final ImageViewerService _imageViewerService;
@@ -20,7 +19,7 @@ class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
),
);
void downloadAsset(AssetResponseDto asset, BuildContext context) async {
void downloadAsset(Asset asset, BuildContext context) async {
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading);
bool isSuccess = await _imageViewerService.downloadAssetToDevice(asset);

View File

@@ -2,10 +2,9 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:openapi/api.dart';
import 'package:path/path.dart' as p;
import 'package:photo_manager/photo_manager.dart';
import 'package:path_provider/path_provider.dart';
@@ -18,22 +17,16 @@ class ImageViewerService {
ImageViewerService(this._apiService);
Future<bool> downloadAssetToDevice(AssetResponseDto asset) async {
Future<bool> downloadAssetToDevice(Asset asset) async {
try {
String fileName = p.basename(asset.originalPath);
// Download LivePhotos image and motion part
if (asset.type == AssetTypeEnum.IMAGE && asset.livePhotoVideoId != null) {
if (asset.isImage && asset.livePhotoVideoId != null) {
var imageResponse = await _apiService.assetApi.downloadFileWithHttpInfo(
asset.id,
isThumb: false,
isWeb: false,
asset.remoteId!,
);
var motionReponse = await _apiService.assetApi.downloadFileWithHttpInfo(
asset.livePhotoVideoId!,
isThumb: false,
isWeb: false,
);
final AssetEntity? entity;
@@ -47,30 +40,28 @@ class ImageViewerService {
entity = await PhotoManager.editor.darwin.saveLivePhoto(
imageFile: imageFile,
videoFile: videoFile,
title: p.basename(asset.originalPath),
title: asset.fileName,
);
return entity != null;
} else {
var res = await _apiService.assetApi.downloadFileWithHttpInfo(
asset.id,
isThumb: false,
isWeb: false,
);
var res = await _apiService.assetApi
.downloadFileWithHttpInfo(asset.remoteId!);
final AssetEntity? entity;
if (asset.type == AssetTypeEnum.IMAGE) {
if (asset.isImage) {
entity = await PhotoManager.editor.saveImage(
res.bodyBytes,
title: p.basename(asset.originalPath),
title: asset.fileName,
);
} else {
final tempDir = await getTemporaryDirectory();
File tempFile = await File('${tempDir.path}/$fileName').create();
File tempFile =
await File('${tempDir.path}/${asset.fileName}').create();
tempFile.writeAsBytesSync(res.bodyBytes);
entity =
await PhotoManager.editor.saveVideo(tempFile, title: fileName);
entity = await PhotoManager.editor
.saveVideo(tempFile, title: asset.fileName);
}
return entity != null;
}

View File

@@ -3,9 +3,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:openapi/api.dart';
import 'package:path/path.dart' as p;
import 'package:latlong2/latlong.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
@@ -68,7 +67,7 @@ class ExifBottomSheet extends HookConsumerWidget {
final textColor = Theme.of(context).primaryColor;
ExifResponseDto? exifInfo = assetDetail.remote?.exifInfo;
ExifInfo? exifInfo = assetDetail.exifInfo;
buildLocationText() {
return Text(
@@ -81,6 +80,17 @@ class ExifBottomSheet extends HookConsumerWidget {
);
}
buildSizeText(Asset a) {
String resolution = a.width != null && a.height != null
? "${a.height} x ${a.width} "
: "";
String fileSize = a.exifInfo?.fileSize != null
? formatBytes(a.exifInfo!.fileSize!)
: "";
String text = resolution + fileSize;
return text.isEmpty ? null : Text(text);
}
return SingleChildScrollView(
child: Card(
shape: const RoundedRectangleBorder(
@@ -101,19 +111,18 @@ class ExifBottomSheet extends HookConsumerWidget {
child: CustomDraggingHandle(),
),
const SizedBox(height: 12),
if (exifInfo?.dateTimeOriginal != null)
Text(
DateFormat('date_format'.tr()).format(
exifInfo!.dateTimeOriginal!.toLocal(),
),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
Text(
DateFormat('date_format'.tr()).format(
assetDetail.createdAt.toLocal(),
),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
// Location
if (assetDetail.latitude != null)
if (assetDetail.latitude != null && assetDetail.longitude != null)
Padding(
padding: const EdgeInsets.only(top: 32.0),
child: Column(
@@ -126,74 +135,67 @@ class ExifBottomSheet extends HookConsumerWidget {
"exif_bottom_sheet_location",
style: TextStyle(fontSize: 11, color: textColor),
).tr(),
if (assetDetail.latitude != null &&
assetDetail.longitude != null)
buildMap(),
buildMap(),
if (exifInfo != null &&
exifInfo.city != null &&
exifInfo.state != null)
buildLocationText(),
Text(
"${assetDetail.latitude?.toStringAsFixed(4)}, ${assetDetail.longitude?.toStringAsFixed(4)}",
"${assetDetail.latitude!.toStringAsFixed(4)}, ${assetDetail.longitude!.toStringAsFixed(4)}",
style: const TextStyle(fontSize: 12),
)
],
),
),
// Detail
if (exifInfo != null)
Padding(
padding: const EdgeInsets.only(top: 32.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Divider(
thickness: 1,
color: Colors.grey[600],
),
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
"exif_bottom_sheet_details",
style: TextStyle(fontSize: 11, color: textColor),
).tr(),
Padding(
padding: const EdgeInsets.only(top: 32.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Divider(
thickness: 1,
color: Colors.grey[600],
),
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
"exif_bottom_sheet_details",
style: TextStyle(fontSize: 11, color: textColor),
).tr(),
),
ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
leading: const Icon(Icons.image),
title: Text(
assetDetail.fileName,
style: TextStyle(
fontWeight: FontWeight.bold,
color: textColor,
),
),
subtitle: buildSizeText(assetDetail),
),
if (exifInfo?.make != null)
ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
leading: const Icon(Icons.image),
leading: const Icon(Icons.camera),
title: Text(
"${exifInfo.imageName!}${p.extension(assetDetail.remote!.originalPath)}",
"${exifInfo!.make} ${exifInfo.model}",
style: TextStyle(
fontWeight: FontWeight.bold,
color: textColor,
fontWeight: FontWeight.bold,
),
),
subtitle: exifInfo.exifImageHeight != null
? Text(
"${exifInfo.exifImageHeight} x ${exifInfo.exifImageWidth} ${formatBytes(exifInfo.fileSizeInByte ?? 0)} ",
)
: null,
subtitle: Text(
"ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO${exifInfo.iso} ",
),
),
if (exifInfo.make != null)
ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
leading: const Icon(Icons.camera),
title: Text(
"${exifInfo.make} ${exifInfo.model}",
style: TextStyle(
color: textColor,
fontWeight: FontWeight.bold,
),
),
subtitle: Text(
"ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO${exifInfo.iso} ",
),
),
],
),
],
),
),
const SizedBox(
height: 50,
),

View File

@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget {
class TopControlAppBar extends HookConsumerWidget {
const TopControlAppBar({
Key? key,
required this.asset,
@@ -31,7 +31,6 @@ class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget {
return AppBar(
foregroundColor: Colors.grey[100],
toolbarHeight: 60,
backgroundColor: Colors.transparent,
leading: IconButton(
onPressed: () {
@@ -44,7 +43,7 @@ class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget {
),
),
actions: [
if (asset.remote?.livePhotoVideoId != null)
if (asset.livePhotoVideoId != null)
IconButton(
iconSize: iconSize,
splashRadius: iconSize,
@@ -105,22 +104,18 @@ class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget {
color: Colors.grey[200],
),
),
if (asset.isRemote)
IconButton(
iconSize: iconSize,
splashRadius: iconSize,
onPressed: () {
onMoreInfoPressed();
},
icon: Icon(
Icons.more_horiz_rounded,
color: Colors.grey[200],
),
IconButton(
iconSize: iconSize,
splashRadius: iconSize,
onPressed: () {
onMoreInfoPressed();
},
icon: Icon(
Icons.more_horiz_rounded,
color: Colors.grey[200],
),
),
],
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}

View File

@@ -13,7 +13,7 @@ import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_s
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/shared/services/asset.service.dart';
import 'package:immich_mobile/modules/home/ui/delete_diaglog.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
@@ -48,6 +48,7 @@ class GalleryViewerPage extends HookConsumerWidget {
final isLoadPreview = useState(AppSettingsEnum.loadPreview.defaultValue);
final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue);
final isZoomed = useState<bool>(false);
final showAppBar = useState<bool>(true);
final indexOfAsset = useState(assetList.indexOf(asset));
final isPlayingMotionVideo = useState(false);
late Offset localPosition;
@@ -79,41 +80,46 @@ class GalleryViewerPage extends HookConsumerWidget {
}
}
/// Thumbnail image of a remote asset. Required asset.remote != null
ImageProvider remoteThumbnailImageProvider(Asset asset, api.ThumbnailFormat type) {
/// Thumbnail image of a remote asset. Required asset.isRemote
ImageProvider remoteThumbnailImageProvider(
Asset asset,
api.ThumbnailFormat type,
) {
return CachedNetworkImageProvider(
getThumbnailUrl(
asset.remote!,
asset,
type: type,
),
cacheKey: getThumbnailCacheKey(
asset.remote!,
asset,
type: type,
),
headers: {"Authorization": authToken},
);
}
/// Original (large) image of a remote asset. Required asset.remote != null
/// Original (large) image of a remote asset. Required asset.isRemote
ImageProvider originalImageProvider(Asset asset) {
return CachedNetworkImageProvider(
getImageUrl(asset.remote!),
cacheKey: getImageCacheKey(asset.remote!),
getImageUrl(asset),
cacheKey: getImageCacheKey(asset),
headers: {"Authorization": authToken},
);
}
/// Thumbnail image of a local asset. Required asset.local != null
/// Thumbnail image of a local asset. Required asset.isLocal
ImageProvider localThumbnailImageProvider(Asset asset) {
return AssetEntityImageProvider(
asset.local!,
isOriginal: false,
thumbnailSize: const ThumbnailSize.square(250),
thumbnailSize: ThumbnailSize(
MediaQuery.of(context).size.width.floor(),
MediaQuery.of(context).size.height.floor(),
),
);
}
/// Original (large) image of a local asset. Required asset.local != null
/// Original (large) image of a local asset. Required asset.isLocal
ImageProvider localImageProvider(Asset asset) {
return AssetEntityImageProvider(asset.local!);
}
@@ -128,7 +134,7 @@ class GalleryViewerPage extends HookConsumerWidget {
// Probably load WEBP either way
precacheImage(
remoteThumbnailImageProvider(
asset,
asset,
api.ThumbnailFormat.WEBP,
),
context,
@@ -150,26 +156,23 @@ class GalleryViewerPage extends HookConsumerWidget {
context,
);
}
}
}
}
void showInfo() {
if (assetList[indexOfAsset.value].isRemote) {
showModalBottomSheet(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15.0),
),
barrierColor: Colors.transparent,
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) {
return ExifBottomSheet(assetDetail: assetDetail!);
},
);
}
showModalBottomSheet(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15.0),
),
barrierColor: Colors.transparent,
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) {
return ExifBottomSheet(assetDetail: assetDetail!);
},
);
}
void handleDelete(Asset deleteAsset) {
@@ -224,128 +227,174 @@ class GalleryViewerPage extends HookConsumerWidget {
}
}
buildAppBar() {
return AnimatedOpacity(
duration: const Duration(milliseconds: 100),
opacity: (showAppBar.value || !isZoomed.value) ? 1.0 : 0.0,
child: Container(
color: Colors.black.withOpacity(0.4),
child: TopControlAppBar(
isPlayingMotionVideo: isPlayingMotionVideo.value,
asset: assetList[indexOfAsset.value],
onMoreInfoPressed: () {
showInfo();
},
onDownloadPressed: assetList[indexOfAsset.value].isLocal
? null
: () {
ref.watch(imageViewerStateProvider.notifier).downloadAsset(
assetList[indexOfAsset.value],
context,
);
},
onSharePressed: () {
ref
.watch(imageViewerStateProvider.notifier)
.shareAsset(assetList[indexOfAsset.value], context);
},
onToggleMotionVideo: (() {
isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
}),
onDeletePressed: () =>
handleDelete((assetList[indexOfAsset.value])),
onAddToAlbumPressed: () =>
addToAlbum(assetList[indexOfAsset.value]),
),
),
);
}
return Scaffold(
backgroundColor: Colors.black,
appBar: TopControlAppBar(
isPlayingMotionVideo: isPlayingMotionVideo.value,
asset: assetList[indexOfAsset.value],
onMoreInfoPressed: () {
showInfo();
},
onDownloadPressed: assetList[indexOfAsset.value].isLocal
? null
: () {
ref.watch(imageViewerStateProvider.notifier).downloadAsset(
assetList[indexOfAsset.value].remote!,
context,
);
},
onSharePressed: () {
ref
.watch(imageViewerStateProvider.notifier)
.shareAsset(assetList[indexOfAsset.value], context);
},
onToggleMotionVideo: (() {
isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
}),
onDeletePressed: () => handleDelete((assetList[indexOfAsset.value])),
onAddToAlbumPressed: () => addToAlbum(assetList[indexOfAsset.value]),
),
body: SafeArea(
child: PhotoViewGallery.builder(
scaleStateChangedCallback: (state) => isZoomed.value = state != PhotoViewScaleState.initial,
pageController: controller,
scrollPhysics: isZoomed.value
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
: (Platform.isIOS
? const BouncingScrollPhysics() // Use bouncing physics for iOS
: const ClampingScrollPhysics() // Use heavy physics for Android
),
itemCount: assetList.length,
scrollDirection: Axis.horizontal,
onPageChanged: (value) {
// Precache image
if (indexOfAsset.value < value) {
// Moving forwards, so precache the next asset
precacheNextImage(value + 1);
} else {
// Moving backwards, so precache previous asset
precacheNextImage(value - 1);
}
indexOfAsset.value = value;
HapticFeedback.selectionClick();
},
loadingBuilder: isLoadPreview.value ? (context, event) {
final asset = assetList[indexOfAsset.value];
if (!asset.isLocal) {
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to acheive
// Three-Stage Loading (WEBP -> JPEG -> Original)
final webPThumbnail = CachedNetworkImage(
imageUrl: getThumbnailUrl(asset.remote!, type: api.ThumbnailFormat.WEBP),
cacheKey: getThumbnailCacheKey(asset.remote!, type: api.ThumbnailFormat.WEBP),
httpHeaders: { 'Authorization': authToken },
progressIndicatorBuilder: (_, __, ___) => const Center(child: ImmichLoadingIndicator(),),
fit: BoxFit.contain,
);
return CachedNetworkImage(
imageUrl: getThumbnailUrl(asset.remote!, type: api.ThumbnailFormat.JPEG),
cacheKey: getThumbnailCacheKey(asset.remote!, type: api.ThumbnailFormat.JPEG),
httpHeaders: { 'Authorization': authToken },
fit: BoxFit.contain,
placeholder: (_, __) => webPThumbnail,
);
} else {
return Image(
image: localThumbnailImageProvider(asset),
fit: BoxFit.contain,
);
}
} : null,
builder: (context, index) {
getAssetExif();
if (assetList[index].isImage && !isPlayingMotionVideo.value) {
// Show photo
final ImageProvider provider;
if (assetList[index].isLocal) {
provider = localImageProvider(assetList[index]);
} else {
if (isLoadOriginal.value) {
provider = originalImageProvider(assetList[index]);
} else {
provider = remoteThumbnailImageProvider(
assetList[index],
api.ThumbnailFormat.JPEG,
);
}
}
return PhotoViewGalleryPageOptions(
onDragStart: (_, details, __) => localPosition = details.localPosition,
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
imageProvider: provider,
heroAttributes: PhotoViewHeroAttributes(tag: assetList[index].id),
minScale: PhotoViewComputedScale.contained,
);
} else {
return PhotoViewGalleryPageOptions.customChild(
onDragStart: (_, details, __) => localPosition = details.localPosition,
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
heroAttributes: PhotoViewHeroAttributes(tag: assetList[index].id),
child: VideoViewerPage(
asset: assetList[index],
isMotionVideo: isPlayingMotionVideo.value,
onVideoEnded: () {
if (isPlayingMotionVideo.value) {
isPlayingMotionVideo.value = false;
}
},
body: Stack(
children: [
PhotoViewGallery.builder(
scaleStateChangedCallback: (state) {
isZoomed.value = state != PhotoViewScaleState.initial;
showAppBar.value = !isZoomed.value;
},
pageController: controller,
scrollPhysics: isZoomed.value
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
: (Platform.isIOS
? const BouncingScrollPhysics() // Use bouncing physics for iOS
: const ClampingScrollPhysics() // Use heavy physics for Android
),
);
}
},
),
itemCount: assetList.length,
scrollDirection: Axis.horizontal,
onPageChanged: (value) {
// Precache image
if (indexOfAsset.value < value) {
// Moving forwards, so precache the next asset
precacheNextImage(value + 1);
} else {
// Moving backwards, so precache previous asset
precacheNextImage(value - 1);
}
indexOfAsset.value = value;
HapticFeedback.selectionClick();
},
loadingBuilder: isLoadPreview.value
? (context, event) {
final asset = assetList[indexOfAsset.value];
if (!asset.isLocal) {
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to acheive
// Three-Stage Loading (WEBP -> JPEG -> Original)
final webPThumbnail = CachedNetworkImage(
imageUrl: getThumbnailUrl(asset),
cacheKey: getThumbnailCacheKey(asset),
httpHeaders: {'Authorization': authToken},
progressIndicatorBuilder: (_, __, ___) => const Center(
child: ImmichLoadingIndicator(),
),
fadeInDuration: const Duration(milliseconds: 0),
fit: BoxFit.contain,
);
return CachedNetworkImage(
imageUrl: getThumbnailUrl(
asset,
type: api.ThumbnailFormat.JPEG,
),
cacheKey: getThumbnailCacheKey(
asset,
type: api.ThumbnailFormat.JPEG,
),
httpHeaders: {'Authorization': authToken},
fit: BoxFit.contain,
fadeInDuration: const Duration(milliseconds: 0),
placeholder: (_, __) => webPThumbnail,
);
} else {
return Image(
image: localThumbnailImageProvider(asset),
fit: BoxFit.contain,
);
}
}
: null,
builder: (context, index) {
getAssetExif();
if (assetList[index].isImage && !isPlayingMotionVideo.value) {
// Show photo
final ImageProvider provider;
if (assetList[index].isLocal) {
provider = localImageProvider(assetList[index]);
} else {
if (isLoadOriginal.value) {
provider = originalImageProvider(assetList[index]);
} else {
provider = remoteThumbnailImageProvider(
assetList[index],
api.ThumbnailFormat.JPEG,
);
}
}
return PhotoViewGalleryPageOptions(
onDragStart: (_, details, __) =>
localPosition = details.localPosition,
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
onTapDown: (_, __, ___) =>
showAppBar.value = !showAppBar.value,
imageProvider: provider,
heroAttributes:
PhotoViewHeroAttributes(tag: assetList[index].id),
minScale: PhotoViewComputedScale.contained,
);
} else {
return PhotoViewGalleryPageOptions.customChild(
onDragStart: (_, details, __) =>
localPosition = details.localPosition,
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
onTapDown: (_, __, ___) =>
showAppBar.value = !showAppBar.value,
heroAttributes:
PhotoViewHeroAttributes(tag: assetList[index].id),
maxScale: 1.0,
minScale: 1.0,
child: SafeArea(
child: VideoViewerPage(
asset: assetList[index],
isMotionVideo: isPlayingMotionVideo.value,
onVideoEnded: () {
if (isPlayingMotionVideo.value) {
isPlayingMotionVideo.value = false;
}
},
),
),
);
}
},
),
Positioned(
top: 0,
left: 0,
right: 0,
child: buildAppBar(),
),
],
),
);
}
}

View File

@@ -53,8 +53,8 @@ class VideoViewerPage extends HookConsumerWidget {
final box = Hive.box(userInfoBox);
final String jwtToken = box.get(accessTokenKey);
final String videoUrl = isMotionVideo
? '${box.get(serverEndpointKey)}/asset/file/${asset.remote?.livePhotoVideoId!}'
: '${box.get(serverEndpointKey)}/asset/file/${asset.id}';
? '${box.get(serverEndpointKey)}/asset/file/${asset.livePhotoVideoId}'
: '${box.get(serverEndpointKey)}/asset/file/${asset.remoteId}';
return Stack(
children: [

View File

@@ -75,6 +75,9 @@ class BackupService {
final filter = FilterOptionGroup(
containsPathModified: true,
orders: [const OrderOption(type: OrderOptionType.updateDate)],
// title is needed to create Assets
imageOption: const FilterOption(needTitle: true),
videoOption: const FilterOption(needTitle: true),
);
final now = DateTime.now();
final List<AssetPathEntity?> selectedAlbums =

View File

@@ -1,76 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:openapi/api.dart';
class ImmichAssetGroupByDate {
final String date;
List<AssetResponseDto> assets;
ImmichAssetGroupByDate({
required this.date,
required this.assets,
});
ImmichAssetGroupByDate copyWith({
String? date,
List<AssetResponseDto>? assets,
}) {
return ImmichAssetGroupByDate(
date: date ?? this.date,
assets: assets ?? this.assets,
);
}
@override
String toString() => 'ImmichAssetGroupByDate(date: $date, assets: $assets)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ImmichAssetGroupByDate &&
other.date == date &&
listEquals(other.assets, assets);
}
@override
int get hashCode => date.hashCode ^ assets.hashCode;
}
class GetAllAssetResponse {
final int count;
final List<ImmichAssetGroupByDate> data;
final String nextPageKey;
GetAllAssetResponse({
required this.count,
required this.data,
required this.nextPageKey,
});
GetAllAssetResponse copyWith({
int? count,
List<ImmichAssetGroupByDate>? data,
String? nextPageKey,
}) {
return GetAllAssetResponse(
count: count ?? this.count,
data: data ?? this.data,
nextPageKey: nextPageKey ?? this.nextPageKey,
);
}
@override
String toString() =>
'GetAllAssetResponse(count: $count, data: $data, nextPageKey: $nextPageKey)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is GetAllAssetResponse &&
other.count == count &&
listEquals(other.data, data) &&
other.nextPageKey == nextPageKey;
}
@override
int get hashCode => count.hashCode ^ data.hashCode ^ nextPageKey.hashCode;
}

View File

@@ -24,7 +24,6 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
bool _scrolling = false;
final Set<String> _selectedAssets = HashSet();
Set<Asset> _getSelectedAssets() {
return _selectedAssets
.map((e) => widget.allAssets.firstWhereOrNull((a) => a.id == e))
@@ -103,7 +102,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
return Row(
key: Key("asset-row-${row.assets.first.id}"),
children: row.assets.map((Asset asset) {
bool last = asset == row.assets.last;
bool last = asset.id == row.assets.last.id;
return Container(
key: Key("asset-${asset.id}"),
@@ -224,13 +223,25 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
}
}
Future<bool> onWillPop() async {
if (widget.selectionActive && _selectedAssets.isNotEmpty) {
_deselectAll();
return false;
}
return true;
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
_buildAssetGrid(),
if (widget.selectionActive) _buildMultiSelectIndicator(),
],
return WillPopScope(
onWillPop: onWillPop,
child: Stack(
children: [
_buildAssetGrid(),
if (widget.selectionActive) _buildMultiSelectIndicator(),
],
),
);
}
}

View File

@@ -200,46 +200,34 @@ class HomePage extends HookConsumerWidget {
);
}
Future<bool> onWillPop() async {
if (multiselectEnabled.state) {
selectionEnabledHook.value = false;
return false;
}
return true;
}
return WillPopScope(
onWillPop: onWillPop,
child: SafeArea(
bottom: !multiselectEnabled.state,
top: true,
child: Stack(
children: [
ref.watch(assetProvider).renderList == null ||
ref.watch(assetProvider).allAssets.isEmpty
? buildLoadingIndicator()
: ImmichAssetGrid(
renderList: ref.watch(assetProvider).renderList!,
allAssets: ref.watch(assetProvider).allAssets,
assetsPerRow: appSettingService
.getSetting(AppSettingsEnum.tilesPerRow),
showStorageIndicator: appSettingService
.getSetting(AppSettingsEnum.storageIndicator),
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
),
if (selectionEnabledHook.value)
ControlBottomAppBar(
onShare: onShareAssets,
onDelete: onDelete,
onAddToAlbum: onAddToAlbum,
albums: albums,
sharedAlbums: sharedAlbums,
onCreateNewAlbum: onCreateNewAlbum,
),
],
),
return SafeArea(
bottom: !multiselectEnabled.state,
top: true,
child: Stack(
children: [
ref.watch(assetProvider).renderList == null ||
ref.watch(assetProvider).allAssets.isEmpty
? buildLoadingIndicator()
: ImmichAssetGrid(
renderList: ref.watch(assetProvider).renderList!,
allAssets: ref.watch(assetProvider).allAssets,
assetsPerRow: appSettingService
.getSetting(AppSettingsEnum.tilesPerRow),
showStorageIndicator: appSettingService
.getSetting(AppSettingsEnum.storageIndicator),
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
),
if (selectionEnabledHook.value)
ControlBottomAppBar(
onShare: onShareAssets,
onDelete: onDelete,
onAddToAlbum: onAddToAlbum,
albums: albums,
sharedAlbums: sharedAlbums,
onCreateNewAlbum: onCreateNewAlbum,
),
],
),
);
}

View File

@@ -4,7 +4,7 @@ import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
import 'package:immich_mobile/modules/home/services/asset_cache.service.dart';
import 'package:immich_mobile/shared/services/asset_cache.service.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
@@ -166,6 +166,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
var deviceInfo = await _deviceInfoService.getDeviceInfo();
userInfoHiveBox.put(deviceIdKey, deviceInfo["deviceId"]);
userInfoHiveBox.put(accessTokenKey, accessToken);
userInfoHiveBox.put(userIdKey, userResponseDto.id);
state = state.copyWith(
isAuthenticated: true,

View File

@@ -45,9 +45,11 @@ class SearchResultPageState {
isLoading: map['isLoading'] ?? false,
isSuccess: map['isSuccess'] ?? false,
isError: map['isError'] ?? false,
searchResult: List<Asset>.from(
searchResult: List.from(
map['searchResult']
?.map((x) => Asset.remote(AssetResponseDto.fromJson(x))),
.map(AssetResponseDto.fromJson)
.where((e) => e != null)
.map(Asset.remote),
),
);
}

View File

@@ -30,9 +30,7 @@ class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> {
isSuccess: false,
);
List<Asset>? assets = (await _searchService.searchAsset(searchTerm))
?.map((e) => Asset.remote(e))
.toList();
List<Asset>? assets = await _searchService.searchAsset(searchTerm);
if (assets != null) {
state = state.copyWith(

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:openapi/api.dart';
@@ -24,10 +25,14 @@ class SearchService {
}
}
Future<List<AssetResponseDto>?> searchAsset(String searchTerm) async {
Future<List<Asset>?> searchAsset(String searchTerm) async {
try {
return await _apiService.assetApi
final List<AssetResponseDto>? results = await _apiService.assetApi
.searchAsset(SearchAssetDto(searchTerm: searchTerm));
if (results == null) {
return null;
}
return results.map((e) => Asset.remote(e)).toList();
} catch (e) {
debugPrint("[ERROR] [searchAsset] ${e.toString()}");
return null;
@@ -50,7 +55,7 @@ class SearchService {
return await _apiService.assetApi.getCuratedObjects();
} catch (e) {
debugPrint("Error [getCuratedObjects] ${e.toString()}");
throw [];
return [];
}
}
}

View File

@@ -1,63 +1,128 @@
import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/utils/builtin_extensions.dart';
import 'package:path/path.dart' as p;
/// Asset (online or local)
class Asset {
Asset.remote(this.remote) {
local = null;
}
Asset.remote(AssetResponseDto remote)
: remoteId = remote.id,
createdAt = DateTime.parse(remote.createdAt),
modifiedAt = DateTime.parse(remote.modifiedAt),
durationInSeconds = remote.duration.toDuration().inSeconds,
fileName = p.basename(remote.originalPath),
height = remote.exifInfo?.exifImageHeight?.toInt(),
width = remote.exifInfo?.exifImageWidth?.toInt(),
livePhotoVideoId = remote.livePhotoVideoId,
deviceAssetId = remote.deviceAssetId,
deviceId = remote.deviceId,
ownerId = remote.ownerId,
latitude = remote.exifInfo?.latitude?.toDouble(),
longitude = remote.exifInfo?.longitude?.toDouble(),
exifInfo =
remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null;
Asset.local(this.local) {
remote = null;
}
late final AssetResponseDto? remote;
late final AssetEntity? local;
bool get isRemote => remote != null;
bool get isLocal => local != null;
String get deviceId =>
isRemote ? remote!.deviceId : Hive.box(userInfoBox).get(deviceIdKey);
String get deviceAssetId => isRemote ? remote!.deviceAssetId : local!.id;
String get id => isLocal ? local!.id : remote!.id;
double? get latitude =>
isLocal ? local!.latitude : remote!.exifInfo?.latitude?.toDouble();
double? get longitude =>
isLocal ? local!.longitude : remote!.exifInfo?.longitude?.toDouble();
DateTime get createdAt {
if (isLocal) {
if (local!.createDateTime.year == 1970) {
return local!.modifiedDateTime;
}
return local!.createDateTime;
} else {
return DateTime.parse(remote!.createdAt);
Asset.local(AssetEntity local, String owner)
: localId = local.id,
latitude = local.latitude,
longitude = local.longitude,
durationInSeconds = local.duration,
height = local.height,
width = local.width,
fileName = local.title!,
deviceAssetId = local.id,
deviceId = Hive.box(userInfoBox).get(deviceIdKey),
ownerId = owner,
modifiedAt = local.modifiedDateTime.toUtc(),
createdAt = local.createDateTime.toUtc() {
if (createdAt.year == 1970) {
createdAt = modifiedAt;
}
}
bool get isImage => isLocal
? local!.type == AssetType.image
: remote!.type == AssetTypeEnum.IMAGE;
Asset({
this.localId,
this.remoteId,
required this.deviceAssetId,
required this.deviceId,
required this.ownerId,
required this.createdAt,
required this.modifiedAt,
this.latitude,
this.longitude,
required this.durationInSeconds,
this.width,
this.height,
required this.fileName,
this.livePhotoVideoId,
this.exifInfo,
});
String get duration => isRemote
? remote!.duration
: Duration(seconds: local!.duration).toString();
AssetEntity? _local;
/// use only for tests
set createdAt(DateTime val) {
if (isRemote) {
remote!.createdAt = val.toIso8601String();
AssetEntity? get local {
if (isLocal && _local == null) {
_local = AssetEntity(
id: localId!.toString(),
typeInt: isImage ? 1 : 2,
width: width!,
height: height!,
duration: durationInSeconds,
createDateSecond: createdAt.millisecondsSinceEpoch ~/ 1000,
latitude: latitude,
longitude: longitude,
modifiedDateSecond: modifiedAt.millisecondsSinceEpoch ~/ 1000,
title: fileName,
);
}
return _local;
}
String? localId;
String? remoteId;
String deviceAssetId;
String deviceId;
String ownerId;
DateTime createdAt;
DateTime modifiedAt;
double? latitude;
double? longitude;
int durationInSeconds;
int? width;
int? height;
String fileName;
String? livePhotoVideoId;
ExifInfo? exifInfo;
String get id => isLocal ? localId.toString() : remoteId!;
String get name => p.withoutExtension(fileName);
bool get isRemote => remoteId != null;
bool get isLocal => localId != null;
bool get isImage => durationInSeconds == 0;
Duration get duration => Duration(seconds: durationInSeconds);
@override
bool operator ==(other) {
if (other is! Asset) return false;
@@ -67,12 +132,26 @@ class Asset {
@override
int get hashCode => id.hashCode;
// methods below are only required for caching as JSON
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (isLocal) {
json["local"] = _assetEntityToJson(local!);
} else {
json["remote"] = remote!.toJson();
json["localId"] = localId;
json["remoteId"] = remoteId;
json["deviceAssetId"] = deviceAssetId;
json["deviceId"] = deviceId;
json["ownerId"] = ownerId;
json["createdAt"] = createdAt.millisecondsSinceEpoch;
json["modifiedAt"] = modifiedAt.millisecondsSinceEpoch;
json["latitude"] = latitude;
json["longitude"] = longitude;
json["durationInSeconds"] = durationInSeconds;
json["width"] = width;
json["height"] = height;
json["fileName"] = fileName;
json["livePhotoVideoId"] = livePhotoVideoId;
if (exifInfo != null) {
json["exifInfo"] = exifInfo!.toJson();
}
return json;
}
@@ -80,55 +159,28 @@ class Asset {
static Asset? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
final l = json["local"];
if (l != null) {
return Asset.local(_assetEntityFromJson(l));
} else {
return Asset.remote(AssetResponseDto.fromJson(json["remote"]));
}
return Asset(
localId: json["localId"],
remoteId: json["remoteId"],
deviceAssetId: json["deviceAssetId"],
deviceId: json["deviceId"],
ownerId: json["ownerId"],
createdAt:
DateTime.fromMillisecondsSinceEpoch(json["createdAt"], isUtc: true),
modifiedAt: DateTime.fromMillisecondsSinceEpoch(
json["modifiedAt"],
isUtc: true,
),
latitude: json["latitude"],
longitude: json["longitude"],
durationInSeconds: json["durationInSeconds"],
width: json["width"],
height: json["height"],
fileName: json["fileName"],
livePhotoVideoId: json["livePhotoVideoId"],
exifInfo: ExifInfo.fromJson(json["exifInfo"]),
);
}
return null;
}
}
Map<String, dynamic> _assetEntityToJson(AssetEntity a) {
final json = <String, dynamic>{};
json["id"] = a.id;
json["typeInt"] = a.typeInt;
json["width"] = a.width;
json["height"] = a.height;
json["duration"] = a.duration;
json["orientation"] = a.orientation;
json["isFavorite"] = a.isFavorite;
json["title"] = a.title;
json["createDateSecond"] = a.createDateSecond;
json["modifiedDateSecond"] = a.modifiedDateSecond;
json["latitude"] = a.latitude;
json["longitude"] = a.longitude;
json["mimeType"] = a.mimeType;
json["subtype"] = a.subtype;
return json;
}
AssetEntity? _assetEntityFromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetEntity(
id: json["id"],
typeInt: json["typeInt"],
width: json["width"],
height: json["height"],
duration: json["duration"],
orientation: json["orientation"],
isFavorite: json["isFavorite"],
title: json["title"],
createDateSecond: json["createDateSecond"],
modifiedDateSecond: json["modifiedDateSecond"],
latitude: json["latitude"],
longitude: json["longitude"],
mimeType: json["mimeType"],
subtype: json["subtype"],
);
}
return null;
}

View File

@@ -0,0 +1,86 @@
import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/builtin_extensions.dart';
class ExifInfo {
int? fileSize;
String? make;
String? model;
String? orientation;
String? lensModel;
double? fNumber;
double? focalLength;
int? iso;
double? exposureTime;
String? city;
String? state;
String? country;
ExifInfo.fromDto(ExifResponseDto dto)
: fileSize = dto.fileSizeInByte,
make = dto.make,
model = dto.model,
orientation = dto.orientation,
lensModel = dto.lensModel,
fNumber = dto.fNumber?.toDouble(),
focalLength = dto.focalLength?.toDouble(),
iso = dto.iso?.toInt(),
exposureTime = dto.exposureTime?.toDouble(),
city = dto.city,
state = dto.state,
country = dto.country;
// stuff below is only required for caching as JSON
ExifInfo(
this.fileSize,
this.make,
this.model,
this.orientation,
this.lensModel,
this.fNumber,
this.focalLength,
this.iso,
this.exposureTime,
this.city,
this.state,
this.country,
);
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json["fileSize"] = fileSize;
json["make"] = make;
json["model"] = model;
json["orientation"] = orientation;
json["lensModel"] = lensModel;
json["fNumber"] = fNumber;
json["focalLength"] = focalLength;
json["iso"] = iso;
json["exposureTime"] = exposureTime;
json["city"] = city;
json["state"] = state;
json["country"] = country;
return json;
}
static ExifInfo? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return ExifInfo(
json["fileSize"],
json["make"],
json["model"],
json["orientation"],
json["lensModel"],
json["fNumber"],
json["focalLength"],
json["iso"],
json["exposureTime"],
json["city"],
json["state"],
json["country"],
);
}
return null;
}
}

View File

@@ -4,8 +4,8 @@ import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/modules/home/services/asset_cache.service.dart';
import 'package:immich_mobile/shared/services/asset.service.dart';
import 'package:immich_mobile/shared/services/asset_cache.service.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
@@ -36,7 +36,7 @@ class AssetsState {
return AssetsState([...allAssets, ...toAdd]);
}
_groupByDate() async {
Future<Map<String, List<Asset>>> _groupByDate() async {
sortCompare(List<Asset> assets) {
assets.sortByCompare<DateTime>(
(e) => e.createdAt,
@@ -50,11 +50,11 @@ class AssetsState {
return await compute(sortCompare, allAssets.toList());
}
static fromAssetList(List<Asset> assets) {
static AssetsState fromAssetList(List<Asset> assets) {
return AssetsState(assets);
}
static empty() {
static AssetsState empty() {
return AssetsState([]);
}
}
@@ -82,7 +82,10 @@ class AssetNotifier extends StateNotifier<AssetsState> {
this._settingsService,
) : super(AssetsState.fromAssetList([]));
_updateAssetsState(List<Asset> newAssetList, {bool cache = true}) async {
Future<void> _updateAssetsState(
List<Asset> newAssetList, {
bool cache = true,
}) async {
if (cache) {
_assetCacheService.put(newAssetList);
}
@@ -101,20 +104,26 @@ class AssetNotifier extends StateNotifier<AssetsState> {
final stopwatch = Stopwatch();
try {
_getAllAssetInProgress = true;
final bool isCacheValid = await _assetCacheService.isValid();
bool isCacheValid = await _assetCacheService.isValid();
stopwatch.start();
final Box box = Hive.box(userInfoBox);
if (isCacheValid && state.allAssets.isEmpty) {
final List<Asset>? cachedData = await _assetCacheService.get();
if (cachedData == null) {
isCacheValid = false;
log.warning("Cached asset data is invalid, fetching new data");
} else {
await _updateAssetsState(cachedData, cache: false);
log.info(
"Reading assets ${state.allAssets.length} from cache: ${stopwatch.elapsedMilliseconds}ms",
);
}
stopwatch.reset();
}
final localTask = _assetService.getLocalAssets(urgent: !isCacheValid);
final remoteTask = _assetService.getRemoteAssets(
etag: isCacheValid ? box.get(assetEtagKey) : null,
);
if (isCacheValid && state.allAssets.isEmpty) {
await _updateAssetsState(await _assetCacheService.get(), cache: false);
log.info(
"Reading assets ${state.allAssets.length} from cache: ${stopwatch.elapsedMilliseconds}ms",
);
stopwatch.reset();
}
int remoteBegin = state.allAssets.indexWhere((a) => a.isRemote);
remoteBegin = remoteBegin == -1 ? state.allAssets.length : remoteBegin;
@@ -184,7 +193,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
_updateAssetsState([]);
}
onNewAssetUploaded(AssetResponseDto newAsset) {
void onNewAssetUploaded(Asset newAsset) {
final int i = state.allAssets.indexWhere(
(a) =>
a.isRemote ||
@@ -192,13 +201,13 @@ class AssetNotifier extends StateNotifier<AssetsState> {
);
if (i == -1 || state.allAssets[i].deviceAssetId != newAsset.deviceAssetId) {
_updateAssetsState([...state.allAssets, Asset.remote(newAsset)]);
_updateAssetsState([...state.allAssets, newAsset]);
} else {
// order is important to keep all local-only assets at the beginning!
_updateAssetsState([
...state.allAssets.slice(0, i),
...state.allAssets.slice(i + 1),
Asset.remote(newAsset),
newAsset,
]);
// TODO here is a place to unify local/remote assets by replacing the
// local-only asset in the state with a local&remote asset
@@ -230,7 +239,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
// Delete asset from device
for (final Asset asset in assetsToDelete) {
if (asset.isLocal) {
local.add(asset.id);
local.add(asset.localId!);
} else if (asset.deviceId == deviceId) {
// Delete asset on device if it is still present
var localAsset = await AssetEntity.fromId(asset.deviceAssetId);
@@ -252,8 +261,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
Future<Iterable<String>> _deleteRemoteAssets(
Set<Asset> assetsToDelete,
) async {
final Iterable<AssetResponseDto> remote =
assetsToDelete.where((e) => e.isRemote).map((e) => e.remote!);
final Iterable<Asset> remote = assetsToDelete.where((e) => e.isRemote);
final List<DeleteAssetResponseDto> deleteAssetResult =
await _assetService.deleteAssets(remote) ?? [];
return deleteAssetResult

View File

@@ -5,6 +5,7 @@ import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@@ -91,14 +92,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
state = WebsocketState(isConnected: false, socket: null);
});
socket.on('on_upload_success', (data) {
var jsonString = jsonDecode(data.toString());
AssetResponseDto? newAsset = AssetResponseDto.fromJson(jsonString);
if (newAsset != null) {
ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
}
});
socket.on('on_upload_success', _handleOnUploadSuccess);
} catch (e) {
debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
}
@@ -122,14 +116,16 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
listenUploadEvent() {
debugPrint("Start listening to event on_upload_success");
state.socket?.on('on_upload_success', (data) {
var jsonString = jsonDecode(data.toString());
AssetResponseDto? newAsset = AssetResponseDto.fromJson(jsonString);
state.socket?.on('on_upload_success', _handleOnUploadSuccess);
}
if (newAsset != null) {
ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
}
});
_handleOnUploadSuccess(dynamic data) {
final jsonString = jsonDecode(data.toString());
final dto = AssetResponseDto.fromJson(jsonString);
if (dto != null) {
final newAsset = Asset.remote(dto);
ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
}
}
}

View File

@@ -62,10 +62,11 @@ class AssetService {
}
final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
final String userId = Hive.box(userInfoBox).get(userIdKey);
if (backupAlbumInfo != null) {
return (await _backupService
.buildUploadCandidates(backupAlbumInfo.deepCopy()))
.map(Asset.local)
.map((e) => Asset.local(e, userId))
.toList(growable: false);
}
} catch (e) {
@@ -76,21 +77,24 @@ class AssetService {
Future<Asset?> getAssetById(String assetId) async {
try {
return Asset.remote(await _apiService.assetApi.getAssetById(assetId));
final dto = await _apiService.assetApi.getAssetById(assetId);
if (dto != null) {
return Asset.remote(dto);
}
} catch (e) {
debugPrint("Error [getAssetById] ${e.toString()}");
return null;
}
return null;
}
Future<List<DeleteAssetResponseDto>?> deleteAssets(
Iterable<AssetResponseDto> deleteAssets,
Iterable<Asset> deleteAssets,
) async {
try {
final List<String> payload = [];
for (final asset in deleteAssets) {
payload.add(asset.id);
payload.add(asset.remoteId!);
}
return await _apiService.assetApi

View File

@@ -23,17 +23,15 @@ class AssetCacheService extends JsonCache<List<Asset>> {
}
@override
Future<List<Asset>> get() async {
Future<List<Asset>?> get() async {
try {
final mapList = await readRawData() as List<dynamic>;
final responseData = await compute(_computeEncode, mapList);
return responseData;
} catch (e) {
debugPrint(e.toString());
return [];
await invalidate();
return null;
}
}
}

View File

@@ -60,5 +60,5 @@ abstract class JsonCache<T> {
}
void put(T data);
Future<T> get();
Future<T?> get();
}

View File

@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'api.service.dart';
@@ -25,13 +24,10 @@ class ShareService {
final downloadedXFiles = assets.map<Future<XFile>>((asset) async {
if (asset.isRemote) {
final tempDir = await getTemporaryDirectory();
final fileName = basename(asset.remote!.originalPath);
final fileName = asset.fileName;
final tempFile = await File('${tempDir.path}/$fileName').create();
final res = await _apiService.assetApi.downloadFileWithHttpInfo(
asset.remote!.id,
isThumb: false,
isWeb: false,
);
final res = await _apiService.assetApi
.downloadFileWithHttpInfo(asset.remoteId!);
tempFile.writeAsBytesSync(res.bodyBytes);
return XFile(tempFile.path);
} else {

View File

@@ -1,5 +1,6 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/models/asset.dart';
@@ -15,13 +16,28 @@ class ImmichImage extends StatelessWidget {
this.useGrayBoxPlaceholder = false,
super.key,
});
final Asset asset;
final Asset? asset;
final bool useGrayBoxPlaceholder;
final double width;
final double height;
@override
Widget build(BuildContext context) {
if (this.asset == null) {
return Container(
decoration: const BoxDecoration(
color: Colors.grey,
),
child: SizedBox(
width: width,
height: height,
child: const Center(
child: Icon(Icons.no_photography),
),
),
);
}
final Asset asset = this.asset!;
if (asset.isLocal) {
return Image(
image: AssetEntityImageProvider(
@@ -49,7 +65,16 @@ class ImmichImage extends StatelessWidget {
));
},
errorBuilder: (context, error, stackTrace) {
debugPrint("Error getting thumb for assetId=${asset.id}: $error");
if (error is PlatformException &&
error.code == "The asset not found!") {
debugPrint(
"Asset ${asset.localId} does not exist anymore on device!",
);
} else {
debugPrint(
"Error getting thumb for assetId=${asset.localId}: $error",
);
}
return Icon(
Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor,
@@ -57,12 +82,12 @@ class ImmichImage extends StatelessWidget {
},
);
}
final String token = Hive.box(userInfoBox).get(accessTokenKey);
final String thumbnailRequestUrl = getThumbnailUrl(asset.remote!);
final String? token = Hive.box(userInfoBox).get(accessTokenKey);
final String thumbnailRequestUrl = getThumbnailUrl(asset);
return CachedNetworkImage(
imageUrl: thumbnailRequestUrl,
httpHeaders: {"Authorization": "Bearer $token"},
cacheKey: getThumbnailCacheKey(asset.remote!),
cacheKey: getThumbnailCacheKey(asset),
width: width,
height: height,
// keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and

View File

@@ -0,0 +1,11 @@
extension DurationExtension on String {
Duration toDuration() {
final parts =
split(':').map((e) => double.parse(e).toInt()).toList(growable: false);
return Duration(hours: parts[0], minutes: parts[1], seconds: parts[2]);
}
double? toDouble() {
return double.tryParse(this);
}
}

View File

@@ -1,17 +1,18 @@
import 'package:hive/hive.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:openapi/api.dart';
import '../constants/hive_box.dart';
String getThumbnailUrl(
final AssetResponseDto asset, {
final Asset asset, {
ThumbnailFormat type = ThumbnailFormat.WEBP,
}) {
return _getThumbnailUrl(asset.id, type: type);
return _getThumbnailUrl(asset.remoteId!, type: type);
}
String getThumbnailCacheKey(
final AssetResponseDto asset, {
final Asset asset, {
ThumbnailFormat type = ThumbnailFormat.WEBP,
}) {
return _getThumbnailCacheKey(asset.id, type);
@@ -45,12 +46,12 @@ String getAlbumThumbNailCacheKey(
return _getThumbnailCacheKey(album.albumThumbnailAssetId!, type);
}
String getImageUrl(final AssetResponseDto asset) {
String getImageUrl(final Asset asset) {
final box = Hive.box(userInfoBox);
return '${box.get(serverEndpointKey)}/asset/file/${asset.id}?isThumb=false';
return '${box.get(serverEndpointKey)}/asset/file/${asset.remoteId}?isThumb=false';
}
String getImageCacheKey(final AssetResponseDto asset) {
String getImageCacheKey(final Asset asset) {
return '${asset.id}_fullStage';
}

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.43.0
- API version: 1.43.1
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements

View File

@@ -230,7 +230,7 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **downloadFile**
> Object downloadFile(assetId, isThumb, isWeb)
> Object downloadFile(assetId)
@@ -248,11 +248,9 @@ import 'package:openapi/api.dart';
final api_instance = AssetApi();
final assetId = assetId_example; // String |
final isThumb = true; // bool |
final isWeb = true; // bool |
try {
final result = api_instance.downloadFile(assetId, isThumb, isWeb);
final result = api_instance.downloadFile(assetId);
print(result);
} catch (e) {
print('Exception when calling AssetApi->downloadFile: $e\n');
@@ -264,8 +262,6 @@ try {
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**assetId** | **String**| |
**isThumb** | **bool**| | [optional]
**isWeb** | **bool**| | [optional]
### Return type

View File

@@ -234,11 +234,7 @@ class AssetApi {
/// Parameters:
///
/// * [String] assetId (required):
///
/// * [bool] isThumb:
///
/// * [bool] isWeb:
Future<Response> downloadFileWithHttpInfo(String assetId, { bool? isThumb, bool? isWeb, }) async {
Future<Response> downloadFileWithHttpInfo(String assetId,) async {
// ignore: prefer_const_declarations
final path = r'/asset/download/{assetId}'
.replaceAll('{assetId}', assetId);
@@ -250,13 +246,6 @@ class AssetApi {
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (isThumb != null) {
queryParams.addAll(_queryParams('', 'isThumb', isThumb));
}
if (isWeb != null) {
queryParams.addAll(_queryParams('', 'isWeb', isWeb));
}
const contentTypes = <String>[];
@@ -276,12 +265,8 @@ class AssetApi {
/// Parameters:
///
/// * [String] assetId (required):
///
/// * [bool] isThumb:
///
/// * [bool] isWeb:
Future<Object?> downloadFile(String assetId, { bool? isThumb, bool? isWeb, }) async {
final response = await downloadFileWithHttpInfo(assetId, isThumb: isThumb, isWeb: isWeb, );
Future<Object?> downloadFile(String assetId,) async {
final response = await downloadFileWithHttpInfo(assetId,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View File

@@ -43,51 +43,48 @@ class AlbumResponseDto {
List<AssetResponseDto> assets;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AlbumResponseDto &&
other.assetCount == assetCount &&
other.id == id &&
other.ownerId == ownerId &&
other.albumName == albumName &&
other.createdAt == createdAt &&
other.albumThumbnailAssetId == albumThumbnailAssetId &&
other.shared == shared &&
other.sharedUsers == sharedUsers &&
other.assets == assets;
bool operator ==(Object other) => identical(this, other) || other is AlbumResponseDto &&
other.assetCount == assetCount &&
other.id == id &&
other.ownerId == ownerId &&
other.albumName == albumName &&
other.createdAt == createdAt &&
other.albumThumbnailAssetId == albumThumbnailAssetId &&
other.shared == shared &&
other.sharedUsers == sharedUsers &&
other.assets == assets;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(assetCount.hashCode) +
(id.hashCode) +
(ownerId.hashCode) +
(albumName.hashCode) +
(createdAt.hashCode) +
(albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
(shared.hashCode) +
(sharedUsers.hashCode) +
(assets.hashCode);
// ignore: unnecessary_parenthesis
(assetCount.hashCode) +
(id.hashCode) +
(ownerId.hashCode) +
(albumName.hashCode) +
(createdAt.hashCode) +
(albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
(shared.hashCode) +
(sharedUsers.hashCode) +
(assets.hashCode);
@override
String toString() =>
'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]';
String toString() => 'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'assetCount'] = this.assetCount;
json[r'id'] = this.id;
json[r'ownerId'] = this.ownerId;
json[r'albumName'] = this.albumName;
json[r'createdAt'] = this.createdAt;
json[r'assetCount'] = this.assetCount;
json[r'id'] = this.id;
json[r'ownerId'] = this.ownerId;
json[r'albumName'] = this.albumName;
json[r'createdAt'] = this.createdAt;
if (this.albumThumbnailAssetId != null) {
json[r'albumThumbnailAssetId'] = this.albumThumbnailAssetId;
} else {
// json[r'albumThumbnailAssetId'] = null;
}
json[r'shared'] = this.shared;
json[r'sharedUsers'] = this.sharedUsers;
json[r'assets'] = this.assets;
json[r'shared'] = this.shared;
json[r'sharedUsers'] = this.sharedUsers;
json[r'assets'] = this.assets;
return json;
}
@@ -101,13 +98,13 @@ class AlbumResponseDto {
// Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode!
// assert(() {
// requiredKeys.forEach((key) {
// assert(json.containsKey(key), 'Required key "AlbumResponseDto[$key]" is missing from JSON.');
// assert(json[key] != null, 'Required key "AlbumResponseDto[$key]" has a null value in JSON.');
// });
// return true;
// }());
assert(() {
requiredKeys.forEach((key) {
assert(json.containsKey(key), 'Required key "AlbumResponseDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "AlbumResponseDto[$key]" has a null value in JSON.');
});
return true;
}());
return AlbumResponseDto(
assetCount: mapValueOfType<int>(json, r'assetCount')!,
@@ -115,8 +112,7 @@ class AlbumResponseDto {
ownerId: mapValueOfType<String>(json, r'ownerId')!,
albumName: mapValueOfType<String>(json, r'albumName')!,
createdAt: mapValueOfType<String>(json, r'createdAt')!,
albumThumbnailAssetId:
mapValueOfType<String>(json, r'albumThumbnailAssetId'),
albumThumbnailAssetId: mapValueOfType<String>(json, r'albumThumbnailAssetId'),
shared: mapValueOfType<bool>(json, r'shared')!,
sharedUsers: UserResponseDto.listFromJson(json[r'sharedUsers'])!,
assets: AssetResponseDto.listFromJson(json[r'assets'])!,
@@ -125,10 +121,7 @@ class AlbumResponseDto {
return null;
}
static List<AlbumResponseDto>? listFromJson(
dynamic json, {
bool growable = false,
}) {
static List<AlbumResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <AlbumResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
@@ -156,18 +149,12 @@ class AlbumResponseDto {
}
// maps a json object with a list of AlbumResponseDto-objects as value to a dart map
static Map<String, List<AlbumResponseDto>> mapListFromJson(
dynamic json, {
bool growable = false,
}) {
static Map<String, List<AlbumResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AlbumResponseDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AlbumResponseDto.listFromJson(
entry.value,
growable: growable,
);
final value = AlbumResponseDto.listFromJson(entry.value, growable: growable,);
if (value != null) {
map[entry.key] = value;
}
@@ -189,3 +176,4 @@ class AlbumResponseDto {
'assets',
};
}

View File

@@ -82,76 +82,73 @@ class AssetResponseDto {
List<TagResponseDto> tags;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AssetResponseDto &&
other.type == type &&
other.id == id &&
other.deviceAssetId == deviceAssetId &&
other.ownerId == ownerId &&
other.deviceId == deviceId &&
other.originalPath == originalPath &&
other.resizePath == resizePath &&
other.createdAt == createdAt &&
other.modifiedAt == modifiedAt &&
other.isFavorite == isFavorite &&
other.mimeType == mimeType &&
other.duration == duration &&
other.webpPath == webpPath &&
other.encodedVideoPath == encodedVideoPath &&
other.exifInfo == exifInfo &&
other.smartInfo == smartInfo &&
other.livePhotoVideoId == livePhotoVideoId &&
other.tags == tags;
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
other.type == type &&
other.id == id &&
other.deviceAssetId == deviceAssetId &&
other.ownerId == ownerId &&
other.deviceId == deviceId &&
other.originalPath == originalPath &&
other.resizePath == resizePath &&
other.createdAt == createdAt &&
other.modifiedAt == modifiedAt &&
other.isFavorite == isFavorite &&
other.mimeType == mimeType &&
other.duration == duration &&
other.webpPath == webpPath &&
other.encodedVideoPath == encodedVideoPath &&
other.exifInfo == exifInfo &&
other.smartInfo == smartInfo &&
other.livePhotoVideoId == livePhotoVideoId &&
other.tags == tags;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(type.hashCode) +
(id.hashCode) +
(deviceAssetId.hashCode) +
(ownerId.hashCode) +
(deviceId.hashCode) +
(originalPath.hashCode) +
(resizePath == null ? 0 : resizePath!.hashCode) +
(createdAt.hashCode) +
(modifiedAt.hashCode) +
(isFavorite.hashCode) +
(mimeType == null ? 0 : mimeType!.hashCode) +
(duration.hashCode) +
(webpPath == null ? 0 : webpPath!.hashCode) +
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
(exifInfo == null ? 0 : exifInfo!.hashCode) +
(smartInfo == null ? 0 : smartInfo!.hashCode) +
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
(tags.hashCode);
// ignore: unnecessary_parenthesis
(type.hashCode) +
(id.hashCode) +
(deviceAssetId.hashCode) +
(ownerId.hashCode) +
(deviceId.hashCode) +
(originalPath.hashCode) +
(resizePath == null ? 0 : resizePath!.hashCode) +
(createdAt.hashCode) +
(modifiedAt.hashCode) +
(isFavorite.hashCode) +
(mimeType == null ? 0 : mimeType!.hashCode) +
(duration.hashCode) +
(webpPath == null ? 0 : webpPath!.hashCode) +
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
(exifInfo == null ? 0 : exifInfo!.hashCode) +
(smartInfo == null ? 0 : smartInfo!.hashCode) +
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
(tags.hashCode);
@override
String toString() =>
'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId, tags=$tags]';
String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId, tags=$tags]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'type'] = this.type;
json[r'id'] = this.id;
json[r'deviceAssetId'] = this.deviceAssetId;
json[r'ownerId'] = this.ownerId;
json[r'deviceId'] = this.deviceId;
json[r'originalPath'] = this.originalPath;
json[r'type'] = this.type;
json[r'id'] = this.id;
json[r'deviceAssetId'] = this.deviceAssetId;
json[r'ownerId'] = this.ownerId;
json[r'deviceId'] = this.deviceId;
json[r'originalPath'] = this.originalPath;
if (this.resizePath != null) {
json[r'resizePath'] = this.resizePath;
} else {
// json[r'resizePath'] = null;
}
json[r'createdAt'] = this.createdAt;
json[r'modifiedAt'] = this.modifiedAt;
json[r'isFavorite'] = this.isFavorite;
json[r'createdAt'] = this.createdAt;
json[r'modifiedAt'] = this.modifiedAt;
json[r'isFavorite'] = this.isFavorite;
if (this.mimeType != null) {
json[r'mimeType'] = this.mimeType;
} else {
// json[r'mimeType'] = null;
}
json[r'duration'] = this.duration;
json[r'duration'] = this.duration;
if (this.webpPath != null) {
json[r'webpPath'] = this.webpPath;
} else {
@@ -177,7 +174,7 @@ class AssetResponseDto {
} else {
// json[r'livePhotoVideoId'] = null;
}
json[r'tags'] = this.tags;
json[r'tags'] = this.tags;
return json;
}
@@ -191,13 +188,13 @@ class AssetResponseDto {
// Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode!
// assert(() {
// requiredKeys.forEach((key) {
// assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
// assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
// });
// return true;
// }());
assert(() {
requiredKeys.forEach((key) {
assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
});
return true;
}());
return AssetResponseDto(
type: AssetTypeEnum.fromJson(json[r'type'])!,
@@ -223,10 +220,7 @@ class AssetResponseDto {
return null;
}
static List<AssetResponseDto>? listFromJson(
dynamic json, {
bool growable = false,
}) {
static List<AssetResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
@@ -254,18 +248,12 @@ class AssetResponseDto {
}
// maps a json object with a list of AssetResponseDto-objects as value to a dart map
static Map<String, List<AssetResponseDto>> mapListFromJson(
dynamic json, {
bool growable = false,
}) {
static Map<String, List<AssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetResponseDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetResponseDto.listFromJson(
entry.value,
growable: growable,
);
final value = AssetResponseDto.listFromJson(entry.value, growable: growable,);
if (value != null) {
map[entry.key] = value;
}
@@ -292,3 +280,4 @@ class AssetResponseDto {
'tags',
};
}

View File

@@ -47,7 +47,7 @@ void main() {
//
//
//Future<Object> downloadFile(String assetId, { bool isThumb, bool isWeb }) async
//Future<Object> downloadFile(String assetId) async
test('test downloadFile', () async {
// TODO
});

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.43.0+66
version: 1.45.0+68
environment:
sdk: ">=2.17.0 <3.0.0"

View File

@@ -1,7 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:openapi/api.dart';
void main() {
final List<Asset> testAssets = [];
@@ -13,24 +12,14 @@ void main() {
DateTime date = DateTime(2022, month, day);
testAssets.add(
Asset.remote(
AssetResponseDto(
type: AssetTypeEnum.IMAGE,
id: '$i',
deviceAssetId: '',
ownerId: '',
deviceId: '',
originalPath: '',
resizePath: '',
createdAt: date.toIso8601String(),
modifiedAt: date.toIso8601String(),
isFavorite: false,
mimeType: 'image/jpeg',
duration: '',
webpPath: '',
encodedVideoPath: '',
livePhotoVideoId: '',
),
Asset(
deviceAssetId: '$i',
deviceId: '',
ownerId: '',
createdAt: date,
modifiedAt: date,
durationInSeconds: 0,
fileName: '',
),
);
}
@@ -70,11 +59,20 @@ void main() {
// Day 1
// 15 Assets => 5 Rows
expect(renderList.elements.length, 18);
expect(renderList.elements[0].type, RenderAssetGridElementType.monthTitle);
expect(
renderList.elements[0].type,
RenderAssetGridElementType.monthTitle,
);
expect(renderList.elements[0].date.month, 1);
expect(renderList.elements[7].type, RenderAssetGridElementType.monthTitle);
expect(
renderList.elements[7].type,
RenderAssetGridElementType.monthTitle,
);
expect(renderList.elements[7].date.month, 2);
expect(renderList.elements[11].type, RenderAssetGridElementType.monthTitle);
expect(
renderList.elements[11].type,
RenderAssetGridElementType.monthTitle,
);
expect(renderList.elements[11].date.month, 10);
});

View File

@@ -2,7 +2,7 @@ FROM node:16-alpine3.14 as builder
WORKDIR /usr/src/app
RUN apk add --update-cache build-base python3 libheif vips-dev ffmpeg exiftool perl
RUN apk add --update-cache build-base python3 libheif vips-dev ffmpeg perl
COPY package.json package-lock.json ./
@@ -14,14 +14,14 @@ COPY . .
FROM builder as prod
RUN npm run build
RUN npm prune --omit=dev
RUN npm prune --omit=dev --omit=optional
FROM node:16-alpine3.14
WORKDIR /usr/src/app
RUN apk add --no-cache libheif vips ffmpeg exiftool perl
RUN apk add --no-cache libheif vips ffmpeg perl
COPY --from=prod /usr/src/app/node_modules ./node_modules
COPY --from=prod /usr/src/app/dist ./dist
@@ -32,7 +32,7 @@ COPY LICENSE /LICENSE
COPY package.json package-lock.json ./
COPY start-server.sh start-microservices.sh ./
RUN npm link
RUN npm link && npm cache clean --force
VOLUME /usr/src/app/upload

View File

@@ -15,6 +15,7 @@ import {
Put,
UploadedFiles,
Patch,
StreamableFile,
} from '@nestjs/common';
import { Authenticated } from '../../decorators/authenticated.decorator';
import { AssetService } from './asset.service';
@@ -28,7 +29,7 @@ import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { AssetResponseDto } from '@app/domain';
import { AssetResponseDto, ImmichReadStream } from '@app/domain';
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
import { AssetFileUploadDto } from './dto/asset-file-upload.dto';
import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.dto';
@@ -55,6 +56,10 @@ import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto
import { AssetSearchDto } from './dto/asset-search.dto';
import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config';
function asStreamableFile({ stream, type, length }: ImmichReadStream) {
return new StreamableFile(stream, { type, length });
}
@ApiBearerAuth()
@ApiTags('Asset')
@Controller('asset')
@@ -92,7 +97,7 @@ export class AssetController {
const responseDto = await this.assetService.uploadFile(authUser, dto, file, livePhotoFile);
if (responseDto.duplicate) {
res.send(200);
res.status(200);
}
return responseDto;
@@ -103,12 +108,9 @@ export class AssetController {
async downloadFile(
@GetAuthUser() authUser: AuthUserDto,
@Response({ passthrough: true }) res: Res,
@Query(new ValidationPipe({ transform: true })) query: ServeFileDto,
@Param('assetId') assetId: string,
): Promise<any> {
this.assetService.checkDownloadAccess(authUser);
await this.assetService.checkAssetsAccess(authUser, [assetId]);
return this.assetService.downloadFile(query, assetId, res);
return this.assetService.downloadFile(authUser, assetId).then(asStreamableFile);
}
@Authenticated({ isShared: true })

View File

@@ -9,12 +9,13 @@ import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-use
import { DownloadService } from '../../modules/download/download.service';
import { AlbumRepository, IAlbumRepository } from '../album/album-repository';
import { StorageService } from '@app/storage';
import { ICryptoRepository, IJobRepository, ISharedLinkRepository, JobName } from '@app/domain';
import { ICryptoRepository, IJobRepository, ISharedLinkRepository, IStorageRepository, JobName } from '@app/domain';
import {
authStub,
newCryptoRepositoryMock,
newJobRepositoryMock,
newSharedLinkRepositoryMock,
newStorageRepositoryMock,
sharedLinkResponseStub,
sharedLinkStub,
} from '@app/domain/../test';
@@ -110,6 +111,7 @@ describe('AssetService', () => {
let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
beforeEach(() => {
assetRepositoryMock = {
@@ -154,6 +156,7 @@ describe('AssetService', () => {
sharedLinkRepositoryMock = newSharedLinkRepositoryMock();
jobMock = newJobRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
storageMock = newStorageRepositoryMock();
sut = new AssetService(
assetRepositoryMock,
@@ -164,6 +167,7 @@ describe('AssetService', () => {
sharedLinkRepositoryMock,
jobMock,
cryptoMock,
storageMock,
);
});
@@ -413,4 +417,15 @@ describe('AssetService', () => {
expect(() => sut.checkDownloadAccess(authStub.readonlySharedLink)).toThrow(ForbiddenException);
});
});
describe('downloadFile', () => {
it('should download a single file', async () => {
assetRepositoryMock.countByIdAndUser.mockResolvedValue(1);
assetRepositoryMock.get.mockResolvedValue(_getAsset_1());
await sut.downloadFile(authStub.admin, 'id_1');
expect(storageMock.createReadStream).toHaveBeenCalledWith('fake_path/asset_1.jpeg', 'image/jpeg');
});
});
});

View File

@@ -10,7 +10,6 @@ import {
StreamableFile,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { createHash } from 'node:crypto';
import { QueryFailedError, Repository } from 'typeorm';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { AssetEntity, AssetType, SharedLinkType } from '@app/infra';
@@ -23,7 +22,14 @@ import { SearchAssetDto } from './dto/search-asset.dto';
import fs from 'fs/promises';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { AssetResponseDto, JobName, mapAsset, mapAssetWithoutExif } from '@app/domain';
import {
AssetResponseDto,
ImmichReadStream,
IStorageRepository,
JobName,
mapAsset,
mapAssetWithoutExif,
} from '@app/domain';
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto';
@@ -73,6 +79,7 @@ export class AssetService {
@Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
@Inject(IStorageRepository) private storage: IStorageRepository,
) {
this.assetCore = new AssetCore(_assetRepository, jobRepository, storageService);
this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository);
@@ -189,62 +196,21 @@ export class AssetService {
return this.downloadService.downloadArchive(`immich-${now}`, assetToDownload);
}
public async downloadFile(query: ServeFileDto, assetId: string, res: Res) {
public async downloadFile(authUser: AuthUserDto, assetId: string): Promise<ImmichReadStream> {
this.checkDownloadAccess(authUser);
await this.checkAssetsAccess(authUser, [assetId]);
try {
let fileReadStream = null;
const asset = await this._assetRepository.getById(assetId);
// Download Video
if (asset.type === AssetType.VIDEO) {
const { size } = await fileInfo(asset.originalPath);
res.set({
'Content-Type': asset.mimeType,
'Content-Length': size,
});
await fs.access(asset.originalPath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.originalPath);
} else {
// Download Image
if (!query.isThumb) {
/**
* Download Image Original File
*/
const { size } = await fileInfo(asset.originalPath);
res.set({
'Content-Type': asset.mimeType,
'Content-Length': size,
});
await fs.access(asset.originalPath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.originalPath);
} else {
/**
* Download Image Resize File
*/
if (!asset.resizePath) {
throw new NotFoundException('resizePath not set');
}
const { size } = await fileInfo(asset.resizePath);
res.set({
'Content-Type': 'image/jpeg',
'Content-Length': size,
});
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.resizePath);
}
const asset = await this._assetRepository.get(assetId);
if (asset && asset.originalPath && asset.mimeType) {
return this.storage.createReadStream(asset.originalPath, asset.mimeType);
}
return new StreamableFile(fileReadStream);
} catch (e) {
Logger.error(`Error download asset ${e}`, 'downloadFile');
throw new InternalServerErrorException(`Failed to download asset ${e}`, 'DownloadFile');
}
throw new NotFoundException();
}
public async getAssetThumbnail(
@@ -255,8 +221,7 @@ export class AssetService {
) {
let fileReadStream: ReadStream;
const asset = await this.assetRepository.findOne({ where: { id: assetId } });
const asset = await this._assetRepository.get(assetId);
if (!asset) {
throw new NotFoundException('Asset not found');
}
@@ -460,7 +425,7 @@ export class AssetService {
try {
await this._assetRepository.remove(asset);
result.push({ id: asset.id, status: DeleteAssetStatusEnum.SUCCESS });
result.push({ id, status: DeleteAssetStatusEnum.SUCCESS });
deleteQueue.push(asset as any);
// TODO refactor this to use cascades
@@ -584,18 +549,6 @@ export class AssetService {
return this._assetRepository.getAssetByChecksum(userId, checksum);
}
calculateChecksum(filePath: string): Promise<Buffer> {
const fileReadStream = createReadStream(filePath);
const sha1Hash = createHash('sha1');
const deferred = new Promise<Buffer>((resolve, reject) => {
sha1Hash.once('error', (err) => reject(err));
sha1Hash.once('finish', () => resolve(sha1Hash.read()));
});
fileReadStream.pipe(sha1Hash);
return deferred;
}
getAssetCountByUserId(authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
return this._assetRepository.getAssetCountByUserId(authUser.id);
}

View File

@@ -1,14 +1,10 @@
import { Module } from '@nestjs/common';
import { JobService } from './job.service';
import { JobController } from './job.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ExifEntity } from '@app/infra';
import { TagModule } from '../tag/tag.module';
import { AssetModule } from '../asset/asset.module';
import { StorageModule } from '@app/storage';
@Module({
imports: [TypeOrmModule.forFeature([ExifEntity]), TagModule, AssetModule, StorageModule],
imports: [AssetModule],
controllers: [JobController],
providers: [JobService],
})

View File

@@ -1,5 +1,5 @@
import { immichAppConfig } from '@app/common/config';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { AssetModule } from './api-v1/asset/asset.module';
import { ConfigModule } from '@nestjs/config';
import { ServerInfoModule } from './api-v1/server-info/server-info.module';
@@ -61,12 +61,4 @@ import { AuthGuard } from './middlewares/auth.guard';
],
providers: [{ provide: APP_GUARD, useExisting: AuthGuard }, AuthGuard],
})
export class AppModule implements NestModule {
// TODO: check if consumer is needed or remove
// eslint-disable-next-line @typescript-eslint/no-unused-vars
configure(consumer: MiddlewareConsumer): void {
if (process.env.NODE_ENV == 'development') {
// consumer.apply(AppLoggerMiddleware).forRoutes('*');
}
}
}
export class AppModule {}

View File

@@ -1,22 +0,0 @@
import { Injectable, NestMiddleware, Logger } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class AppLoggerMiddleware implements NestMiddleware {
private logger = new Logger('HTTP');
use(request: Request, response: Response, next: NextFunction): void {
const { ip, method, baseUrl } = request;
const userAgent = request.get('user-agent') || '';
response.on('close', () => {
const { statusCode } = response;
const contentLength = response.get('content-length');
this.logger.log(`${method} ${baseUrl} ${statusCode} ${contentLength} - ${userAgent} ${ip}`);
});
next();
}
}

View File

@@ -1109,24 +1109,6 @@
"operationId": "downloadFile",
"description": "",
"parameters": [
{
"name": "isThumb",
"required": false,
"in": "query",
"schema": {
"title": "Is serve thumbnail (resize) file",
"type": "boolean"
}
},
{
"name": "isWeb",
"required": false,
"in": "query",
"schema": {
"title": "Is request made from web",
"type": "boolean"
}
},
{
"name": "assetId",
"required": true,
@@ -2707,7 +2689,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.43.1",
"version": "1.45.0",
"contact": {}
},
"tags": [],

View File

@@ -6,11 +6,6 @@ import { ICryptoRepository } from '../crypto/crypto.repository';
import { LoginResponseDto, mapLoginResponse } from './response-dto';
import { IUserTokenRepository, UserTokenCore } from '../user-token';
export type JwtValidationResult = {
status: boolean;
userId: string | null;
};
export class AuthCore {
private userTokenCore: UserTokenCore;
constructor(

View File

@@ -1,5 +1,4 @@
export * from './auth-user.dto';
export * from './change-password.dto';
export * from './jwt-payload.dto';
export * from './login-credential.dto';
export * from './sign-up.dto';

View File

@@ -1,4 +0,0 @@
export class JwtPayloadDto {
userId!: string;
email!: string;
}

View File

@@ -8,6 +8,7 @@ export * from './domain.module';
export * from './job';
export * from './oauth';
export * from './share';
export * from './storage';
export * from './system-config';
export * from './tag';
export * from './user';

View File

@@ -0,0 +1 @@
export * from './storage.repository';

View File

@@ -0,0 +1,13 @@
import { ReadStream } from 'fs';
export interface ImmichReadStream {
stream: ReadStream;
type: string;
length: number;
}
export const IStorageRepository = 'IStorageRepository';
export interface IStorageRepository {
createReadStream(filepath: string, mimeType: string): Promise<ImmichReadStream>;
}

View File

@@ -4,6 +4,7 @@ export * from './device-info.repository.mock';
export * from './fixtures';
export * from './job.repository.mock';
export * from './shared-link.repository.mock';
export * from './storage.repository.mock';
export * from './system-config.repository.mock';
export * from './user-token.repository.mock';
export * from './user.repository.mock';

View File

@@ -0,0 +1,7 @@
import { IStorageRepository } from '../src';
export const newStorageRepositoryMock = (): jest.Mocked<IStorageRepository> => {
return {
createReadStream: jest.fn(),
};
};

View File

@@ -4,6 +4,7 @@ import {
IJobRepository,
IKeyRepository,
ISharedLinkRepository,
IStorageRepository,
ISystemConfigRepository,
IUserRepository,
QueueName,
@@ -29,6 +30,7 @@ import {
UserTokenEntity,
} from './db';
import { JobRepository } from './job';
import { FilesystemProvider } from './storage';
const providers: Provider[] = [
{ provide: ICryptoRepository, useClass: CryptoRepository },
@@ -36,6 +38,7 @@ const providers: Provider[] = [
{ provide: IKeyRepository, useClass: APIKeyRepository },
{ provide: IJobRepository, useClass: JobRepository },
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository },
{ provide: IStorageRepository, useClass: FilesystemProvider },
{ provide: ISystemConfigRepository, useClass: SystemConfigRepository },
{ provide: IUserRepository, useClass: UserRepository },
{ provide: IUserTokenRepository, useClass: UserTokenRepository },

View File

@@ -0,0 +1,18 @@
import { ImmichReadStream, IStorageRepository } from '@app/domain';
import { constants, createReadStream, stat } from 'fs';
import fs from 'fs/promises';
import { promisify } from 'util';
const fileInfo = promisify(stat);
export class FilesystemProvider implements IStorageRepository {
async createReadStream(filepath: string, mimeType: string): Promise<ImmichReadStream> {
const { size } = await fileInfo(filepath);
await fs.access(filepath, constants.R_OK | constants.W_OK);
return {
stream: createReadStream(filepath),
length: size,
type: mimeType,
};
}
}

View File

@@ -0,0 +1 @@
export * from './filesystem.provider';

1247
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.44.0",
"version": "1.45.0",
"description": "",
"author": "",
"private": true,
@@ -42,7 +42,6 @@
"@nestjs/common": "^9.2.1",
"@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.2.1",
"@nestjs/mapped-types": "1.2.0",
"@nestjs/platform-express": "^9.2.1",
"@nestjs/platform-socket.io": "^9.2.1",
"@nestjs/schedule": "^2.1.0",
@@ -58,15 +57,11 @@
"class-validator": "^0.13.2",
"cookie-parser": "^1.4.6",
"diskusage": "^1.1.3",
"dotenv": "^14.2.0",
"exiftool-vendored": "^19.0.0",
"fdir": "^5.3.0",
"exiftool-vendored.pl": "^12.54.0",
"fluent-ffmpeg": "^2.1.2",
"geo-tz": "^7.0.2",
"handlebars": "^4.7.7",
"i18n-iso-countries": "^7.5.0",
"ioredis": "^5.2.4",
"jest-when": "^3.5.2",
"joi": "^17.5.0",
"local-reverse-geocoder": "0.12.5",
"lodash": "^4.17.21",
@@ -77,11 +72,9 @@
"pg": "^8.8.0",
"redis": "^4.5.1",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0",
"sanitize-filename": "^1.6.3",
"sharp": "^0.28.0",
"systeminformation": "^5.11.0",
"typeorm": "^0.3.11"
},
"devDependencies": {
@@ -107,11 +100,14 @@
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.48.1",
"@typescript-eslint/parser": "^5.48.1",
"dotenv": "^14.2.0",
"eslint": "^8.31.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"jest": "^27.2.5",
"jest-when": "^3.5.2",
"prettier": "^2.3.2",
"rimraf": "^3.0.2",
"source-map-support": "^0.5.20",
"supertest": "^6.1.3",
"ts-jest": "^27.0.3",

View File

@@ -1,29 +1,40 @@
# Our Node base image
FROM node:16-alpine3.14 as base
COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE
WORKDIR /usr/src/app
EXPOSE 3000
RUN apk add --no-cache setpriv
FROM base as builder
RUN chown node:node /usr/src/app
RUN apk add --no-cache setpriv
COPY --chown=node:node package*.json ./
RUN npm ci
COPY --chown=node:node . .
RUN npm run build
EXPOSE 3000
FROM base AS dev
FROM builder AS dev
ENV CHOKIDAR_USEPOLLING=true
EXPOSE 24678
CMD ["npm", "run", "dev"]
FROM base as prod
FROM builder AS prod
RUN npm run build
RUN npm prune --omit=dev
FROM base
ENV NODE_ENV=production
WORKDIR /usr/src/app
COPY --from=prod /usr/src/app/node_modules ./node_modules
COPY --from=prod /usr/src/app/build ./build
COPY package.json package-lock.json ./
COPY entrypoint.sh ./

7
web/package-lock.json generated
View File

@@ -14,7 +14,6 @@
"exifr": "^7.1.3",
"handlebars": "^4.7.7",
"leaflet": "^1.8.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"luxon": "^3.1.1",
"socket.io-client": "^4.5.1",
@@ -8999,7 +8998,8 @@
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
},
"node_modules/lodash-es": {
"version": "4.17.21",
@@ -17880,7 +17880,8 @@
"lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
},
"lodash-es": {
"version": "4.17.21",

View File

@@ -66,7 +66,6 @@
"exifr": "^7.1.3",
"handlebars": "^4.7.7",
"leaflet": "^1.8.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"luxon": "^3.1.1",
"socket.io-client": "^4.5.1",

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.43.0
* The version of the OpenAPI document: 1.43.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@@ -3729,12 +3729,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
/**
*
* @param {string} assetId
* @param {boolean} [isThumb]
* @param {boolean} [isWeb]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
downloadFile: async (assetId: string, isThumb?: boolean, isWeb?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
downloadFile: async (assetId: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'assetId' is not null or undefined
assertParamExists('downloadFile', 'assetId', assetId)
const localVarPath = `/asset/download/{assetId}`
@@ -3754,14 +3752,6 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (isThumb !== undefined) {
localVarQueryParameter['isThumb'] = isThumb;
}
if (isWeb !== undefined) {
localVarQueryParameter['isWeb'] = isWeb;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
@@ -4489,13 +4479,11 @@ export const AssetApiFp = function(configuration?: Configuration) {
/**
*
* @param {string} assetId
* @param {boolean} [isThumb]
* @param {boolean} [isWeb]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async downloadFile(assetId: string, isThumb?: boolean, isWeb?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<object>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFile(assetId, isThumb, isWeb, options);
async downloadFile(assetId: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<object>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFile(assetId, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@@ -4719,13 +4707,11 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
/**
*
* @param {string} assetId
* @param {boolean} [isThumb]
* @param {boolean} [isWeb]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
downloadFile(assetId: string, isThumb?: boolean, isWeb?: boolean, options?: any): AxiosPromise<object> {
return localVarFp.downloadFile(assetId, isThumb, isWeb, options).then((request) => request(axios, basePath));
downloadFile(assetId: string, options?: any): AxiosPromise<object> {
return localVarFp.downloadFile(assetId, options).then((request) => request(axios, basePath));
},
/**
*
@@ -4939,14 +4925,12 @@ export class AssetApi extends BaseAPI {
/**
*
* @param {string} assetId
* @param {boolean} [isThumb]
* @param {boolean} [isWeb]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public downloadFile(assetId: string, isThumb?: boolean, isWeb?: boolean, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).downloadFile(assetId, isThumb, isWeb, options).then((request) => request(this.axios, this.basePath));
public downloadFile(assetId: string, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).downloadFile(assetId, options).then((request) => request(this.axios, this.basePath));
}
/**

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.43.0
* The version of the OpenAPI document: 1.43.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.43.0
* The version of the OpenAPI document: 1.43.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.43.0
* The version of the OpenAPI document: 1.43.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.43.0
* The version of the OpenAPI document: 1.43.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -136,10 +136,8 @@
$downloadAssets[imageFileName] = 0;
const { data, status } = await api.assetApi.downloadFile(assetId, false, false, {
params: {
key
},
const { data, status } = await api.assetApi.downloadFile(assetId, {
params: { key },
responseType: 'blob',
onDownloadProgress: (progressEvent) => {
if (progressEvent.lengthComputable) {