Compare commits

...

22 Commits

Author SHA1 Message Date
Alex
f3032f74a4 Added changelog for Fdroid 2022-07-06 22:35:07 -05:00
Alex
58ec7553ea Add information for uploading asset and error indication with error message for each failed upload. (#315)
* Added info box

* Fixed upload endpoint doesn't report error status code

* Added chip to show update error

* Added chip to show failed upload

* Add duplication check for upload

* Better duplication-checking placement

* Remove check for duplicated asset

* Added failed backup status route

* added page

* Display error card with thumbnail

* Improved styling

* Set thumbnail with better quality

* Remove force upload error
2022-07-06 16:12:55 -05:00
Alex
357f7d1c31 Added schedule job to perform reverse geocoding if key is added after backing up assets (#305) 2022-07-04 15:16:39 -05:00
Zack Pollard
e6d30d72fa Fix typeorm migrations (#297)
* fix: remove config parameter from typeorm cli and update config

the config parameter is no longer supported since version 0.3
the config now needs to export a DataSource object to work with the 0.3 cli

* fix: update all typeorm entities and migrations to be aligned with database structure

* Fixed test-util import databaseConfig

* Fixed column mismatch in raw query with new migration

* Remove dist build directory when starting dev server

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2022-07-04 14:20:43 -05:00
Jaime Baez
355038a91a Use npm ci for installing pacakages (#304) 2022-07-04 13:47:25 -05:00
Alex
97d9b80baa Added creation date for video from ffmpeg.prob (#303) 2022-07-04 13:44:43 -05:00
Alex Tran
b6814fad57 Up version for hotfix 2022-07-03 20:55:30 -05:00
Alex
7586c65103 Fix cannot query shared album on mobile (#298) 2022-07-03 20:52:03 -05:00
Alex
633170d743 Fixed inconnect image grouping with the same date but different year (#296) 2022-07-03 18:00:56 -05:00
Alex Tran
c5be7827c3 Remove 2284 to avoid confusion since 443 is not exposed from internal proxy 2022-07-03 11:37:26 -05:00
Alex Tran
e84c705e31 Added changelog to Fdroid 2022-07-03 10:49:37 -05:00
Alex Tran
36162509e0 Up version for release 2022-07-03 10:39:09 -05:00
Alex
76bf1c0379 Remove thumbnail generation on mobile app (#292)
* Remove thumbnail generation on mobile

* Remove tconditions for missing thumbnail on the backend

* Remove console.log

* Refactor queue systems

* Convert queue and processor name to constant

* Added corresponding interface to job queue
2022-07-02 21:06:36 -05:00
Alex
32b847c26e Fixed event propagation trigger navigating twice (#293) 2022-07-01 20:49:41 -05:00
Alex
a45d6fdf57 Fix server crash on bad file operation and other optimizations (#291)
* Fixed issue with generating thumbnail for video with 0 length cause undefined file and crash the server
* Added all file error handling operation
* Temporarily disabled WebSocket on the web because receiving a new upload event doesn't put the new file in the correct place. 
* Cosmetic fixed on the info panel
2022-07-01 12:00:12 -05:00
Zack Pollard
c071e64a7e infra: switch port to 3003 for machine learning container (#290)
* infra: switch port to 3003 for machine learning container

fixes #289

* Changed port of machine-learning-endpoint to match with new port

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2022-07-01 10:20:04 -05:00
Alex
663f12851e Fixed filename duplication when upload from web (#288)
* Fixed filename duplication when upload from web

* Fixed cosmetic of detail panel view
2022-06-30 20:43:33 -05:00
xpwmaosldk
c4ef523564 Optimize mobile - Avoid creating unnecessary widgets (#268)
* Avoid creating unnecessary widgets

* more flexible null handling and runtime errors prevention
2022-06-30 20:08:49 -05:00
Alex
992f792c0a Fixed admin is forced to change password on mobile app (#287)
* Fixed issues

* Upversion and add changed log
2022-06-30 13:59:02 -05:00
Alex Tran
97611fa057 Fixed issue with unexposed Nginx port on release image 2022-06-30 00:26:54 -05:00
Alex Tran
32240777c3 fixed release build directory for Github action 2022-06-30 00:10:01 -05:00
Alex Tran
6065ff8caa Update readme with new discord invitation link 2022-06-29 23:50:24 -05:00
96 changed files with 1783 additions and 822 deletions

View File

@@ -149,8 +149,8 @@ jobs:
- name: Build and push immich-proxy release - name: Build and push immich-proxy release
uses: docker/build-push-action@v3.0.0 uses: docker/build-push-action@v3.0.0
with: with:
context: ./web context: ./nginx
file: ./web/Dockerfile file: ./nginx/Dockerfile
platforms: linux/arm/v7,linux/amd64,linux/arm64 platforms: linux/arm/v7,linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: | tags: |

View File

@@ -1,11 +1,11 @@
dev: dev:
docker-compose -f ./docker/docker-compose.dev.yml up --remove-orphans rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --remove-orphans
dev-update: dev-update:
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
dev-scale: dev-scale:
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
stage: stage:
docker-compose -f ./docker/docker-compose.staging.yml up --build -V --remove-orphans docker-compose -f ./docker/docker-compose.staging.yml up --build -V --remove-orphans
@@ -17,4 +17,4 @@ prod:
docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans
prod-scale: prod-scale:
docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich-server=5 --scale immich-microservices=3 --remove-orphans docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans

View File

@@ -10,7 +10,7 @@
<a href="https://actions-badge.atrox.dev/alextran1502/immich/goto?ref=main"> <a href="https://actions-badge.atrox.dev/alextran1502/immich/goto?ref=main">
<img alt="Build Status" src="https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Falextran1502%2Fimmich%2Fbadge%3Fref%3Dmain&style=for-the-badge&label=Github Action&logo=github&labelColor=ececec&logoColor=000000" /> <img alt="Build Status" src="https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Falextran1502%2Fimmich%2Fbadge%3Fref%3Dmain&style=for-the-badge&label=Github Action&logo=github&labelColor=ececec&logoColor=000000" />
</a> </a>
<a href="https://discord.gg/rxnyVTXGbM"> <a href="https://discord.gg/D8JsnBEuKb">
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Immich%20Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Immich Discord"/> <img src="https://img.shields.io/discord/979116623879368755.svg?label=Immich%20Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Immich Discord"/>
</a> </a>
<br/> <br/>

View File

@@ -73,7 +73,6 @@ services:
image: altran1502/immich-proxy:release image: altran1502/immich-proxy:release
ports: ports:
- 2283:80 - 2283:80
- 2284:443
logging: logging:
driver: none driver: none
depends_on: depends_on:

View File

@@ -1,4 +1,4 @@
FROM node:16-bullseye-slim FROM node:16-bullseye-slim
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
@@ -9,7 +9,7 @@ COPY package.json package-lock.json ./
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 ffmpeg -y
RUN npm install RUN npm ci
COPY . . COPY . .

View File

@@ -5,7 +5,7 @@ import { Logger } from '@nestjs/common';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
await app.listen(3001, () => { await app.listen(3003, () => {
if (process.env.NODE_ENV == 'development') { if (process.env.NODE_ENV == 'development') {
Logger.log( Logger.log(
'Running Immich Machine Learning in DEVELOPMENT environment', 'Running Immich Machine Learning in DEVELOPMENT environment',

View File

@@ -0,0 +1,2 @@
* Fixed admin is forced to change password upon logging in on mobile app
* Fixed change password form validation

View File

@@ -0,0 +1 @@
* Removed thumbnail generation on mobile - the operation now will be on the server to reduce CPU load and battery usage.

View File

@@ -0,0 +1 @@
* Hot fix: Restore shared album functionality

View File

@@ -0,0 +1 @@
* Add information for uploading asset and error indication with error message for each failed upload.

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.14.0" version_number: "1.17.0"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,

View File

@@ -39,14 +39,10 @@ class ImageViewerService {
entity = await PhotoManager.editor.saveVideo(tempFile, title: fileName); entity = await PhotoManager.editor.saveVideo(tempFile, title: fileName);
} }
if (entity != null) { return entity != null;
return true;
}
} catch (e) { } catch (e) {
debugPrint("Error saving file $e"); debugPrint("Error saving file $e");
return false; return false;
} }
return false;
} }
} }

View File

@@ -15,81 +15,72 @@ class ExifBottomSheet extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
_buildMap() { _buildMap() {
return (assetDetail.exifInfo!.latitude != null && return Padding(
assetDetail.exifInfo!.longitude != null) padding: const EdgeInsets.symmetric(vertical: 16.0),
? Padding( child: Container(
padding: const EdgeInsets.symmetric(vertical: 16.0), height: 150,
child: Container( width: MediaQuery.of(context).size.width,
height: 150, decoration: const BoxDecoration(
width: MediaQuery.of(context).size.width, borderRadius: BorderRadius.all(Radius.circular(15)),
decoration: const BoxDecoration( ),
borderRadius: BorderRadius.all(Radius.circular(15)), child: FlutterMap(
), options: MapOptions(
child: FlutterMap( center: LatLng(assetDetail.exifInfo!.latitude!,
options: MapOptions( assetDetail.exifInfo!.longitude!),
center: LatLng(assetDetail.exifInfo!.latitude!, zoom: 16.0,
assetDetail.exifInfo!.longitude!), ),
zoom: 16.0, layers: [
), TileLayerOptions(
layers: [ urlTemplate:
TileLayerOptions( "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
urlTemplate: subdomains: ['a', 'b', 'c'],
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", attributionBuilder: (_) {
subdomains: ['a', 'b', 'c'], return const Text(
attributionBuilder: (_) { "© OpenStreetMap",
return const Text( style: TextStyle(fontSize: 10),
"© OpenStreetMap", );
style: TextStyle(fontSize: 10), },
);
},
),
MarkerLayerOptions(
markers: [
Marker(
anchorPos: AnchorPos.align(AnchorAlign.top),
point: LatLng(assetDetail.exifInfo!.latitude!,
assetDetail.exifInfo!.longitude!),
builder: (ctx) => const Image(
image: AssetImage('assets/location-pin.png')),
),
],
),
],
),
), ),
) MarkerLayerOptions(
: Container(); markers: [
Marker(
anchorPos: AnchorPos.align(AnchorAlign.top),
point: LatLng(assetDetail.exifInfo!.latitude!,
assetDetail.exifInfo!.longitude!),
builder: (ctx) => const Image(
image: AssetImage('assets/location-pin.png')),
),
],
),
],
),
),
);
} }
_buildLocationText() { _buildLocationText() {
return (assetDetail.exifInfo!.city != null && return Text(
assetDetail.exifInfo!.state != null) "${assetDetail.exifInfo!.city}, ${assetDetail.exifInfo!.state}",
? Text( style: TextStyle(
"${assetDetail.exifInfo!.city}, ${assetDetail.exifInfo!.state}", fontSize: 12, color: Colors.grey[200], fontWeight: FontWeight.bold),
style: TextStyle( );
fontSize: 12,
color: Colors.grey[200],
fontWeight: FontWeight.bold),
)
: Container();
} }
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8), padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8),
child: ListView( child: ListView(
children: [ children: [
assetDetail.exifInfo?.dateTimeOriginal != null if (assetDetail.exifInfo?.dateTimeOriginal != null)
? Text( Text(
DateFormat('E, LLL d, y • h:mm a').format( DateFormat('E, LLL d, y • h:mm a').format(
DateTime.parse(assetDetail.exifInfo!.dateTimeOriginal!), DateTime.parse(assetDetail.exifInfo!.dateTimeOriginal!),
), ),
style: TextStyle( style: TextStyle(
color: Colors.grey[400], color: Colors.grey[400],
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 14, fontSize: 14,
), ),
) ),
: Container(),
Padding( Padding(
padding: const EdgeInsets.only(top: 16.0), padding: const EdgeInsets.only(top: 16.0),
child: Text( child: Text(
@@ -102,84 +93,83 @@ class ExifBottomSheet extends ConsumerWidget {
), ),
// Location // Location
assetDetail.exifInfo?.latitude != null if (assetDetail.exifInfo?.latitude != null)
? Padding( Padding(
padding: const EdgeInsets.only(top: 32.0), padding: const EdgeInsets.only(top: 32.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Divider( Divider(
thickness: 1, thickness: 1,
color: Colors.grey[600], color: Colors.grey[600],
),
Text(
"LOCATION",
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
),
_buildMap(),
_buildLocationText(),
Text(
"${assetDetail.exifInfo!.latitude!.toStringAsFixed(4)}, ${assetDetail.exifInfo!.longitude!.toStringAsFixed(4)}",
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
)
],
), ),
) Text(
: Container(), "LOCATION",
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
),
if (assetDetail.exifInfo?.latitude != null &&
assetDetail.exifInfo?.longitude != null)
_buildMap(),
if (assetDetail.exifInfo?.city != null &&
assetDetail.exifInfo?.state != null)
_buildLocationText(),
Text(
"${assetDetail.exifInfo?.latitude?.toStringAsFixed(4)}, ${assetDetail.exifInfo?.longitude?.toStringAsFixed(4)}",
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
)
],
),
),
// Detail // Detail
assetDetail.exifInfo != null if (assetDetail.exifInfo != null)
? Padding( Padding(
padding: const EdgeInsets.only(top: 32.0), padding: const EdgeInsets.only(top: 32.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Divider( Divider(
thickness: 1, thickness: 1,
color: Colors.grey[600], color: Colors.grey[600],
),
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
"DETAILS",
style:
TextStyle(fontSize: 11, color: Colors.grey[400]),
),
),
ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
textColor: Colors.grey[300],
iconColor: Colors.grey[300],
leading: const Icon(Icons.image),
title: Text(
"${assetDetail.exifInfo?.imageName!}${p.extension(assetDetail.originalPath)}",
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: assetDetail.exifInfo?.exifImageHeight != null
? Text(
"${assetDetail.exifInfo?.exifImageHeight} x ${assetDetail.exifInfo?.exifImageWidth} ${assetDetail.exifInfo?.fileSizeInByte!}B ")
: Container(),
),
assetDetail.exifInfo?.make != null
? ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
textColor: Colors.grey[300],
iconColor: Colors.grey[300],
leading: const Icon(Icons.camera),
title: Text(
"${assetDetail.exifInfo?.make} ${assetDetail.exifInfo?.model}",
style: const TextStyle(
fontWeight: FontWeight.bold),
),
subtitle: Text(
"ƒ/${assetDetail.exifInfo?.fNumber} 1/${(1 / assetDetail.exifInfo!.exposureTime!).toStringAsFixed(0)} ${assetDetail.exifInfo?.focalLength}mm ISO${assetDetail.exifInfo?.iso} "),
)
: Container()
],
), ),
) Padding(
: Container() padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
"DETAILS",
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
),
),
ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
textColor: Colors.grey[300],
iconColor: Colors.grey[300],
leading: const Icon(Icons.image),
title: Text(
"${assetDetail.exifInfo?.imageName!}${p.extension(assetDetail.originalPath)}",
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: assetDetail.exifInfo?.exifImageHeight != null
? Text(
"${assetDetail.exifInfo?.exifImageHeight} x ${assetDetail.exifInfo?.exifImageWidth} ${assetDetail.exifInfo?.fileSizeInByte!}B ")
: null,
),
if (assetDetail.exifInfo?.make != null)
ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
textColor: Colors.grey[300],
iconColor: Colors.grey[300],
leading: const Icon(Icons.camera),
title: Text(
"${assetDetail.exifInfo?.make} ${assetDetail.exifInfo?.model}",
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
"ƒ/${assetDetail.exifInfo?.fNumber} 1/${(1 / (assetDetail.exifInfo?.exposureTime ?? 1)).toStringAsFixed(0)} ${assetDetail.exifInfo?.focalLength}mm ISO${assetDetail.exifInfo?.iso} "),
),
],
),
),
], ],
), ),
); );

View File

@@ -147,8 +147,7 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return chewieController != null && return chewieController?.videoPlayerController.value.isInitialized == true
chewieController!.videoPlayerController.value.isInitialized
? SizedBox( ? SizedBox(
child: Chewie( child: Chewie(
controller: chewieController!, controller: chewieController!,

View File

@@ -1,13 +1,14 @@
import 'package:cancellation_token_http/http.dart'; import 'package:cancellation_token_http/http.dart';
import 'package:equatable/equatable.dart'; import 'package:collection/collection.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/modules/backup/models/available_album.model.dart'; import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/shared/models/server_info.model.dart'; import 'package:immich_mobile/shared/models/server_info.model.dart';
enum BackUpProgressEnum { idle, inProgress, done } enum BackUpProgressEnum { idle, inProgress, done }
class BackUpState extends Equatable { class BackUpState {
// enum // enum
final BackUpProgressEnum backupProgress; final BackUpProgressEnum backupProgress;
final List<String> allAssetsInDatabase; final List<String> allAssetsInDatabase;
@@ -26,6 +27,9 @@ class BackUpState extends Equatable {
/// All assets from the selected albums that have been backup /// All assets from the selected albums that have been backup
final Set<String> selectedAlbumsBackupAssetsIds; final Set<String> selectedAlbumsBackupAssetsIds;
// Current Backup Asset
final CurrentUploadAsset currentUploadAsset;
const BackUpState({ const BackUpState({
required this.backupProgress, required this.backupProgress,
required this.allAssetsInDatabase, required this.allAssetsInDatabase,
@@ -37,6 +41,7 @@ class BackUpState extends Equatable {
required this.excludedBackupAlbums, required this.excludedBackupAlbums,
required this.allUniqueAssets, required this.allUniqueAssets,
required this.selectedAlbumsBackupAssetsIds, required this.selectedAlbumsBackupAssetsIds,
required this.currentUploadAsset,
}); });
BackUpState copyWith({ BackUpState copyWith({
@@ -50,6 +55,7 @@ class BackUpState extends Equatable {
Set<AssetPathEntity>? excludedBackupAlbums, Set<AssetPathEntity>? excludedBackupAlbums,
Set<AssetEntity>? allUniqueAssets, Set<AssetEntity>? allUniqueAssets,
Set<String>? selectedAlbumsBackupAssetsIds, Set<String>? selectedAlbumsBackupAssetsIds,
CurrentUploadAsset? currentUploadAsset,
}) { }) {
return BackUpState( return BackUpState(
backupProgress: backupProgress ?? this.backupProgress, backupProgress: backupProgress ?? this.backupProgress,
@@ -63,27 +69,47 @@ class BackUpState extends Equatable {
allUniqueAssets: allUniqueAssets ?? this.allUniqueAssets, allUniqueAssets: allUniqueAssets ?? this.allUniqueAssets,
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssetsIds:
selectedAlbumsBackupAssetsIds ?? this.selectedAlbumsBackupAssetsIds, selectedAlbumsBackupAssetsIds ?? this.selectedAlbumsBackupAssetsIds,
currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset,
); );
} }
@override @override
String toString() { String toString() {
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds)'; return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
} }
@override @override
List<Object> get props { bool operator ==(Object other) {
return [ if (identical(this, other)) return true;
backupProgress, final collectionEquals = const DeepCollectionEquality().equals;
allAssetsInDatabase,
progressInPercentage, return other is BackUpState &&
cancelToken, other.backupProgress == backupProgress &&
serverInfo, collectionEquals(other.allAssetsInDatabase, allAssetsInDatabase) &&
availableAlbums, other.progressInPercentage == progressInPercentage &&
selectedBackupAlbums, other.cancelToken == cancelToken &&
excludedBackupAlbums, other.serverInfo == serverInfo &&
allUniqueAssets, collectionEquals(other.availableAlbums, availableAlbums) &&
selectedAlbumsBackupAssetsIds, collectionEquals(other.selectedBackupAlbums, selectedBackupAlbums) &&
]; collectionEquals(other.excludedBackupAlbums, excludedBackupAlbums) &&
collectionEquals(other.allUniqueAssets, allUniqueAssets) &&
collectionEquals(other.selectedAlbumsBackupAssetsIds,
selectedAlbumsBackupAssetsIds) &&
other.currentUploadAsset == currentUploadAsset;
}
@override
int get hashCode {
return backupProgress.hashCode ^
allAssetsInDatabase.hashCode ^
progressInPercentage.hashCode ^
cancelToken.hashCode ^
serverInfo.hashCode ^
availableAlbums.hashCode ^
selectedBackupAlbums.hashCode ^
excludedBackupAlbums.hashCode ^
allUniqueAssets.hashCode ^
selectedAlbumsBackupAssetsIds.hashCode ^
currentUploadAsset.hashCode;
} }
} }

View File

@@ -0,0 +1,48 @@
import 'dart:convert';
class CheckDuplicateAssetResponse {
final bool isExist;
CheckDuplicateAssetResponse({
required this.isExist,
});
CheckDuplicateAssetResponse copyWith({
bool? isExist,
}) {
return CheckDuplicateAssetResponse(
isExist: isExist ?? this.isExist,
);
}
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
result.addAll({'isExist': isExist});
return result;
}
factory CheckDuplicateAssetResponse.fromMap(Map<String, dynamic> map) {
return CheckDuplicateAssetResponse(
isExist: map['isExist'] ?? false,
);
}
String toJson() => json.encode(toMap());
factory CheckDuplicateAssetResponse.fromJson(String source) =>
CheckDuplicateAssetResponse.fromMap(json.decode(source));
@override
String toString() => 'CheckDuplicateAssetResponse(isExist: $isExist)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is CheckDuplicateAssetResponse && other.isExist == isExist;
}
@override
int get hashCode => isExist.hashCode;
}

View File

@@ -0,0 +1,78 @@
import 'dart:convert';
class CurrentUploadAsset {
final String id;
final DateTime createdAt;
final String fileName;
final String fileType;
CurrentUploadAsset({
required this.id,
required this.createdAt,
required this.fileName,
required this.fileType,
});
CurrentUploadAsset copyWith({
String? id,
DateTime? createdAt,
String? fileName,
String? fileType,
}) {
return CurrentUploadAsset(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
fileName: fileName ?? this.fileName,
fileType: fileType ?? this.fileType,
);
}
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
result.addAll({'id': id});
result.addAll({'createdAt': createdAt.millisecondsSinceEpoch});
result.addAll({'fileName': fileName});
result.addAll({'fileType': fileType});
return result;
}
factory CurrentUploadAsset.fromMap(Map<String, dynamic> map) {
return CurrentUploadAsset(
id: map['id'] ?? '',
createdAt: DateTime.fromMillisecondsSinceEpoch(map['createdAt']),
fileName: map['fileName'] ?? '',
fileType: map['fileType'] ?? '',
);
}
String toJson() => json.encode(toMap());
factory CurrentUploadAsset.fromJson(String source) =>
CurrentUploadAsset.fromMap(json.decode(source));
@override
String toString() {
return 'CurrentUploadAsset(id: $id, createdAt: $createdAt, fileName: $fileName, fileType: $fileType)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is CurrentUploadAsset &&
other.id == id &&
other.createdAt == createdAt &&
other.fileName == fileName &&
other.fileType == fileType;
}
@override
int get hashCode {
return id.hashCode ^
createdAt.hashCode ^
fileName.hashCode ^
fileType.hashCode;
}
}

View File

@@ -0,0 +1,53 @@
import 'package:equatable/equatable.dart';
import 'package:photo_manager/photo_manager.dart';
class ErrorUploadAsset extends Equatable {
final String id;
final DateTime createdAt;
final String fileName;
final String fileType;
final AssetEntity asset;
final String errorMessage;
const ErrorUploadAsset({
required this.id,
required this.createdAt,
required this.fileName,
required this.fileType,
required this.asset,
required this.errorMessage,
});
ErrorUploadAsset copyWith({
String? id,
DateTime? createdAt,
String? fileName,
String? fileType,
AssetEntity? asset,
String? errorMessage,
}) {
return ErrorUploadAsset(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
fileName: fileName ?? this.fileName,
fileType: fileType ?? this.fileType,
asset: asset ?? this.asset,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
String toString() {
return 'ErrorUploadAsset(id: $id, createdAt: $createdAt, fileName: $fileName, fileType: $fileType, asset: $asset, errorMessage: $errorMessage)';
}
@override
List<Object> get props {
return [
id,
fileName,
fileType,
errorMessage,
];
}
}

View File

@@ -5,7 +5,10 @@ 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/backup/models/available_album.model.dart'; import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart'; import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart'; import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart'; import 'package:immich_mobile/modules/backup/services/backup.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/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
@@ -14,8 +17,12 @@ import 'package:immich_mobile/shared/services/server_info.service.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
class BackupNotifier extends StateNotifier<BackUpState> { class BackupNotifier extends StateNotifier<BackUpState> {
BackupNotifier(this._backupService, this._serverInfoService, this._authState) BackupNotifier(
: super( this._backupService,
this._serverInfoService,
this._authState,
this.ref,
) : super(
BackUpState( BackUpState(
backupProgress: BackUpProgressEnum.idle, backupProgress: BackUpProgressEnum.idle,
allAssetsInDatabase: const [], allAssetsInDatabase: const [],
@@ -35,6 +42,12 @@ class BackupNotifier extends StateNotifier<BackUpState> {
excludedBackupAlbums: const {}, excludedBackupAlbums: const {},
allUniqueAssets: const {}, allUniqueAssets: const {},
selectedAlbumsBackupAssetsIds: const {}, selectedAlbumsBackupAssetsIds: const {},
currentUploadAsset: CurrentUploadAsset(
id: '...',
createdAt: DateTime.parse('2020-10-04'),
fileName: '...',
fileType: '...',
),
), ),
) { ) {
getBackupInfo(); getBackupInfo();
@@ -43,6 +56,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final BackupService _backupService; final BackupService _backupService;
final ServerInfoService _serverInfoService; final ServerInfoService _serverInfoService;
final AuthenticationState _authState; final AuthenticationState _authState;
final Ref ref;
/// ///
/// UI INTERACTION /// UI INTERACTION
@@ -235,8 +249,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// and then update the UI according to those information /// and then update the UI according to those information
/// ///
Future<void> getBackupInfo() async { Future<void> getBackupInfo() async {
await _getBackupAlbumsInfo(); await Future.wait([
await _updateServerInfo(); _getBackupAlbumsInfo(),
_updateServerInfo(),
]);
await _updateBackupAssetCount(); await _updateBackupAssetCount();
} }
@@ -287,13 +304,27 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Perform Backup // Perform Backup
state = state.copyWith(cancelToken: CancellationToken()); state = state.copyWith(cancelToken: CancellationToken());
_backupService.backupAsset(assetsWillBeBackup, state.cancelToken, _backupService.backupAsset(
_onAssetUploaded, _onUploadProgress); assetsWillBeBackup,
state.cancelToken,
_onAssetUploaded,
_onUploadProgress,
_onSetCurrentBackupAsset,
_onBackupError,
);
} else { } else {
PhotoManager.openSetting(); PhotoManager.openSetting();
} }
} }
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
ref.watch(errorBackupListProvider.notifier).add(errorAssetInfo);
}
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
state = state.copyWith(currentUploadAsset: currentUploadAsset);
}
void cancelBackup() { void cancelBackup() {
state.cancelToken.cancel(); state.cancelToken.cancel();
state = state.copyWith( state = state.copyWith(
@@ -375,5 +406,6 @@ final backupProvider =
ref.watch(backupServiceProvider), ref.watch(backupServiceProvider),
ref.watch(serverInfoServiceProvider), ref.watch(serverInfoServiceProvider),
ref.watch(authenticationProvider), ref.watch(authenticationProvider),
ref,
); );
}); });

View File

@@ -0,0 +1,23 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
class ErrorBackupListNotifier extends StateNotifier<Set<ErrorUploadAsset>> {
ErrorBackupListNotifier() : super({});
add(ErrorUploadAsset errorAsset) {
state = state.union({errorAsset});
}
remove(ErrorUploadAsset errorAsset) {
state = state.difference({errorAsset});
}
empty() {
state = {};
}
}
final errorBackupListProvider =
StateNotifierProvider<ErrorBackupListNotifier, Set<ErrorUploadAsset>>(
(ref) => ErrorBackupListNotifier(),
);

View File

@@ -7,6 +7,9 @@ import 'package:flutter/material.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/backup/models/check_duplicate_asset_response.model.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
import 'package:immich_mobile/shared/services/network.service.dart'; import 'package:immich_mobile/shared/services/network.service.dart';
import 'package:immich_mobile/shared/models/device_info.model.dart'; import 'package:immich_mobile/shared/models/device_info.model.dart';
import 'package:immich_mobile/utils/files_helper.dart'; import 'package:immich_mobile/utils/files_helper.dart';
@@ -20,6 +23,7 @@ final backupServiceProvider =
class BackupService { class BackupService {
final NetworkService _networkService; final NetworkService _networkService;
BackupService(this._networkService); BackupService(this._networkService);
Future<List<String>> getDeviceBackupAsset() async { Future<List<String>> getDeviceBackupAsset() async {
@@ -32,17 +36,40 @@ class BackupService {
return result.cast<String>(); return result.cast<String>();
} }
Future<bool> checkDuplicateAsset(String deviceAssetId) async {
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
try {
Response response =
await _networkService.postRequest(url: "asset/check", data: {
"deviceId": deviceId,
"deviceAssetId": deviceAssetId,
});
if (response.statusCode == 200) {
var result = CheckDuplicateAssetResponse.fromJson(response.toString());
return result.isExist;
} else {
return false;
}
} catch (e) {
return false;
}
}
backupAsset( backupAsset(
Set<AssetEntity> assetList, Set<AssetEntity> assetList,
http.CancellationToken cancelToken, http.CancellationToken cancelToken,
Function(String, String) singleAssetDoneCb, Function(String, String) singleAssetDoneCb,
Function(int, int) uploadProgress) async { Function(int, int) uploadProgressCb,
Function(CurrentUploadAsset) setCurrentUploadAssetCb,
Function(ErrorUploadAsset) errorCb,
) async {
String deviceId = Hive.box(userInfoBox).get(deviceIdKey); String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey); String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
File? file; File? file;
http.MultipartFile? thumbnailUploadData;
for (var entity in assetList) { for (var entity in assetList) {
try { try {
if (entity.type == AssetType.video) { if (entity.type == AssetType.video) {
@@ -69,27 +96,12 @@ class BackupService {
), ),
); );
// Build thumbnail multipart data
var thumbnailData = await entity
.thumbnailDataWithSize(const ThumbnailSize(1440, 2560));
if (thumbnailData != null) {
thumbnailUploadData = http.MultipartFile.fromBytes(
"thumbnailData",
List.from(thumbnailData),
filename: fileNameWithoutPath,
contentType: MediaType(
"image",
"jpeg",
),
);
}
var box = Hive.box(userInfoBox); var box = Hive.box(userInfoBox);
var req = MultipartRequest( var req = MultipartRequest(
'POST', Uri.parse('$savedEndpoint/asset/upload'), 'POST', Uri.parse('$savedEndpoint/asset/upload'),
onProgress: ((bytes, totalBytes) => onProgress: ((bytes, totalBytes) =>
uploadProgress(bytes, totalBytes))); uploadProgressCb(bytes, totalBytes)));
req.headers["Authorization"] = "Bearer ${box.get(accessTokenKey)}"; req.headers["Authorization"] = "Bearer ${box.get(accessTokenKey)}";
req.fields['deviceAssetId'] = entity.id; req.fields['deviceAssetId'] = entity.id;
@@ -101,15 +113,37 @@ class BackupService {
req.fields['fileExtension'] = fileExtension; req.fields['fileExtension'] = fileExtension;
req.fields['duration'] = entity.videoDuration.toString(); req.fields['duration'] = entity.videoDuration.toString();
if (thumbnailUploadData != null) {
req.files.add(thumbnailUploadData);
}
req.files.add(assetRawUploadData); req.files.add(assetRawUploadData);
var res = await req.send(cancellationToken: cancelToken); setCurrentUploadAssetCb(
CurrentUploadAsset(
id: entity.id,
createdAt: entity.createDateTime,
fileName: originalFileName,
fileType: _getAssetType(entity.type),
),
);
if (res.statusCode == 201) { var response = await req.send(cancellationToken: cancelToken);
if (response.statusCode == 201) {
singleAssetDoneCb(entity.id, deviceId); singleAssetDoneCb(entity.id, deviceId);
} else {
var data = await response.stream.bytesToString();
var error = jsonDecode(data);
debugPrint(
"Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}");
errorCb(ErrorUploadAsset(
asset: entity,
id: entity.id,
createdAt: entity.createDateTime,
fileName: originalFileName,
fileType: _getAssetType(entity.type),
errorMessage: error['error'],
));
continue;
} }
} }
} on http.CancelledException { } on http.CancelledException {
@@ -126,6 +160,8 @@ class BackupService {
} }
} }
void sendBackupRequest(AssetEntity entity) {}
String _getAssetType(AssetType assetType) { String _getAssetType(AssetType assetType) {
switch (assetType) { switch (assetType) {
case AssetType.audio: case AssetType.audio:

View File

@@ -56,7 +56,7 @@ class AlbumInfoCard extends HookConsumerWidget {
); );
} }
return Container(); return const SizedBox();
} }
_buildImageFilter() { _buildImageFilter() {
@@ -151,7 +151,11 @@ class AlbumInfoCard extends HookConsumerWidget {
), ),
child: null, child: null,
), ),
Positioned(bottom: 10, left: 25, child: _buildSelectedTextBox()) Positioned(
bottom: 10,
left: 25,
child: _buildSelectedTextBox(),
)
], ],
), ),
Padding( Padding(
@@ -176,8 +180,7 @@ class AlbumInfoCard extends HookConsumerWidget {
Padding( Padding(
padding: const EdgeInsets.only(top: 2.0), padding: const EdgeInsets.only(top: 2.0),
child: Text( child: Text(
albumInfo.assetCount.toString() + '${albumInfo.assetCount} ${(albumInfo.isAll ? " (ALL)" : "")}',
(albumInfo.isAll ? " (ALL)" : ""),
style: TextStyle( style: TextStyle(
fontSize: 12, color: Colors.grey[600]), fontSize: 12, color: Colors.grey[600]),
), ),

View File

@@ -188,11 +188,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
color: Colors.grey[700]), color: Colors.grey[700]),
), ),
trailing: Text( trailing: Text(
ref '${ref.watch(backupProvider).allUniqueAssets.length}',
.watch(backupProvider)
.allUniqueAssets
.length
.toString(),
style: const TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
), ),
), ),
@@ -203,7 +199,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
ListTile( ListTile(
title: Text( title: Text(
"Albums on device (${availableAlbums.length.toString()})", "Albums on device (${availableAlbums.length})",
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
), ),
subtitle: Padding( subtitle: Padding(

View File

@@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.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/backup/models/backup_state.model.dart'; import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
@@ -9,6 +10,7 @@ import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/modules/backup/ui/backup_info_card.dart'; import 'package:immich_mobile/modules/backup/ui/backup_info_card.dart';
import 'package:intl/intl.dart';
import 'package:percent_indicator/linear_percent_indicator.dart'; import 'package:percent_indicator/linear_percent_indicator.dart';
class BackupControllerPage extends HookConsumerWidget { class BackupControllerPage extends HookConsumerWidget {
@@ -42,7 +44,7 @@ class BackupControllerPage extends HookConsumerWidget {
color: Theme.of(context).primaryColor, color: Theme.of(context).primaryColor,
), ),
title: const Text( title: const Text(
"Server Storage", "Server storage",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
), ),
subtitle: Padding( subtitle: Padding(
@@ -56,7 +58,7 @@ class BackupControllerPage extends HookConsumerWidget {
padding: padding:
const EdgeInsets.symmetric(horizontal: 0, vertical: 0), const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
barRadius: const Radius.circular(2), barRadius: const Radius.circular(2),
lineHeight: 6.0, lineHeight: 10.0,
percent: backupState.serverInfo.diskUsagePercentage / 100.0, percent: backupState.serverInfo.diskUsagePercentage / 100.0,
backgroundColor: Colors.grey, backgroundColor: Colors.grey,
progressColor: Theme.of(context).primaryColor, progressColor: Theme.of(context).primaryColor,
@@ -96,12 +98,11 @@ class BackupControllerPage extends HookConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
!isAutoBackup if (!isAutoBackup)
? const Text( const Text(
"Turn on backup to automatically upload new assets to the server.", "Turn on backup to automatically upload new assets to the server.",
style: TextStyle(fontSize: 14), style: TextStyle(fontSize: 14),
) ),
: Container(),
Padding( Padding(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
child: OutlinedButton( child: OutlinedButton(
@@ -189,7 +190,7 @@ class BackupControllerPage extends HookConsumerWidget {
), ),
); );
} else { } else {
return Container(); return const SizedBox();
} }
} }
@@ -247,6 +248,141 @@ class BackupControllerPage extends HookConsumerWidget {
); );
} }
_buildCurrentBackupAssetInfoCard() {
return ListTile(
leading: Icon(
Icons.info_outline_rounded,
color: Theme.of(context).primaryColor,
),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Uploading file info",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
if (ref.watch(errorBackupListProvider).isNotEmpty)
ActionChip(
avatar: Icon(
Icons.info,
size: 24,
color: Colors.red[400],
),
elevation: 1,
visualDensity: VisualDensity.compact,
label: Text(
"Failed (${ref.watch(errorBackupListProvider).length})",
style: TextStyle(
color: Colors.red[400],
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
backgroundColor: Colors.white,
onPressed: () {
AutoRouter.of(context).push(const FailedBackupStatusRoute());
},
),
],
),
subtitle: Column(
children: [
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: LinearPercentIndicator(
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
barRadius: const Radius.circular(2),
lineHeight: 10.0,
trailing: Text(
" ${backupState.progressInPercentage.toStringAsFixed(0)}%",
style: const TextStyle(fontSize: 12),
),
percent: backupState.progressInPercentage / 100.0,
backgroundColor: Colors.grey,
progressColor: Theme.of(context).primaryColor,
),
),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Table(
border: TableBorder.all(
color: Colors.black12,
width: 1,
),
children: [
TableRow(
decoration: BoxDecoration(
color: Colors.grey[100],
),
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.middle,
child: Padding(
padding: const EdgeInsets.all(6.0),
child: Text(
'File name: ${backupState.currentUploadAsset.fileName} [${backupState.currentUploadAsset.fileType.toLowerCase()}]',
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 10.0),
),
),
),
],
),
TableRow(
decoration: BoxDecoration(
color: Colors.grey[200],
),
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.middle,
child: Padding(
padding: const EdgeInsets.all(6.0),
child: Text(
"Created on: ${DateFormat.yMMMMd('en_US').format(
DateTime.parse(
backupState.currentUploadAsset.createdAt
.toString(),
),
)}",
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 10.0),
),
),
),
],
),
TableRow(
decoration: BoxDecoration(
color: Colors.grey[100],
),
children: [
TableCell(
child: Padding(
padding: const EdgeInsets.all(6.0),
child: Text(
"ID: ${backupState.currentUploadAsset.id}",
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 10.0,
),
),
),
),
],
),
],
),
),
],
),
);
}
void startBackup() {
ref.watch(errorBackupListProvider.notifier).empty();
ref.watch(backupProvider.notifier).startBackupProcess();
}
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
elevation: 0, elevation: 0,
@@ -265,7 +401,7 @@ class BackupControllerPage extends HookConsumerWidget {
)), )),
), ),
body: Padding( body: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 32),
child: ListView( child: ListView(
// crossAxisAlignment: CrossAxisAlignment.start, // crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -298,23 +434,11 @@ class BackupControllerPage extends HookConsumerWidget {
const Divider(), const Divider(),
_buildStorageInformation(), _buildStorageInformation(),
const Divider(), const Divider(),
_buildCurrentBackupAssetInfoCard(),
Padding( Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.only(
child: Text( top: 24,
"Asset that were being backup: ${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length} [${backupState.progressInPercentage.toStringAsFixed(0)}%]"), ),
),
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Row(children: [
const Text("Backup Progress:"),
const Padding(padding: EdgeInsets.symmetric(horizontal: 2)),
backupState.backupProgress == BackUpProgressEnum.inProgress
? const CircularProgressIndicator.adaptive()
: const Text("Done"),
]),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Container( child: Container(
child: child:
backupState.backupProgress == BackUpProgressEnum.inProgress backupState.backupProgress == BackUpProgressEnum.inProgress
@@ -322,25 +446,33 @@ class BackupControllerPage extends HookConsumerWidget {
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
primary: Colors.red[300], primary: Colors.red[300],
onPrimary: Colors.grey[50], onPrimary: Colors.grey[50],
padding: const EdgeInsets.all(14),
), ),
onPressed: () { onPressed: () {
ref.read(backupProvider.notifier).cancelBackup(); ref.read(backupProvider.notifier).cancelBackup();
}, },
child: const Text("Cancel"), child: const Text(
"CANCEL",
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
) )
: ElevatedButton( : ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
primary: Theme.of(context).primaryColor, primary: Theme.of(context).primaryColor,
onPrimary: Colors.grey[50], onPrimary: Colors.grey[50],
padding: const EdgeInsets.all(14),
),
onPressed: shouldBackup ? startBackup : null,
child: const Text(
"START BACKUP",
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
), ),
onPressed: shouldBackup
? () {
ref
.read(backupProvider.notifier)
.startBackupProcess();
}
: null,
child: const Text("Start Backup"),
), ),
), ),
) )

