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
+14
View File
@@ -3,6 +3,10 @@ name: Build Mobile
on: on:
workflow_dispatch: workflow_dispatch:
workflow_call: workflow_call:
inputs:
ref:
required: false
type: string
pull_request: pull_request:
push: push:
branches: [main] branches: [main]
@@ -13,7 +17,17 @@ jobs:
runs-on: macos-12 runs-on: macos-12
steps: 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 - uses: actions/checkout@v3
with:
ref: ${{ steps.get-ref.outputs.ref }}
- uses: actions/setup-java@v3 - uses: actions/setup-java@v3
with: with:
+1
View File
@@ -98,3 +98,4 @@ jobs:
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{matrix.image}} cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{matrix.image}}
cache-to: ${{ steps.cache-target.outputs.cache-to }} cache-to: ${{ steps.cache-target.outputs.cache-to }}
tags: ${{ steps.metadata.outputs.tags }} tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}
+17 -6
View File
@@ -18,13 +18,11 @@ on:
type: boolean type: boolean
jobs: jobs:
build_mobile: bump_version:
uses: ./.github/workflows/build-mobile.yml
secrets: inherit
tag_release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build_mobile
outputs:
ref: ${{ steps.push-tag.outputs.commit_long_sha }}
steps: steps:
- name: Checkout - name: Checkout
@@ -36,6 +34,7 @@ jobs:
run: misc/release/pump-version.sh -s "${{ inputs.serverBump }}" -m "${{ inputs.mobileBump }}" run: misc/release/pump-version.sh -s "${{ inputs.serverBump }}" -m "${{ inputs.mobileBump }}"
- name: Commit and tag - name: Commit and tag
id: push-tag
uses: EndBug/add-and-commit@v9 uses: EndBug/add-and-commit@v9
with: with:
author_name: Immich Release Bot author_name: Immich Release Bot
@@ -44,6 +43,18 @@ jobs:
tag: ${{ env.IMMICH_VERSION }} tag: ${{ env.IMMICH_VERSION }}
push: true 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 - name: Download APK
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
with: with:
+1 -9
View File
@@ -1,4 +1,3 @@
FROM node:16-bullseye-slim as builder FROM node:16-bullseye-slim as builder
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
@@ -6,7 +5,7 @@ ARG DEBIAN_FRONTEND=noninteractive
WORKDIR /usr/src/app WORKDIR /usr/src/app
RUN apt-get update 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 ./ COPY package.json package-lock.json ./
@@ -15,24 +14,17 @@ RUN npm rebuild @tensorflow/tfjs-node --build-from-source
COPY . . COPY . .
FROM builder as prod FROM builder as prod
RUN npm run build RUN npm run build
RUN npm prune --omit=dev RUN npm prune --omit=dev
FROM node:16-bullseye-slim FROM node:16-bullseye-slim
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
WORKDIR /usr/src/app 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/node_modules ./node_modules
COPY --from=prod /usr/src/app/dist ./dist COPY --from=prod /usr/src/app/dist ./dist
+331 -234
View File
File diff suppressed because it is too large Load Diff
+2 -11
View File
@@ -23,19 +23,9 @@
"dependencies": { "dependencies": {
"@nestjs/common": "^8.0.0", "@nestjs/common": "^8.0.0",
"@nestjs/core": "^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/coco-ssd": "^2.2.2",
"@tensorflow-models/mobilenet": "^2.1.0", "@tensorflow-models/mobilenet": "^2.1.0",
"@tensorflow/tfjs": "^3.19.0", "@tensorflow/tfjs-node": "^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"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^8.2.4", "@nestjs/cli": "^8.2.4",
@@ -52,6 +42,7 @@
"eslint-plugin-prettier": "^4.0.0", "eslint-plugin-prettier": "^4.0.0",
"jest": "^27.2.5", "jest": "^27.2.5",
"prettier": "^2.3.2", "prettier": "^2.3.2",
"rimraf": "^3.0.2",
"source-map-support": "^0.5.20", "source-map-support": "^0.5.20",
"supertest": "^6.1.3", "supertest": "^6.1.3",
"ts-jest": "^27.0.3", "ts-jest": "^27.0.3",
+1
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.json
sed -i "s/^ \"version\": \"$CURRENT_SERVER\",$/ \"version\": \"$NEXT_SERVER\",/" server/package-lock.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/\"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 sed -i "s/version_number: \"$CURRENT_SERVER\"$/version_number: \"$NEXT_SERVER\"/" mobile/ios/fastlane/Fastfile
fi fi
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 67, "android.injected.version.code" => 68,
"android.injected.version.name" => "1.44.0", "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') 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')
@@ -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
@@ -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
+3 -3
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>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="61.218233"> <testcase classname="fastlane.lanes" name="1: bundleRelease" time="36.955598">
</testcase> </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> </testcase>
+3 -3
View File
@@ -360,7 +360,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 82; CURRENT_PROJECT_VERSION = 84;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -495,7 +495,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 82; CURRENT_PROJECT_VERSION = 84;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -522,7 +522,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 82; CURRENT_PROJECT_VERSION = 84;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
+2 -2
View File
@@ -17,11 +17,11 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.43.0</string> <string>1.45.0</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>82</string> <string>84</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true /> <true />
<key>MGLMapboxMetricsEnabledSettingShownInApp</key> <key>MGLMapboxMetricsEnabledSettingShownInApp</key>
+1 -1
View File
@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta" desc "iOS Beta"
lane :beta do lane :beta do
increment_version_number( increment_version_number(
version_number: "1.44.0" version_number: "1.45.0"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,
+6 -6
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>
<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>
<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>
<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>
<testcase classname="fastlane.lanes" name="4: build_app" time="75.618447"> <testcase classname="fastlane.lanes" name="4: build_app" time="94.172587">
</testcase> </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> </testcase>
+1
View File
@@ -5,6 +5,7 @@ const String deviceIdKey = 'immichBoxDeviceIdKey'; // Key 2
const String isLoggedInKey = 'immichIsLoggedInKey'; // Key 3 const String isLoggedInKey = 'immichIsLoggedInKey'; // Key 3
const String serverEndpointKey = 'immichBoxServerEndpoint'; // Key 4 const String serverEndpointKey = 'immichBoxServerEndpoint'; // Key 4
const String assetEtagKey = 'immichAssetEtagKey'; // Key 5 const String assetEtagKey = 'immichAssetEtagKey'; // Key 5
const String userIdKey = 'immichUserIdKey'; // Key 6
// Login Info // Login Info
const String hiveLoginInfoBox = "immichLoginInfoBox"; // Box const String hiveLoginInfoBox = "immichLoginInfoBox"; // Box
+3
View File
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -155,6 +156,8 @@ class ImmichAppState extends ConsumerState<ImmichApp>
var router = ref.watch(appRouterProvider); var router = ref.watch(appRouterProvider);
ref.watch(releaseInfoProvider.notifier).checkGithubReleaseInfo(); ref.watch(releaseInfoProvider.notifier).checkGithubReleaseInfo();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
return MaterialApp( return MaterialApp(
localizationsDelegates: context.localizationDelegates, localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales, supportedLocales: context.supportedLocales,
@@ -85,9 +85,11 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
right: 10, right: 10,
bottom: 5, bottom: 5,
child: Icon( child: Icon(
(deviceId != asset.deviceId) asset.isRemote
? (deviceId == asset.deviceId
? Icons.cloud_done_outlined ? Icons.cloud_done_outlined
: Icons.photo_library_rounded, : Icons.cloud_outlined)
: Icons.cloud_off_outlined,
color: Colors.white, color: Colors.white,
size: 18, size: 18,
), ),
@@ -121,7 +121,7 @@ class SelectionThumbnailImage extends HookConsumerWidget {
child: Row( child: Row(
children: [ children: [
Text( Text(
asset.duration.substring(0, 7), asset.duration.toString().substring(0, 7),
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 10, fontSize: 10,
@@ -118,7 +118,6 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
appBar: AppBar( appBar: AppBar(
title: const Text( title: const Text(
'share_invite', 'share_invite',
style: TextStyle(color: Colors.black),
).tr(), ).tr(),
elevation: 0, elevation: 0,
centerTitle: false, centerTitle: false,
@@ -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/services/share.service.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/ui/share_dialog.dart'; import 'package:immich_mobile/shared/ui/share_dialog.dart';
import 'package:openapi/api.dart';
class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> { class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
final ImageViewerService _imageViewerService; 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); state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading);
bool isSuccess = await _imageViewerService.downloadAssetToDevice(asset); bool isSuccess = await _imageViewerService.downloadAssetToDevice(asset);
@@ -2,10 +2,9 @@ import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.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:photo_manager/photo_manager.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@@ -18,22 +17,16 @@ class ImageViewerService {
ImageViewerService(this._apiService); ImageViewerService(this._apiService);
Future<bool> downloadAssetToDevice(AssetResponseDto asset) async { Future<bool> downloadAssetToDevice(Asset asset) async {
try { try {
String fileName = p.basename(asset.originalPath);
// Download LivePhotos image and motion part // 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( var imageResponse = await _apiService.assetApi.downloadFileWithHttpInfo(
asset.id, asset.remoteId!,
isThumb: false,
isWeb: false,
); );
var motionReponse = await _apiService.assetApi.downloadFileWithHttpInfo( var motionReponse = await _apiService.assetApi.downloadFileWithHttpInfo(
asset.livePhotoVideoId!, asset.livePhotoVideoId!,
isThumb: false,
isWeb: false,
); );
final AssetEntity? entity; final AssetEntity? entity;
@@ -47,30 +40,28 @@ class ImageViewerService {
entity = await PhotoManager.editor.darwin.saveLivePhoto( entity = await PhotoManager.editor.darwin.saveLivePhoto(
imageFile: imageFile, imageFile: imageFile,
videoFile: videoFile, videoFile: videoFile,
title: p.basename(asset.originalPath), title: asset.fileName,
); );
return entity != null; return entity != null;
} else { } else {
var res = await _apiService.assetApi.downloadFileWithHttpInfo( var res = await _apiService.assetApi
asset.id, .downloadFileWithHttpInfo(asset.remoteId!);
isThumb: false,
isWeb: false,
);
final AssetEntity? entity; final AssetEntity? entity;
if (asset.type == AssetTypeEnum.IMAGE) { if (asset.isImage) {
entity = await PhotoManager.editor.saveImage( entity = await PhotoManager.editor.saveImage(
res.bodyBytes, res.bodyBytes,
title: p.basename(asset.originalPath), title: asset.fileName,
); );
} else { } else {
final tempDir = await getTemporaryDirectory(); 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); tempFile.writeAsBytesSync(res.bodyBytes);
entity = entity = await PhotoManager.editor
await PhotoManager.editor.saveVideo(tempFile, title: fileName); .saveVideo(tempFile, title: asset.fileName);
} }
return entity != null; return entity != null;
} }
@@ -3,9 +3,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.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: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:latlong2/latlong.dart';
import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/utils/bytes_units.dart';
@@ -68,7 +67,7 @@ class ExifBottomSheet extends HookConsumerWidget {
final textColor = Theme.of(context).primaryColor; final textColor = Theme.of(context).primaryColor;
ExifResponseDto? exifInfo = assetDetail.remote?.exifInfo; ExifInfo? exifInfo = assetDetail.exifInfo;
buildLocationText() { buildLocationText() {
return Text( 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( return SingleChildScrollView(
child: Card( child: Card(
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
@@ -101,10 +111,9 @@ class ExifBottomSheet extends HookConsumerWidget {
child: CustomDraggingHandle(), child: CustomDraggingHandle(),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
if (exifInfo?.dateTimeOriginal != null)
Text( Text(
DateFormat('date_format'.tr()).format( DateFormat('date_format'.tr()).format(
exifInfo!.dateTimeOriginal!.toLocal(), assetDetail.createdAt.toLocal(),
), ),
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -113,7 +122,7 @@ class ExifBottomSheet extends HookConsumerWidget {
), ),
// Location // Location
if (assetDetail.latitude != null) if (assetDetail.latitude != null && assetDetail.longitude != null)
Padding( Padding(
padding: const EdgeInsets.only(top: 32.0), padding: const EdgeInsets.only(top: 32.0),
child: Column( child: Column(
@@ -126,22 +135,19 @@ class ExifBottomSheet extends HookConsumerWidget {
"exif_bottom_sheet_location", "exif_bottom_sheet_location",
style: TextStyle(fontSize: 11, color: textColor), style: TextStyle(fontSize: 11, color: textColor),
).tr(), ).tr(),
if (assetDetail.latitude != null &&
assetDetail.longitude != null)
buildMap(), buildMap(),
if (exifInfo != null && if (exifInfo != null &&
exifInfo.city != null && exifInfo.city != null &&
exifInfo.state != null) exifInfo.state != null)
buildLocationText(), buildLocationText(),
Text( Text(
"${assetDetail.latitude?.toStringAsFixed(4)}, ${assetDetail.longitude?.toStringAsFixed(4)}", "${assetDetail.latitude!.toStringAsFixed(4)}, ${assetDetail.longitude!.toStringAsFixed(4)}",
style: const TextStyle(fontSize: 12), style: const TextStyle(fontSize: 12),
) )
], ],
), ),
), ),
// Detail // Detail
if (exifInfo != null)
Padding( Padding(
padding: const EdgeInsets.only(top: 32.0), padding: const EdgeInsets.only(top: 32.0),
child: Column( child: Column(
@@ -163,25 +169,21 @@ class ExifBottomSheet extends HookConsumerWidget {
dense: true, dense: true,
leading: const Icon(Icons.image), leading: const Icon(Icons.image),
title: Text( title: Text(
"${exifInfo.imageName!}${p.extension(assetDetail.remote!.originalPath)}", assetDetail.fileName,
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: textColor, color: textColor,
), ),
), ),
subtitle: exifInfo.exifImageHeight != null subtitle: buildSizeText(assetDetail),
? Text(
"${exifInfo.exifImageHeight} x ${exifInfo.exifImageWidth} ${formatBytes(exifInfo.fileSizeInByte ?? 0)} ",
)
: null,
), ),
if (exifInfo.make != null) if (exifInfo?.make != null)
ListTile( ListTile(
contentPadding: const EdgeInsets.all(0), contentPadding: const EdgeInsets.all(0),
dense: true, dense: true,
leading: const Icon(Icons.camera), leading: const Icon(Icons.camera),
title: Text( title: Text(
"${exifInfo.make} ${exifInfo.model}", "${exifInfo!.make} ${exifInfo.model}",
style: TextStyle( style: TextStyle(
color: textColor, color: textColor,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget { class TopControlAppBar extends HookConsumerWidget {
const TopControlAppBar({ const TopControlAppBar({
Key? key, Key? key,
required this.asset, required this.asset,
@@ -31,7 +31,6 @@ class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget {
return AppBar( return AppBar(
foregroundColor: Colors.grey[100], foregroundColor: Colors.grey[100],
toolbarHeight: 60,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
leading: IconButton( leading: IconButton(
onPressed: () { onPressed: () {
@@ -44,7 +43,7 @@ class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget {
), ),
), ),
actions: [ actions: [
if (asset.remote?.livePhotoVideoId != null) if (asset.livePhotoVideoId != null)
IconButton( IconButton(
iconSize: iconSize, iconSize: iconSize,
splashRadius: iconSize, splashRadius: iconSize,
@@ -105,7 +104,6 @@ class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget {
color: Colors.grey[200], color: Colors.grey[200],
), ),
), ),
if (asset.isRemote)
IconButton( IconButton(
iconSize: iconSize, iconSize: iconSize,
splashRadius: iconSize, splashRadius: iconSize,
@@ -120,7 +118,4 @@ class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget {
], ],
); );
} }
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
} }
@@ -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/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.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/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/home/ui/delete_diaglog.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.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 isLoadPreview = useState(AppSettingsEnum.loadPreview.defaultValue);
final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue); final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue);
final isZoomed = useState<bool>(false); final isZoomed = useState<bool>(false);
final showAppBar = useState<bool>(true);
final indexOfAsset = useState(assetList.indexOf(asset)); final indexOfAsset = useState(assetList.indexOf(asset));
final isPlayingMotionVideo = useState(false); final isPlayingMotionVideo = useState(false);
late Offset localPosition; late Offset localPosition;
@@ -79,41 +80,46 @@ class GalleryViewerPage extends HookConsumerWidget {
} }
} }
/// Thumbnail image of a remote asset. Required asset.remote != null /// Thumbnail image of a remote asset. Required asset.isRemote
ImageProvider remoteThumbnailImageProvider(Asset asset, api.ThumbnailFormat type) { ImageProvider remoteThumbnailImageProvider(
Asset asset,
api.ThumbnailFormat type,
) {
return CachedNetworkImageProvider( return CachedNetworkImageProvider(
getThumbnailUrl( getThumbnailUrl(
asset.remote!, asset,
type: type, type: type,
), ),
cacheKey: getThumbnailCacheKey( cacheKey: getThumbnailCacheKey(
asset.remote!, asset,
type: type, type: type,
), ),
headers: {"Authorization": authToken}, 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) { ImageProvider originalImageProvider(Asset asset) {
return CachedNetworkImageProvider( return CachedNetworkImageProvider(
getImageUrl(asset.remote!), getImageUrl(asset),
cacheKey: getImageCacheKey(asset.remote!), cacheKey: getImageCacheKey(asset),
headers: {"Authorization": authToken}, 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) { ImageProvider localThumbnailImageProvider(Asset asset) {
return AssetEntityImageProvider( return AssetEntityImageProvider(
asset.local!, asset.local!,
isOriginal: false, 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) { ImageProvider localImageProvider(Asset asset) {
return AssetEntityImageProvider(asset.local!); return AssetEntityImageProvider(asset.local!);
} }
@@ -150,13 +156,11 @@ class GalleryViewerPage extends HookConsumerWidget {
context, context,
); );
} }
} }
} }
} }
void showInfo() { void showInfo() {
if (assetList[indexOfAsset.value].isRemote) {
showModalBottomSheet( showModalBottomSheet(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15.0), borderRadius: BorderRadius.circular(15.0),
@@ -170,7 +174,6 @@ class GalleryViewerPage extends HookConsumerWidget {
}, },
); );
} }
}
void handleDelete(Asset deleteAsset) { void handleDelete(Asset deleteAsset) {
showDialog( showDialog(
@@ -224,9 +227,13 @@ class GalleryViewerPage extends HookConsumerWidget {
} }
} }
return Scaffold( buildAppBar() {
backgroundColor: Colors.black, return AnimatedOpacity(
appBar: TopControlAppBar( 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, isPlayingMotionVideo: isPlayingMotionVideo.value,
asset: assetList[indexOfAsset.value], asset: assetList[indexOfAsset.value],
onMoreInfoPressed: () { onMoreInfoPressed: () {
@@ -236,7 +243,7 @@ class GalleryViewerPage extends HookConsumerWidget {
? null ? null
: () { : () {
ref.watch(imageViewerStateProvider.notifier).downloadAsset( ref.watch(imageViewerStateProvider.notifier).downloadAsset(
assetList[indexOfAsset.value].remote!, assetList[indexOfAsset.value],
context, context,
); );
}, },
@@ -248,12 +255,24 @@ class GalleryViewerPage extends HookConsumerWidget {
onToggleMotionVideo: (() { onToggleMotionVideo: (() {
isPlayingMotionVideo.value = !isPlayingMotionVideo.value; isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
}), }),
onDeletePressed: () => handleDelete((assetList[indexOfAsset.value])), onDeletePressed: () =>
onAddToAlbumPressed: () => addToAlbum(assetList[indexOfAsset.value]), handleDelete((assetList[indexOfAsset.value])),
onAddToAlbumPressed: () =>
addToAlbum(assetList[indexOfAsset.value]),
), ),
body: SafeArea( ),
child: PhotoViewGallery.builder( );
scaleStateChangedCallback: (state) => isZoomed.value = state != PhotoViewScaleState.initial, }
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
PhotoViewGallery.builder(
scaleStateChangedCallback: (state) {
isZoomed.value = state != PhotoViewScaleState.initial;
showAppBar.value = !isZoomed.value;
},
pageController: controller, pageController: controller,
scrollPhysics: isZoomed.value scrollPhysics: isZoomed.value
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in ? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
@@ -275,24 +294,35 @@ class GalleryViewerPage extends HookConsumerWidget {
indexOfAsset.value = value; indexOfAsset.value = value;
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
}, },
loadingBuilder: isLoadPreview.value ? (context, event) { loadingBuilder: isLoadPreview.value
? (context, event) {
final asset = assetList[indexOfAsset.value]; final asset = assetList[indexOfAsset.value];
if (!asset.isLocal) { if (!asset.isLocal) {
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to acheive // Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to acheive
// Three-Stage Loading (WEBP -> JPEG -> Original) // Three-Stage Loading (WEBP -> JPEG -> Original)
final webPThumbnail = CachedNetworkImage( final webPThumbnail = CachedNetworkImage(
imageUrl: getThumbnailUrl(asset.remote!, type: api.ThumbnailFormat.WEBP), imageUrl: getThumbnailUrl(asset),
cacheKey: getThumbnailCacheKey(asset.remote!, type: api.ThumbnailFormat.WEBP), cacheKey: getThumbnailCacheKey(asset),
httpHeaders: { 'Authorization': authToken }, httpHeaders: {'Authorization': authToken},
progressIndicatorBuilder: (_, __, ___) => const Center(child: ImmichLoadingIndicator(),), progressIndicatorBuilder: (_, __, ___) => const Center(
child: ImmichLoadingIndicator(),
),
fadeInDuration: const Duration(milliseconds: 0),
fit: BoxFit.contain, fit: BoxFit.contain,
); );
return CachedNetworkImage( return CachedNetworkImage(
imageUrl: getThumbnailUrl(asset.remote!, type: api.ThumbnailFormat.JPEG), imageUrl: getThumbnailUrl(
cacheKey: getThumbnailCacheKey(asset.remote!, type: api.ThumbnailFormat.JPEG), asset,
httpHeaders: { 'Authorization': authToken }, type: api.ThumbnailFormat.JPEG,
),
cacheKey: getThumbnailCacheKey(
asset,
type: api.ThumbnailFormat.JPEG,
),
httpHeaders: {'Authorization': authToken},
fit: BoxFit.contain, fit: BoxFit.contain,
fadeInDuration: const Duration(milliseconds: 0),
placeholder: (_, __) => webPThumbnail, placeholder: (_, __) => webPThumbnail,
); );
} else { } else {
@@ -301,7 +331,8 @@ class GalleryViewerPage extends HookConsumerWidget {
fit: BoxFit.contain, fit: BoxFit.contain,
); );
} }
} : null, }
: null,
builder: (context, index) { builder: (context, index) {
getAssetExif(); getAssetExif();
if (assetList[index].isImage && !isPlayingMotionVideo.value) { if (assetList[index].isImage && !isPlayingMotionVideo.value) {
@@ -320,17 +351,28 @@ class GalleryViewerPage extends HookConsumerWidget {
} }
} }
return PhotoViewGalleryPageOptions( return PhotoViewGalleryPageOptions(
onDragStart: (_, details, __) => localPosition = details.localPosition, onDragStart: (_, details, __) =>
localPosition = details.localPosition,
onDragUpdate: (_, details, __) => handleSwipeUpDown(details), onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
onTapDown: (_, __, ___) =>
showAppBar.value = !showAppBar.value,
imageProvider: provider, imageProvider: provider,
heroAttributes: PhotoViewHeroAttributes(tag: assetList[index].id), heroAttributes:
PhotoViewHeroAttributes(tag: assetList[index].id),
minScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained,
); );
} else { } else {
return PhotoViewGalleryPageOptions.customChild( return PhotoViewGalleryPageOptions.customChild(
onDragStart: (_, details, __) => localPosition = details.localPosition, onDragStart: (_, details, __) =>
localPosition = details.localPosition,
onDragUpdate: (_, details, __) => handleSwipeUpDown(details), onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
heroAttributes: PhotoViewHeroAttributes(tag: assetList[index].id), onTapDown: (_, __, ___) =>
showAppBar.value = !showAppBar.value,
heroAttributes:
PhotoViewHeroAttributes(tag: assetList[index].id),
maxScale: 1.0,
minScale: 1.0,
child: SafeArea(
child: VideoViewerPage( child: VideoViewerPage(
asset: assetList[index], asset: assetList[index],
isMotionVideo: isPlayingMotionVideo.value, isMotionVideo: isPlayingMotionVideo.value,
@@ -340,12 +382,19 @@ class GalleryViewerPage extends HookConsumerWidget {
} }
}, },
), ),
),
); );
} }
}, },
), ),
Positioned(
top: 0,
left: 0,
right: 0,
child: buildAppBar(),
),
],
), ),
); );
} }
} }
@@ -53,8 +53,8 @@ class VideoViewerPage extends HookConsumerWidget {
final box = Hive.box(userInfoBox); final box = Hive.box(userInfoBox);
final String jwtToken = box.get(accessTokenKey); final String jwtToken = box.get(accessTokenKey);
final String videoUrl = isMotionVideo final String videoUrl = isMotionVideo
? '${box.get(serverEndpointKey)}/asset/file/${asset.remote?.livePhotoVideoId!}' ? '${box.get(serverEndpointKey)}/asset/file/${asset.livePhotoVideoId}'
: '${box.get(serverEndpointKey)}/asset/file/${asset.id}'; : '${box.get(serverEndpointKey)}/asset/file/${asset.remoteId}';
return Stack( return Stack(
children: [ children: [
@@ -75,6 +75,9 @@ class BackupService {
final filter = FilterOptionGroup( final filter = FilterOptionGroup(
containsPathModified: true, containsPathModified: true,
orders: [const OrderOption(type: OrderOptionType.updateDate)], 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 now = DateTime.now();
final List<AssetPathEntity?> selectedAlbums = final List<AssetPathEntity?> selectedAlbums =
@@ -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;
}
@@ -24,7 +24,6 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
bool _scrolling = false; bool _scrolling = false;
final Set<String> _selectedAssets = HashSet(); final Set<String> _selectedAssets = HashSet();
Set<Asset> _getSelectedAssets() { Set<Asset> _getSelectedAssets() {
return _selectedAssets return _selectedAssets
.map((e) => widget.allAssets.firstWhereOrNull((a) => a.id == e)) .map((e) => widget.allAssets.firstWhereOrNull((a) => a.id == e))
@@ -103,7 +102,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
return Row( return Row(
key: Key("asset-row-${row.assets.first.id}"), key: Key("asset-row-${row.assets.first.id}"),
children: row.assets.map((Asset asset) { children: row.assets.map((Asset asset) {
bool last = asset == row.assets.last; bool last = asset.id == row.assets.last.id;
return Container( return Container(
key: Key("asset-${asset.id}"), 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Stack( return WillPopScope(
onWillPop: onWillPop,
child: Stack(
children: [ children: [
_buildAssetGrid(), _buildAssetGrid(),
if (widget.selectionActive) _buildMultiSelectIndicator(), if (widget.selectionActive) _buildMultiSelectIndicator(),
], ],
),
); );
} }
} }
+1 -13
View File
@@ -200,18 +200,7 @@ class HomePage extends HookConsumerWidget {
); );
} }
Future<bool> onWillPop() async { return SafeArea(
if (multiselectEnabled.state) {
selectionEnabledHook.value = false;
return false;
}
return true;
}
return WillPopScope(
onWillPop: onWillPop,
child: SafeArea(
bottom: !multiselectEnabled.state, bottom: !multiselectEnabled.state,
top: true, top: true,
child: Stack( child: Stack(
@@ -240,7 +229,6 @@ class HomePage extends HookConsumerWidget {
), ),
], ],
), ),
),
); );
} }
@@ -4,7 +4,7 @@ import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.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/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/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.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'; import 'package:immich_mobile/modules/backup/services/backup.service.dart';
@@ -166,6 +166,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
var deviceInfo = await _deviceInfoService.getDeviceInfo(); var deviceInfo = await _deviceInfoService.getDeviceInfo();
userInfoHiveBox.put(deviceIdKey, deviceInfo["deviceId"]); userInfoHiveBox.put(deviceIdKey, deviceInfo["deviceId"]);
userInfoHiveBox.put(accessTokenKey, accessToken); userInfoHiveBox.put(accessTokenKey, accessToken);
userInfoHiveBox.put(userIdKey, userResponseDto.id);
state = state.copyWith( state = state.copyWith(
isAuthenticated: true, isAuthenticated: true,
@@ -45,9 +45,11 @@ class SearchResultPageState {
isLoading: map['isLoading'] ?? false, isLoading: map['isLoading'] ?? false,
isSuccess: map['isSuccess'] ?? false, isSuccess: map['isSuccess'] ?? false,
isError: map['isError'] ?? false, isError: map['isError'] ?? false,
searchResult: List<Asset>.from( searchResult: List.from(
map['searchResult'] map['searchResult']
?.map((x) => Asset.remote(AssetResponseDto.fromJson(x))), .map(AssetResponseDto.fromJson)
.where((e) => e != null)
.map(Asset.remote),
), ),
); );
} }
@@ -30,9 +30,7 @@ class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> {
isSuccess: false, isSuccess: false,
); );
List<Asset>? assets = (await _searchService.searchAsset(searchTerm)) List<Asset>? assets = await _searchService.searchAsset(searchTerm);
?.map((e) => Asset.remote(e))
.toList();
if (assets != null) { if (assets != null) {
state = state.copyWith( state = state.copyWith(
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:openapi/api.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 { try {
return await _apiService.assetApi final List<AssetResponseDto>? results = await _apiService.assetApi
.searchAsset(SearchAssetDto(searchTerm: searchTerm)); .searchAsset(SearchAssetDto(searchTerm: searchTerm));
if (results == null) {
return null;
}
return results.map((e) => Asset.remote(e)).toList();
} catch (e) { } catch (e) {
debugPrint("[ERROR] [searchAsset] ${e.toString()}"); debugPrint("[ERROR] [searchAsset] ${e.toString()}");
return null; return null;
@@ -50,7 +55,7 @@ class SearchService {
return await _apiService.assetApi.getCuratedObjects(); return await _apiService.assetApi.getCuratedObjects();
} catch (e) { } catch (e) {
debugPrint("Error [getCuratedObjects] ${e.toString()}"); debugPrint("Error [getCuratedObjects] ${e.toString()}");
throw []; return [];
} }
} }
} }
+146 -94
View File
@@ -1,63 +1,128 @@
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.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:openapi/api.dart';
import 'package:photo_manager/photo_manager.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) /// Asset (online or local)
class Asset { class Asset {
Asset.remote(this.remote) { Asset.remote(AssetResponseDto remote)
local = null; : 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) { Asset.local(AssetEntity local, String owner)
remote = null; : localId = local.id,
} latitude = local.latitude,
longitude = local.longitude,
late final AssetResponseDto? remote; durationInSeconds = local.duration,
late final AssetEntity? local; height = local.height,
width = local.width,
bool get isRemote => remote != null; fileName = local.title!,
bool get isLocal => local != null; deviceAssetId = local.id,
deviceId = Hive.box(userInfoBox).get(deviceIdKey),
String get deviceId => ownerId = owner,
isRemote ? remote!.deviceId : Hive.box(userInfoBox).get(deviceIdKey); modifiedAt = local.modifiedDateTime.toUtc(),
createdAt = local.createDateTime.toUtc() {
String get deviceAssetId => isRemote ? remote!.deviceAssetId : local!.id; if (createdAt.year == 1970) {
createdAt = modifiedAt;
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);
} }
} }
bool get isImage => isLocal Asset({
? local!.type == AssetType.image this.localId,
: remote!.type == AssetTypeEnum.IMAGE; 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 AssetEntity? _local;
? remote!.duration
: Duration(seconds: local!.duration).toString();
/// use only for tests AssetEntity? get local {
set createdAt(DateTime val) { if (isLocal && _local == null) {
if (isRemote) { _local = AssetEntity(
remote!.createdAt = val.toIso8601String(); 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 @override
bool operator ==(other) { bool operator ==(other) {
if (other is! Asset) return false; if (other is! Asset) return false;
@@ -67,12 +132,26 @@ class Asset {
@override @override
int get hashCode => id.hashCode; int get hashCode => id.hashCode;
// methods below are only required for caching as JSON
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
if (isLocal) { json["localId"] = localId;
json["local"] = _assetEntityToJson(local!); json["remoteId"] = remoteId;
} else { json["deviceAssetId"] = deviceAssetId;
json["remote"] = remote!.toJson(); 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; return json;
} }
@@ -80,55 +159,28 @@ class Asset {
static Asset? fromJson(dynamic value) { static Asset? fromJson(dynamic value) {
if (value is Map) { if (value is Map) {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
final l = json["local"]; return Asset(
if (l != null) { localId: json["localId"],
return Asset.local(_assetEntityFromJson(l)); remoteId: json["remoteId"],
} else { deviceAssetId: json["deviceAssetId"],
return Asset.remote(AssetResponseDto.fromJson(json["remote"])); deviceId: json["deviceId"],
} ownerId: json["ownerId"],
} createdAt:
return null; DateTime.fromMillisecondsSinceEpoch(json["createdAt"], isUtc: true),
} modifiedAt: DateTime.fromMillisecondsSinceEpoch(
} json["modifiedAt"],
isUtc: true,
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"], latitude: json["latitude"],
longitude: json["longitude"], longitude: json["longitude"],
mimeType: json["mimeType"], durationInSeconds: json["durationInSeconds"],
subtype: json["subtype"], width: json["width"],
height: json["height"],
fileName: json["fileName"],
livePhotoVideoId: json["livePhotoVideoId"],
exifInfo: ExifInfo.fromJson(json["exifInfo"]),
); );
} }
return null; return null;
}
} }
+86
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;
}
}
+28 -20
View File
@@ -4,8 +4,8 @@ import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.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/services/asset_cache.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/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/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
@@ -36,7 +36,7 @@ class AssetsState {
return AssetsState([...allAssets, ...toAdd]); return AssetsState([...allAssets, ...toAdd]);
} }
_groupByDate() async { Future<Map<String, List<Asset>>> _groupByDate() async {
sortCompare(List<Asset> assets) { sortCompare(List<Asset> assets) {
assets.sortByCompare<DateTime>( assets.sortByCompare<DateTime>(
(e) => e.createdAt, (e) => e.createdAt,
@@ -50,11 +50,11 @@ class AssetsState {
return await compute(sortCompare, allAssets.toList()); return await compute(sortCompare, allAssets.toList());
} }
static fromAssetList(List<Asset> assets) { static AssetsState fromAssetList(List<Asset> assets) {
return AssetsState(assets); return AssetsState(assets);
} }
static empty() { static AssetsState empty() {
return AssetsState([]); return AssetsState([]);
} }
} }
@@ -82,7 +82,10 @@ class AssetNotifier extends StateNotifier<AssetsState> {
this._settingsService, this._settingsService,
) : super(AssetsState.fromAssetList([])); ) : super(AssetsState.fromAssetList([]));
_updateAssetsState(List<Asset> newAssetList, {bool cache = true}) async { Future<void> _updateAssetsState(
List<Asset> newAssetList, {
bool cache = true,
}) async {
if (cache) { if (cache) {
_assetCacheService.put(newAssetList); _assetCacheService.put(newAssetList);
} }
@@ -101,20 +104,26 @@ class AssetNotifier extends StateNotifier<AssetsState> {
final stopwatch = Stopwatch(); final stopwatch = Stopwatch();
try { try {
_getAllAssetInProgress = true; _getAllAssetInProgress = true;
final bool isCacheValid = await _assetCacheService.isValid(); bool isCacheValid = await _assetCacheService.isValid();
stopwatch.start(); stopwatch.start();
final Box box = Hive.box(userInfoBox); 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 localTask = _assetService.getLocalAssets(urgent: !isCacheValid);
final remoteTask = _assetService.getRemoteAssets( final remoteTask = _assetService.getRemoteAssets(
etag: isCacheValid ? box.get(assetEtagKey) : null, 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); int remoteBegin = state.allAssets.indexWhere((a) => a.isRemote);
remoteBegin = remoteBegin == -1 ? state.allAssets.length : remoteBegin; remoteBegin = remoteBegin == -1 ? state.allAssets.length : remoteBegin;
@@ -184,7 +193,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
_updateAssetsState([]); _updateAssetsState([]);
} }
onNewAssetUploaded(AssetResponseDto newAsset) { void onNewAssetUploaded(Asset newAsset) {
final int i = state.allAssets.indexWhere( final int i = state.allAssets.indexWhere(
(a) => (a) =>
a.isRemote || a.isRemote ||
@@ -192,13 +201,13 @@ class AssetNotifier extends StateNotifier<AssetsState> {
); );
if (i == -1 || state.allAssets[i].deviceAssetId != newAsset.deviceAssetId) { if (i == -1 || state.allAssets[i].deviceAssetId != newAsset.deviceAssetId) {
_updateAssetsState([...state.allAssets, Asset.remote(newAsset)]); _updateAssetsState([...state.allAssets, newAsset]);
} else { } else {
// order is important to keep all local-only assets at the beginning! // order is important to keep all local-only assets at the beginning!
_updateAssetsState([ _updateAssetsState([
...state.allAssets.slice(0, i), ...state.allAssets.slice(0, i),
...state.allAssets.slice(i + 1), ...state.allAssets.slice(i + 1),
Asset.remote(newAsset), newAsset,
]); ]);
// TODO here is a place to unify local/remote assets by replacing the // TODO here is a place to unify local/remote assets by replacing the
// local-only asset in the state with a local&remote asset // local-only asset in the state with a local&remote asset
@@ -230,7 +239,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
// Delete asset from device // Delete asset from device
for (final Asset asset in assetsToDelete) { for (final Asset asset in assetsToDelete) {
if (asset.isLocal) { if (asset.isLocal) {
local.add(asset.id); local.add(asset.localId!);
} else if (asset.deviceId == deviceId) { } else if (asset.deviceId == deviceId) {
// Delete asset on device if it is still present // Delete asset on device if it is still present
var localAsset = await AssetEntity.fromId(asset.deviceAssetId); var localAsset = await AssetEntity.fromId(asset.deviceAssetId);
@@ -252,8 +261,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
Future<Iterable<String>> _deleteRemoteAssets( Future<Iterable<String>> _deleteRemoteAssets(
Set<Asset> assetsToDelete, Set<Asset> assetsToDelete,
) async { ) async {
final Iterable<AssetResponseDto> remote = final Iterable<Asset> remote = assetsToDelete.where((e) => e.isRemote);
assetsToDelete.where((e) => e.isRemote).map((e) => e.remote!);
final List<DeleteAssetResponseDto> deleteAssetResult = final List<DeleteAssetResponseDto> deleteAssetResult =
await _assetService.deleteAssets(remote) ?? []; await _assetService.deleteAssets(remote) ?? [];
return deleteAssetResult return deleteAssetResult
@@ -5,6 +5,7 @@ import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.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:immich_mobile/shared/providers/asset.provider.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -91,14 +92,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
state = WebsocketState(isConnected: false, socket: null); state = WebsocketState(isConnected: false, socket: null);
}); });
socket.on('on_upload_success', (data) { socket.on('on_upload_success', _handleOnUploadSuccess);
var jsonString = jsonDecode(data.toString());
AssetResponseDto? newAsset = AssetResponseDto.fromJson(jsonString);
if (newAsset != null) {
ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
}
});
} catch (e) { } catch (e) {
debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}"); debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
} }
@@ -122,14 +116,16 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
listenUploadEvent() { listenUploadEvent() {
debugPrint("Start listening to event on_upload_success"); debugPrint("Start listening to event on_upload_success");
state.socket?.on('on_upload_success', (data) { state.socket?.on('on_upload_success', _handleOnUploadSuccess);
var jsonString = jsonDecode(data.toString()); }
AssetResponseDto? newAsset = AssetResponseDto.fromJson(jsonString);
if (newAsset != null) { _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); ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
} }
});
} }
} }
@@ -62,10 +62,11 @@ class AssetService {
} }
final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox); final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey); final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
final String userId = Hive.box(userInfoBox).get(userIdKey);
if (backupAlbumInfo != null) { if (backupAlbumInfo != null) {
return (await _backupService return (await _backupService
.buildUploadCandidates(backupAlbumInfo.deepCopy())) .buildUploadCandidates(backupAlbumInfo.deepCopy()))
.map(Asset.local) .map((e) => Asset.local(e, userId))
.toList(growable: false); .toList(growable: false);
} }
} catch (e) { } catch (e) {
@@ -76,21 +77,24 @@ class AssetService {
Future<Asset?> getAssetById(String assetId) async { Future<Asset?> getAssetById(String assetId) async {
try { 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) { } catch (e) {
debugPrint("Error [getAssetById] ${e.toString()}"); debugPrint("Error [getAssetById] ${e.toString()}");
return null;
} }
return null;
} }
Future<List<DeleteAssetResponseDto>?> deleteAssets( Future<List<DeleteAssetResponseDto>?> deleteAssets(
Iterable<AssetResponseDto> deleteAssets, Iterable<Asset> deleteAssets,
) async { ) async {
try { try {
final List<String> payload = []; final List<String> payload = [];
for (final asset in deleteAssets) { for (final asset in deleteAssets) {
payload.add(asset.id); payload.add(asset.remoteId!);
} }
return await _apiService.assetApi return await _apiService.assetApi
@@ -23,17 +23,15 @@ class AssetCacheService extends JsonCache<List<Asset>> {
} }
@override @override
Future<List<Asset>> get() async { Future<List<Asset>?> get() async {
try { try {
final mapList = await readRawData() as List<dynamic>; final mapList = await readRawData() as List<dynamic>;
final responseData = await compute(_computeEncode, mapList); final responseData = await compute(_computeEncode, mapList);
return responseData; return responseData;
} catch (e) { } catch (e) {
debugPrint(e.toString()); debugPrint(e.toString());
await invalidate();
return []; return null;
} }
} }
} }
+1 -1
View File
@@ -60,5 +60,5 @@ abstract class JsonCache<T> {
} }
void put(T data); void put(T data);
Future<T> get(); Future<T?> get();
} }
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'api.service.dart'; import 'api.service.dart';
@@ -25,13 +24,10 @@ class ShareService {
final downloadedXFiles = assets.map<Future<XFile>>((asset) async { final downloadedXFiles = assets.map<Future<XFile>>((asset) async {
if (asset.isRemote) { if (asset.isRemote) {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final fileName = basename(asset.remote!.originalPath); final fileName = asset.fileName;
final tempFile = await File('${tempDir.path}/$fileName').create(); final tempFile = await File('${tempDir.path}/$fileName').create();
final res = await _apiService.assetApi.downloadFileWithHttpInfo( final res = await _apiService.assetApi
asset.remote!.id, .downloadFileWithHttpInfo(asset.remoteId!);
isThumb: false,
isWeb: false,
);
tempFile.writeAsBytesSync(res.bodyBytes); tempFile.writeAsBytesSync(res.bodyBytes);
return XFile(tempFile.path); return XFile(tempFile.path);
} else { } else {
+30 -5
View File
@@ -1,5 +1,6 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
@@ -15,13 +16,28 @@ class ImmichImage extends StatelessWidget {
this.useGrayBoxPlaceholder = false, this.useGrayBoxPlaceholder = false,
super.key, super.key,
}); });
final Asset asset; final Asset? asset;
final bool useGrayBoxPlaceholder; final bool useGrayBoxPlaceholder;
final double width; final double width;
final double height; final double height;
@override @override
Widget build(BuildContext context) { 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) { if (asset.isLocal) {
return Image( return Image(
image: AssetEntityImageProvider( image: AssetEntityImageProvider(
@@ -49,7 +65,16 @@ class ImmichImage extends StatelessWidget {
)); ));
}, },
errorBuilder: (context, error, stackTrace) { 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( return Icon(
Icons.image_not_supported_outlined, Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor, color: Theme.of(context).primaryColor,
@@ -57,12 +82,12 @@ class ImmichImage extends StatelessWidget {
}, },
); );
} }
final String token = Hive.box(userInfoBox).get(accessTokenKey); final String? token = Hive.box(userInfoBox).get(accessTokenKey);
final String thumbnailRequestUrl = getThumbnailUrl(asset.remote!); final String thumbnailRequestUrl = getThumbnailUrl(asset);
return CachedNetworkImage( return CachedNetworkImage(
imageUrl: thumbnailRequestUrl, imageUrl: thumbnailRequestUrl,
httpHeaders: {"Authorization": "Bearer $token"}, httpHeaders: {"Authorization": "Bearer $token"},
cacheKey: getThumbnailCacheKey(asset.remote!), cacheKey: getThumbnailCacheKey(asset),
width: width, width: width,
height: height, height: height,
// keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and // keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and
+11
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);
}
}
+7 -6
View File
@@ -1,17 +1,18 @@
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import '../constants/hive_box.dart'; import '../constants/hive_box.dart';
String getThumbnailUrl( String getThumbnailUrl(
final AssetResponseDto asset, { final Asset asset, {
ThumbnailFormat type = ThumbnailFormat.WEBP, ThumbnailFormat type = ThumbnailFormat.WEBP,
}) { }) {
return _getThumbnailUrl(asset.id, type: type); return _getThumbnailUrl(asset.remoteId!, type: type);
} }
String getThumbnailCacheKey( String getThumbnailCacheKey(
final AssetResponseDto asset, { final Asset asset, {
ThumbnailFormat type = ThumbnailFormat.WEBP, ThumbnailFormat type = ThumbnailFormat.WEBP,
}) { }) {
return _getThumbnailCacheKey(asset.id, type); return _getThumbnailCacheKey(asset.id, type);
@@ -45,12 +46,12 @@ String getAlbumThumbNailCacheKey(
return _getThumbnailCacheKey(album.albumThumbnailAssetId!, type); return _getThumbnailCacheKey(album.albumThumbnailAssetId!, type);
} }
String getImageUrl(final AssetResponseDto asset) { String getImageUrl(final Asset asset) {
final box = Hive.box(userInfoBox); 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'; return '${asset.id}_fullStage';
} }
+1 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: 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 - Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements ## Requirements
+2 -6
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) [[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** # **downloadFile**
> Object downloadFile(assetId, isThumb, isWeb) > Object downloadFile(assetId)
@@ -248,11 +248,9 @@ import 'package:openapi/api.dart';
final api_instance = AssetApi(); final api_instance = AssetApi();
final assetId = assetId_example; // String | final assetId = assetId_example; // String |
final isThumb = true; // bool |
final isWeb = true; // bool |
try { try {
final result = api_instance.downloadFile(assetId, isThumb, isWeb); final result = api_instance.downloadFile(assetId);
print(result); print(result);
} catch (e) { } catch (e) {
print('Exception when calling AssetApi->downloadFile: $e\n'); print('Exception when calling AssetApi->downloadFile: $e\n');
@@ -264,8 +262,6 @@ try {
Name | Type | Description | Notes Name | Type | Description | Notes
------------- | ------------- | ------------- | ------------- ------------- | ------------- | ------------- | -------------
**assetId** | **String**| | **assetId** | **String**| |
**isThumb** | **bool**| | [optional]
**isWeb** | **bool**| | [optional]
### Return type ### Return type
+3 -18
View File
@@ -234,11 +234,7 @@ class AssetApi {
/// Parameters: /// Parameters:
/// ///
/// * [String] assetId (required): /// * [String] assetId (required):
/// Future<Response> downloadFileWithHttpInfo(String assetId,) async {
/// * [bool] isThumb:
///
/// * [bool] isWeb:
Future<Response> downloadFileWithHttpInfo(String assetId, { bool? isThumb, bool? isWeb, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final path = r'/asset/download/{assetId}' final path = r'/asset/download/{assetId}'
.replaceAll('{assetId}', assetId); .replaceAll('{assetId}', assetId);
@@ -250,13 +246,6 @@ class AssetApi {
final headerParams = <String, String>{}; final headerParams = <String, String>{};
final formParams = <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>[]; const contentTypes = <String>[];
@@ -276,12 +265,8 @@ class AssetApi {
/// Parameters: /// Parameters:
/// ///
/// * [String] assetId (required): /// * [String] assetId (required):
/// Future<Object?> downloadFile(String assetId,) async {
/// * [bool] isThumb: final response = await downloadFileWithHttpInfo(assetId,);
///
/// * [bool] isWeb:
Future<Object?> downloadFile(String assetId, { bool? isThumb, bool? isWeb, }) async {
final response = await downloadFileWithHttpInfo(assetId, isThumb: isThumb, isWeb: isWeb, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
+14 -26
View File
@@ -43,9 +43,7 @@ class AlbumResponseDto {
List<AssetResponseDto> assets; List<AssetResponseDto> assets;
@override @override
bool operator ==(Object other) => bool operator ==(Object other) => identical(this, other) || other is AlbumResponseDto &&
identical(this, other) ||
other is AlbumResponseDto &&
other.assetCount == assetCount && other.assetCount == assetCount &&
other.id == id && other.id == id &&
other.ownerId == ownerId && other.ownerId == ownerId &&
@@ -70,8 +68,7 @@ class AlbumResponseDto {
(assets.hashCode); (assets.hashCode);
@override @override
String toString() => String toString() => 'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]';
'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -101,13 +98,13 @@ class AlbumResponseDto {
// Ensure that the map contains the required keys. // Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null. // Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode! // Note 2: this code is stripped in release mode!
// assert(() { assert(() {
// requiredKeys.forEach((key) { requiredKeys.forEach((key) {
// assert(json.containsKey(key), 'Required key "AlbumResponseDto[$key]" is missing from JSON.'); 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.'); assert(json[key] != null, 'Required key "AlbumResponseDto[$key]" has a null value in JSON.');
// }); });
// return true; return true;
// }()); }());
return AlbumResponseDto( return AlbumResponseDto(
assetCount: mapValueOfType<int>(json, r'assetCount')!, assetCount: mapValueOfType<int>(json, r'assetCount')!,
@@ -115,8 +112,7 @@ class AlbumResponseDto {
ownerId: mapValueOfType<String>(json, r'ownerId')!, ownerId: mapValueOfType<String>(json, r'ownerId')!,
albumName: mapValueOfType<String>(json, r'albumName')!, albumName: mapValueOfType<String>(json, r'albumName')!,
createdAt: mapValueOfType<String>(json, r'createdAt')!, createdAt: mapValueOfType<String>(json, r'createdAt')!,
albumThumbnailAssetId: albumThumbnailAssetId: mapValueOfType<String>(json, r'albumThumbnailAssetId'),
mapValueOfType<String>(json, r'albumThumbnailAssetId'),
shared: mapValueOfType<bool>(json, r'shared')!, shared: mapValueOfType<bool>(json, r'shared')!,
sharedUsers: UserResponseDto.listFromJson(json[r'sharedUsers'])!, sharedUsers: UserResponseDto.listFromJson(json[r'sharedUsers'])!,
assets: AssetResponseDto.listFromJson(json[r'assets'])!, assets: AssetResponseDto.listFromJson(json[r'assets'])!,
@@ -125,10 +121,7 @@ class AlbumResponseDto {
return null; return null;
} }
static List<AlbumResponseDto>? listFromJson( static List<AlbumResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
dynamic json, {
bool growable = false,
}) {
final result = <AlbumResponseDto>[]; final result = <AlbumResponseDto>[];
if (json is List && json.isNotEmpty) { if (json is List && json.isNotEmpty) {
for (final row in json) { 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 // maps a json object with a list of AlbumResponseDto-objects as value to a dart map
static Map<String, List<AlbumResponseDto>> mapListFromJson( static Map<String, List<AlbumResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
dynamic json, {
bool growable = false,
}) {
final map = <String, List<AlbumResponseDto>>{}; final map = <String, List<AlbumResponseDto>>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) { for (final entry in json.entries) {
final value = AlbumResponseDto.listFromJson( final value = AlbumResponseDto.listFromJson(entry.value, growable: growable,);
entry.value,
growable: growable,
);
if (value != null) { if (value != null) {
map[entry.key] = value; map[entry.key] = value;
} }
@@ -189,3 +176,4 @@ class AlbumResponseDto {
'assets', 'assets',
}; };
} }
+13 -24
View File
@@ -82,9 +82,7 @@ class AssetResponseDto {
List<TagResponseDto> tags; List<TagResponseDto> tags;
@override @override
bool operator ==(Object other) => bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
identical(this, other) ||
other is AssetResponseDto &&
other.type == type && other.type == type &&
other.id == id && other.id == id &&
other.deviceAssetId == deviceAssetId && other.deviceAssetId == deviceAssetId &&
@@ -127,8 +125,7 @@ class AssetResponseDto {
(tags.hashCode); (tags.hashCode);
@override @override
String toString() => 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]';
'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() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -191,13 +188,13 @@ class AssetResponseDto {
// Ensure that the map contains the required keys. // Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null. // Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode! // Note 2: this code is stripped in release mode!
// assert(() { assert(() {
// requiredKeys.forEach((key) { requiredKeys.forEach((key) {
// assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.'); 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.'); assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
// }); });
// return true; return true;
// }()); }());
return AssetResponseDto( return AssetResponseDto(
type: AssetTypeEnum.fromJson(json[r'type'])!, type: AssetTypeEnum.fromJson(json[r'type'])!,
@@ -223,10 +220,7 @@ class AssetResponseDto {
return null; return null;
} }
static List<AssetResponseDto>? listFromJson( static List<AssetResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
dynamic json, {
bool growable = false,
}) {
final result = <AssetResponseDto>[]; final result = <AssetResponseDto>[];
if (json is List && json.isNotEmpty) { if (json is List && json.isNotEmpty) {
for (final row in json) { 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 // maps a json object with a list of AssetResponseDto-objects as value to a dart map
static Map<String, List<AssetResponseDto>> mapListFromJson( static Map<String, List<AssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
dynamic json, {
bool growable = false,
}) {
final map = <String, List<AssetResponseDto>>{}; final map = <String, List<AssetResponseDto>>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) { for (final entry in json.entries) {
final value = AssetResponseDto.listFromJson( final value = AssetResponseDto.listFromJson(entry.value, growable: growable,);
entry.value,
growable: growable,
);
if (value != null) { if (value != null) {
map[entry.key] = value; map[entry.key] = value;
} }
@@ -292,3 +280,4 @@ class AssetResponseDto {
'tags', 'tags',
}; };
} }
+1 -1
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 { test('test downloadFile', () async {
// TODO // TODO
}); });
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: "none" publish_to: "none"
version: 1.43.0+66 version: 1.45.0+68
environment: environment:
sdk: ">=2.17.0 <3.0.0" sdk: ">=2.17.0 <3.0.0"
+19 -21
View File
@@ -1,7 +1,6 @@
import 'package:flutter_test/flutter_test.dart'; 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/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:openapi/api.dart';
void main() { void main() {
final List<Asset> testAssets = []; final List<Asset> testAssets = [];
@@ -13,24 +12,14 @@ void main() {
DateTime date = DateTime(2022, month, day); DateTime date = DateTime(2022, month, day);
testAssets.add( testAssets.add(
Asset.remote( Asset(
AssetResponseDto( deviceAssetId: '$i',
type: AssetTypeEnum.IMAGE,
id: '$i',
deviceAssetId: '',
ownerId: '',
deviceId: '', deviceId: '',
originalPath: '', ownerId: '',
resizePath: '', createdAt: date,
createdAt: date.toIso8601String(), modifiedAt: date,
modifiedAt: date.toIso8601String(), durationInSeconds: 0,
isFavorite: false, fileName: '',
mimeType: 'image/jpeg',
duration: '',
webpPath: '',
encodedVideoPath: '',
livePhotoVideoId: '',
),
), ),
); );
} }
@@ -70,11 +59,20 @@ void main() {
// Day 1 // Day 1
// 15 Assets => 5 Rows // 15 Assets => 5 Rows
expect(renderList.elements.length, 18); 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[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[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); expect(renderList.elements[11].date.month, 10);
}); });
+4 -4
View File
@@ -2,7 +2,7 @@ FROM node:16-alpine3.14 as builder
WORKDIR /usr/src/app 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 ./ COPY package.json package-lock.json ./
@@ -14,14 +14,14 @@ COPY . .
FROM builder as prod FROM builder as prod
RUN npm run build RUN npm run build
RUN npm prune --omit=dev RUN npm prune --omit=dev --omit=optional
FROM node:16-alpine3.14 FROM node:16-alpine3.14
WORKDIR /usr/src/app 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/node_modules ./node_modules
COPY --from=prod /usr/src/app/dist ./dist COPY --from=prod /usr/src/app/dist ./dist
@@ -32,7 +32,7 @@ COPY LICENSE /LICENSE
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
COPY start-server.sh start-microservices.sh ./ COPY start-server.sh start-microservices.sh ./
RUN npm link RUN npm link && npm cache clean --force
VOLUME /usr/src/app/upload VOLUME /usr/src/app/upload
@@ -15,6 +15,7 @@ import {
Put, Put,
UploadedFiles, UploadedFiles,
Patch, Patch,
StreamableFile,
} from '@nestjs/common'; } from '@nestjs/common';
import { Authenticated } from '../../decorators/authenticated.decorator'; import { Authenticated } from '../../decorators/authenticated.decorator';
import { AssetService } from './asset.service'; 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 { ApiBearerAuth, ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto'; import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-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 { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
import { AssetFileUploadDto } from './dto/asset-file-upload.dto'; import { AssetFileUploadDto } from './dto/asset-file-upload.dto';
import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.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 { AssetSearchDto } from './dto/asset-search.dto';
import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config'; import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config';
function asStreamableFile({ stream, type, length }: ImmichReadStream) {
return new StreamableFile(stream, { type, length });
}
@ApiBearerAuth() @ApiBearerAuth()
@ApiTags('Asset') @ApiTags('Asset')
@Controller('asset') @Controller('asset')
@@ -92,7 +97,7 @@ export class AssetController {
const responseDto = await this.assetService.uploadFile(authUser, dto, file, livePhotoFile); const responseDto = await this.assetService.uploadFile(authUser, dto, file, livePhotoFile);
if (responseDto.duplicate) { if (responseDto.duplicate) {
res.send(200); res.status(200);
} }
return responseDto; return responseDto;
@@ -103,12 +108,9 @@ export class AssetController {
async downloadFile( async downloadFile(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@Response({ passthrough: true }) res: Res, @Response({ passthrough: true }) res: Res,
@Query(new ValidationPipe({ transform: true })) query: ServeFileDto,
@Param('assetId') assetId: string, @Param('assetId') assetId: string,
): Promise<any> { ): Promise<any> {
this.assetService.checkDownloadAccess(authUser); return this.assetService.downloadFile(authUser, assetId).then(asStreamableFile);
await this.assetService.checkAssetsAccess(authUser, [assetId]);
return this.assetService.downloadFile(query, assetId, res);
} }
@Authenticated({ isShared: true }) @Authenticated({ isShared: true })
@@ -9,12 +9,13 @@ import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-use
import { DownloadService } from '../../modules/download/download.service'; import { DownloadService } from '../../modules/download/download.service';
import { AlbumRepository, IAlbumRepository } from '../album/album-repository'; import { AlbumRepository, IAlbumRepository } from '../album/album-repository';
import { StorageService } from '@app/storage'; import { StorageService } from '@app/storage';
import { ICryptoRepository, IJobRepository, ISharedLinkRepository, JobName } from '@app/domain'; import { ICryptoRepository, IJobRepository, ISharedLinkRepository, IStorageRepository, JobName } from '@app/domain';
import { import {
authStub, authStub,
newCryptoRepositoryMock, newCryptoRepositoryMock,
newJobRepositoryMock, newJobRepositoryMock,
newSharedLinkRepositoryMock, newSharedLinkRepositoryMock,
newStorageRepositoryMock,
sharedLinkResponseStub, sharedLinkResponseStub,
sharedLinkStub, sharedLinkStub,
} from '@app/domain/../test'; } from '@app/domain/../test';
@@ -110,6 +111,7 @@ describe('AssetService', () => {
let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>; let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>; let cryptoMock: jest.Mocked<ICryptoRepository>;
let jobMock: jest.Mocked<IJobRepository>; let jobMock: jest.Mocked<IJobRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
beforeEach(() => { beforeEach(() => {
assetRepositoryMock = { assetRepositoryMock = {
@@ -154,6 +156,7 @@ describe('AssetService', () => {
sharedLinkRepositoryMock = newSharedLinkRepositoryMock(); sharedLinkRepositoryMock = newSharedLinkRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
cryptoMock = newCryptoRepositoryMock(); cryptoMock = newCryptoRepositoryMock();
storageMock = newStorageRepositoryMock();
sut = new AssetService( sut = new AssetService(
assetRepositoryMock, assetRepositoryMock,
@@ -164,6 +167,7 @@ describe('AssetService', () => {
sharedLinkRepositoryMock, sharedLinkRepositoryMock,
jobMock, jobMock,
cryptoMock, cryptoMock,
storageMock,
); );
}); });
@@ -413,4 +417,15 @@ describe('AssetService', () => {
expect(() => sut.checkDownloadAccess(authStub.readonlySharedLink)).toThrow(ForbiddenException); 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');
});
});
}); });
@@ -10,7 +10,6 @@ import {
StreamableFile, StreamableFile,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { createHash } from 'node:crypto';
import { QueryFailedError, Repository } from 'typeorm'; import { QueryFailedError, Repository } from 'typeorm';
import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { AssetEntity, AssetType, SharedLinkType } from '@app/infra'; import { AssetEntity, AssetType, SharedLinkType } from '@app/infra';
@@ -23,7 +22,14 @@ import { SearchAssetDto } from './dto/search-asset.dto';
import fs from 'fs/promises'; import fs from 'fs/promises';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto'; import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.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 { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto'; import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto'; import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto';
@@ -73,6 +79,7 @@ export class AssetService {
@Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository, @Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
@Inject(IStorageRepository) private storage: IStorageRepository,
) { ) {
this.assetCore = new AssetCore(_assetRepository, jobRepository, storageService); this.assetCore = new AssetCore(_assetRepository, jobRepository, storageService);
this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository); this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository);
@@ -189,62 +196,21 @@ export class AssetService {
return this.downloadService.downloadArchive(`immich-${now}`, assetToDownload); 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 { try {
let fileReadStream = null; const asset = await this._assetRepository.get(assetId);
const asset = await this._assetRepository.getById(assetId); if (asset && asset.originalPath && asset.mimeType) {
return this.storage.createReadStream(asset.originalPath, asset.mimeType);
// 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);
}
}
return new StreamableFile(fileReadStream);
} catch (e) { } catch (e) {
Logger.error(`Error download asset ${e}`, 'downloadFile'); Logger.error(`Error download asset ${e}`, 'downloadFile');
throw new InternalServerErrorException(`Failed to download asset ${e}`, 'DownloadFile'); throw new InternalServerErrorException(`Failed to download asset ${e}`, 'DownloadFile');
} }
throw new NotFoundException();
} }
public async getAssetThumbnail( public async getAssetThumbnail(
@@ -255,8 +221,7 @@ export class AssetService {
) { ) {
let fileReadStream: ReadStream; let fileReadStream: ReadStream;
const asset = await this.assetRepository.findOne({ where: { id: assetId } }); const asset = await this._assetRepository.get(assetId);
if (!asset) { if (!asset) {
throw new NotFoundException('Asset not found'); throw new NotFoundException('Asset not found');
} }
@@ -460,7 +425,7 @@ export class AssetService {
try { try {
await this._assetRepository.remove(asset); await this._assetRepository.remove(asset);
result.push({ id: asset.id, status: DeleteAssetStatusEnum.SUCCESS }); result.push({ id, status: DeleteAssetStatusEnum.SUCCESS });
deleteQueue.push(asset as any); deleteQueue.push(asset as any);
// TODO refactor this to use cascades // TODO refactor this to use cascades
@@ -584,18 +549,6 @@ export class AssetService {
return this._assetRepository.getAssetByChecksum(userId, checksum); 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> { getAssetCountByUserId(authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
return this._assetRepository.getAssetCountByUserId(authUser.id); return this._assetRepository.getAssetCountByUserId(authUser.id);
} }
@@ -1,14 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JobService } from './job.service'; import { JobService } from './job.service';
import { JobController } from './job.controller'; 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 { AssetModule } from '../asset/asset.module';
import { StorageModule } from '@app/storage';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([ExifEntity]), TagModule, AssetModule, StorageModule], imports: [AssetModule],
controllers: [JobController], controllers: [JobController],
providers: [JobService], providers: [JobService],
}) })
+2 -10
View File
@@ -1,5 +1,5 @@
import { immichAppConfig } from '@app/common/config'; 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 { AssetModule } from './api-v1/asset/asset.module';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ServerInfoModule } from './api-v1/server-info/server-info.module'; 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], providers: [{ provide: APP_GUARD, useExisting: AuthGuard }, AuthGuard],
}) })
export class AppModule implements NestModule { export class AppModule {}
// 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('*');
}
}
}
@@ -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();
}
}
+1 -19
View File
@@ -1109,24 +1109,6 @@
"operationId": "downloadFile", "operationId": "downloadFile",
"description": "", "description": "",
"parameters": [ "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", "name": "assetId",
"required": true, "required": true,
@@ -2707,7 +2689,7 @@
"info": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "1.43.1", "version": "1.45.0",
"contact": {} "contact": {}
}, },
"tags": [], "tags": [],
-5
View File
@@ -6,11 +6,6 @@ import { ICryptoRepository } from '../crypto/crypto.repository';
import { LoginResponseDto, mapLoginResponse } from './response-dto'; import { LoginResponseDto, mapLoginResponse } from './response-dto';
import { IUserTokenRepository, UserTokenCore } from '../user-token'; import { IUserTokenRepository, UserTokenCore } from '../user-token';
export type JwtValidationResult = {
status: boolean;
userId: string | null;
};
export class AuthCore { export class AuthCore {
private userTokenCore: UserTokenCore; private userTokenCore: UserTokenCore;
constructor( constructor(
-1
View File
@@ -1,5 +1,4 @@
export * from './auth-user.dto'; export * from './auth-user.dto';
export * from './change-password.dto'; export * from './change-password.dto';
export * from './jwt-payload.dto';
export * from './login-credential.dto'; export * from './login-credential.dto';
export * from './sign-up.dto'; export * from './sign-up.dto';
@@ -1,4 +0,0 @@
export class JwtPayloadDto {
userId!: string;
email!: string;
}
+1
View File
@@ -8,6 +8,7 @@ export * from './domain.module';
export * from './job'; export * from './job';
export * from './oauth'; export * from './oauth';
export * from './share'; export * from './share';
export * from './storage';
export * from './system-config'; export * from './system-config';
export * from './tag'; export * from './tag';
export * from './user'; export * from './user';
+1
View File
@@ -0,0 +1 @@
export * from './storage.repository';
@@ -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>;
}
+1
View File
@@ -4,6 +4,7 @@ export * from './device-info.repository.mock';
export * from './fixtures'; export * from './fixtures';
export * from './job.repository.mock'; export * from './job.repository.mock';
export * from './shared-link.repository.mock'; export * from './shared-link.repository.mock';
export * from './storage.repository.mock';
export * from './system-config.repository.mock'; export * from './system-config.repository.mock';
export * from './user-token.repository.mock'; export * from './user-token.repository.mock';
export * from './user.repository.mock'; export * from './user.repository.mock';
@@ -0,0 +1,7 @@
import { IStorageRepository } from '../src';
export const newStorageRepositoryMock = (): jest.Mocked<IStorageRepository> => {
return {
createReadStream: jest.fn(),
};
};
+3
View File
@@ -4,6 +4,7 @@ import {
IJobRepository, IJobRepository,
IKeyRepository, IKeyRepository,
ISharedLinkRepository, ISharedLinkRepository,
IStorageRepository,
ISystemConfigRepository, ISystemConfigRepository,
IUserRepository, IUserRepository,
QueueName, QueueName,
@@ -29,6 +30,7 @@ import {
UserTokenEntity, UserTokenEntity,
} from './db'; } from './db';
import { JobRepository } from './job'; import { JobRepository } from './job';
import { FilesystemProvider } from './storage';
const providers: Provider[] = [ const providers: Provider[] = [
{ provide: ICryptoRepository, useClass: CryptoRepository }, { provide: ICryptoRepository, useClass: CryptoRepository },
@@ -36,6 +38,7 @@ const providers: Provider[] = [
{ provide: IKeyRepository, useClass: APIKeyRepository }, { provide: IKeyRepository, useClass: APIKeyRepository },
{ provide: IJobRepository, useClass: JobRepository }, { provide: IJobRepository, useClass: JobRepository },
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository }, { provide: ISharedLinkRepository, useClass: SharedLinkRepository },
{ provide: IStorageRepository, useClass: FilesystemProvider },
{ provide: ISystemConfigRepository, useClass: SystemConfigRepository }, { provide: ISystemConfigRepository, useClass: SystemConfigRepository },
{ provide: IUserRepository, useClass: UserRepository }, { provide: IUserRepository, useClass: UserRepository },
{ provide: IUserTokenRepository, useClass: UserTokenRepository }, { provide: IUserTokenRepository, useClass: UserTokenRepository },
@@ -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,
};
}
}
+1
View File
@@ -0,0 +1 @@
export * from './filesystem.provider';
+731 -516
View File
File diff suppressed because it is too large Load Diff
+5 -9
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "1.44.0", "version": "1.45.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -42,7 +42,6 @@
"@nestjs/common": "^9.2.1", "@nestjs/common": "^9.2.1",
"@nestjs/config": "^2.2.0", "@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.2.1", "@nestjs/core": "^9.2.1",
"@nestjs/mapped-types": "1.2.0",
"@nestjs/platform-express": "^9.2.1", "@nestjs/platform-express": "^9.2.1",
"@nestjs/platform-socket.io": "^9.2.1", "@nestjs/platform-socket.io": "^9.2.1",
"@nestjs/schedule": "^2.1.0", "@nestjs/schedule": "^2.1.0",
@@ -58,15 +57,11 @@
"class-validator": "^0.13.2", "class-validator": "^0.13.2",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"diskusage": "^1.1.3", "diskusage": "^1.1.3",
"dotenv": "^14.2.0",
"exiftool-vendored": "^19.0.0", "exiftool-vendored": "^19.0.0",
"fdir": "^5.3.0", "exiftool-vendored.pl": "^12.54.0",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"geo-tz": "^7.0.2",
"handlebars": "^4.7.7", "handlebars": "^4.7.7",
"i18n-iso-countries": "^7.5.0", "i18n-iso-countries": "^7.5.0",
"ioredis": "^5.2.4",
"jest-when": "^3.5.2",
"joi": "^17.5.0", "joi": "^17.5.0",
"local-reverse-geocoder": "0.12.5", "local-reverse-geocoder": "0.12.5",
"lodash": "^4.17.21", "lodash": "^4.17.21",
@@ -77,11 +72,9 @@
"pg": "^8.8.0", "pg": "^8.8.0",
"redis": "^4.5.1", "redis": "^4.5.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0", "rxjs": "^7.2.0",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"sharp": "^0.28.0", "sharp": "^0.28.0",
"systeminformation": "^5.11.0",
"typeorm": "^0.3.11" "typeorm": "^0.3.11"
}, },
"devDependencies": { "devDependencies": {
@@ -107,11 +100,14 @@
"@types/supertest": "^2.0.11", "@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.48.1", "@typescript-eslint/eslint-plugin": "^5.48.1",
"@typescript-eslint/parser": "^5.48.1", "@typescript-eslint/parser": "^5.48.1",
"dotenv": "^14.2.0",
"eslint": "^8.31.0", "eslint": "^8.31.0",
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0", "eslint-plugin-prettier": "^4.0.0",
"jest": "^27.2.5", "jest": "^27.2.5",
"jest-when": "^3.5.2",
"prettier": "^2.3.2", "prettier": "^2.3.2",
"rimraf": "^3.0.2",
"source-map-support": "^0.5.20", "source-map-support": "^0.5.20",
"supertest": "^6.1.3", "supertest": "^6.1.3",
"ts-jest": "^27.0.3", "ts-jest": "^27.0.3",
+20 -9
View File
@@ -1,29 +1,40 @@
# Our Node base image # Our Node base image
FROM node:16-alpine3.14 as base FROM node:16-alpine3.14 as base
COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE
WORKDIR /usr/src/app WORKDIR /usr/src/app
EXPOSE 3000
RUN apk add --no-cache setpriv
FROM base as builder
RUN chown node:node /usr/src/app RUN chown node:node /usr/src/app
RUN apk add --no-cache setpriv
COPY --chown=node:node package*.json ./ COPY --chown=node:node package*.json ./
RUN npm ci RUN npm ci
COPY --chown=node:node . . COPY --chown=node:node . .
RUN npm run build
EXPOSE 3000 EXPOSE 3000
FROM base AS dev FROM builder AS dev
ENV CHOKIDAR_USEPOLLING=true ENV CHOKIDAR_USEPOLLING=true
EXPOSE 24678 EXPOSE 24678
CMD ["npm", "run", "dev"] 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 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 ./
+4 -3
View File
@@ -14,7 +14,6 @@
"exifr": "^7.1.3", "exifr": "^7.1.3",
"handlebars": "^4.7.7", "handlebars": "^4.7.7",
"leaflet": "^1.8.0", "leaflet": "^1.8.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"luxon": "^3.1.1", "luxon": "^3.1.1",
"socket.io-client": "^4.5.1", "socket.io-client": "^4.5.1",
@@ -8999,7 +8998,8 @@
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "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": { "node_modules/lodash-es": {
"version": "4.17.21", "version": "4.17.21",
@@ -17880,7 +17880,8 @@
"lodash": { "lodash": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
}, },
"lodash-es": { "lodash-es": {
"version": "4.17.21", "version": "4.17.21",
-1
View File
@@ -66,7 +66,6 @@
"exifr": "^7.1.3", "exifr": "^7.1.3",
"handlebars": "^4.7.7", "handlebars": "^4.7.7",
"leaflet": "^1.8.0", "leaflet": "^1.8.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"luxon": "^3.1.1", "luxon": "^3.1.1",
"socket.io-client": "^4.5.1", "socket.io-client": "^4.5.1",
+8 -24
View File
@@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * 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). * 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 {string} assetId
* @param {boolean} [isThumb]
* @param {boolean} [isWeb]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @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 // verify required parameter 'assetId' is not null or undefined
assertParamExists('downloadFile', 'assetId', assetId) assertParamExists('downloadFile', 'assetId', assetId)
const localVarPath = `/asset/download/{assetId}` const localVarPath = `/asset/download/{assetId}`
@@ -3754,14 +3752,6 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
// http bearer authentication required // http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration) await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (isThumb !== undefined) {
localVarQueryParameter['isThumb'] = isThumb;
}
if (isWeb !== undefined) {
localVarQueryParameter['isWeb'] = isWeb;
}
setSearchParams(localVarUrlObj, localVarQueryParameter); setSearchParams(localVarUrlObj, localVarQueryParameter);
@@ -4489,13 +4479,11 @@ export const AssetApiFp = function(configuration?: Configuration) {
/** /**
* *
* @param {string} assetId * @param {string} assetId
* @param {boolean} [isThumb]
* @param {boolean} [isWeb]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
async downloadFile(assetId: string, isThumb?: boolean, isWeb?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<object>> { async downloadFile(assetId: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<object>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFile(assetId, isThumb, isWeb, options); const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFile(assetId, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/** /**
@@ -4719,13 +4707,11 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
/** /**
* *
* @param {string} assetId * @param {string} assetId
* @param {boolean} [isThumb]
* @param {boolean} [isWeb]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
downloadFile(assetId: string, isThumb?: boolean, isWeb?: boolean, options?: any): AxiosPromise<object> { downloadFile(assetId: string, options?: any): AxiosPromise<object> {
return localVarFp.downloadFile(assetId, isThumb, isWeb, options).then((request) => request(axios, basePath)); return localVarFp.downloadFile(assetId, options).then((request) => request(axios, basePath));
}, },
/** /**
* *
@@ -4939,14 +4925,12 @@ export class AssetApi extends BaseAPI {
/** /**
* *
* @param {string} assetId * @param {string} assetId
* @param {boolean} [isThumb]
* @param {boolean} [isWeb]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
* @memberof AssetApi * @memberof AssetApi
*/ */
public downloadFile(assetId: string, isThumb?: boolean, isWeb?: boolean, options?: AxiosRequestConfig) { public downloadFile(assetId: string, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).downloadFile(assetId, isThumb, isWeb, options).then((request) => request(this.axios, this.basePath)); return AssetApiFp(this.configuration).downloadFile(assetId, options).then((request) => request(this.axios, this.basePath));
} }
/** /**
+1 -1
View File
@@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * 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). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+1 -1
View File
@@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * 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). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+1 -1
View File
@@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * 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). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+1 -1
View File
@@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * 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). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@@ -136,10 +136,8 @@
$downloadAssets[imageFileName] = 0; $downloadAssets[imageFileName] = 0;
const { data, status } = await api.assetApi.downloadFile(assetId, false, false, { const { data, status } = await api.assetApi.downloadFile(assetId, {
params: { params: { key },
key
},
responseType: 'blob', responseType: 'blob',
onDownloadProgress: (progressEvent) => { onDownloadProgress: (progressEvent) => {
if (progressEvent.lengthComputable) { if (progressEvent.lengthComputable) {