Compare commits

...

12 Commits

Author SHA1 Message Date
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
70 changed files with 835 additions and 708 deletions

View File

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

View File

@@ -10,7 +10,7 @@
<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" />
</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"/>
</a>
<br/>

View File

@@ -5,7 +5,7 @@ import { Logger } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3001, () => {
await app.listen(3003, () => {
if (process.env.NODE_ENV == 'development') {
Logger.log(
'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

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

View File

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

View File

@@ -15,81 +15,72 @@ class ExifBottomSheet extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
_buildMap() {
return (assetDetail.exifInfo!.latitude != null &&
assetDetail.exifInfo!.longitude != null)
? Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Container(
height: 150,
width: MediaQuery.of(context).size.width,
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(15)),
),
child: FlutterMap(
options: MapOptions(
center: LatLng(assetDetail.exifInfo!.latitude!,
assetDetail.exifInfo!.longitude!),
zoom: 16.0,
),
layers: [
TileLayerOptions(
urlTemplate:
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
subdomains: ['a', 'b', 'c'],
attributionBuilder: (_) {
return const Text(
"© 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')),
),
],
),
],
),
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Container(
height: 150,
width: MediaQuery.of(context).size.width,
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(15)),
),
child: FlutterMap(
options: MapOptions(
center: LatLng(assetDetail.exifInfo!.latitude!,
assetDetail.exifInfo!.longitude!),
zoom: 16.0,
),
layers: [
TileLayerOptions(
urlTemplate:
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
subdomains: ['a', 'b', 'c'],
attributionBuilder: (_) {
return const Text(
"© OpenStreetMap",
style: TextStyle(fontSize: 10),
);
},
),
)
: Container();
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')),
),
],
),
],
),
),
);
}
_buildLocationText() {
return (assetDetail.exifInfo!.city != null &&
assetDetail.exifInfo!.state != null)
? Text(
"${assetDetail.exifInfo!.city}, ${assetDetail.exifInfo!.state}",
style: TextStyle(
fontSize: 12,
color: Colors.grey[200],
fontWeight: FontWeight.bold),
)
: Container();
return Text(
"${assetDetail.exifInfo!.city}, ${assetDetail.exifInfo!.state}",
style: TextStyle(
fontSize: 12, color: Colors.grey[200], fontWeight: FontWeight.bold),
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8),
child: ListView(
children: [
assetDetail.exifInfo?.dateTimeOriginal != null
? Text(
DateFormat('E, LLL d, y • h:mm a').format(
DateTime.parse(assetDetail.exifInfo!.dateTimeOriginal!),
),
style: TextStyle(
color: Colors.grey[400],
fontWeight: FontWeight.bold,
fontSize: 14,
),
)
: Container(),
if (assetDetail.exifInfo?.dateTimeOriginal != null)
Text(
DateFormat('E, LLL d, y • h:mm a').format(
DateTime.parse(assetDetail.exifInfo!.dateTimeOriginal!),
),
style: TextStyle(
color: Colors.grey[400],
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Text(
@@ -102,84 +93,83 @@ class ExifBottomSheet extends ConsumerWidget {
),
// Location
assetDetail.exifInfo?.latitude != null
? Padding(
padding: const EdgeInsets.only(top: 32.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Divider(
thickness: 1,
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]),
)
],
if (assetDetail.exifInfo?.latitude != null)
Padding(
padding: const EdgeInsets.only(top: 32.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Divider(
thickness: 1,
color: Colors.grey[600],
),
)
: Container(),
Text(
"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
assetDetail.exifInfo != null
? Padding(
padding: const EdgeInsets.only(top: 32.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Divider(
thickness: 1,
color: Colors.grey[600],
),
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
"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()
],
if (assetDetail.exifInfo != null)
Padding(
padding: const EdgeInsets.only(top: 32.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Divider(
thickness: 1,
color: Colors.grey[600],
),
)
: Container()
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 ")
: 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
Widget build(BuildContext context) {
return chewieController != null &&
chewieController!.videoPlayerController.value.isInitialized
return chewieController?.videoPlayerController.value.isInitialized == true
? SizedBox(
child: Chewie(
controller: chewieController!,

View File

@@ -69,21 +69,6 @@ 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 req = MultipartRequest(
@@ -101,9 +86,6 @@ class BackupService {
req.fields['fileExtension'] = fileExtension;
req.fields['duration'] = entity.videoDuration.toString();
if (thumbnailUploadData != null) {
req.files.add(thumbnailUploadData);
}
req.files.add(assetRawUploadData);
var res = await req.send(cancellationToken: cancelToken);

View File

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

View File

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

View File

@@ -96,12 +96,11 @@ class BackupControllerPage extends HookConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
!isAutoBackup
? const Text(
"Turn on backup to automatically upload new assets to the server.",
style: TextStyle(fontSize: 14),
)
: Container(),
if (!isAutoBackup)
const Text(
"Turn on backup to automatically upload new assets to the server.",
style: TextStyle(fontSize: 14),
),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: OutlinedButton(
@@ -189,7 +188,7 @@ class BackupControllerPage extends HookConsumerWidget {
),
);
} else {
return Container();
return const SizedBox();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -107,19 +107,12 @@ class ServerEndpointInput extends StatelessWidget {
: super(key: key);
String? _validateInput(String? url) {
if (url == null) {
if (url?.startsWith(RegExp(r'https?://')) == true) {
return null;
}
if (url.isEmpty) {
return 'Server endpoint is required';
}
if (!url.startsWith(RegExp(r'https?://'))) {
} else {
return 'Please specify http:// or https://';
}
return null;
}
@override
@@ -219,7 +212,8 @@ class LoginButton extends ConsumerWidget {
if (isAuthenticated) {
// 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());
} else {
ref.watch(backupProvider.notifier).resumeBackup();

View File

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

View File

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

View File

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

View File

@@ -72,10 +72,7 @@ class SharedAlbum {
albumThumbnailAssetId: map['albumThumbnailAssetId'],
sharedUsers:
List<User>.from(map['sharedUsers']?.map((x) => User.fromMap(x))),
assets: map['assets'] != null
? List<ImmichAsset>.from(
map['assets']?.map((x) => ImmichAsset.fromMap(x)))
: null,
assets: map['assets']?.map((x) => ImmichAsset.fromMap(x)).toList(),
);
}

View File

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

View File

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

View File

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

View File

@@ -131,27 +131,26 @@ class SelectionThumbnailImage extends HookConsumerWidget {
child: _buildSelectionIcon(asset),
),
),
asset.type == 'IMAGE'
? Container()
: Positioned(
bottom: 5,
right: 5,
child: Row(
children: [
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,
),
],
if (asset.type != 'IMAGE')
Positioned(
bottom: 5,
right: 5,
child: Row(
children: [
Text(
'${asset.duration?.substring(0, 7)}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
)
const Icon(
Icons.play_circle_outline_rounded,
color: Colors.white,
),
],
),
),
],
),
);

View File

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

View File

@@ -37,7 +37,7 @@ class AlbumViewerPage extends HookConsumerWidget {
/// 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.
void _onAddPhotosPressed(SharedAlbum albumInfo) async {
if (albumInfo.assets != null && albumInfo.assets!.isNotEmpty) {
if (albumInfo.assets?.isNotEmpty == true) {
ref
.watch(assetSelectionProvider.notifier)
.addNewAssets(albumInfo.assets!.toList());
@@ -109,32 +109,28 @@ class AlbumViewerPage extends HookConsumerWidget {
}
Widget _buildAlbumDateRange(SharedAlbum albumInfo) {
if (albumInfo.assets != null && albumInfo.assets!.isNotEmpty) {
String startDate = "";
DateTime parsedStartDate =
DateTime.parse(albumInfo.assets!.first.createdAt);
DateTime parsedEndDate =
DateTime.parse(albumInfo.assets!.last.createdAt);
String startDate = "";
DateTime parsedStartDate =
DateTime.parse(albumInfo.assets!.first.createdAt);
DateTime parsedEndDate = DateTime.parse(
albumInfo.assets?.last.createdAt ?? '11111111'); //Need default.
if (parsedStartDate.year == parsedEndDate.year) {
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),
),
);
if (parsedStartDate.year == parsedEndDate.year) {
startDate = DateFormat('LLL d').format(parsedStartDate);
} 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) {
@@ -143,7 +139,8 @@ class AlbumViewerPage extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTitle(albumInfo),
_buildAlbumDateRange(albumInfo),
if (albumInfo.assets?.isNotEmpty == true)
_buildAlbumDateRange(albumInfo),
SizedBox(
height: 60,
child: ListView.builder(
@@ -175,7 +172,7 @@ class AlbumViewerPage extends HookConsumerWidget {
}
Widget _buildImageGrid(SharedAlbum albumInfo) {
if (albumInfo.assets != null && albumInfo.assets!.isNotEmpty) {
if (albumInfo.assets?.isNotEmpty == true) {
return SliverPadding(
padding: const EdgeInsets.only(top: 10.0),
sliver: SliverGrid(
@@ -209,13 +206,12 @@ class AlbumViewerPage extends HookConsumerWidget {
onPressed: () => _onAddPhotosPressed(albumInfo),
labelText: "Add photos",
),
userId == albumInfo.ownerId
? AlbumActionOutlinedButton(
iconData: Icons.person_add_alt_rounded,
onPressed: () => _onAddUsersPressed(albumInfo),
labelText: "Add users",
)
: Container(),
if (userId == albumInfo.ownerId)
AlbumActionOutlinedButton(
iconData: Icons.person_add_alt_rounded,
onPressed: () => _onAddUsersPressed(albumInfo),
labelText: "Add users",
),
],
),
),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.14.0+21
version: 1.16.0+23
environment:
sdk: ">=2.17.0 <3.0.0"

View File

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

View File

@@ -31,6 +31,9 @@ import { SearchAssetDto } from './dto/search-asset.dto';
import { CommunicationGateway } from '../communication/communication.gateway';
import { InjectQueue } from '@nestjs/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';
@UseGuards(JwtAuthGuard)
@Controller('asset')
@@ -40,8 +43,8 @@ export class AssetController {
private assetService: AssetService,
private backgroundTaskService: BackgroundTaskService,
@InjectQueue('asset-uploaded-queue')
private assetUploadedQueue: Queue,
@InjectQueue(assetUploadedQueueName)
private assetUploadedQueue: Queue<IAssetUploadedJob>,
) {}
@Post('upload')
@@ -56,7 +59,7 @@ export class AssetController {
)
async uploadFile(
@GetAuthUser() authUser: AuthUserDto,
@UploadedFiles() uploadFiles: { assetData: Express.Multer.File[]; thumbnailData?: Express.Multer.File[] },
@UploadedFiles() uploadFiles: { assetData: Express.Multer.File[] },
@Body(ValidationPipe) assetInfo: CreateAssetDto,
): Promise<'ok' | undefined> {
for (const file of uploadFiles.assetData) {
@@ -66,28 +69,12 @@ export class AssetController {
if (!savedAsset) {
return;
}
if (uploadFiles.thumbnailData != null) {
const assetWithThumbnail = await this.assetService.updateThumbnailInfo(
savedAsset,
uploadFiles.thumbnailData[0].path,
);
await this.assetUploadedQueue.add(
'asset-uploaded',
{ asset: assetWithThumbnail, fileName: file.originalname, fileSize: file.size, hasThumbnail: true },
{ 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 },
);
}
await this.assetUploadedQueue.add(
assetUploadedProcessorName,
{ asset: savedAsset, fileName: file.originalname, fileSize: file.size },
{ jobId: savedAsset.id },
);
} catch (e) {
Logger.error(`Error receiving upload file ${e}`);
}
@@ -116,7 +103,7 @@ export class AssetController {
}
@Get('/thumbnail/:assetId')
async getAssetThumbnail(@Param('assetId') assetId: string): Promise<StreamableFile> {
async getAssetThumbnail(@Param('assetId') assetId: string) {
return await this.assetService.getAssetThumbnail(assetId);
}

View File

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

View File

@@ -11,12 +11,13 @@ import { IsNull, Not, Repository } from 'typeorm';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateAssetDto } from './dto/create-asset.dto';
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 { Response as Res } from 'express';
import { promisify } from 'util';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-asset.dto';
import fs from 'fs/promises';
const fileInfo = promisify(stat);
@@ -123,7 +124,7 @@ export class AssetService {
public async downloadFile(query: ServeFileDto, res: Res) {
try {
let file = null;
let fileReadStream = null;
const asset = await this.findOne(query.did, query.aid);
if (query.isThumb === 'false' || !query.isThumb) {
@@ -132,76 +133,90 @@ export class AssetService {
'Content-Type': asset.mimeType,
'Content-Length': size,
});
file = createReadStream(asset.originalPath);
await fs.access(asset.originalPath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.originalPath);
} else {
if (!asset.resizePath) {
throw new Error('resizePath not set');
throw new NotFoundException('resizePath not set');
}
const { size } = await fileInfo(asset.resizePath);
res.set({
'Content-Type': 'image/jpeg',
'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) {
Logger.error('Error download asset ', e);
Logger.error(`Error download asset`, 'downloadFile');
throw new InternalServerErrorException(`Failed to download asset ${e}`, 'DownloadFile');
}
}
public async getAssetThumbnail(assetId: string): Promise<StreamableFile> {
try {
const asset = await this.assetRepository.findOne({ where: { id: assetId } });
if (!asset) {
throw new NotFoundException('Asset not found');
}
public async getAssetThumbnail(assetId: string) {
let fileReadStream: ReadStream;
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) {
return new StreamableFile(createReadStream(asset.webpPath));
await fs.access(asset.webpPath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.webpPath);
} else {
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) {
if (e instanceof NotFoundException) {
throw e;
}
Logger.error('Error serving asset thumbnail ', e);
throw new InternalServerErrorException('Failed to serve asset thumbnail', 'GetAssetThumbnail');
Logger.error(`Cannot create read stream for asset ${asset.id}`, 'getAssetThumbnail');
throw new InternalServerErrorException(
e,
`Cannot read thumbnail file for asset ${asset.id} - contact your administrator`,
);
}
}
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);
if (!asset) {
// TODO: maybe this should be a NotFoundException?
throw new BadRequestException('Asset does not exist');
throw new NotFoundException('Asset does not exist');
}
// Handle Sending Images
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 {
/**
* 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
*/
@@ -209,34 +224,38 @@ export class AssetService {
res.set({
'Content-Type': asset.mimeType,
});
file = createReadStream(asset.originalPath);
await fs.access(asset.originalPath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.originalPath);
} else {
if (asset.webpPath && asset.webpPath.length > 0) {
res.set({
'Content-Type': 'image/webp',
});
file = createReadStream(asset.webpPath);
await fs.access(asset.webpPath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.webpPath);
} else {
res.set({
'Content-Type': 'image/jpeg',
});
if (!asset.resizePath) {
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) => {
Logger.log(`Cannot create read stream ${error}`);
return new BadRequestException('Cannot Create Read Stream');
});
return new StreamableFile(file);
return new StreamableFile(fileReadStream);
} catch (e) {
Logger.error('Error serving IMAGE asset ', e);
throw new InternalServerErrorException(`Failed to serve image asset ${e}`, 'ServeFile');
Logger.error(`Cannot create read stream for asset ${asset.id}`, 'serveFile[IMAGE]');
throw new InternalServerErrorException(
e,
`Cannot read thumbnail file for asset ${asset.id} - contact your administrator`,
);
}
} else if (asset.type == AssetType.VIDEO) {
try {
@@ -244,6 +263,8 @@ export class AssetService {
let videoPath = asset.originalPath;
let mimeType = asset.mimeType;
await fs.access(videoPath, constants.R_OK | constants.W_OK);
if (query.isWeb && asset.mimeType == 'video/quicktime') {
videoPath = asset.encodedVideoPath == '' ? asset.originalPath : asset.encodedVideoPath;
mimeType = asset.encodedVideoPath == '' ? asset.mimeType : 'video/mp4';
@@ -279,7 +300,6 @@ export class AssetService {
}
/** Sending Partial Content With HTTP Code 206 */
console.log('Send Range', range);
res.status(206).set({
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
@@ -298,7 +318,7 @@ export class AssetService {
return new StreamableFile(createReadStream(videoPath));
}
} 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');
}
}
@@ -335,11 +355,11 @@ export class AssetService {
// TODO: should use query builder
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
from assets a
left join exif e on a.id = e."assetId"
left join smart_info si on a.id = si."assetId"
where a."userId" = $1;
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
LEFT JOIN exif e ON a.id = e."assetId"
LEFT JOIN smart_info si ON a.id = si."assetId"
WHERE a."userId" = $1;
`,
[authUser.id],
);
@@ -395,12 +415,12 @@ export class AssetService {
async getCuratedLocation(authUser: AuthUserDto) {
return await this.assetRepository.query(
`
select distinct on (e.city) a.id, e.city, a."resizePath", a."deviceAssetId", a."deviceId"
from assets a
left join exif e on a.id = e."assetId"
where a."userId" = $1
and e.city is not null
and a.type = 'IMAGE';
SELECT DISTINCT ON (e.city) a.id, e.city, a."resizePath", a."deviceAssetId", a."deviceId"
FROM assets a
LEFT JOIN exif e ON a.id = e."assetId"
WHERE a."userId" = $1
AND e.city IS NOT NULL
AND a.type = 'IMAGE';
`,
[authUser.id],
);
@@ -409,11 +429,11 @@ export class AssetService {
async getCuratedObject(authUser: AuthUserDto) {
return await this.assetRepository.query(
`
select distinct on (unnest(si.objects)) a.id, unnest(si.objects) as "object", a."resizePath", a."deviceAssetId", a."deviceId"
from assets a
left join smart_info si on a.id = si."assetId"
where a."userId" = $1
and si.objects is not null
SELECT DISTINCT ON (unnest(si.objects)) a.id, unnest(si.objects) as "object", a."resizePath", a."deviceAssetId", a."deviceId"
FROM assets a
LEFT JOIN smart_info si ON a.id = si."assetId"
WHERE a."userId" = $1
AND si.objects IS NOT NULL
`,
[authUser.id],
);

View File

@@ -154,7 +154,6 @@ export class UserService {
}
if (!user.profileImagePath) {
// throw new BadRequestException('User does not have a profile image');
res.status(404).send('User does not have a profile image');
return;
}
@@ -165,7 +164,7 @@ export class UserService {
const fileStream = createReadStream(user.profileImagePath);
return new StreamableFile(fileStream);
} 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 { APP_UPLOAD_LOCATION } from '../constants/upload_location.constant';
import { randomUUID } from 'crypto';
// import { CreateAssetDto } from '../api-v1/asset/dto/create-asset.dto';
export const assetUploadOption: MulterOptions = {
fileFilter: (req: Request, file: any, cb: any) => {
@@ -30,34 +29,20 @@ export const assetUploadOption: MulterOptions = {
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)) {
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);
if (!existsSync(originalUploadFolder)) {
mkdirSync(originalUploadFolder, { recursive: true });
}
// Save original to disk
cb(null, originalUploadFolder);
},
filename: (req: Request, file: Express.Multer.File, cb: any) => {
const fileNameUUID = randomUUID();
if (file.fieldname == 'assetData') {
cb(null, `${fileNameUUID}${req.body['fileExtension'].toLowerCase()}`);
} else if (file.fieldname == 'thumbnailData') {
cb(null, `${fileNameUUID}.jpeg`);
}
cb(null, `${fileNameUUID}${req.body['fileExtension'].toLowerCase()}`);
},
}),
};

View File

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

View File

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

View File

@@ -6,6 +6,9 @@ import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull';
import { randomUUID } from 'crypto';
import { generateWEBPThumbnailProcessorName, mp4ConversionProcessorName } from '@app/job/constants/job-name.constant';
import { thumbnailGeneratorQueueName, videoConversionQueueName } from '@app/job/constants/queue-name.constant';
import { IVideoTranscodeJob } from '@app/job/interfaces/video-transcode.interface';
@Injectable()
export class ScheduleTasksService {
@@ -13,11 +16,11 @@ export class ScheduleTasksService {
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
@InjectQueue('thumbnail-generator-queue')
@InjectQueue(thumbnailGeneratorQueueName)
private thumbnailGeneratorQueue: Queue,
@InjectQueue('video-conversion-queue')
private videoConversionQueue: Queue,
@InjectQueue(videoConversionQueueName)
private videoConversionQueue: Queue<IVideoTranscodeJob>,
) {}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
@@ -36,7 +39,11 @@ export class ScheduleTasksService {
}
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 +61,7 @@ export class ScheduleTasksService {
});
for (const asset of assets) {
await this.videoConversionQueue.add('mp4-conversion', { asset }, { jobId: randomUUID() });
await this.videoConversionQueue.add(mp4ConversionProcessorName, { asset }, { jobId: randomUUID() });
}
}
}

View File

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

View File

@@ -1,61 +1,58 @@
import { InjectQueue, Process, Processor } from '@nestjs/bull';
import { Job, Queue } from 'bull';
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AssetType } from '@app/database/entities/asset.entity';
import { randomUUID } from 'crypto';
import {
IAssetUploadedJob,
IMetadataExtractionJob,
IThumbnailGenerationJob,
IVideoTranscodeJob,
assetUploadedQueueName,
metadataExtractionQueueName,
thumbnailGeneratorQueueName,
videoConversionQueueName,
assetUploadedProcessorName,
exifExtractionProcessorName,
generateJPEGThumbnailProcessorName,
mp4ConversionProcessorName,
videoLengthExtractionProcessorName,
} from '@app/job';
@Processor('asset-uploaded-queue')
@Processor(assetUploadedQueueName)
export class AssetUploadedProcessor {
constructor(
@InjectQueue('thumbnail-generator-queue')
private thumbnailGeneratorQueue: Queue,
@InjectQueue(thumbnailGeneratorQueueName)
private thumbnailGeneratorQueue: Queue<IThumbnailGenerationJob>,
@InjectQueue('metadata-extraction-queue')
private metadataExtractionQueue: Queue,
@InjectQueue(metadataExtractionQueueName)
private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
@InjectQueue('video-conversion-queue')
private videoConversionQueue: Queue,
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
@InjectQueue(videoConversionQueueName)
private videoConversionQueue: Queue<IVideoTranscodeJob>,
) {}
/**
* Post processing uploaded asset to perform the following function if missing
* 1. Generate JPEG Thumbnail
* 2. Generate Webp Thumbnail <-> if JPEG thumbnail exist
* 2. Generate Webp Thumbnail
* 3. EXIF extractor
* 4. Reverse Geocoding
*
* @param job asset-uploaded
*/
@Process('asset-uploaded')
async processUploadedVideo(job: Job) {
const {
asset,
fileName,
fileSize,
hasThumbnail,
}: { asset: AssetEntity; fileName: string; fileSize: number; hasThumbnail: boolean } = job.data;
@Process(assetUploadedProcessorName)
async processUploadedVideo(job: Job<IAssetUploadedJob>) {
const { asset, fileName, fileSize } = job.data;
if (hasThumbnail) {
// 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() });
}
await this.thumbnailGeneratorQueue.add(generateJPEGThumbnailProcessorName, { asset }, { jobId: randomUUID() });
// Video Conversion
if (asset.type == AssetType.VIDEO) {
await this.videoConversionQueue.add('mp4-conversion', { asset }, { jobId: randomUUID() });
await this.videoConversionQueue.add(mp4ConversionProcessorName, { asset }, { jobId: randomUUID() });
} 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(
'exif-extraction',
exifExtractionProcessorName,
{
asset,
fileName,
@@ -67,7 +64,7 @@ export class AssetUploadedProcessor {
// Extract video duration if uploaded from the web
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(videoLengthExtractionProcessorName, { asset }, { jobId: randomUUID() });
}
}
}

View File

@@ -12,9 +12,18 @@ import { Logger } from '@nestjs/common';
import axios from 'axios';
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
import ffmpeg from 'fluent-ffmpeg';
// import moment from 'moment';
import path from 'path';
import {
IExifExtractionProcessor,
IVideoLengthExtractionProcessor,
exifExtractionProcessorName,
imageTaggingProcessorName,
objectDetectionProcessorName,
videoLengthExtractionProcessorName,
metadataExtractionQueueName,
} from '@app/job';
@Processor('metadata-extraction-queue')
@Processor(metadataExtractionQueueName)
export class MetadataExtractionProcessor {
private geocodingClient?: GeocodeService;
@@ -35,8 +44,8 @@ export class MetadataExtractionProcessor {
}
}
@Process('exif-extraction')
async extractExifInfo(job: Job) {
@Process(exifExtractionProcessorName)
async extractExifInfo(job: Job<IExifExtractionProcessor>) {
try {
const { asset, fileName, fileSize }: { asset: AssetEntity; fileName: string; fileSize: number } = job.data;
@@ -48,7 +57,7 @@ export class MetadataExtractionProcessor {
newExif.assetId = asset.id;
newExif.make = exifData['Make'] || null;
newExif.model = exifData['Model'] || null;
newExif.imageName = fileName || null;
newExif.imageName = path.parse(fileName).name || null;
newExif.exifImageHeight = exifData['ExifImageHeight'] || null;
newExif.exifImageWidth = exifData['ExifImageWidth'] || null;
newExif.fileSizeInByte = fileSize || null;
@@ -89,11 +98,11 @@ export class MetadataExtractionProcessor {
}
}
@Process({ name: 'tag-image', concurrency: 2 })
@Process({ name: imageTaggingProcessorName, concurrency: 2 })
async tagImage(job: Job) {
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,
});
@@ -108,12 +117,12 @@ export class MetadataExtractionProcessor {
}
}
@Process({ name: 'detect-object', concurrency: 2 })
@Process({ name: objectDetectionProcessorName, concurrency: 2 })
async detectObject(job: Job) {
try {
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,
});
@@ -131,9 +140,9 @@ export class MetadataExtractionProcessor {
}
}
@Process({ name: 'extract-video-length', concurrency: 2 })
async extractVideoLength(job: Job) {
const { asset }: { asset: AssetEntity } = job.data;
@Process({ name: videoLengthExtractionProcessorName, concurrency: 2 })
async extractVideoLength(job: Job<IVideoLengthExtractionProcessor>) {
const { asset } = job.data;
ffmpeg.ffprobe(asset.originalPath, async (err, data) => {
if (!err) {

View File

@@ -9,25 +9,35 @@ import { randomUUID } from 'node:crypto';
import { CommunicationGateway } from '../../../immich/src/api-v1/communication/communication.gateway';
import ffmpeg from 'fluent-ffmpeg';
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 {
constructor(
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
@InjectQueue('thumbnail-generator-queue')
@InjectQueue(thumbnailGeneratorQueueName)
private thumbnailGeneratorQueue: Queue,
private wsCommunicateionGateway: CommunicationGateway,
@InjectQueue('metadata-extraction-queue')
@InjectQueue(metadataExtractionQueueName)
private metadataExtractionQueue: Queue,
) {}
@Process('generate-jpeg-thumbnail')
async generateJPEGThumbnail(job: Job) {
const { asset }: { asset: AssetEntity } = job.data;
@Process({ name: generateJPEGThumbnailProcessorName, concurrency: 3 })
async generateJPEGThumbnail(job: Job<JpegGeneratorProcessor>) {
const { asset } = job.data;
const resizePath = `upload/${asset.userId}/thumb/${asset.deviceId}/`;
@@ -43,6 +53,7 @@ export class ThumbnailGeneratorProcessor {
sharp(asset.originalPath)
.resize(1440, 2560, { fit: 'inside' })
.jpeg()
.rotate()
.toFile(jpegThumbnailPath, async (err) => {
if (!err) {
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
asset.resizePath = jpegThumbnailPath;
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() });
await this.thumbnailGeneratorQueue.add(
generateWEBPThumbnailProcessorName,
{ 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));
}
});
@@ -60,7 +75,7 @@ export class ThumbnailGeneratorProcessor {
if (asset.type == AssetType.VIDEO) {
ffmpeg(asset.originalPath)
.outputOptions(['-ss 00:00:01.000', '-frames:v 1'])
.outputOptions(['-ss 00:00:00.000', '-frames:v 1'])
.output(jpegThumbnailPath)
.on('start', () => {
Logger.log('Start Generating Video Thumbnail', 'generateJPEGThumbnail');
@@ -76,9 +91,13 @@ export class ThumbnailGeneratorProcessor {
// Update resize path to send to generate webp queue
asset.resizePath = jpegThumbnailPath;
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() });
await this.thumbnailGeneratorQueue.add(
generateWEBPThumbnailProcessorName,
{ 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));
})
@@ -86,8 +105,8 @@ export class ThumbnailGeneratorProcessor {
}
}
@Process({ name: 'generate-webp-thumbnail', concurrency: 2 })
async generateWepbThumbnail(job: Job<{ asset: AssetEntity }>) {
@Process({ name: generateWEBPThumbnailProcessorName, concurrency: 3 })
async generateWepbThumbnail(job: Job<WebpGeneratorProcessor>) {
const { asset } = job.data;
if (!asset.resizePath) {
@@ -98,6 +117,7 @@ export class ThumbnailGeneratorProcessor {
sharp(asset.resizePath)
.resize(250)
.webp()
.rotate()
.toFile(webpPath, (err) => {
if (!err) {
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 { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
@@ -8,16 +11,16 @@ import { Repository } from 'typeorm';
import { AssetEntity } from '../../../../libs/database/src/entities/asset.entity';
import { APP_UPLOAD_LOCATION } from '../../../immich/src/constants/upload_location.constant';
@Processor('video-conversion-queue')
@Processor(videoConversionQueueName)
export class VideoTranscodeProcessor {
constructor(
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
) {}
@Process({ name: 'mp4-conversion', concurrency: 1 })
async mp4Conversion(job: Job) {
const { asset }: { asset: AssetEntity } = job.data;
@Process({ name: mp4ConversionProcessorName, concurrency: 1 })
async mp4Conversion(job: Job<IMp4ConversionProcessor>) {
const { asset } = job.data;
if (asset.mimeType != 'video/mp4') {
const basePath = APP_UPLOAD_LOCATION;

View File

@@ -0,0 +1,23 @@
/**
* 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 videoLengthExtractionProcessorName = 'extract-video-length';
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,27 @@
import { AssetEntity } from '@app/database/entities/asset.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 type IMetadataExtractionJob = IExifExtractionProcessor | IVideoLengthExtractionProcessor;

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": {
"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

@@ -120,7 +120,8 @@
"moduleNameMapper": {
"^@app/database(|/.*)$": "<rootDir>/libs/database/src/$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/*": [
"libs/database/src/*"
],
"@app/job": [
"libs/job/src"
],
"@app/job/*": [
"libs/job/src/*"
]
}
},

View File

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

View File

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

View File

@@ -5,6 +5,8 @@
import CloudUploadOutline from 'svelte-material-icons/CloudUploadOutline.svelte';
import WindowMinimize from 'svelte-material-icons/WindowMinimize.svelte';
import type { UploadAsset } from '$lib/models/upload-asset';
import { getAssetsInfo } from '$lib/stores/assets';
import { session } from '$app/stores';
let showDetail = true;
@@ -73,8 +75,15 @@
}
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>
{#if isUploading}

View File

@@ -2,8 +2,8 @@ import { writable, derived } from 'svelte/store';
import { getRequest } from '$lib/api';
import type { ImmichAsset } from '$lib/models/immich-asset';
import lodash from 'lodash-es';
import _ from 'lodash';
import moment from 'moment';
export const assets = writable<ImmichAsset[]>([]);
export const assetsGroupByDate = derived(assets, ($assets) => {
@@ -14,7 +14,6 @@ export const assetsGroupByDate = derived(assets, ($assets) => {
.sortBy((group) => $assets.indexOf(group[0]))
.value();
} catch (e) {
console.log('error deriving state assets', e);
return [];
}
});

View File

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

View File

@@ -84,7 +84,7 @@ export async function fileUploader(asset: File, accessToken: string) {
request.upload.onload = () => {
setTimeout(() => {
uploadAssetsStore.removeUploadAsset(deviceAssetId);
}, 2500);
}, 1000);
};
// listen for `error` event

View File

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