View File

@@ -0,0 +1,139 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
import 'package:intl/intl.dart';
import 'package:photo_manager/photo_manager.dart';
class FailedBackupStatusPage extends HookConsumerWidget {
const FailedBackupStatusPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final errorBackupList = ref.watch(errorBackupListProvider);
return Scaffold(
appBar: AppBar(
elevation: 0,
title: Text(
"Failed Backup (${errorBackupList.length})",
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
leading: IconButton(
onPressed: () {
AutoRouter.of(context).pop(true);
},
splashRadius: 24,
icon: const Icon(
Icons.arrow_back_ios_rounded,
)),
),
body: ListView.builder(
shrinkWrap: true,
itemCount: errorBackupList.length,
itemBuilder: ((context, index) {
var errorAsset = errorBackupList.elementAt(index);
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 4,
),
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15), // if you need this
side: const BorderSide(
color: Colors.black12,
width: 1,
),
),
elevation: 0,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 100,
minHeight: 150,
maxWidth: 100,
maxHeight: 200,
),
child: ClipRRect(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(15),
topLeft: Radius.circular(15),
),
clipBehavior: Clip.hardEdge,
child: Image(
fit: BoxFit.cover,
image: AssetEntityImageProvider(
errorAsset.asset,
isOriginal: false,
thumbnailSize: const ThumbnailSize.square(512),
thumbnailFormat: ThumbnailFormat.jpeg,
),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
DateFormat.yMMMMd('en_US').format(
DateTime.parse(
errorAsset.createdAt.toString(),
),
),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.grey[700]),
),
Icon(
Icons.error,
color: Colors.red.withAlpha(200),
size: 18,
),
],
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
errorAsset.fileName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: Theme.of(context).primaryColor,
),
),
),
Text(
errorAsset.errorMessage,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.grey[800],
),
),
],
),
),
)
],
),
),
);
}),
),
);
}
}

View File

@@ -34,7 +34,7 @@ class DisableMultiSelectButton extends ConsumerWidget {
}, },
icon: const Icon(Icons.close_rounded), icon: const Icon(Icons.close_rounded),
label: Text( label: Text(
selectedItemCount.toString(), '$selectedItemCount',
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 18), fontWeight: FontWeight.w600, fontSize: 18),
)), )),

View File

@@ -615,7 +615,7 @@ class SlideFadeTransition extends StatelessWidget {
return AnimatedBuilder( return AnimatedBuilder(
animation: animation, animation: animation,
builder: (context, child) => builder: (context, child) =>
animation.value == 0.0 ? Container() : child!, animation.value == 0.0 ? const SizedBox() : child!,
child: SlideTransition( child: SlideTransition(
position: Tween( position: Tween(
begin: const Offset(0.3, 0.0), begin: const Offset(0.3, 0.0),

View File

@@ -25,30 +25,26 @@ class ImageGrid extends ConsumerWidget {
child: Stack( child: Stack(
children: [ children: [
ThumbnailImage(asset: assetGroup[index]), ThumbnailImage(asset: assetGroup[index]),
assetType == 'IMAGE' if (assetType != 'IMAGE')
? Container() Positioned(
: Positioned( top: 5,
top: 5, right: 5,
right: 5, child: Row(
child: Row( children: [
children: [ Text(
Text( assetGroup[index].duration.toString().substring(0, 7),
assetGroup[index] style: const TextStyle(
.duration color: Colors.white,
.toString() fontSize: 10,
.substring(0, 7), ),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
const Icon(
Icons.play_circle_outline_rounded,
color: Colors.white,
),
],
), ),
) const Icon(
Icons.play_circle_outline_rounded,
color: Colors.white,
),
],
),
),
], ],
), ),
); );

View File

@@ -49,30 +49,29 @@ class ImmichSliverAppBar extends ConsumerWidget {
}, },
), ),
), ),
serverInfoState.isVersionMismatch if (serverInfoState.isVersionMismatch)
? Positioned( Positioned(
bottom: 12, bottom: 12,
right: 12, right: 12,
child: GestureDetector( child: GestureDetector(
onTap: () => Scaffold.of(context).openDrawer(), onTap: () => Scaffold.of(context).openDrawer(),
child: Material( child: Material(
color: Colors.grey[200], color: Colors.grey[200],
elevation: 1, elevation: 1,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50.0), borderRadius: BorderRadius.circular(50.0),
), ),
child: const Padding( child: const Padding(
padding: EdgeInsets.all(2.0), padding: EdgeInsets.all(2.0),
child: Icon( child: Icon(
Icons.info, Icons.info,
color: Color.fromARGB(255, 243, 188, 106), color: Color.fromARGB(255, 243, 188, 106),
size: 15, size: 15,
),
),
), ),
), ),
) ),
: Container(), ),
),
], ],
); );
}, },
@@ -90,21 +89,20 @@ class ImmichSliverAppBar extends ConsumerWidget {
Stack( Stack(
alignment: AlignmentDirectional.center, alignment: AlignmentDirectional.center,
children: [ children: [
backupState.backupProgress == BackUpProgressEnum.inProgress if (backupState.backupProgress == BackUpProgressEnum.inProgress)
? Positioned( Positioned(
top: 10, top: 10,
right: 12, right: 12,
child: SizedBox( child: SizedBox(
height: 8, height: 8,
width: 8, width: 8,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 1, strokeWidth: 1,
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).primaryColor), Theme.of(context).primaryColor),
), ),
), ),
) ),
: Container(),
IconButton( IconButton(
splashRadius: 25, splashRadius: 25,
iconSize: 30, iconSize: 30,
@@ -129,18 +127,15 @@ class ImmichSliverAppBar extends ConsumerWidget {
} }
}, },
), ),
backupState.backupProgress == BackUpProgressEnum.inProgress if (backupState.backupProgress == BackUpProgressEnum.inProgress)
? Positioned( Positioned(
bottom: 5, bottom: 5,
child: Text( child: Text(
(backupState.allUniqueAssets.length - '${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}',
backupState.selectedAlbumsBackupAssetsIds.length) style:
.toString(), const TextStyle(fontSize: 9, fontWeight: FontWeight.bold),
style: const TextStyle( ),
fontSize: 9, fontWeight: FontWeight.bold), ),
),
)
: Container()
], ],
), ),
], ],

View File

@@ -87,7 +87,7 @@ class ProfileDrawer extends HookConsumerWidget {
return const ImmichLoadingIndicator(); return const ImmichLoadingIndicator();
} }
return Container(); return const SizedBox();
} }
_pickUserProfileImage() async { _pickUserProfileImage() async {

View File

@@ -122,17 +122,14 @@ class ThumbnailImage extends HookConsumerWidget {
}, },
), ),
), ),
Container( if (isMultiSelectEnable)
child: isMultiSelectEnable Padding(
? Padding( padding: const EdgeInsets.all(3.0),
padding: const EdgeInsets.all(3.0), child: Align(
child: Align( alignment: Alignment.topLeft,
alignment: Alignment.topLeft, child: _buildSelectionIcon(asset),
child: _buildSelectionIcon(asset), ),
), ),
)
: Container(),
),
Positioned( Positioned(
right: 10, right: 10,
bottom: 5, bottom: 5,

View File

@@ -38,17 +38,10 @@ class HomePage extends HookConsumerWidget {
} }
_buildSelectedItemCountIndicator() { _buildSelectedItemCountIndicator() {
return isMultiSelectEnable return DisableMultiSelectButton(
? DisableMultiSelectButton( onPressed: ref.watch(homePageStateProvider.notifier).disableMultiSelect,
onPressed: selectedItemCount: homePageState.selectedItems.length,
ref.watch(homePageStateProvider.notifier).disableMultiSelect, );
selectedItemCount: homePageState.selectedItems.length,
)
: Container();
}
_buildBottomAppBar() {
return isMultiSelectEnable ? const ControlBottomAppBar() : Container();
} }
Widget _buildBody() { Widget _buildBody() {
@@ -121,8 +114,10 @@ class HomePage extends HookConsumerWidget {
), ),
), ),
), ),
_buildSelectedItemCountIndicator(), if (isMultiSelectEnable) ...[
_buildBottomAppBar(), _buildSelectedItemCountIndicator(),
const ControlBottomAppBar(),
],
], ],
), ),
); );

View File

@@ -18,6 +18,7 @@ class ChangePasswordForm extends HookConsumerWidget {
final confirmPasswordController = final confirmPasswordController =
useTextEditingController.fromValue(TextEditingValue.empty); useTextEditingController.fromValue(TextEditingValue.empty);
final authState = ref.watch(authenticationProvider); final authState = ref.watch(authenticationProvider);
final formKey = GlobalKey<FormState>();
return Center( return Center(
child: ConstrainedBox( child: ConstrainedBox(
@@ -47,15 +48,24 @@ class ChangePasswordForm extends HookConsumerWidget {
), ),
), ),
), ),
PasswordInput(controller: passwordController), Form(
ConfirmPasswordInput( key: formKey,
originalController: passwordController, child: Column(
confirmController: confirmPasswordController, children: [
), PasswordInput(controller: passwordController),
Align( Padding(
alignment: Alignment.center, padding: const EdgeInsets.symmetric(vertical: 16.0),
child: ChangePasswordButton( child: ConfirmPasswordInput(
passwordController: passwordController), originalController: passwordController,
confirmController: confirmPasswordController,
),
),
ChangePasswordButton(
passwordController: passwordController,
formKey: formKey,
),
],
),
) )
], ],
), ),
@@ -119,10 +129,12 @@ class ConfirmPasswordInput extends StatelessWidget {
class ChangePasswordButton extends ConsumerWidget { class ChangePasswordButton extends ConsumerWidget {
final TextEditingController passwordController; final TextEditingController passwordController;
final GlobalKey<FormState> formKey;
const ChangePasswordButton({ const ChangePasswordButton({
Key? key, Key? key,
required this.passwordController, required this.passwordController,
required this.formKey,
}) : super(key: key); }) : super(key: key);
@override @override
@@ -136,19 +148,21 @@ class ChangePasswordButton extends ConsumerWidget {
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 25), padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 25),
), ),
onPressed: () async { onPressed: () async {
var isSuccess = await ref if (formKey.currentState!.validate()) {
.watch(authenticationProvider.notifier) var isSuccess = await ref
.changePassword(passwordController.value.text); .watch(authenticationProvider.notifier)
.changePassword(passwordController.value.text);
if (isSuccess) { if (isSuccess) {
bool res = bool res =
await ref.watch(authenticationProvider.notifier).logout(); await ref.watch(authenticationProvider.notifier).logout();
if (res) { if (res) {
ref.watch(backupProvider.notifier).cancelBackup(); ref.watch(backupProvider.notifier).cancelBackup();
ref.watch(assetProvider.notifier).clearAllAsset(); ref.watch(assetProvider.notifier).clearAllAsset();
ref.watch(websocketProvider.notifier).disconnect(); ref.watch(websocketProvider.notifier).disconnect();
AutoRouter.of(context).replace(const LoginRoute()); AutoRouter.of(context).replace(const LoginRoute());
}
} }
} }
}, },

View File

@@ -107,19 +107,12 @@ class ServerEndpointInput extends StatelessWidget {
: super(key: key); : super(key: key);
String? _validateInput(String? url) { String? _validateInput(String? url) {
if (url == null) {
if (url?.startsWith(RegExp(r'https?://')) == true) {
return null; return null;
} } else {
if (url.isEmpty) {
return 'Server endpoint is required';
}
if (!url.startsWith(RegExp(r'https?://'))) {
return 'Please specify http:// or https://'; return 'Please specify http:// or https://';
} }
return null;
} }
@override @override
@@ -219,7 +212,8 @@ class LoginButton extends ConsumerWidget {
if (isAuthenticated) { if (isAuthenticated) {
// Resume backup (if enable) then navigate // Resume backup (if enable) then navigate
if (ref.watch(authenticationProvider).shouldChangePassword) { if (ref.watch(authenticationProvider).shouldChangePassword &&
!ref.watch(authenticationProvider).isAdmin) {
AutoRouter.of(context).push(const ChangePasswordRoute()); AutoRouter.of(context).push(const ChangePasswordRoute());
} else { } else {
ref.watch(backupProvider.notifier).resumeBackup(); ref.watch(backupProvider.notifier).resumeBackup();

View File

@@ -62,11 +62,7 @@ final getCuratedLocationProvider =
final SearchService searchService = ref.watch(searchServiceProvider); final SearchService searchService = ref.watch(searchServiceProvider);
var curatedLocation = await searchService.getCuratedLocation(); var curatedLocation = await searchService.getCuratedLocation();
if (curatedLocation != null) { return curatedLocation ?? [];
return curatedLocation;
} else {
return [];
}
}); });
final getCuratedObjectProvider = final getCuratedObjectProvider =
@@ -74,9 +70,6 @@ final getCuratedObjectProvider =
final SearchService searchService = ref.watch(searchServiceProvider); final SearchService searchService = ref.watch(searchServiceProvider);
var curatedObject = await searchService.getCuratedObjects(); var curatedObject = await searchService.getCuratedObjects();
if (curatedObject != null) {
return curatedObject; return curatedObject ?? [];
} else {
return [];
}
}); });

View File

@@ -176,9 +176,8 @@ class SearchPage extends HookConsumerWidget {
_buildThings() _buildThings()
], ],
), ),
isSearchEnabled if (isSearchEnabled)
? SearchSuggestionList(onSubmitted: _onSearchSubmitted) SearchSuggestionList(onSubmitted: _onSearchSubmitted),
: Container(),
], ],
), ),
), ),

View File

@@ -166,7 +166,7 @@ class SearchResultPage extends HookConsumerWidget {
} }
} }
return Container(); return const SizedBox();
} }
return Scaffold( return Scaffold(
@@ -198,9 +198,8 @@ class SearchResultPage extends HookConsumerWidget {
child: Stack( child: Stack(
children: [ children: [
_buildSearchResult(), _buildSearchResult(),
isNewSearch.value if (isNewSearch.value)
? SearchSuggestionList(onSubmitted: _onSearchSubmitted) SearchSuggestionList(onSubmitted: _onSearchSubmitted),
: Container(),
], ],
), ),
), ),

View File

@@ -39,11 +39,7 @@ class SharedAlbumService {
"assetIds": assets.map((asset) => asset.id).toList(), "assetIds": assets.map((asset) => asset.id).toList(),
}); });
if (res == null) { return res != null;
return false;
}
return true;
} catch (e) { } catch (e) {
debugPrint("Error createSharedAlbum ${e.toString()}"); debugPrint("Error createSharedAlbum ${e.toString()}");
return false; return false;
@@ -71,11 +67,7 @@ class SharedAlbumService {
"assetIds": assets.map((asset) => asset.id).toList(), "assetIds": assets.map((asset) => asset.id).toList(),
}); });
if (res == null) { return res != null;
return false;
}
return true;
} catch (e) { } catch (e) {
debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}"); debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}");
return false; return false;
@@ -90,11 +82,7 @@ class SharedAlbumService {
"sharedUserIds": sharedUserIds, "sharedUserIds": sharedUserIds,
}); });
if (res == null) { return res != null;
return false;
}
return true;
} catch (e) { } catch (e) {
debugPrint("Error addAdditionalUserToAlbum ${e.toString()}"); debugPrint("Error addAdditionalUserToAlbum ${e.toString()}");
return false; return false;

View File

@@ -114,7 +114,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
onTap: () => _onRemoveFromAlbumPressed(albumId), onTap: () => _onRemoveFromAlbumPressed(albumId),
); );
} else { } else {
return Container(); return const SizedBox();
} }
} else { } else {
if (_albumInfo.asData?.value.ownerId == userId) { if (_albumInfo.asData?.value.ownerId == userId) {
@@ -198,8 +198,8 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
elevation: 0, elevation: 0,
leading: _buildLeadingButton(), leading: _buildLeadingButton(),
title: isMultiSelectionEnable title: isMultiSelectionEnable
? Text(selectedAssetsInAlbum.length.toString()) ? Text('${selectedAssetsInAlbum.length}')
: Container(), : null,
centerTitle: false, centerTitle: false,
actions: [ actions: [
IconButton( IconButton(

View File

@@ -71,29 +71,25 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
} }
_buildVideoLabel() { _buildVideoLabel() {
if (asset.type == 'IMAGE') { return Positioned(
return Container(); top: 5,
} else { right: 5,
return Positioned( child: Row(
top: 5, children: [
right: 5, Text(
child: Row( asset.duration.toString().substring(0, 7),
children: [ style: const TextStyle(
Text(
asset.duration.toString().substring(0, 7),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
const Icon(
Icons.play_circle_outline_rounded,
color: Colors.white, color: Colors.white,
fontSize: 10,
), ),
], ),
), const Icon(
); Icons.play_circle_outline_rounded,
} color: Colors.white,
),
],
),
);
} }
_buildAssetStoreLocationIcon() { _buildAssetStoreLocationIcon() {
@@ -112,23 +108,20 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
_buildAssetSelectionIcon() { _buildAssetSelectionIcon() {
bool isSelected = selectedAssetsInAlbumViewer.contains(asset); bool isSelected = selectedAssetsInAlbumViewer.contains(asset);
if (isMultiSelectionEnable) {
return Positioned( return Positioned(
left: 10, left: 10,
top: 5, top: 5,
child: isSelected child: isSelected
? Icon( ? Icon(
Icons.check_circle_rounded, Icons.check_circle_rounded,
color: Theme.of(context).primaryColor, color: Theme.of(context).primaryColor,
) )
: const Icon( : const Icon(
Icons.check_circle_outline_rounded, Icons.check_circle_outline_rounded,
color: Colors.white, color: Colors.white,
), ),
); );
} else {
return Container();
}
} }
_buildThumbnailImage() { _buildThumbnailImage() {
@@ -183,8 +176,8 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
children: [ children: [
_buildThumbnailImage(), _buildThumbnailImage(),
_buildAssetStoreLocationIcon(), _buildAssetStoreLocationIcon(),
_buildVideoLabel(), if (asset.type != 'IMAGE') _buildVideoLabel(),
_buildAssetSelectionIcon(), if (isMultiSelectionEnable) _buildAssetSelectionIcon(),
], ],
), ),
), ),

View File

@@ -131,27 +131,26 @@ class SelectionThumbnailImage extends HookConsumerWidget {
child: _buildSelectionIcon(asset), child: _buildSelectionIcon(asset),
), ),
), ),
asset.type == 'IMAGE' if (asset.type != 'IMAGE')
? Container() Positioned(
: Positioned( bottom: 5,
bottom: 5, right: 5,
right: 5, 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, ),
),
),
const Icon(
Icons.play_circle_outline_rounded,
color: Colors.white,
),
],
), ),
) const Icon(
Icons.play_circle_outline_rounded,
color: Colors.white,
),
],
),
),
], ],
), ),
); );

View File

@@ -14,7 +14,8 @@ class SharingSliverAppBar extends StatelessWidget {
floating: false, floating: false,
pinned: true, pinned: true,
snap: false, snap: false,
leading: Container(), automaticallyImplyLeading: false,
// leading: Container(),
// elevation: 0, // elevation: 0,
title: Text( title: Text(
'IMMICH', 'IMMICH',

View File

@@ -37,7 +37,7 @@ class AlbumViewerPage extends HookConsumerWidget {
/// Find out if the assets in album exist on the device /// Find out if the assets in album exist on the device
/// If they exist, add to selected asset state to show they are already selected. /// If they exist, add to selected asset state to show they are already selected.
void _onAddPhotosPressed(SharedAlbum albumInfo) async { void _onAddPhotosPressed(SharedAlbum albumInfo) async {
if (albumInfo.assets != null && albumInfo.assets!.isNotEmpty) { if (albumInfo.assets?.isNotEmpty == true) {
ref ref
.watch(assetSelectionProvider.notifier) .watch(assetSelectionProvider.notifier)
.addNewAssets(albumInfo.assets!.toList()); .addNewAssets(albumInfo.assets!.toList());
@@ -109,32 +109,28 @@ class AlbumViewerPage extends HookConsumerWidget {
} }
Widget _buildAlbumDateRange(SharedAlbum albumInfo) { Widget _buildAlbumDateRange(SharedAlbum albumInfo) {
if (albumInfo.assets != null && albumInfo.assets!.isNotEmpty) { String startDate = "";
String startDate = ""; DateTime parsedStartDate =
DateTime parsedStartDate = DateTime.parse(albumInfo.assets!.first.createdAt);
DateTime.parse(albumInfo.assets!.first.createdAt); DateTime parsedEndDate = DateTime.parse(
DateTime parsedEndDate = albumInfo.assets?.last.createdAt ?? '11111111'); //Need default.
DateTime.parse(albumInfo.assets!.last.createdAt);
if (parsedStartDate.year == parsedEndDate.year) { if (parsedStartDate.year == parsedEndDate.year) {
startDate = DateFormat('LLL d').format(parsedStartDate); startDate = DateFormat('LLL d').format(parsedStartDate);
} else {
startDate = DateFormat('LLL d, y').format(parsedStartDate);
}
String endDate = DateFormat('LLL d, y').format(parsedEndDate);
return Padding(
padding: const EdgeInsets.only(left: 16.0, top: 8),
child: Text(
"$startDate-$endDate",
style: const TextStyle(
fontSize: 14, fontWeight: FontWeight.bold, color: Colors.grey),
),
);
} else { } else {
return Container(); startDate = DateFormat('LLL d, y').format(parsedStartDate);
} }
String endDate = DateFormat('LLL d, y').format(parsedEndDate);
return Padding(
padding: const EdgeInsets.only(left: 16.0, top: 8),
child: Text(
"$startDate-$endDate",
style: const TextStyle(
fontSize: 14, fontWeight: FontWeight.bold, color: Colors.grey),
),
);
} }
Widget _buildHeader(SharedAlbum albumInfo) { Widget _buildHeader(SharedAlbum albumInfo) {
@@ -143,7 +139,8 @@ class AlbumViewerPage extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildTitle(albumInfo), _buildTitle(albumInfo),
_buildAlbumDateRange(albumInfo), if (albumInfo.assets?.isNotEmpty == true)
_buildAlbumDateRange(albumInfo),
SizedBox( SizedBox(
height: 60, height: 60,
child: ListView.builder( child: ListView.builder(
@@ -175,7 +172,7 @@ class AlbumViewerPage extends HookConsumerWidget {
} }
Widget _buildImageGrid(SharedAlbum albumInfo) { Widget _buildImageGrid(SharedAlbum albumInfo) {
if (albumInfo.assets != null && albumInfo.assets!.isNotEmpty) { if (albumInfo.assets?.isNotEmpty == true) {
return SliverPadding( return SliverPadding(
padding: const EdgeInsets.only(top: 10.0), padding: const EdgeInsets.only(top: 10.0),
sliver: SliverGrid( sliver: SliverGrid(
@@ -209,13 +206,12 @@ class AlbumViewerPage extends HookConsumerWidget {
onPressed: () => _onAddPhotosPressed(albumInfo), onPressed: () => _onAddPhotosPressed(albumInfo),
labelText: "Add photos", labelText: "Add photos",
), ),
userId == albumInfo.ownerId if (userId == albumInfo.ownerId)
? AlbumActionOutlinedButton( AlbumActionOutlinedButton(
iconData: Icons.person_add_alt_rounded, iconData: Icons.person_add_alt_rounded,
onPressed: () => _onAddUsersPressed(albumInfo), onPressed: () => _onAddUsersPressed(albumInfo),
labelText: "Add users", labelText: "Add users",
) ),
: Container(),
], ],
), ),
), ),

View File

@@ -74,23 +74,22 @@ class AssetSelectionPage extends HookConsumerWidget {
), ),
centerTitle: false, centerTitle: false,
actions: [ actions: [
(!isAlbumExist && selectedAssets.isNotEmpty) || if ((!isAlbumExist && selectedAssets.isNotEmpty) ||
(isAlbumExist && newAssetsForAlbum.isNotEmpty) (isAlbumExist && newAssetsForAlbum.isNotEmpty))
? TextButton( TextButton(
onPressed: () { onPressed: () {
var payload = AssetSelectionPageResult( var payload = AssetSelectionPageResult(
isAlbumExist: isAlbumExist, isAlbumExist: isAlbumExist,
selectedAdditionalAsset: newAssetsForAlbum, selectedAdditionalAsset: newAssetsForAlbum,
selectedNewAsset: selectedAssets, selectedNewAsset: selectedAssets,
); );
AutoRouter.of(context).pop(payload); AutoRouter.of(context).pop(payload);
}, },
child: const Text( child: const Text(
"Add", "Add",
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
) ),
: Container()
], ],
), ),
body: _buildBody(), body: _buildBody(),

View File

@@ -113,26 +113,22 @@ class CreateSharedAlbumPage extends HookConsumerWidget {
} }
_buildControlButton() { _buildControlButton() {
if (selectedAssets.isNotEmpty) { return Padding(
return Padding( padding: const EdgeInsets.only(left: 12.0, top: 16, bottom: 16),
padding: const EdgeInsets.only(left: 12.0, top: 16, bottom: 16), child: SizedBox(
child: SizedBox( height: 30,
height: 30, child: ListView(
child: ListView( scrollDirection: Axis.horizontal,
scrollDirection: Axis.horizontal, children: [
children: [ AlbumActionOutlinedButton(
AlbumActionOutlinedButton( iconData: Icons.add_photo_alternate_outlined,
iconData: Icons.add_photo_alternate_outlined, onPressed: _onSelectPhotosButtonPressed,
onPressed: _onSelectPhotosButtonPressed, labelText: "Add photos",
labelText: "Add photos", ),
), ],
],
),
), ),
); ),
} );
return Container();
} }
_buildSelectedImageGrid() { _buildSelectedImageGrid() {
@@ -196,7 +192,8 @@ class CreateSharedAlbumPage extends HookConsumerWidget {
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
elevation: 5, elevation: 5,
leading: Container(), automaticallyImplyLeading: false,
// leading: Container(),
pinned: true, pinned: true,
floating: false, floating: false,
bottom: PreferredSize( bottom: PreferredSize(
@@ -204,7 +201,7 @@ class CreateSharedAlbumPage extends HookConsumerWidget {
child: Column( child: Column(
children: [ children: [
_buildTitleInputField(), _buildTitleInputField(),
_buildControlButton(), if (selectedAssets.isNotEmpty) _buildControlButton(),
], ],
), ),
), ),

View File

@@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/backup/views/album_preview_page.dart'; import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart'; import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
import 'package:immich_mobile/modules/backup/views/failed_backup_status_page.dart';
import 'package:immich_mobile/modules/login/views/change_password_page.dart'; import 'package:immich_mobile/modules/login/views/change_password_page.dart';
import 'package:immich_mobile/modules/login/views/login_page.dart'; import 'package:immich_mobile/modules/login/views/login_page.dart';
import 'package:immich_mobile/modules/home/views/home_page.dart'; import 'package:immich_mobile/modules/home/views/home_page.dart';
@@ -65,6 +66,11 @@ part 'router.gr.dart';
), ),
AutoRoute(page: BackupAlbumSelectionPage, guards: [AuthGuard]), AutoRoute(page: BackupAlbumSelectionPage, guards: [AuthGuard]),
AutoRoute(page: AlbumPreviewPage, guards: [AuthGuard]), AutoRoute(page: AlbumPreviewPage, guards: [AuthGuard]),
CustomRoute(
page: FailedBackupStatusPage,
guards: [AuthGuard],
transitionsBuilder: TransitionsBuilders.slideBottom,
),
], ],
) )
class AppRouter extends _$AppRouter { class AppRouter extends _$AppRouter {

View File

@@ -115,6 +115,14 @@ class _$AppRouter extends RootStackRouter {
routeData: routeData, routeData: routeData,
child: AlbumPreviewPage(key: args.key, album: args.album)); child: AlbumPreviewPage(key: args.key, album: args.album));
}, },
FailedBackupStatusRoute.name: (routeData) {
return CustomPage<dynamic>(
routeData: routeData,
child: const FailedBackupStatusPage(),
transitionsBuilder: TransitionsBuilders.slideBottom,
opaque: true,
barrierDismissible: false);
},
HomeRoute.name: (routeData) { HomeRoute.name: (routeData) {
return MaterialPageX<dynamic>( return MaterialPageX<dynamic>(
routeData: routeData, child: const HomePage()); routeData: routeData, child: const HomePage());
@@ -177,7 +185,9 @@ class _$AppRouter extends RootStackRouter {
RouteConfig(BackupAlbumSelectionRoute.name, RouteConfig(BackupAlbumSelectionRoute.name,
path: '/backup-album-selection-page', guards: [authGuard]), path: '/backup-album-selection-page', guards: [authGuard]),
RouteConfig(AlbumPreviewRoute.name, RouteConfig(AlbumPreviewRoute.name,
path: '/album-preview-page', guards: [authGuard]) path: '/album-preview-page', guards: [authGuard]),
RouteConfig(FailedBackupStatusRoute.name,
path: '/failed-backup-status-page', guards: [authGuard])
]; ];
} }
@@ -437,6 +447,15 @@ class AlbumPreviewRouteArgs {
} }
} }
/// generated route for
/// [FailedBackupStatusPage]
class FailedBackupStatusRoute extends PageRouteInfo<void> {
const FailedBackupStatusRoute()
: super(FailedBackupStatusRoute.name, path: '/failed-backup-status-page');
static const String name = 'FailedBackupStatusRoute';
}
/// generated route for /// generated route for
/// [HomePage] /// [HomePage]
class HomeRoute extends PageRouteInfo<void> { class HomeRoute extends PageRouteInfo<void> {

View File

@@ -104,10 +104,9 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
disconnect() { disconnect() {
debugPrint("[WEBSOCKET] Attempting to disconnect"); debugPrint("[WEBSOCKET] Attempting to disconnect");
var socket = state.socket?.disconnect(); var socket = state.socket?.disconnect();
if (socket != null) {
if (socket.disconnected) { if (socket?.disconnected == true) {
state = WebscoketState(isConnected: false, socket: null); state = WebscoketState(isConnected: false, socket: null);
}
} }
} }

View File

@@ -79,7 +79,7 @@ class NetworkService {
return res; return res;
} on DioError catch (e) { } on DioError catch (e) {
debugPrint("DioError: ${e.response}"); debugPrint("[postRequest] DioError: ${e.response}");
return null; return null;
} catch (e) { } catch (e) {
debugPrint("ERROR PostRequest: $e"); debugPrint("ERROR PostRequest: $e");

View File

@@ -34,7 +34,7 @@ class SplashScreenPage extends HookConsumerWidget {
} }
useEffect(() { useEffect(() {
if (loginInfo != null && loginInfo.isSaveLogin) { if (loginInfo?.isSaveLogin == true) {
performLoggingIn(); performLoggingIn();
} else { } else {
AutoRouter.of(context).push(const LoginRoute()); AutoRouter.of(context).push(const LoginRoute());

View File

@@ -121,7 +121,7 @@ class VersionAnnouncementOverlay extends HookConsumerWidget {
), ),
); );
} else { } else {
return Container(); return const SizedBox();
} }
}, },
); );

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.14.0+21 version: 1.17.0+25
environment: environment:
sdk: ">=2.17.0 <3.0.0" sdk: ">=2.17.0 <3.0.0"

View File

@@ -1,3 +1,6 @@
FROM nginx:latest FROM nginx:latest
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
EXPOSE 443

View File

@@ -8,7 +8,7 @@ COPY package.json package-lock.json ./
RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg
RUN npm install --legacy-peer-deps RUN npm ci
COPY . . COPY . .

View File

@@ -15,6 +15,7 @@ import {
Delete, Delete,
Logger, Logger,
HttpCode, HttpCode,
BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AssetService } from './asset.service'; import { AssetService } from './asset.service';
@@ -31,6 +32,10 @@ import { SearchAssetDto } from './dto/search-asset.dto';
import { CommunicationGateway } from '../communication/communication.gateway'; import { CommunicationGateway } from '../communication/communication.gateway';
import { InjectQueue } from '@nestjs/bull'; import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull'; import { Queue } from 'bull';
import { IAssetUploadedJob } from '@app/job/index';
import { assetUploadedQueueName } from '@app/job/constants/queue-name.constant';
import { assetUploadedProcessorName } from '@app/job/constants/job-name.constant';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('asset') @Controller('asset')
@@ -40,8 +45,8 @@ export class AssetController {
private assetService: AssetService, private assetService: AssetService,
private backgroundTaskService: BackgroundTaskService, private backgroundTaskService: BackgroundTaskService,
@InjectQueue('asset-uploaded-queue') @InjectQueue(assetUploadedQueueName)
private assetUploadedQueue: Queue, private assetUploadedQueue: Queue<IAssetUploadedJob>,
) {} ) {}
@Post('upload') @Post('upload')
@@ -56,40 +61,23 @@ export class AssetController {
) )
async uploadFile( async uploadFile(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@UploadedFiles() uploadFiles: { assetData: Express.Multer.File[]; thumbnailData?: Express.Multer.File[] }, @UploadedFiles() uploadFiles: { assetData: Express.Multer.File[] },
@Body(ValidationPipe) assetInfo: CreateAssetDto, @Body(ValidationPipe) assetInfo: CreateAssetDto,
): Promise<'ok' | undefined> { ): Promise<'ok' | undefined> {
for (const file of uploadFiles.assetData) { for (const file of uploadFiles.assetData) {
try { try {
const savedAsset = await this.assetService.createUserAsset(authUser, assetInfo, file.path, file.mimetype); const savedAsset = await this.assetService.createUserAsset(authUser, assetInfo, file.path, file.mimetype);
if (!savedAsset) { if (savedAsset) {
return;
}
if (uploadFiles.thumbnailData != null) {
const assetWithThumbnail = await this.assetService.updateThumbnailInfo(
savedAsset,
uploadFiles.thumbnailData[0].path,
);
await this.assetUploadedQueue.add( await this.assetUploadedQueue.add(
'asset-uploaded', assetUploadedProcessorName,
{ asset: assetWithThumbnail, fileName: file.originalname, fileSize: file.size, hasThumbnail: true }, { asset: savedAsset, fileName: file.originalname, fileSize: file.size },
{ jobId: savedAsset.id },
);
this.wsCommunicateionGateway.server
.to(savedAsset.userId)
.emit('on_upload_success', JSON.stringify(assetWithThumbnail));
} else {
await this.assetUploadedQueue.add(
'asset-uploaded',
{ asset: savedAsset, fileName: file.originalname, fileSize: file.size, hasThumbnail: false },
{ jobId: savedAsset.id }, { jobId: savedAsset.id },
); );
} }
} catch (e) { } catch (e) {
Logger.error(`Error receiving upload file ${e}`); Logger.error(`Error uploading file ${e}`);
throw new BadRequestException(`Error uploading file`, `${e}`);
} }
} }
@@ -116,7 +104,7 @@ export class AssetController {
} }
@Get('/thumbnail/:assetId') @Get('/thumbnail/:assetId')
async getAssetThumbnail(@Param('assetId') assetId: string): Promise<StreamableFile> { async getAssetThumbnail(@Param('assetId') assetId: string) {
return await this.assetService.getAssetThumbnail(assetId); return await this.assetService.getAssetThumbnail(assetId);
} }
@@ -185,9 +173,9 @@ export class AssetController {
@HttpCode(200) @HttpCode(200)
async checkDuplicateAsset( async checkDuplicateAsset(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) { deviceAssetId }: { deviceAssetId: string }, @Body(ValidationPipe) checkDuplicateAssetDto: CheckDuplicateAssetDto,
) { ) {
const res = await this.assetService.checkDuplicatedAsset(authUser, deviceAssetId); const res = await this.assetService.checkDuplicatedAsset(authUser, checkDuplicateAssetDto);
return { return {
isExist: res, isExist: res,

View File

@@ -7,6 +7,7 @@ import { BullModule } from '@nestjs/bull';
import { BackgroundTaskModule } from '../../modules/background-task/background-task.module'; import { BackgroundTaskModule } from '../../modules/background-task/background-task.module';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service'; import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
import { CommunicationModule } from '../communication/communication.module'; import { CommunicationModule } from '../communication/communication.module';
import { assetUploadedQueueName } from '@app/job/constants/queue-name.constant';
@Module({ @Module({
imports: [ imports: [
@@ -14,7 +15,7 @@ import { CommunicationModule } from '../communication/communication.module';
BackgroundTaskModule, BackgroundTaskModule,
TypeOrmModule.forFeature([AssetEntity]), TypeOrmModule.forFeature([AssetEntity]),
BullModule.registerQueue({ BullModule.registerQueue({
name: 'asset-uploaded-queue', name: assetUploadedQueueName,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,

View File

@@ -11,12 +11,14 @@ import { IsNull, Not, Repository } from 'typeorm';
import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateAssetDto } from './dto/create-asset.dto'; import { CreateAssetDto } from './dto/create-asset.dto';
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity'; import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
import { createReadStream, stat } from 'fs'; import { constants, createReadStream, ReadStream, stat } from 'fs';
import { ServeFileDto } from './dto/serve-file.dto'; import { ServeFileDto } from './dto/serve-file.dto';
import { Response as Res } from 'express'; import { Response as Res } from 'express';
import { promisify } from 'util'; import { promisify } from 'util';
import { DeleteAssetDto } from './dto/delete-asset.dto'; import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-asset.dto'; import { SearchAssetDto } from './dto/search-asset.dto';
import fs from 'fs/promises';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
const fileInfo = promisify(stat); const fileInfo = promisify(stat);
@@ -57,15 +59,11 @@ export class AssetService {
asset.mimeType = mimeType; asset.mimeType = mimeType;
asset.duration = assetInfo.duration || null; asset.duration = assetInfo.duration || null;
try { const createdAsset = await this.assetRepository.save(asset);
const createdAsset = await this.assetRepository.save(asset); if (!createdAsset) {
if (!createdAsset) { throw new Error('Asset not created');
throw new Error('Asset not created');
}
return createdAsset;
} catch (e) {
Logger.error(`Error Create New Asset ${e}`, 'createUserAsset');
} }
return createdAsset;
} }
public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) { public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) {
@@ -123,7 +121,7 @@ export class AssetService {
public async downloadFile(query: ServeFileDto, res: Res) { public async downloadFile(query: ServeFileDto, res: Res) {
try { try {
let file = null; let fileReadStream = null;
const asset = await this.findOne(query.did, query.aid); const asset = await this.findOne(query.did, query.aid);
if (query.isThumb === 'false' || !query.isThumb) { if (query.isThumb === 'false' || !query.isThumb) {
@@ -132,76 +130,90 @@ export class AssetService {
'Content-Type': asset.mimeType, 'Content-Type': asset.mimeType,
'Content-Length': size, 'Content-Length': size,
}); });
file = createReadStream(asset.originalPath);
await fs.access(asset.originalPath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.originalPath);
} else { } else {
if (!asset.resizePath) { if (!asset.resizePath) {
throw new Error('resizePath not set'); throw new NotFoundException('resizePath not set');
} }
const { size } = await fileInfo(asset.resizePath); const { size } = await fileInfo(asset.resizePath);
res.set({ res.set({
'Content-Type': 'image/jpeg', 'Content-Type': 'image/jpeg',
'Content-Length': size, 'Content-Length': size,
}); });
file = createReadStream(asset.resizePath);
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.resizePath);
} }
return new StreamableFile(file); return new StreamableFile(fileReadStream);
} catch (e) { } catch (e) {
Logger.error('Error download asset ', e); Logger.error(`Error download asset`, 'downloadFile');
throw new InternalServerErrorException(`Failed to download asset ${e}`, 'DownloadFile'); throw new InternalServerErrorException(`Failed to download asset ${e}`, 'DownloadFile');
} }
} }
public async getAssetThumbnail(assetId: string): Promise<StreamableFile> { public async getAssetThumbnail(assetId: string) {
try { let fileReadStream: ReadStream;
const asset = await this.assetRepository.findOne({ where: { id: assetId } });
if (!asset) {
throw new NotFoundException('Asset not found');
}
const asset = await this.assetRepository.findOne({ where: { id: assetId } });
if (!asset) {
throw new NotFoundException('Asset not found');
}
try {
if (asset.webpPath && asset.webpPath.length > 0) { if (asset.webpPath && asset.webpPath.length > 0) {
return new StreamableFile(createReadStream(asset.webpPath)); await fs.access(asset.webpPath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.webpPath);
} else { } else {
if (!asset.resizePath) { if (!asset.resizePath) {
throw new Error('resizePath not set'); return new NotFoundException('resizePath not set');
} }
return new StreamableFile(createReadStream(asset.resizePath));
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.resizePath);
} }
return new StreamableFile(fileReadStream);
} catch (e) { } catch (e) {
if (e instanceof NotFoundException) { Logger.error(`Cannot create read stream for asset ${asset.id}`, 'getAssetThumbnail');
throw e; throw new InternalServerErrorException(
} e,
Logger.error('Error serving asset thumbnail ', e); `Cannot read thumbnail file for asset ${asset.id} - contact your administrator`,
throw new InternalServerErrorException('Failed to serve asset thumbnail', 'GetAssetThumbnail'); );
} }
} }
public async serveFile(authUser: AuthUserDto, query: ServeFileDto, res: Res, headers: any) { public async serveFile(authUser: AuthUserDto, query: ServeFileDto, res: Res, headers: any) {
let file = null; let fileReadStream: ReadStream;
const asset = await this.findOne(query.did, query.aid); const asset = await this.findOne(query.did, query.aid);
if (!asset) { if (!asset) {
// TODO: maybe this should be a NotFoundException? throw new NotFoundException('Asset does not exist');
throw new BadRequestException('Asset does not exist');
} }
// Handle Sending Images // Handle Sending Images
if (asset.type == AssetType.IMAGE || query.isThumb == 'true') { if (asset.type == AssetType.IMAGE || query.isThumb == 'true') {
/**
* Serve file viewer on the web
*/
if (query.isWeb) {
res.set({
'Content-Type': 'image/jpeg',
});
if (!asset.resizePath) {
Logger.error('Error serving IMAGE asset for web', 'ServeFile');
throw new InternalServerErrorException(`Failed to serve image asset for web`, 'ServeFile');
}
return new StreamableFile(createReadStream(asset.resizePath));
}
try { try {
/**
* Serve file viewer on the web
*/
if (query.isWeb) {
res.set({
'Content-Type': 'image/jpeg',
});
if (!asset.resizePath) {
Logger.error('Error serving IMAGE asset for web', 'ServeFile');
throw new InternalServerErrorException(`Failed to serve image asset for web`, 'ServeFile');
}
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.resizePath);
return new StreamableFile(fileReadStream);
}
/** /**
* Serve thumbnail image for both web and mobile app * Serve thumbnail image for both web and mobile app
*/ */
@@ -209,34 +221,38 @@ export class AssetService {
res.set({ res.set({
'Content-Type': asset.mimeType, 'Content-Type': asset.mimeType,
}); });
file = createReadStream(asset.originalPath);
await fs.access(asset.originalPath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.originalPath);
} else { } else {
if (asset.webpPath && asset.webpPath.length > 0) { if (asset.webpPath && asset.webpPath.length > 0) {
res.set({ res.set({
'Content-Type': 'image/webp', 'Content-Type': 'image/webp',
}); });
file = createReadStream(asset.webpPath); await fs.access(asset.webpPath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.webpPath);
} else { } else {
res.set({ res.set({
'Content-Type': 'image/jpeg', 'Content-Type': 'image/jpeg',
}); });
if (!asset.resizePath) { if (!asset.resizePath) {
throw new Error('resizePath not set'); throw new Error('resizePath not set');
} }
file = createReadStream(asset.resizePath);
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.resizePath);
} }
} }
file.on('error', (error) => { return new StreamableFile(fileReadStream);
Logger.log(`Cannot create read stream ${error}`);
return new BadRequestException('Cannot Create Read Stream');
});
return new StreamableFile(file);
} catch (e) { } catch (e) {
Logger.error('Error serving IMAGE asset ', e); Logger.error(`Cannot create read stream for asset ${asset.id}`, 'serveFile[IMAGE]');
throw new InternalServerErrorException(`Failed to serve image asset ${e}`, 'ServeFile'); throw new InternalServerErrorException(
e,
`Cannot read thumbnail file for asset ${asset.id} - contact your administrator`,
);
} }
} else if (asset.type == AssetType.VIDEO) { } else if (asset.type == AssetType.VIDEO) {
try { try {
@@ -244,6 +260,8 @@ export class AssetService {
let videoPath = asset.originalPath; let videoPath = asset.originalPath;
let mimeType = asset.mimeType; let mimeType = asset.mimeType;
await fs.access(videoPath, constants.R_OK | constants.W_OK);
if (query.isWeb && asset.mimeType == 'video/quicktime') { if (query.isWeb && asset.mimeType == 'video/quicktime') {
videoPath = asset.encodedVideoPath == '' ? asset.originalPath : asset.encodedVideoPath; videoPath = asset.encodedVideoPath == '' ? asset.originalPath : asset.encodedVideoPath;
mimeType = asset.encodedVideoPath == '' ? asset.mimeType : 'video/mp4'; mimeType = asset.encodedVideoPath == '' ? asset.mimeType : 'video/mp4';
@@ -279,7 +297,6 @@ export class AssetService {
} }
/** Sending Partial Content With HTTP Code 206 */ /** Sending Partial Content With HTTP Code 206 */
console.log('Send Range', range);
res.status(206).set({ res.status(206).set({
'Content-Range': `bytes ${start}-${end}/${size}`, 'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes', 'Accept-Ranges': 'bytes',
@@ -298,7 +315,7 @@ export class AssetService {
return new StreamableFile(createReadStream(videoPath)); return new StreamableFile(createReadStream(videoPath));
} }
} catch (e) { } catch (e) {
Logger.error('Error serving VIDEO asset ', e); Logger.error(`Error serving VIDEO asset id ${asset.id}`, 'serveFile[VIDEO]');
throw new InternalServerErrorException(`Failed to serve video asset ${e}`, 'ServeFile'); throw new InternalServerErrorException(`Failed to serve video asset ${e}`, 'ServeFile');
} }
} }
@@ -335,11 +352,11 @@ export class AssetService {
// TODO: should use query builder // TODO: should use query builder
const rows = await this.assetRepository.query( const rows = await this.assetRepository.query(
` `
select distinct si.tags, si.objects, e.orientation, e."lensModel", e.make, e.model , a.type, e.city, e.state, e.country SELECT DISTINCT si.tags, si.objects, e.orientation, e."lensModel", e.make, e.model , a.type, e.city, e.state, e.country
from assets a FROM assets a
left join exif e on a.id = e."assetId" LEFT JOIN exif e ON a.id = e."assetId"
left join smart_info si on a.id = si."assetId" LEFT JOIN smart_info si ON a.id = si."assetId"
where a."userId" = $1; WHERE a."userId" = $1;
`, `,
[authUser.id], [authUser.id],
); );
@@ -385,7 +402,7 @@ export class AssetService {
( (
TO_TSVECTOR('english', ARRAY_TO_STRING(si.tags, ',')) @@ PLAINTO_TSQUERY('english', $2) OR TO_TSVECTOR('english', ARRAY_TO_STRING(si.tags, ',')) @@ PLAINTO_TSQUERY('english', $2) OR
TO_TSVECTOR('english', ARRAY_TO_STRING(si.objects, ',')) @@ PLAINTO_TSQUERY('english', $2) OR TO_TSVECTOR('english', ARRAY_TO_STRING(si.objects, ',')) @@ PLAINTO_TSQUERY('english', $2) OR
e.exif_text_searchable_column @@ PLAINTO_TSQUERY('english', $2) e."exifTextSearchableColumn" @@ PLAINTO_TSQUERY('english', $2)
); );
`; `;
@@ -395,12 +412,12 @@ export class AssetService {
async getCuratedLocation(authUser: AuthUserDto) { async getCuratedLocation(authUser: AuthUserDto) {
return await this.assetRepository.query( return await this.assetRepository.query(
` `
select distinct on (e.city) a.id, e.city, a."resizePath", a."deviceAssetId", a."deviceId" SELECT DISTINCT ON (e.city) a.id, e.city, a."resizePath", a."deviceAssetId", a."deviceId"
from assets a FROM assets a
left join exif e on a.id = e."assetId" LEFT JOIN exif e ON a.id = e."assetId"
where a."userId" = $1 WHERE a."userId" = $1
and e.city is not null AND e.city IS NOT NULL
and a.type = 'IMAGE'; AND a.type = 'IMAGE';
`, `,
[authUser.id], [authUser.id],
); );
@@ -409,20 +426,21 @@ export class AssetService {
async getCuratedObject(authUser: AuthUserDto) { async getCuratedObject(authUser: AuthUserDto) {
return await this.assetRepository.query( return await this.assetRepository.query(
` `
select distinct on (unnest(si.objects)) a.id, unnest(si.objects) as "object", a."resizePath", a."deviceAssetId", a."deviceId" SELECT DISTINCT ON (unnest(si.objects)) a.id, unnest(si.objects) as "object", a."resizePath", a."deviceAssetId", a."deviceId"
from assets a FROM assets a
left join smart_info si on a.id = si."assetId" LEFT JOIN smart_info si ON a.id = si."assetId"
where a."userId" = $1 WHERE a."userId" = $1
and si.objects is not null AND si.objects IS NOT NULL
`, `,
[authUser.id], [authUser.id],
); );
} }
async checkDuplicatedAsset(authUser: AuthUserDto, deviceAssetId: string) { async checkDuplicatedAsset(authUser: AuthUserDto, checkDuplicateAssetDto: CheckDuplicateAssetDto) {
const res = await this.assetRepository.findOne({ const res = await this.assetRepository.findOne({
where: { where: {
deviceAssetId, deviceAssetId: checkDuplicateAssetDto.deviceAssetId,
deviceId: checkDuplicateAssetDto.deviceId,
userId: authUser.id, userId: authUser.id,
}, },
}); });

View File

@@ -0,0 +1,9 @@
import { IsNotEmpty } from 'class-validator';
export class CheckDuplicateAssetDto {
@IsNotEmpty()
deviceAssetId!: string;
@IsNotEmpty()
deviceId!: string;
}

View File

@@ -154,7 +154,6 @@ export class UserService {
} }
if (!user.profileImagePath) { if (!user.profileImagePath) {
// throw new BadRequestException('User does not have a profile image');
res.status(404).send('User does not have a profile image'); res.status(404).send('User does not have a profile image');
return; return;
} }
@@ -165,7 +164,7 @@ export class UserService {
const fileStream = createReadStream(user.profileImagePath); const fileStream = createReadStream(user.profileImagePath);
return new StreamableFile(fileStream); return new StreamableFile(fileStream);
} catch (e) { } catch (e) {
console.log('error getting user profile'); res.status(404).send('User does not have a profile image');
} }
} }
} }

View File

@@ -6,7 +6,6 @@ import { extname } from 'path';
import { Request } from 'express'; import { Request } from 'express';
import { APP_UPLOAD_LOCATION } from '../constants/upload_location.constant'; import { APP_UPLOAD_LOCATION } from '../constants/upload_location.constant';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
// import { CreateAssetDto } from '../api-v1/asset/dto/create-asset.dto';
export const assetUploadOption: MulterOptions = { export const assetUploadOption: MulterOptions = {
fileFilter: (req: Request, file: any, cb: any) => { fileFilter: (req: Request, file: any, cb: any) => {
@@ -30,34 +29,20 @@ export const assetUploadOption: MulterOptions = {
return; return;
} }
if (file.fieldname == 'assetData') { const originalUploadFolder = `${basePath}/${req.user.id}/original/${req.body['deviceId']}`;
const originalUploadFolder = `${basePath}/${req.user.id}/original/${req.body['deviceId']}`;
if (!existsSync(originalUploadFolder)) { if (!existsSync(originalUploadFolder)) {
mkdirSync(originalUploadFolder, { recursive: true }); mkdirSync(originalUploadFolder, { recursive: true });
}
// Save original to disk
cb(null, originalUploadFolder);
} else if (file.fieldname == 'thumbnailData') {
const thumbnailUploadFolder = `${basePath}/${req.user.id}/thumb/${req.body['deviceId']}`;
if (!existsSync(thumbnailUploadFolder)) {
mkdirSync(thumbnailUploadFolder, { recursive: true });
}
// Save thumbnail to disk
cb(null, thumbnailUploadFolder);
} }
// Save original to disk
cb(null, originalUploadFolder);
}, },
filename: (req: Request, file: Express.Multer.File, cb: any) => { filename: (req: Request, file: Express.Multer.File, cb: any) => {
const fileNameUUID = randomUUID(); const fileNameUUID = randomUUID();
if (file.fieldname == 'assetData') {
cb(null, `${fileNameUUID}${req.body['fileExtension'].toLowerCase()}`); cb(null, `${fileNameUUID}${req.body['fileExtension'].toLowerCase()}`);
} else if (file.fieldname == 'thumbnailData') {
cb(null, `${fileNameUUID}.jpeg`);
}
}, },
}), }),
}; };

View File

@@ -3,7 +3,7 @@
export const serverVersion = { export const serverVersion = {
major: 1, major: 1,
minor: 15, minor: 17,
patch: 0, patch: 0,
build: 21, build: 25,
}; };

View File

@@ -3,12 +3,18 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetEntity } from '@app/database/entities/asset.entity'; import { AssetEntity } from '@app/database/entities/asset.entity';
import { ScheduleTasksService } from './schedule-tasks.service'; import { ScheduleTasksService } from './schedule-tasks.service';
import {
metadataExtractionQueueName,
thumbnailGeneratorQueueName,
videoConversionQueueName,
} from '@app/job/constants/queue-name.constant';
import { ExifEntity } from '@app/database/entities/exif.entity';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([AssetEntity]), TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
BullModule.registerQueue({ BullModule.registerQueue({
name: 'video-conversion-queue', name: videoConversionQueueName,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
@@ -16,7 +22,16 @@ import { ScheduleTasksService } from './schedule-tasks.service';
}, },
}), }),
BullModule.registerQueue({ BullModule.registerQueue({
name: 'thumbnail-generator-queue', name: thumbnailGeneratorQueueName,
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
BullModule.registerQueue({
name: metadataExtractionQueueName,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,

View File

@@ -1,11 +1,23 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { IsNull, Not, Repository } from 'typeorm';
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity'; import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
import { InjectQueue } from '@nestjs/bull'; import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull'; import { Queue } from 'bull';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { ExifEntity } from '@app/database/entities/exif.entity';
import {
IMetadataExtractionJob,
IVideoTranscodeJob,
metadataExtractionQueueName,
thumbnailGeneratorQueueName,
videoConversionQueueName,
generateWEBPThumbnailProcessorName,
mp4ConversionProcessorName,
reverseGeocodingProcessorName,
} from '@app/job';
import { ConfigService } from '@nestjs/config';
@Injectable() @Injectable()
export class ScheduleTasksService { export class ScheduleTasksService {
@@ -13,17 +25,23 @@ export class ScheduleTasksService {
@InjectRepository(AssetEntity) @InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>, private assetRepository: Repository<AssetEntity>,
@InjectQueue('thumbnail-generator-queue') @InjectRepository(ExifEntity)
private exifRepository: Repository<ExifEntity>,
@InjectQueue(thumbnailGeneratorQueueName)
private thumbnailGeneratorQueue: Queue, private thumbnailGeneratorQueue: Queue,
@InjectQueue('video-conversion-queue') @InjectQueue(videoConversionQueueName)
private videoConversionQueue: Queue, private videoConversionQueue: Queue<IVideoTranscodeJob>,
@InjectQueue(metadataExtractionQueueName)
private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
private configService: ConfigService,
) {} ) {}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async webpConversion() { async webpConversion() {
Logger.log('Starting Schedule Webp Conversion Tasks', 'CronjobWebpGenerator');
const assets = await this.assetRepository.find({ const assets = await this.assetRepository.find({
where: { where: {
webpPath: '', webpPath: '',
@@ -36,7 +54,11 @@ export class ScheduleTasksService {
} }
for (const asset of assets) { for (const asset of assets) {
await this.thumbnailGeneratorQueue.add('generate-webp-thumbnail', { asset: asset }, { jobId: randomUUID() }); await this.thumbnailGeneratorQueue.add(
generateWEBPThumbnailProcessorName,
{ asset: asset },
{ jobId: randomUUID() },
);
} }
} }
@@ -54,7 +76,26 @@ export class ScheduleTasksService {
}); });
for (const asset of assets) { for (const asset of assets) {
await this.videoConversionQueue.add('mp4-conversion', { asset }, { jobId: randomUUID() }); await this.videoConversionQueue.add(mp4ConversionProcessorName, { asset }, { jobId: randomUUID() });
}
}
@Cron(CronExpression.EVERY_5_SECONDS)
async reverseGeocoding() {
const isMapboxEnable = this.configService.get('ENABLE_MAPBOX');
if (isMapboxEnable) {
const exifInfo = await this.exifRepository.find({
where: {
city: IsNull(),
longitude: Not(IsNull()),
latitude: Not(IsNull()),
},
});
for (const exif of exifInfo) {
await this.metadataExtractionQueue.add(reverseGeocodingProcessorName, { exif }, { jobId: randomUUID() });
}
} }
} }
} }

View File

@@ -3,7 +3,7 @@ import { CanActivate, ExecutionContext } from '@nestjs/common';
import { TestingModuleBuilder } from '@nestjs/testing'; import { TestingModuleBuilder } from '@nestjs/testing';
import { AuthUserDto } from '../src/decorators/auth-user.decorator'; import { AuthUserDto } from '../src/decorators/auth-user.decorator';
import { JwtAuthGuard } from '../src/modules/immich-jwt/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../src/modules/immich-jwt/guards/jwt-auth.guard';
import databaseConfig from '@app/database/config/database.config'; import { databaseConfig } from '@app/database/config/database.config';
type CustomAuthCallback = () => AuthUserDto; type CustomAuthCallback = () => AuthUserDto;

View File

@@ -12,6 +12,12 @@ import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor';
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor'; import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
import { VideoTranscodeProcessor } from './processors/video-transcode.processor'; import { VideoTranscodeProcessor } from './processors/video-transcode.processor';
import { CommunicationModule } from '../../immich/src/api-v1/communication/communication.module'; import { CommunicationModule } from '../../immich/src/api-v1/communication/communication.module';
import {
assetUploadedQueueName,
metadataExtractionQueueName,
thumbnailGeneratorQueueName,
videoConversionQueueName,
} from '@app/job/constants/queue-name.constant';
@Module({ @Module({
imports: [ imports: [
@@ -26,7 +32,7 @@ import { CommunicationModule } from '../../immich/src/api-v1/communication/commu
}), }),
}), }),
BullModule.registerQueue({ BullModule.registerQueue({
name: 'thumbnail-generator-queue', name: thumbnailGeneratorQueueName,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
@@ -34,7 +40,7 @@ import { CommunicationModule } from '../../immich/src/api-v1/communication/commu
}, },
}), }),
BullModule.registerQueue({ BullModule.registerQueue({
name: 'asset-uploaded-queue', name: assetUploadedQueueName,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
@@ -42,7 +48,7 @@ import { CommunicationModule } from '../../immich/src/api-v1/communication/commu
}, },
}), }),
BullModule.registerQueue({ BullModule.registerQueue({
name: 'metadata-extraction-queue', name: metadataExtractionQueueName,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
@@ -50,7 +56,7 @@ import { CommunicationModule } from '../../immich/src/api-v1/communication/commu
}, },
}), }),
BullModule.registerQueue({ BullModule.registerQueue({
name: 'video-conversion-queue', name: videoConversionQueueName,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,

View File

@@ -1,61 +1,58 @@
import { InjectQueue, Process, Processor } from '@nestjs/bull'; import { InjectQueue, Process, Processor } from '@nestjs/bull';
import { Job, Queue } from 'bull'; import { Job, Queue } from 'bull';
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity'; import { AssetType } from '@app/database/entities/asset.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import {
IAssetUploadedJob,
IMetadataExtractionJob,
IThumbnailGenerationJob,
IVideoTranscodeJob,
assetUploadedQueueName,
metadataExtractionQueueName,
thumbnailGeneratorQueueName,
videoConversionQueueName,
assetUploadedProcessorName,
exifExtractionProcessorName,
generateJPEGThumbnailProcessorName,
mp4ConversionProcessorName,
videoMetadataExtractionProcessorName,
} from '@app/job';
@Processor('asset-uploaded-queue') @Processor(assetUploadedQueueName)
export class AssetUploadedProcessor { export class AssetUploadedProcessor {
constructor( constructor(
@InjectQueue('thumbnail-generator-queue') @InjectQueue(thumbnailGeneratorQueueName)
private thumbnailGeneratorQueue: Queue, private thumbnailGeneratorQueue: Queue<IThumbnailGenerationJob>,
@InjectQueue('metadata-extraction-queue') @InjectQueue(metadataExtractionQueueName)
private metadataExtractionQueue: Queue, private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
@InjectQueue('video-conversion-queue') @InjectQueue(videoConversionQueueName)
private videoConversionQueue: Queue, private videoConversionQueue: Queue<IVideoTranscodeJob>,
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
) {} ) {}
/** /**
* Post processing uploaded asset to perform the following function if missing * Post processing uploaded asset to perform the following function if missing
* 1. Generate JPEG Thumbnail * 1. Generate JPEG Thumbnail
* 2. Generate Webp Thumbnail <-> if JPEG thumbnail exist * 2. Generate Webp Thumbnail
* 3. EXIF extractor * 3. EXIF extractor
* 4. Reverse Geocoding * 4. Reverse Geocoding
* *
* @param job asset-uploaded * @param job asset-uploaded
*/ */
@Process('asset-uploaded') @Process(assetUploadedProcessorName)
async processUploadedVideo(job: Job) { async processUploadedVideo(job: Job<IAssetUploadedJob>) {
const { const { asset, fileName, fileSize } = job.data;
asset,
fileName,
fileSize,
hasThumbnail,
}: { asset: AssetEntity; fileName: string; fileSize: number; hasThumbnail: boolean } = job.data;
if (hasThumbnail) { await this.thumbnailGeneratorQueue.add(generateJPEGThumbnailProcessorName, { asset }, { jobId: randomUUID() });
// The jobs below depends on the existence of jpeg thumbnail
await this.thumbnailGeneratorQueue.add('generate-webp-thumbnail', { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add('tag-image', { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add('detect-object', { asset }, { jobId: randomUUID() });
} else {
// Generate Thumbnail -> Then generate webp, tag image and detect object
await this.thumbnailGeneratorQueue.add('generate-jpeg-thumbnail', { asset }, { jobId: randomUUID() });
}
// Video Conversion // Video Conversion
if (asset.type == AssetType.VIDEO) { if (asset.type == AssetType.VIDEO) {
await this.videoConversionQueue.add('mp4-conversion', { asset }, { jobId: randomUUID() }); await this.videoConversionQueue.add(mp4ConversionProcessorName, { asset }, { jobId: randomUUID() });
} else { } else {
// Extract Metadata/Exif for Images - Currently the library cannot extract EXIF for video yet // Extract Metadata/Exif for Images - Currently the EXIF library on the web cannot extract EXIF for video yet
await this.metadataExtractionQueue.add( await this.metadataExtractionQueue.add(
'exif-extraction', exifExtractionProcessorName,
{ {
asset, asset,
fileName, fileName,
@@ -65,9 +62,9 @@ export class AssetUploadedProcessor {
); );
} }
// Extract video duration if uploaded from the web // Extract video duration if uploaded from the web & CLI
if (asset.type == AssetType.VIDEO && asset.duration == '0:00:00.000000') { if (asset.type == AssetType.VIDEO && asset.duration == '0:00:00.000000') {
await this.metadataExtractionQueue.add('extract-video-length', { asset }, { jobId: randomUUID() }); await this.metadataExtractionQueue.add(videoMetadataExtractionProcessorName, { asset }, { jobId: randomUUID() });
} }
} }
} }

View File

@@ -12,9 +12,20 @@ import { Logger } from '@nestjs/common';
import axios from 'axios'; import axios from 'axios';
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity'; import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
import ffmpeg from 'fluent-ffmpeg'; import ffmpeg from 'fluent-ffmpeg';
// import moment from 'moment'; import path from 'path';
import {
IExifExtractionProcessor,
IVideoLengthExtractionProcessor,
exifExtractionProcessorName,
imageTaggingProcessorName,
objectDetectionProcessorName,
videoMetadataExtractionProcessorName,
metadataExtractionQueueName,
reverseGeocodingProcessorName,
IReverseGeocodingProcessor,
} from '@app/job';
@Processor('metadata-extraction-queue') @Processor(metadataExtractionQueueName)
export class MetadataExtractionProcessor { export class MetadataExtractionProcessor {
private geocodingClient?: GeocodeService; private geocodingClient?: GeocodeService;
@@ -35,8 +46,8 @@ export class MetadataExtractionProcessor {
} }
} }
@Process('exif-extraction') @Process(exifExtractionProcessorName)
async extractExifInfo(job: Job) { async extractExifInfo(job: Job<IExifExtractionProcessor>) {
try { try {
const { asset, fileName, fileSize }: { asset: AssetEntity; fileName: string; fileSize: number } = job.data; const { asset, fileName, fileSize }: { asset: AssetEntity; fileName: string; fileSize: number } = job.data;
@@ -48,7 +59,7 @@ export class MetadataExtractionProcessor {
newExif.assetId = asset.id; newExif.assetId = asset.id;
newExif.make = exifData['Make'] || null; newExif.make = exifData['Make'] || null;
newExif.model = exifData['Model'] || null; newExif.model = exifData['Model'] || null;
newExif.imageName = fileName || null; newExif.imageName = path.parse(fileName).name || null;
newExif.exifImageHeight = exifData['ExifImageHeight'] || null; newExif.exifImageHeight = exifData['ExifImageHeight'] || null;
newExif.exifImageWidth = exifData['ExifImageWidth'] || null; newExif.exifImageWidth = exifData['ExifImageWidth'] || null;
newExif.fileSizeInByte = fileSize || null; newExif.fileSizeInByte = fileSize || null;
@@ -89,11 +100,33 @@ export class MetadataExtractionProcessor {
} }
} }
@Process({ name: 'tag-image', concurrency: 2 }) @Process({ name: reverseGeocodingProcessorName })
async reverseGeocoding(job: Job<IReverseGeocodingProcessor>) {
const { exif } = job.data;
if (this.geocodingClient) {
const geoCodeInfo: MapiResponse = await this.geocodingClient
.reverseGeocode({
query: [Number(exif.longitude), Number(exif.latitude)],
types: ['country', 'region', 'place'],
})
.send();
const res: [] = geoCodeInfo.body['features'];
const city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
const state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
const country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
await this.exifRepository.update({ id: exif.id }, { city, state, country });
}
}
@Process({ name: imageTaggingProcessorName, concurrency: 2 })
async tagImage(job: Job) { async tagImage(job: Job) {
const { asset }: { asset: AssetEntity } = job.data; const { asset }: { asset: AssetEntity } = job.data;
const res = await axios.post('http://immich-machine-learning:3001/image-classifier/tag-image', { const res = await axios.post('http://immich-machine-learning:3003/image-classifier/tag-image', {
thumbnailPath: asset.resizePath, thumbnailPath: asset.resizePath,
}); });
@@ -108,12 +141,12 @@ export class MetadataExtractionProcessor {
} }
} }
@Process({ name: 'detect-object', concurrency: 2 }) @Process({ name: objectDetectionProcessorName, concurrency: 2 })
async detectObject(job: Job) { async detectObject(job: Job) {
try { try {
const { asset }: { asset: AssetEntity } = job.data; const { asset }: { asset: AssetEntity } = job.data;
const res = await axios.post('http://immich-machine-learning:3001/object-detection/detect-object', { const res = await axios.post('http://immich-machine-learning:3003/object-detection/detect-object', {
thumbnailPath: asset.resizePath, thumbnailPath: asset.resizePath,
}); });
@@ -131,26 +164,42 @@ export class MetadataExtractionProcessor {
} }
} }
@Process({ name: 'extract-video-length', concurrency: 2 }) @Process({ name: videoMetadataExtractionProcessorName, concurrency: 2 })
async extractVideoLength(job: Job) { async extractVideoMetadata(job: Job<IVideoLengthExtractionProcessor>) {
const { asset }: { asset: AssetEntity } = job.data; const { asset } = job.data;
ffmpeg.ffprobe(asset.originalPath, async (err, data) => { ffmpeg.ffprobe(asset.originalPath, async (err, data) => {
if (!err) { if (!err) {
let durationString = asset.duration;
let createdAt = asset.createdAt;
if (data.format.duration) { if (data.format.duration) {
const videoDurationInSecond = parseInt(data.format.duration.toString(), 0); durationString = this.extractDuration(data.format.duration);
const hours = Math.floor(videoDurationInSecond / 3600);
const minutes = Math.floor((videoDurationInSecond - hours * 3600) / 60);
const seconds = videoDurationInSecond - hours * 3600 - minutes * 60;
const durationString = `${hours}:${minutes < 10 ? '0' + minutes.toString() : minutes}:${
seconds < 10 ? '0' + seconds.toString() : seconds
}.000000`;
await this.assetRepository.update({ id: asset.id }, { duration: durationString });
} }
const videoTags = data.format.tags;
if (videoTags) {
if (videoTags['com.apple.quicktime.creationdate']) {
createdAt = String(videoTags['com.apple.quicktime.creationdate']);
} else {
createdAt = String(videoTags['creation_time']);
}
}
await this.assetRepository.update({ id: asset.id }, { duration: durationString, createdAt: createdAt });
} }
}); });
} }
private extractDuration(duration: number) {
const videoDurationInSecond = parseInt(duration.toString(), 0);
const hours = Math.floor(videoDurationInSecond / 3600);
const minutes = Math.floor((videoDurationInSecond - hours * 3600) / 60);
const seconds = videoDurationInSecond - hours * 3600 - minutes * 60;
return `${hours}:${minutes < 10 ? '0' + minutes.toString() : minutes}:${
seconds < 10 ? '0' + seconds.toString() : seconds
}.000000`;
}
} }

View File

@@ -9,25 +9,35 @@ import { randomUUID } from 'node:crypto';
import { CommunicationGateway } from '../../../immich/src/api-v1/communication/communication.gateway'; import { CommunicationGateway } from '../../../immich/src/api-v1/communication/communication.gateway';
import ffmpeg from 'fluent-ffmpeg'; import ffmpeg from 'fluent-ffmpeg';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import {
WebpGeneratorProcessor,
generateJPEGThumbnailProcessorName,
generateWEBPThumbnailProcessorName,
imageTaggingProcessorName,
objectDetectionProcessorName,
metadataExtractionQueueName,
thumbnailGeneratorQueueName,
JpegGeneratorProcessor,
} from '@app/job';
@Processor('thumbnail-generator-queue') @Processor(thumbnailGeneratorQueueName)
export class ThumbnailGeneratorProcessor { export class ThumbnailGeneratorProcessor {
constructor( constructor(
@InjectRepository(AssetEntity) @InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>, private assetRepository: Repository<AssetEntity>,
@InjectQueue('thumbnail-generator-queue') @InjectQueue(thumbnailGeneratorQueueName)
private thumbnailGeneratorQueue: Queue, private thumbnailGeneratorQueue: Queue,
private wsCommunicateionGateway: CommunicationGateway, private wsCommunicateionGateway: CommunicationGateway,
@InjectQueue('metadata-extraction-queue') @InjectQueue(metadataExtractionQueueName)
private metadataExtractionQueue: Queue, private metadataExtractionQueue: Queue,
) {} ) {}
@Process('generate-jpeg-thumbnail') @Process({ name: generateJPEGThumbnailProcessorName, concurrency: 3 })
async generateJPEGThumbnail(job: Job) { async generateJPEGThumbnail(job: Job<JpegGeneratorProcessor>) {
const { asset }: { asset: AssetEntity } = job.data; const { asset } = job.data;
const resizePath = `upload/${asset.userId}/thumb/${asset.deviceId}/`; const resizePath = `upload/${asset.userId}/thumb/${asset.deviceId}/`;
@@ -43,6 +53,7 @@ export class ThumbnailGeneratorProcessor {
sharp(asset.originalPath) sharp(asset.originalPath)
.resize(1440, 2560, { fit: 'inside' }) .resize(1440, 2560, { fit: 'inside' })
.jpeg() .jpeg()
.rotate()
.toFile(jpegThumbnailPath, async (err) => { .toFile(jpegThumbnailPath, async (err) => {
if (!err) { if (!err) {
await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath }); await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath });
@@ -50,9 +61,13 @@ export class ThumbnailGeneratorProcessor {
// Update resize path to send to generate webp queue // Update resize path to send to generate webp queue
asset.resizePath = jpegThumbnailPath; asset.resizePath = jpegThumbnailPath;
await this.thumbnailGeneratorQueue.add('generate-webp-thumbnail', { asset }, { jobId: randomUUID() }); await this.thumbnailGeneratorQueue.add(
await this.metadataExtractionQueue.add('tag-image', { asset }, { jobId: randomUUID() }); generateWEBPThumbnailProcessorName,
await this.metadataExtractionQueue.add('detect-object', { asset }, { jobId: randomUUID() }); { asset },
{ jobId: randomUUID() },
);
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
this.wsCommunicateionGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(asset)); this.wsCommunicateionGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(asset));
} }
}); });
@@ -60,7 +75,7 @@ export class ThumbnailGeneratorProcessor {
if (asset.type == AssetType.VIDEO) { if (asset.type == AssetType.VIDEO) {
ffmpeg(asset.originalPath) ffmpeg(asset.originalPath)
.outputOptions(['-ss 00:00:01.000', '-frames:v 1']) .outputOptions(['-ss 00:00:00.000', '-frames:v 1'])
.output(jpegThumbnailPath) .output(jpegThumbnailPath)
.on('start', () => { .on('start', () => {
Logger.log('Start Generating Video Thumbnail', 'generateJPEGThumbnail'); Logger.log('Start Generating Video Thumbnail', 'generateJPEGThumbnail');
@@ -76,9 +91,13 @@ export class ThumbnailGeneratorProcessor {
// Update resize path to send to generate webp queue // Update resize path to send to generate webp queue
asset.resizePath = jpegThumbnailPath; asset.resizePath = jpegThumbnailPath;
await this.thumbnailGeneratorQueue.add('generate-webp-thumbnail', { asset }, { jobId: randomUUID() }); await this.thumbnailGeneratorQueue.add(
await this.metadataExtractionQueue.add('tag-image', { asset }, { jobId: randomUUID() }); generateWEBPThumbnailProcessorName,
await this.metadataExtractionQueue.add('detect-object', { asset }, { jobId: randomUUID() }); { asset },
{ jobId: randomUUID() },
);
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
this.wsCommunicateionGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(asset)); this.wsCommunicateionGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(asset));
}) })
@@ -86,8 +105,8 @@ export class ThumbnailGeneratorProcessor {
} }
} }
@Process({ name: 'generate-webp-thumbnail', concurrency: 2 }) @Process({ name: generateWEBPThumbnailProcessorName, concurrency: 3 })
async generateWepbThumbnail(job: Job<{ asset: AssetEntity }>) { async generateWepbThumbnail(job: Job<WebpGeneratorProcessor>) {
const { asset } = job.data; const { asset } = job.data;
if (!asset.resizePath) { if (!asset.resizePath) {
@@ -98,6 +117,7 @@ export class ThumbnailGeneratorProcessor {
sharp(asset.resizePath) sharp(asset.resizePath)
.resize(250) .resize(250)
.webp() .webp()
.rotate()
.toFile(webpPath, (err) => { .toFile(webpPath, (err) => {
if (!err) { if (!err) {
this.assetRepository.update({ id: asset.id }, { webpPath: webpPath }); this.assetRepository.update({ id: asset.id }, { webpPath: webpPath });

View File

@@ -1,3 +1,6 @@
import { mp4ConversionProcessorName } from '@app/job/constants/job-name.constant';
import { videoConversionQueueName } from '@app/job/constants/queue-name.constant';
import { IMp4ConversionProcessor } from '@app/job/interfaces/video-transcode.interface';
import { Process, Processor } from '@nestjs/bull'; import { Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
@@ -8,16 +11,16 @@ import { Repository } from 'typeorm';
import { AssetEntity } from '../../../../libs/database/src/entities/asset.entity'; import { AssetEntity } from '../../../../libs/database/src/entities/asset.entity';
import { APP_UPLOAD_LOCATION } from '../../../immich/src/constants/upload_location.constant'; import { APP_UPLOAD_LOCATION } from '../../../immich/src/constants/upload_location.constant';
@Processor('video-conversion-queue') @Processor(videoConversionQueueName)
export class VideoTranscodeProcessor { export class VideoTranscodeProcessor {
constructor( constructor(
@InjectRepository(AssetEntity) @InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>, private assetRepository: Repository<AssetEntity>,
) {} ) {}
@Process({ name: 'mp4-conversion', concurrency: 1 }) @Process({ name: mp4ConversionProcessorName, concurrency: 1 })
async mp4Conversion(job: Job) { async mp4Conversion(job: Job<IMp4ConversionProcessor>) {
const { asset }: { asset: AssetEntity } = job.data; const { asset } = job.data;
if (asset.mimeType != 'video/mp4') { if (asset.mimeType != 'video/mp4') {
const basePath = APP_UPLOAD_LOCATION; const basePath = APP_UPLOAD_LOCATION;

View File

@@ -1,5 +1,5 @@
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions'; import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions';
import {DataSource} from "typeorm";
export const databaseConfig: PostgresConnectionOptions = { export const databaseConfig: PostgresConnectionOptions = {
type: 'postgres', type: 'postgres',
@@ -14,4 +14,4 @@ export const databaseConfig: PostgresConnectionOptions = {
migrationsRun: true, migrationsRun: true,
}; };
export default databaseConfig; export const dataSource = new DataSource(databaseConfig);

View File

@@ -1,9 +1,9 @@
import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm'; import {Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn, Unique} from 'typeorm';
import { AlbumEntity } from './album.entity'; import { AlbumEntity } from './album.entity';
import { AssetEntity } from './asset.entity'; import { AssetEntity } from './asset.entity';
@Entity('asset_album') @Entity('asset_album')
@Unique('PK_unique_asset_in_album', ['albumId', 'assetId']) @Unique('UQ_unique_asset_in_album', ['albumId', 'assetId'])
export class AssetAlbumEntity { export class AssetAlbumEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: string; id!: string;
@@ -12,6 +12,7 @@ export class AssetAlbumEntity {
albumId!: string; albumId!: string;
@Column() @Column()
@OneToOne(() => AssetEntity, (entity) => entity.id)
assetId!: string; assetId!: string;
@ManyToOne(() => AlbumEntity, (album) => album.assets, { @ManyToOne(() => AlbumEntity, (album) => album.assets, {

View File

@@ -26,10 +26,10 @@ export class AssetEntity {
@Column({ type: 'varchar', nullable: true }) @Column({ type: 'varchar', nullable: true })
resizePath!: string | null; resizePath!: string | null;
@Column({ type: 'varchar', nullable: true }) @Column({ type: 'varchar', nullable: true, default: '' })
webpPath!: string | null; webpPath!: string | null;
@Column({ type: 'varchar', nullable: true }) @Column({ type: 'varchar', nullable: true, default: '' })
encodedVideoPath!: string; encodedVideoPath!: string;
@Column() @Column()

View File

@@ -73,4 +73,19 @@ export class ExifEntity {
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true }) @OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' }) @JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
asset?: ExifEntity; asset?: ExifEntity;
@Index("exif_text_searchable", { synchronize: false })
@Column({
type: 'tsvector',
generatedType: 'STORED',
asExpression: `TO_TSVECTOR('english',
COALESCE(make, '') || ' ' ||
COALESCE(model, '') || ' ' ||
COALESCE(orientation, '') || ' ' ||
COALESCE("lensModel", '') || ' ' ||
COALESCE("city", '') || ' ' ||
COALESCE("state", '') || ' ' ||
COALESCE("country", ''))`
})
exifTextSearchableColumn!: string
} }

View File

@@ -5,13 +5,13 @@ export class UserEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id!: string; id!: string;
@Column() @Column({ default: '' })
firstName!: string; firstName!: string;
@Column() @Column({ default: '' })
lastName!: string; lastName!: string;
@Column() @Column({ default: false })
isAdmin!: boolean; isAdmin!: boolean;
@Column() @Column()
@@ -23,10 +23,10 @@ export class UserEntity {
@Column({ select: false }) @Column({ select: false })
salt?: string; salt?: string;
@Column() @Column({ default: '' })
profileImagePath!: string; profileImagePath!: string;
@Column() @Column({ default: true })
shouldChangePassword!: boolean; shouldChangePassword!: boolean;
@CreateDateColumn() @CreateDateColumn()

View File

@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from "typeorm"
export class RenameAssetAlbumIdSequence1656888591977 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`alter sequence asset_shared_album_id_seq rename to asset_album_id_seq;`);
await queryRunner.query(`alter table asset_album alter column id set default nextval('asset_album_id_seq'::regclass);`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`alter sequence asset_album_id_seq rename to asset_shared_album_id_seq;`);
await queryRunner.query(`alter table asset_album alter column id set default nextval('asset_shared_album_id_seq'::regclass);`);
}
}

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class DropExifTextSearchableColumns1656888918620 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exif_text_searchable_column"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE exif
DROP COLUMN IF EXISTS exif_text_searchable_column;
ALTER TABLE exif
ADD COLUMN IF NOT EXISTS exif_text_searchable_column tsvector
GENERATED ALWAYS AS (
TO_TSVECTOR('english',
COALESCE(make, '') || ' ' ||
COALESCE(model, '') || ' ' ||
COALESCE(orientation, '') || ' ' ||
COALESCE("lensModel", '') || ' ' ||
COALESCE("city", '') || ' ' ||
COALESCE("state", '') || ' ' ||
COALESCE("country", '')
)
) STORED;
CREATE INDEX exif_text_searchable_idx
ON exif
USING GIN (exif_text_searchable_column);
`);
}
}

View File

@@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MatchMigrationsWithTypeORMEntities1656889061566 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "exif" ADD "exifTextSearchableColumn" tsvector GENERATED ALWAYS AS (TO_TSVECTOR('english',
COALESCE(make, '') || ' ' ||
COALESCE(model, '') || ' ' ||
COALESCE(orientation, '') || ' ' ||
COALESCE("lensModel", '') || ' ' ||
COALESCE("city", '') || ' ' ||
COALESCE("state", '') || ' ' ||
COALESCE("country", ''))) STORED`);
await queryRunner.query(`ALTER TABLE "exif" ALTER COLUMN "exifTextSearchableColumn" SET NOT NULL`);
await queryRunner.query(`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "database" = $3 AND "schema" = $4 AND "table" = $5`, ["GENERATED_COLUMN","exifTextSearchableColumn","postgres","public","exif"]);
await queryRunner.query(`INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES ($1, $2, $3, $4, $5, $6)`, ["postgres","public","exif","GENERATED_COLUMN","exifTextSearchableColumn","TO_TSVECTOR('english',\n COALESCE(make, '') || ' ' ||\n COALESCE(model, '') || ' ' ||\n COALESCE(orientation, '') || ' ' ||\n COALESCE(\"lensModel\", '') || ' ' ||\n COALESCE(\"city\", '') || ' ' ||\n COALESCE(\"state\", '') || ' ' ||\n COALESCE(\"country\", ''))"]);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "firstName" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "lastName" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "isAdmin" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "profileImagePath" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "shouldChangePassword" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_a8b79a84996cef6ba6a3662825d"`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_64f2e7d68d1d1d8417acc844a4a"`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "UQ_a1e2734a1ce361e7a26f6b28288"`);
await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "UQ_unique_asset_in_album" UNIQUE ("albumId", "assetId")`);
await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_256a30a03a4a0aff0394051397d" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_7ae4e03729895bf87e056d7b598" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "shouldChangePassword" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "profileImagePath" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "isAdmin" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "lastName" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "firstName" DROP NOT NULL`);
await queryRunner.query(`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "database" = $3 AND "schema" = $4 AND "table" = $5`, ["GENERATED_COLUMN","exifTextSearchableColumn","immich","public","exif"]);
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exifTextSearchableColumn"`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_7ae4e03729895bf87e056d7b598"`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_256a30a03a4a0aff0394051397d"`);
await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "UQ_unique_asset_in_album"`);
await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "UQ_a1e2734a1ce361e7a26f6b28288" UNIQUE ("albumId", "assetId")`);
await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_64f2e7d68d1d1d8417acc844a4a" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "asset_album" ADD CONSTRAINT "FK_a8b79a84996cef6ba6a3662825d" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
}

View File

@@ -0,0 +1,24 @@
/**
* Asset Uploaded Queue Jobs
*/
export const assetUploadedProcessorName = 'asset-uploaded';
/**
* Video Conversion Queue Jobs
**/
export const mp4ConversionProcessorName = 'mp4-conversion';
/**
* Thumbnail Generator Queue Jobs
*/
export const generateJPEGThumbnailProcessorName = 'generate-jpeg-thumbnail';
export const generateWEBPThumbnailProcessorName = 'generate-webp-thumbnail';
/**
* Metadata Extraction Queue Jobs
*/
export const exifExtractionProcessorName = 'exif-extraction';
export const videoMetadataExtractionProcessorName = 'extract-video-metadata';
export const reverseGeocodingProcessorName = 'reverse-geocoding';
export const objectDetectionProcessorName = 'detect-object';
export const imageTaggingProcessorName = 'tag-image';

View File

@@ -0,0 +1,4 @@
export const thumbnailGeneratorQueueName = 'thumbnail-generator-queue';
export const assetUploadedQueueName = 'asset-uploaded-queue';
export const metadataExtractionQueueName = 'metadata-extraction-queue';
export const videoConversionQueueName = 'video-conversion-queue';

View File

@@ -0,0 +1,7 @@
export * from './interfaces/asset-uploaded.interface';
export * from './interfaces/metadata-extraction.interface';
export * from './interfaces/video-transcode.interface';
export * from './interfaces/thumbnail-generation.interface';
export * from './constants/job-name.constant';
export * from './constants/queue-name.constant';

View File

@@ -0,0 +1,18 @@
import { AssetEntity } from '@app/database/entities/asset.entity';
export interface IAssetUploadedJob {
/**
* The Asset entity that was saved in the database
*/
asset: AssetEntity;
/**
* Original file name
*/
fileName: string;
/**
* File size in byte
*/
fileSize: number;
}

View File

@@ -0,0 +1,38 @@
import { AssetEntity } from '@app/database/entities/asset.entity';
import { ExifEntity } from '@app/database/entities/exif.entity';
export interface IExifExtractionProcessor {
/**
* The Asset entity that was saved in the database
*/
asset: AssetEntity;
/**
* Original file name
*/
fileName: string;
/**
* File size in byte
*/
fileSize: number;
}
export interface IVideoLengthExtractionProcessor {
/**
* The Asset entity that was saved in the database
*/
asset: AssetEntity;
}
export interface IReverseGeocodingProcessor {
/**
* The Asset entity that was saved in the database
*/
exif: ExifEntity;
}
export type IMetadataExtractionJob =
| IExifExtractionProcessor
| IVideoLengthExtractionProcessor
| IReverseGeocodingProcessor;

View File

@@ -0,0 +1,17 @@
import { AssetEntity } from '@app/database/entities/asset.entity';
export interface JpegGeneratorProcessor {
/**
* The Asset entity that was saved in the database
*/
asset: AssetEntity;
}
export interface WebpGeneratorProcessor {
/**
* The Asset entity that was saved in the database
*/
asset: AssetEntity;
}
export type IThumbnailGenerationJob = JpegGeneratorProcessor | WebpGeneratorProcessor;

View File

@@ -0,0 +1,10 @@
import { AssetEntity } from '@app/database/entities/asset.entity';
export interface IMp4ConversionProcessor {
/**
* The Asset entity that was saved in the database
*/
asset: AssetEntity;
}
export type IVideoTranscodeJob = IMp4ConversionProcessor;

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"declaration": true,
"outDir": "../../dist/libs/job"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}

View File

@@ -34,6 +34,15 @@
"compilerOptions": { "compilerOptions": {
"tsConfigPath": "libs/database/tsconfig.lib.json" "tsConfigPath": "libs/database/tsconfig.lib.json"
} }
},
"job": {
"type": "library",
"root": "libs/job",
"entryFile": "index",
"sourceRoot": "libs/job/src",
"compilerOptions": {
"tsConfigPath": "libs/job/tsconfig.lib.json"
}
} }
} }
} }

View File

@@ -22,7 +22,7 @@
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./apps/immich/test/jest-e2e.json", "test:e2e": "jest --config ./apps/immich/test/jest-e2e.json",
"typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js --config libs/database/src/config/database.config.ts" "typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js"
}, },
"dependencies": { "dependencies": {
"@mapbox/mapbox-sdk": "^0.13.3", "@mapbox/mapbox-sdk": "^0.13.3",
@@ -120,7 +120,8 @@
"moduleNameMapper": { "moduleNameMapper": {
"^@app/database(|/.*)$": "<rootDir>/libs/database/src/$1", "^@app/database(|/.*)$": "<rootDir>/libs/database/src/$1",
"@app/database/config/(.*)": "<rootDir>/libs/database/src/config/$1", "@app/database/config/(.*)": "<rootDir>/libs/database/src/config/$1",
"@app/database/config": "<rootDir>/libs/database/src/config" "@app/database/config": "<rootDir>/libs/database/src/config",
"^@app/job(|/.*)$": "<rootDir>/libs/job/src/$1"
} }
} }
} }

View File

@@ -21,6 +21,12 @@
], ],
"@app/database/*": [ "@app/database/*": [
"libs/database/src/*" "libs/database/src/*"
],
"@app/job": [
"libs/job/src"
],
"@app/job/*": [
"libs/job/src/*"
] ]
} }
}, },

View File

@@ -7,9 +7,9 @@ RUN chown node:node /usr/src/app
COPY --chown=node:node package*.json ./ COPY --chown=node:node package*.json ./
RUN apk add --update-cache build-base python3 RUN apk add --update-cache build-base python3
RUN npm install RUN npm ci
COPY --chown=node:node . . COPY --chown=node:node . .
@@ -29,9 +29,9 @@ RUN chown node:node /usr/src/app
COPY --chown=node:node package*.json ./ COPY --chown=node:node package*.json ./
COPY --chown=node:node . . COPY --chown=node:node . .
RUN apk add --update-cache build-base python3 RUN apk add --update-cache build-base python3
RUN npm install RUN npm ci
EXPOSE 3000 EXPOSE 3000

View File

@@ -63,7 +63,9 @@
dispatch('close'); dispatch('close');
}; };
const navigateAssetForward = () => { const navigateAssetForward = (e?: Event) => {
e?.stopPropagation();
const nextAsset = $flattenAssetGroupByDate[selectedIndex + 1]; const nextAsset = $flattenAssetGroupByDate[selectedIndex + 1];
viewDeviceId = nextAsset.deviceId; viewDeviceId = nextAsset.deviceId;
viewAssetId = nextAsset.id; viewAssetId = nextAsset.id;
@@ -73,7 +75,9 @@
pushState(viewAssetId); pushState(viewAssetId);
}; };
const navigateAssetBackward = () => { const navigateAssetBackward = (e?: Event) => {
e?.stopPropagation();
const lastAsset = $flattenAssetGroupByDate[selectedIndex - 1]; const lastAsset = $flattenAssetGroupByDate[selectedIndex - 1];
viewDeviceId = lastAsset.deviceId; viewDeviceId = lastAsset.deviceId;
viewAssetId = lastAsset.id; viewAssetId = lastAsset.id;

View File

@@ -8,6 +8,7 @@
import type { ImmichAsset } from '../../models/immich-asset'; import type { ImmichAsset } from '../../models/immich-asset';
import { createEventDispatcher, onMount } from 'svelte'; import { createEventDispatcher, onMount } from 'svelte';
import { browser } from '$app/env'; import { browser } from '$app/env';
import { round } from 'lodash';
// Map Property // Map Property
let map: any; let map: any;
@@ -80,6 +81,16 @@
return `${sizeInByte}B`; return `${sizeInByte}B`;
} }
}; };
const getMegapixel = (width: number, height: number): number | undefined => {
const megapixel = Math.round((height * width) / 1_000_000);
if (megapixel) {
return megapixel;
}
return undefined;
};
</script> </script>
<section class="p-2"> <section class="p-2">
@@ -129,8 +140,13 @@
<div> <div>
<p>{`${asset.exifInfo.imageName}.${asset.originalPath.split('.')[1]}` || ''}</p> <p>{`${asset.exifInfo.imageName}.${asset.originalPath.split('.')[1]}` || ''}</p>
<div class="flex text-sm gap-2"> <div class="flex text-sm gap-2">
<p>{((asset.exifInfo.exifImageHeight * asset.exifInfo.exifImageWidth) / 1_000_000).toFixed(0)}MP</p> {#if asset.exifInfo.exifImageHeight && asset.exifInfo.exifImageWidth}
<p>{asset.exifInfo.exifImageHeight} x {asset.exifInfo.exifImageWidth}</p> {#if getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)}
<p>{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)}MP</p>
{/if}
<p>{asset.exifInfo.exifImageHeight} x {asset.exifInfo.exifImageWidth}</p>
{/if}
<p>{getHumanReadableString(asset.exifInfo.fileSizeInByte)}</p> <p>{getHumanReadableString(asset.exifInfo.fileSizeInByte)}</p>
</div> </div>
</div> </div>

View File

@@ -5,6 +5,8 @@
import CloudUploadOutline from 'svelte-material-icons/CloudUploadOutline.svelte'; import CloudUploadOutline from 'svelte-material-icons/CloudUploadOutline.svelte';
import WindowMinimize from 'svelte-material-icons/WindowMinimize.svelte'; import WindowMinimize from 'svelte-material-icons/WindowMinimize.svelte';
import type { UploadAsset } from '$lib/models/upload-asset'; import type { UploadAsset } from '$lib/models/upload-asset';
import { getAssetsInfo } from '$lib/stores/assets';
import { session } from '$app/stores';
let showDetail = true; let showDetail = true;
@@ -73,8 +75,15 @@
} }
let isUploading = false; let isUploading = false;
uploadAssetsStore.isUploading.subscribe((value) => {
isUploading = value;
uploadAssetsStore.isUploading.subscribe((value) => (isUploading = value)); if (isUploading == false) {
if ($session.user) {
getAssetsInfo($session.user.accessToken);
}
}
});
</script> </script>
{#if isUploading} {#if isUploading}

View File

@@ -2,19 +2,18 @@ import { writable, derived } from 'svelte/store';
import { getRequest } from '$lib/api'; import { getRequest } from '$lib/api';
import type { ImmichAsset } from '$lib/models/immich-asset'; import type { ImmichAsset } from '$lib/models/immich-asset';
import lodash from 'lodash-es'; import lodash from 'lodash-es';
import _ from 'lodash';
import moment from 'moment'; import moment from 'moment';
export const assets = writable<ImmichAsset[]>([]); export const assets = writable<ImmichAsset[]>([]);
export const assetsGroupByDate = derived(assets, ($assets) => { export const assetsGroupByDate = derived(assets, ($assets) => {
try { try {
return lodash return lodash
.chain($assets) .chain($assets)
.groupBy((a) => moment(a.createdAt).format('ddd, MMM DD')) .groupBy((a) => moment(a.createdAt).format('ddd, MMM DD YYYY'))
.sortBy((group) => $assets.indexOf(group[0])) .sortBy((group) => $assets.indexOf(group[0]))
.value(); .value();
} catch (e) { } catch (e) {
console.log('error deriving state assets', e);
return []; return [];
} }
}); });

View File

@@ -1,12 +1,16 @@
import { Socket, io } from 'socket.io-client'; import { Socket, io } from 'socket.io-client';
import { writable } from 'svelte/store';
import { serverEndpoint } from '../constants'; import { serverEndpoint } from '../constants';
import type { ImmichAsset } from '../models/immich-asset'; import type { ImmichAsset } from '../models/immich-asset';
import { assets } from './assets'; import { assets } from './assets';
let websocket: Socket;
export const openWebsocketConnection = (accessToken: string) => { export const openWebsocketConnection = (accessToken: string) => {
const websocketEndpoint = serverEndpoint.replace('/api', ''); const websocketEndpoint = serverEndpoint.replace('/api', '');
try { try {
const websocket = io(websocketEndpoint, { websocket = io(websocketEndpoint, {
path: '/api/socket.io', path: '/api/socket.io',
transports: ['polling'], transports: ['polling'],
reconnection: true, reconnection: true,
@@ -26,11 +30,14 @@ export const openWebsocketConnection = (accessToken: string) => {
const listenToEvent = (socket: Socket) => { const listenToEvent = (socket: Socket) => {
socket.on('on_upload_success', (data) => { socket.on('on_upload_success', (data) => {
const newUploadedAsset: ImmichAsset = JSON.parse(data); const newUploadedAsset: ImmichAsset = JSON.parse(data);
// assets.update((assets) => [...assets, newUploadedAsset]);
assets.update((assets) => [...assets, newUploadedAsset]);
}); });
socket.on('error', (e) => { socket.on('error', (e) => {
console.log('Websocket Error', e); console.log('Websocket Error', e);
}); });
}; };
export const closeWebsocketConnection = () => {
websocket?.close();
};

View File

@@ -53,7 +53,7 @@ export async function fileUploader(asset: File, accessToken: string) {
// Check if asset upload on server before performing upload // Check if asset upload on server before performing upload
const res = await fetch(serverEndpoint + '/asset/check', { const res = await fetch(serverEndpoint + '/asset/check', {
method: 'POST', method: 'POST',
body: JSON.stringify({ deviceAssetId }), body: JSON.stringify({ deviceAssetId, deviceId: 'WEB' }),
headers: { headers: {
Authorization: 'Bearer ' + accessToken, Authorization: 'Bearer ' + accessToken,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -84,7 +84,7 @@ export async function fileUploader(asset: File, accessToken: string) {
request.upload.onload = () => { request.upload.onload = () => {
setTimeout(() => { setTimeout(() => {
uploadAssetsStore.removeUploadAsset(deviceAssetId); uploadAssetsStore.removeUploadAsset(deviceAssetId);
}, 2500); }, 1000);
}; };
// listen for `error` event // listen for `error` event

View File

@@ -31,7 +31,7 @@
import ImageOutline from 'svelte-material-icons/ImageOutline.svelte'; import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
import { AppSideBarSelection } from '$lib/models/admin-sidebar-selection'; import { AppSideBarSelection } from '$lib/models/admin-sidebar-selection';
import { onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { session } from '$app/stores'; import { session } from '$app/stores';
import { assetsGroupByDate, flattenAssetGroupByDate } from '$lib/stores/assets'; import { assetsGroupByDate, flattenAssetGroupByDate } from '$lib/stores/assets';
@@ -42,7 +42,7 @@
import DownloadPanel from '../../lib/components/asset-viewer/download-panel.svelte'; import DownloadPanel from '../../lib/components/asset-viewer/download-panel.svelte';
import StatusBox from '../../lib/components/shared/status-box.svelte'; import StatusBox from '../../lib/components/shared/status-box.svelte';
import { fileUploader } from '../../lib/utils/file-uploader'; import { fileUploader } from '../../lib/utils/file-uploader';
import { openWebsocketConnection } from '../../lib/stores/websocket'; import { openWebsocketConnection, closeWebsocketConnection } from '../../lib/stores/websocket';
export let user: ImmichUser; export let user: ImmichUser;
let selectedAction: AppSideBarSelection; let selectedAction: AppSideBarSelection;
@@ -71,6 +71,10 @@
} }
}); });
onDestroy(() => {
closeWebsocketConnection();
});
const thumbnailMouseEventHandler = (event: CustomEvent) => { const thumbnailMouseEventHandler = (event: CustomEvent) => {
const { selectedGroupIndex }: { selectedGroupIndex: number } = event.detail; const { selectedGroupIndex }: { selectedGroupIndex: number } = event.detail;