Compare commits

...

35 Commits

Author SHA1 Message Date
Alex
c234c95880 websocket upload notification - closed #24 (#25)
* Render when a new asset is uploaded from WebSocket notification
* Update Readme
2022-02-14 10:40:41 -06:00
dependabot[bot]
7cc7fc0a0c Bump follow-redirects from 1.14.7 to 1.14.8 in /server (#23)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.7 to 1.14.8.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.7...v1.14.8)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-02-13 15:12:51 -06:00
Alex
897d49f734 Implemented delete asset on device and on database (#22)
* refactor serving file function asset service
* Remove PhotoViewer for now since it creates a problem in 2.10
* Added error message for wrong decode file and logo for failed to load file
* Fixed error when read stream cannot be created and crash server
* Added method to get all assets as a raw array
* Implemented cleaner way of grouping image
* Implemented operation to delete assets in the database
* Implemented delete on database operation
* Implemented delete on device operation
* Fixed issue display wrong information when the auto backup is enabled after deleting all assets
2022-02-13 15:10:42 -06:00
Alex Tran
051c958c8b Update Readme 2022-02-11 22:35:14 -06:00
Alex Tran
56627caf5b Fixed EACCES permission when mounting bind volume, add dynamic location for backup directory 2022-02-11 22:23:06 -06:00
Tran, Alex
4f47c8c06b Update readme 2022-02-10 20:42:22 -06:00
Alex
de1dbcea9c Implemented EXIF store and display (#19)
* Added EXIF extracting in the backend
* Added EXIF displaying on `image_viewer_page.dart`
* Added Icon for backup option not enable
2022-02-10 20:40:11 -06:00
Alex Tran
d1498506a8 Remove TensorFlow dependency to work with ARM64 2022-02-09 21:06:37 -06:00
Alex Tran
9bcbdd31ce Added arm64 to github action for docker build 2022-02-09 20:52:38 -06:00
Alex
38c968d47e Support HEIC/HEIF (#16)
* Support HEIC/HEIF backup
* Storing backup directly from original file from the phone
* Directly read and backup video file - Improve performance on video backup
2022-02-09 20:48:06 -06:00
Alex
f578ca6d47 Implemented bottom app bar with control buttons for asset's operation (#15) 2022-02-09 12:41:02 -06:00
Alex Tran
b04e69fd66 Update readme with screenshots 2022-02-08 14:18:51 -06:00
Alex Tran
0a1e28a08f Update readme 2022-02-08 14:06:58 -06:00
Alex Tran
9fdaa82d77 Update readme with gif 2022-02-08 14:05:31 -06:00
Alex Tran
88123b1cd2 Update readme with gif 2022-02-08 13:59:25 -06:00
Alex Tran
42c4c9dba1 Update readme with gif 2022-02-08 13:56:37 -06:00
Alex Tran
e63dc49475 Update readme with gif 2022-02-08 13:55:18 -06:00
Alex Tran
690f30f3dd Update Artifact path 2022-02-08 11:57:49 -06:00
Alex Tran
561b030e80 Update github action 2022-02-08 11:43:38 -06:00
Alex Tran
4756c075b6 Added work flow to build APK on push to master 2022-02-08 11:36:43 -06:00
Alex
328f382f86 Implemented multi select interaction (#13) 2022-02-08 11:24:49 -06:00
Alex Tran
6ad77e9434 Update readme 2022-02-07 23:55:30 -06:00
Alex
919928ab70 Implemented auto backup (#11) 2022-02-07 23:42:35 -06:00
Alex Tran
2a4d4ea999 Change docker hub name to the correct one 2022-02-07 16:20:21 -06:00
Alex Tran
547ce49500 Remove armv7-64bit for docker build as Tensorflow doesn't support that architecture, add amd64 2022-02-07 15:52:42 -06:00
Alex Tran
f4970ed053 Update readme 2022-02-07 15:44:03 -06:00
Alex Tran
9cf083decf Update readme 2022-02-07 15:25:51 -06:00
Alex Tran
d078367c04 change path in of target docker file in docker-compose for server 2022-02-07 15:11:59 -06:00
Alex Tran
a8edc85183 rename docker-minimal to dockerfile as target for github action 2022-02-07 15:06:30 -06:00
Alex Tran
5d48de7fa9 Change to npm instead of yarn in docker image to test for build error on github action 2022-02-07 14:58:23 -06:00
Alex Tran
82beb040bc Remove production build on docker file to test build for arm architecture 2022-02-07 14:38:02 -06:00
Alex Tran
03864e52ff Enable automated dockerhub image build 2022-02-07 08:55:15 -06:00
Alex
c24fb403c5 Implemented load new image when navigating back from backup page (#9) 2022-02-06 20:31:32 -06:00
Alex
1d3ee2008c Update workflow to build on pull request only 2022-02-06 13:11:17 -06:00
schklom
c917875943 Automated multi-platform build and DockerHub publication (#8)
* Automated multi-arch build

This setup uses GitHub Actions to build an image for arm/v7 and arm64 then publish them on DockerHub (you need to setup repo secrets first) every time you want (workflow_dispatch), every push, every pull requests (pull_requests), or on a schedule (cronjob) :)

Remove the triggers you don't want.

Reminder: if you ever move the Dockerfile (or some dependencies), you will have to correct the Dockerfile path (and/or the context path).

* Create dependabot.yml

This checks the dependencies' versions for the Actions everyday and creates a pull request if there are new versions available.
2022-02-06 13:06:01 -06:00
89 changed files with 21155 additions and 7433 deletions

7
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,7 @@
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"

46
.github/workflows/Build+push Immich.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: Build+push Immich
on:
# Triggers the workflow on push or pull request events but only for the main branch
#schedule:
# * is a special character in YAML so you have to quote this string
#- cron: '0 0 * * *'
workflow_dispatch:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
buildandpush:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2.4.0
with:
ref: "main" # branch
# https://github.com/docker/setup-qemu-action#usage
- name: Set up QEMU
uses: docker/setup-qemu-action@v1.2.0
# https://github.com/marketplace/actions/docker-setup-buildx
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1.6.0
# https://github.com/docker/login-action#docker-hub
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# https://github.com/docker/build-push-action#multi-platform-image
- name: Build and push Immich
uses: docker/build-push-action@v2.9.0
with:
context: ./server
file: ./server/Dockerfile
#platforms: linux/amd64,linux/arm64,linux/riscv64,linux/ppc64le,linux/s390x,linux/386,linux/mips64le,linux/mips64,linux/arm/v7,linux/arm/v6
platforms: linux/arm/v7,linux/amd64,linux/arm64
pull: true
push: true
tags: |
altran1502/immich-server:latest

32
.github/workflows/build_apk.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: Build APK Android
on:
workflow_dispatch:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./mobile
steps:
- uses: actions/checkout@v2
# Build
- uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
cache-key: flutter2.10 # optional, change this to force refresh cache
- run: flutter --version
- run: flutter pub get
- run: flutter build apk
- run: flutter build appbundle
# Upload Artifact
- uses: actions/upload-artifact@v2
with:
name: release-apk
path: mobile/build/app/outputs/apk/release/app-release.apk

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2022 Alex Copyright (c) 2022 Hau Tran
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -2,4 +2,7 @@ dev:
docker-compose -f ./server/docker-compose.yml up docker-compose -f ./server/docker-compose.yml up
dev-update: dev-update:
docker-compose -f ./server/docker-compose.yml up --build -V docker-compose -f ./server/docker-compose.yml up --build -V
dev-scale:
docker-compose -f ./server/docker-compose.yml up --build -V --scale immich_server=3 --remove-orphans

View File

@@ -4,19 +4,43 @@
# IMMICH # IMMICH
Self-hosted Photo backup solution directly from your mobile phone. Self-hosted photo and video backup solution directly from your mobile phone.
![](https://media.giphy.com/media/y8ZeaAigGmNvlSoKhU/giphy.gif)
Loading ~4000 images/videos
## Screenshots
<p align="left">
<img src="design/sc1.PNG" width="150" title="Login With Custom URL">
<img src="design/sc2.PNG" width="150" title="Backup Setting Info">
<img src="design/sc4.PNG" width="150" title="Home Page">
<img src="design/sc3.PNG" width="150" title="Multiple seelct">
<img src="design/sc5.PNG" width="150" title="Multipe select group">
</p>
# Note # Note
**!! NOT READY FOR PRODUCTION! DO NOT USE TO STORE YOUR ASSETS !!**
This project is under heavy development, there will be continous functions, features and api changes. This project is under heavy development, there will be continous functions, features and api changes.
**!! NOT READY FOR PRODUCTION! DO NOT USE TO STORE YOUR ASSETS !!** # Features
- Upload assets(videos/images).
- View assets.
- Quick navigation with drag scroll bar.
- Auto Backup.
- Support HEIC/HEIF Backup.
- Extract and display EXIF info.
- Real-time render from multi-device upload event.
# Development # Development
You can use docker compose for development, there are several services that compose Immich You can use docker compose for development, there are several services that compose Immich
1. The server 1. NestJs
2. PostgreSQL 2. PostgreSQL
3. Redis 3. Redis
4. Nginx 4. Nginx
@@ -25,17 +49,18 @@ You can use docker compose for development, there are several services that comp
Navigate to `server` directory and run Navigate to `server` directory and run
``` ````
cp .env.example .env cp .env.example .env
```
Then populate the value in there. Then populate the value in there.
Pay attention to the key `UPLOAD_LOCATION`, this directory must exist and is owned by the user that run the `docker-compose` command below.
To start, run To start, run
```bash ```bash
docker-compose -f ./server/docker-compose.yml up docker-compose -f ./server/docker-compose.yml up
``` ````
To force rebuild node modules after installing new packages To force rebuild node modules after installing new packages
@@ -79,7 +104,7 @@ flutter run --release
# Known Issue # Known Issue
TensorFlow doesn't run with older CPU architecture, it requires CPU with AVX and AVX2 instruction set. If you encounter error `illegal instruction core dump` when running the docker-compose command above, check for your CPU flags with the command ad make sure you see `AVX` and `AVX2`. Otherwise, switch to a different VM/desktop with different architecture. TensorFlow doesn't run with older CPU architecture, it requires CPU with AVX and AVX2 instruction set. If you encounter the error `illegal instruction core dump` when running the docker-compose command above, check for your CPU flags with the command and make sure you see `AVX` and `AVX2`. Otherwise, switch to a different VM/desktop with different architecture.
```bash ```bash
more /proc/cpuinfo | grep flags more /proc/cpuinfo | grep flags

BIN
design/sc1.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

BIN
design/sc2.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

BIN
design/sc3.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 765 KiB

BIN
design/sc4.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

BIN
design/sc5.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 KiB

View File

@@ -1,16 +1 @@
# immich_mobile # Immich Mobile Application - Flutter
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
Few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://flutter.dev/docs/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://flutter.dev/docs/cookbook)
For help getting started with Flutter, view our
[online documentation](https://flutter.dev/docs), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

View File

@@ -2,8 +2,11 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/app_state.provider.dart'; import 'package:immich_mobile/shared/providers/app_state.provider.dart';
import 'package:immich_mobile/shared/providers/backup.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'constants/hive_box.dart'; import 'constants/hive_box.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
@@ -35,25 +38,29 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
switch (state) { switch (state) {
case AppLifecycleState.resumed: case AppLifecycleState.resumed:
debugPrint("[APP STATE] resumed"); debugPrint("[APP STATE] resumed");
ref.read(appStateProvider.notifier).state = AppStateEnum.resumed; ref.watch(appStateProvider.notifier).state = AppStateEnum.resumed;
ref.watch(backupProvider.notifier).resumeBackup();
ref.watch(websocketProvider.notifier).connect();
ref.watch(assetProvider.notifier).getAllAsset();
break; break;
case AppLifecycleState.inactive: case AppLifecycleState.inactive:
debugPrint("[APP STATE] inactive"); debugPrint("[APP STATE] inactive");
ref.read(appStateProvider.notifier).state = AppStateEnum.inactive; ref.watch(appStateProvider.notifier).state = AppStateEnum.inactive;
ref.watch(websocketProvider.notifier).disconnect();
break; break;
case AppLifecycleState.paused: case AppLifecycleState.paused:
debugPrint("[APP STATE] paused"); debugPrint("[APP STATE] paused");
ref.read(appStateProvider.notifier).state = AppStateEnum.paused; ref.watch(appStateProvider.notifier).state = AppStateEnum.paused;
break; break;
case AppLifecycleState.detached: case AppLifecycleState.detached:
debugPrint("[APP STATE] detached"); debugPrint("[APP STATE] detached");
ref.read(appStateProvider.notifier).state = AppStateEnum.detached; ref.watch(appStateProvider.notifier).state = AppStateEnum.detached;
break; break;
} }
} }
Future<void> initApp() async { Future<void> initApp() async {
// WidgetsBinding.instance?.addObserver(this); WidgetsBinding.instance?.addObserver(this);
} }
@override @override

View File

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

View File

@@ -0,0 +1,21 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
import 'package:immich_mobile/modules/home/models/home_page_state.model.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
class ImageViewerPageStateNotifier extends StateNotifier<ImageViewerPageState> {
ImageViewerPageStateNotifier() : super(ImageViewerPageState(isBottomSheetEnable: false));
void toggleBottomSheet() {
bool isBottomSheetEnable = state.isBottomSheetEnable;
if (isBottomSheetEnable) {
state.copyWith(isBottomSheetEnable: false);
} else {
state.copyWith(isBottomSheetEnable: true);
}
}
}
final homePageStateProvider = StateNotifierProvider<ImageViewerPageStateNotifier, ImageViewerPageState>(
((ref) => ImageViewerPageStateNotifier()));

View File

@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
import 'package:intl/intl.dart';
import 'package:path/path.dart' as p;
class ExifBottomSheet extends ConsumerWidget {
final ImmichAssetWithExif assetDetail;
const ExifBottomSheet({Key? key, required this.assetDetail}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
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(),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Text(
"Add Description...",
style: TextStyle(
color: Colors.grey[500],
fontSize: 11,
),
),
),
// 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]),
),
Text(
"${assetDetail.exifInfo!.latitude!.toStringAsFixed(4)}, ${assetDetail.exifInfo!.longitude!.toStringAsFixed(4)}",
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
)
],
),
)
: Container(),
// 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: Text(
"${assetDetail.exifInfo?.exifImageHeight!} x ${assetDetail.exifInfo?.exifImageWidth!} ${assetDetail.exifInfo?.fileSizeInByte!}B "),
),
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()
],
),
)
: Container()
],
),
);
}
}

View File

@@ -0,0 +1,57 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
class TopControlAppBar extends StatelessWidget with PreferredSizeWidget {
const TopControlAppBar({Key? key, required this.asset, required this.onMoreInfoPressed}) : super(key: key);
final ImmichAsset asset;
final Function onMoreInfoPressed;
@override
Widget build(BuildContext context) {
double iconSize = 18.0;
return AppBar(
foregroundColor: Colors.grey[100],
toolbarHeight: 60,
backgroundColor: Colors.black,
leading: IconButton(
onPressed: () {
AutoRouter.of(context).pop();
},
icon: const Icon(
Icons.arrow_back_ios_new_rounded,
size: 20.0,
),
),
actions: [
IconButton(
iconSize: iconSize,
splashRadius: iconSize,
onPressed: () {
print("backup");
},
icon: const Icon(Icons.backup_outlined),
),
IconButton(
iconSize: iconSize,
splashRadius: iconSize,
onPressed: () {
print("favorite");
},
icon: asset.isFavorite ? const Icon(Icons.favorite_rounded) : const Icon(Icons.favorite_border_rounded),
),
IconButton(
iconSize: iconSize,
splashRadius: iconSize,
onPressed: () {
onMoreInfoPressed();
},
icon: const Icon(Icons.more_horiz_rounded))
],
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}

View File

@@ -0,0 +1,109 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
// ignore: must_be_immutable
class ImageViewerPage extends HookConsumerWidget {
final String imageUrl;
final String heroTag;
final String thumbnailUrl;
final ImmichAsset asset;
final AssetService _assetService = AssetService();
ImmichAssetWithExif? assetDetail;
ImageViewerPage(
{Key? key, required this.imageUrl, required this.heroTag, required this.thumbnailUrl, required this.asset})
: super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox);
getAssetExif() async {
assetDetail = await _assetService.getAssetById(asset.id);
}
useEffect(() {
getAssetExif();
return null;
}, []);
return Scaffold(
backgroundColor: Colors.black,
appBar: TopControlAppBar(
asset: asset,
onMoreInfoPressed: () {
showModalBottomSheet(
backgroundColor: Colors.black,
barrierColor: Colors.transparent,
isScrollControlled: false,
context: context,
builder: (context) {
return ExifBottomSheet(assetDetail: assetDetail!);
});
},
),
body: Center(
child: Hero(
tag: heroTag,
child: CachedNetworkImage(
fit: BoxFit.cover,
imageUrl: imageUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
fadeInDuration: const Duration(milliseconds: 250),
errorWidget: (context, url, error) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: Wrap(
spacing: 32,
runSpacing: 32,
alignment: WrapAlignment.center,
children: [
const Text(
"Failed To Render Image - Possibly Corrupted Data",
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Colors.white),
),
SingleChildScrollView(
child: Text(
error.toString(),
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
),
),
],
),
),
// imageBuilder: (context, imageProvider) {
// return PhotoView(imageProvider: imageProvider);
// },
placeholder: (context, url) {
return CachedNetworkImage(
cacheKey: thumbnailUrl,
fit: BoxFit.cover,
imageUrl: thumbnailUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
placeholderFadeInDuration: const Duration(milliseconds: 0),
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(value: downloadProgress.progress),
),
errorWidget: (context, url, error) => Icon(
Icons.error,
color: Colors.grey[300],
),
);
},
),
),
),
);
}
}

View File

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

View File

@@ -0,0 +1,66 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
class HomePageState {
final bool isMultiSelectEnable;
final Set<ImmichAsset> selectedItems;
final Set<String> selectedDateGroup;
HomePageState({
required this.isMultiSelectEnable,
required this.selectedItems,
required this.selectedDateGroup,
});
HomePageState copyWith({
bool? isMultiSelectEnable,
Set<ImmichAsset>? selectedItems,
Set<String>? selectedDateGroup,
}) {
return HomePageState(
isMultiSelectEnable: isMultiSelectEnable ?? this.isMultiSelectEnable,
selectedItems: selectedItems ?? this.selectedItems,
selectedDateGroup: selectedDateGroup ?? this.selectedDateGroup,
);
}
Map<String, dynamic> toMap() {
return {
'isMultiSelectEnable': isMultiSelectEnable,
'selectedItems': selectedItems.map((x) => x.toMap()).toList(),
'selectedDateGroup': selectedDateGroup.toList(),
};
}
factory HomePageState.fromMap(Map<String, dynamic> map) {
return HomePageState(
isMultiSelectEnable: map['isMultiSelectEnable'] ?? false,
selectedItems: Set<ImmichAsset>.from(map['selectedItems']?.map((x) => ImmichAsset.fromMap(x))),
selectedDateGroup: Set<String>.from(map['selectedDateGroup']),
);
}
String toJson() => json.encode(toMap());
factory HomePageState.fromJson(String source) => HomePageState.fromMap(json.decode(source));
@override
String toString() =>
'HomePageState(isMultiSelectEnable: $isMultiSelectEnable, selectedItems: $selectedItems, selectedDateGroup: $selectedDateGroup)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
final setEquals = const DeepCollectionEquality().equals;
return other is HomePageState &&
other.isMultiSelectEnable == isMultiSelectEnable &&
setEquals(other.selectedItems, selectedItems) &&
setEquals(other.selectedDateGroup, selectedDateGroup);
}
@override
int get hashCode => isMultiSelectEnable.hashCode ^ selectedItems.hashCode ^ selectedDateGroup.hashCode;
}

View File

@@ -1,60 +1,74 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart'; import 'package:immich_mobile/modules/home/models/delete_asset_response.model.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart'; import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/services/device_info.service.dart';
import 'package:collection/collection.dart';
import 'package:intl/intl.dart';
import 'package:photo_manager/photo_manager.dart';
class AssetNotifier extends StateNotifier<List<ImmichAssetGroupByDate>> { class AssetNotifier extends StateNotifier<List<ImmichAsset>> {
final imagePerPage = 100;
final AssetService _assetService = AssetService(); final AssetService _assetService = AssetService();
final DeviceInfoService _deviceInfoService = DeviceInfoService();
final Ref ref;
AssetNotifier() : super([]); AssetNotifier(this.ref) : super([]);
late String? nextPageKey = "";
bool isFetching = false;
getImmichAssets() async { getAllAsset() async {
GetAllAssetResponse? res = await _assetService.getAllAsset(); List<ImmichAsset>? allAssets = await _assetService.getAllAsset();
nextPageKey = res?.nextPageKey;
if (res != null) { if (allAssets != null) {
for (var assets in res.data) { state = allAssets;
state = [...state, assets];
}
}
}
getMoreAsset() async {
if (nextPageKey != null && !isFetching) {
isFetching = true;
GetAllAssetResponse? res = await _assetService.getMoreAsset(nextPageKey);
if (res != null) {
nextPageKey = res.nextPageKey;
List<ImmichAssetGroupByDate> previousState = state;
List<ImmichAssetGroupByDate> currentState = [];
for (var assets in res.data) {
currentState = [...currentState, assets];
}
if (previousState.last.date == currentState.first.date) {
previousState.last.assets = [...previousState.last.assets, ...currentState.first.assets];
state = [...previousState, ...currentState.sublist(1)];
} else {
state = [...previousState, ...currentState];
}
}
isFetching = false;
} }
} }
clearAllAsset() { clearAllAsset() {
state = []; state = [];
} }
onNewAssetUploaded(ImmichAsset newAsset) {
state = [...state, newAsset];
}
deleteAssets(Set<ImmichAsset> deleteAssets) async {
var deviceInfo = await _deviceInfoService.getDeviceInfo();
var deviceId = deviceInfo["deviceId"];
List<String> deleteIdList = [];
// Delete asset from device
for (var asset in deleteAssets) {
// Delete asset on device if present
if (asset.deviceId == deviceId) {
AssetEntity? localAsset = await AssetEntity.fromId(asset.deviceAssetId);
if (localAsset != null) {
deleteIdList.add(localAsset.id);
}
}
}
final List<String> result = await PhotoManager.editor.deleteWithIds(deleteIdList);
// Delete asset on server
List<DeleteAssetResponse>? deleteAssetResult = await _assetService.deleteAssets(deleteAssets);
if (deleteAssetResult == null) {
return;
}
for (var asset in deleteAssetResult) {
if (asset.status == 'success') {
state = state.where((immichAsset) => immichAsset.id != asset.id).toList();
}
}
}
} }
final currentLocalPageProvider = StateProvider<int>((ref) => 0); final assetProvider = StateNotifierProvider<AssetNotifier, List<ImmichAsset>>((ref) {
return AssetNotifier(ref);
final assetProvider = StateNotifierProvider<AssetNotifier, List<ImmichAssetGroupByDate>>((ref) { });
return AssetNotifier();
final assetGroupByDateTimeProvider = StateProvider((ref) {
var assets = ref.watch(assetProvider);
assets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
return assets.groupListsBy((element) => DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)));
}); });

View File

@@ -0,0 +1,63 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/models/home_page_state.model.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
class HomePageStateNotifier extends StateNotifier<HomePageState> {
HomePageStateNotifier()
: super(
HomePageState(
isMultiSelectEnable: false,
selectedItems: {},
selectedDateGroup: {},
),
);
void addSelectedDateGroup(String dateGroupTitle) {
state = state.copyWith(selectedDateGroup: {...state.selectedDateGroup, dateGroupTitle});
}
void removeSelectedDateGroup(String dateGroupTitle) {
var currentDateGroup = state.selectedDateGroup;
currentDateGroup.removeWhere((e) => e == dateGroupTitle);
state = state.copyWith(selectedDateGroup: currentDateGroup);
}
void enableMultiSelect(Set<ImmichAsset> selectedItems) {
state = state.copyWith(isMultiSelectEnable: true, selectedItems: selectedItems);
}
void disableMultiSelect() {
state = state.copyWith(isMultiSelectEnable: false, selectedItems: {}, selectedDateGroup: {});
}
void addSingleSelectedItem(ImmichAsset asset) {
state = state.copyWith(selectedItems: {...state.selectedItems, asset});
}
void addMultipleSelectedItems(List<ImmichAsset> assets) {
state = state.copyWith(selectedItems: {...state.selectedItems, ...assets});
}
void removeSingleSelectedItem(ImmichAsset asset) {
Set<ImmichAsset> currentList = state.selectedItems;
currentList.removeWhere((e) => e.id == asset.id);
state = state.copyWith(selectedItems: currentList);
}
void removeMultipleSelectedItem(List<ImmichAsset> assets) {
Set<ImmichAsset> currentList = state.selectedItems;
for (ImmichAsset asset in assets) {
currentList.removeWhere((e) => e.id == asset.id);
}
state = state.copyWith(selectedItems: currentList);
}
}
final homePageStateProvider =
StateNotifierProvider<HomePageStateNotifier, HomePageState>(((ref) => HomePageStateNotifier()));

View File

@@ -1,13 +1,29 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart'; import 'package:immich_mobile/modules/home/models/delete_asset_response.model.dart';
import 'package:immich_mobile/modules/home/models/get_all_asset_response.model.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
import 'package:immich_mobile/shared/services/network.service.dart'; import 'package:immich_mobile/shared/services/network.service.dart';
class AssetService { class AssetService {
final NetworkService _networkService = NetworkService(); final NetworkService _networkService = NetworkService();
Future<GetAllAssetResponse?> getAllAsset() async { Future<List<ImmichAsset>?> getAllAsset() async {
var res = await _networkService.getRequest(url: "asset/");
try {
List<dynamic> decodedData = jsonDecode(res.toString());
List<ImmichAsset> result = List.from(decodedData.map((a) => ImmichAsset.fromMap(a)));
return result;
} catch (e) {
debugPrint("Error getAllAsset ${e.toString()}");
}
return null;
}
Future<GetAllAssetResponse?> getAllAssetWithPagination() async {
var res = await _networkService.getRequest(url: "asset/all"); var res = await _networkService.getRequest(url: "asset/all");
try { try {
Map<String, dynamic> decodedData = jsonDecode(res.toString()); Map<String, dynamic> decodedData = jsonDecode(res.toString());
@@ -17,9 +33,10 @@ class AssetService {
} catch (e) { } catch (e) {
debugPrint("Error getAllAsset ${e.toString()}"); debugPrint("Error getAllAsset ${e.toString()}");
} }
return null;
} }
Future<GetAllAssetResponse?> getMoreAsset(String? nextPageKey) async { Future<GetAllAssetResponse?> getOlderAsset(String? nextPageKey) async {
try { try {
var res = await _networkService.getRequest( var res = await _networkService.getRequest(
url: "asset/all?nextPageKey=$nextPageKey", url: "asset/all?nextPageKey=$nextPageKey",
@@ -34,5 +51,63 @@ class AssetService {
} catch (e) { } catch (e) {
debugPrint("Error getAllAsset ${e.toString()}"); debugPrint("Error getAllAsset ${e.toString()}");
} }
return null;
}
Future<List<ImmichAsset>> getNewAsset(String latestDate) async {
try {
var res = await _networkService.getRequest(
url: "asset/new?latestDate=$latestDate",
);
List<dynamic> decodedData = jsonDecode(res.toString());
List<ImmichAsset> result = List.from(decodedData.map((a) => ImmichAsset.fromMap(a)));
if (result.isNotEmpty) {
return result;
}
return [];
} catch (e) {
debugPrint("Error getAllAsset ${e.toString()}");
return [];
}
}
Future<ImmichAssetWithExif?> getAssetById(String assetId) async {
try {
var res = await _networkService.getRequest(
url: "asset/assetById/$assetId",
);
Map<String, dynamic> decodedData = jsonDecode(res.toString());
ImmichAssetWithExif result = ImmichAssetWithExif.fromMap(decodedData);
return result;
} catch (e) {
debugPrint("Error getAllAsset ${e.toString()}");
return null;
}
}
Future<List<DeleteAssetResponse>?> deleteAssets(Set<ImmichAsset> deleteAssets) async {
try {
var payload = [];
for (var asset in deleteAssets) {
payload.add(asset.id);
}
var res = await _networkService.deleteRequest(url: "asset/", data: {"ids": payload});
List<dynamic> decodedData = jsonDecode(res.toString());
List<DeleteAssetResponse> result = List.from(decodedData.map((a) => DeleteAssetResponse.fromMap(a)));
return result;
} catch (e) {
debugPrint("Error getAllAsset ${e.toString()}");
return null;
}
} }
} }

View File

@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/home/ui/delete_diaglog.dart';
class ControlBottomAppBar extends StatelessWidget {
const ControlBottomAppBar({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Positioned(
bottom: 0,
left: 0,
child: Container(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height * 0.15,
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(topLeft: Radius.circular(15), topRight: Radius.circular(15)),
color: Colors.grey[300]?.withOpacity(0.98),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ControlBoxButton(
iconData: Icons.delete_forever_rounded,
label: "Delete",
onPressed: () {
showDialog(
context: context,
builder: (BuildContext context) {
return const DeleteDialog();
},
);
},
),
],
),
)
],
),
),
);
}
}
class ControlBoxButton extends StatelessWidget {
const ControlBoxButton({Key? key, required this.label, required this.iconData, required this.onPressed})
: super(key: key);
final String label;
final IconData iconData;
final Function onPressed;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 60,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButton(
onPressed: () {
onPressed();
},
icon: Icon(iconData, size: 30),
),
Text(label)
],
),
);
}
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:intl/intl.dart';
class DailyTitleText extends ConsumerWidget {
const DailyTitleText({
Key? key,
required this.isoDate,
required this.assetGroup,
}) : super(key: key);
final String isoDate;
final List<ImmichAsset> assetGroup;
@override
Widget build(BuildContext context, WidgetRef ref) {
var currentYear = DateTime.now().year;
var groupYear = DateTime.parse(isoDate).year;
var formatDateTemplate = currentYear == groupYear ? 'E, MMM dd' : 'E, MMM dd, yyyy';
var dateText = DateFormat(formatDateTemplate).format(DateTime.parse(isoDate));
var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable;
var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;
var selectedItems = ref.watch(homePageStateProvider).selectedItems;
void _handleTitleIconClick() {
if (isMultiSelectEnable &&
selectedDateGroup.contains(dateText) &&
selectedDateGroup.length == 1 &&
selectedItems.length <= assetGroup.length) {
// Multi select is active - click again on the icon while it is the only active group -> disable multi select
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
} else if (isMultiSelectEnable &&
selectedDateGroup.contains(dateText) &&
selectedItems.length != assetGroup.length) {
// Multi select is active - click again on the icon while it is not the only active group -> remove that group from selected group/items
ref.watch(homePageStateProvider.notifier).removeSelectedDateGroup(dateText);
ref.watch(homePageStateProvider.notifier).removeMultipleSelectedItem(assetGroup);
} else if (isMultiSelectEnable && selectedDateGroup.contains(dateText) && selectedDateGroup.length > 1) {
ref.watch(homePageStateProvider.notifier).removeSelectedDateGroup(dateText);
ref.watch(homePageStateProvider.notifier).removeMultipleSelectedItem(assetGroup);
} else if (isMultiSelectEnable && !selectedDateGroup.contains(dateText)) {
ref.watch(homePageStateProvider.notifier).addSelectedDateGroup(dateText);
ref.watch(homePageStateProvider.notifier).addMultipleSelectedItems(assetGroup);
} else {
ref.watch(homePageStateProvider.notifier).enableMultiSelect(assetGroup.toSet());
ref.watch(homePageStateProvider.notifier).addSelectedDateGroup(dateText);
}
}
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 29.0, bottom: 29.0, left: 12.0, right: 12.0),
child: Row(
children: [
Text(
dateText,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const Spacer(),
GestureDetector(
onTap: _handleTitleIconClick,
child: isMultiSelectEnable && selectedDateGroup.contains(dateText)
? Icon(
Icons.check_circle_rounded,
color: Theme.of(context).primaryColor,
)
: const Icon(
Icons.check_circle_outline_rounded,
color: Colors.grey,
),
)
],
),
),
);
}
}

View File

@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
class DeleteDialog extends ConsumerWidget {
const DeleteDialog({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final homePageState = ref.watch(homePageStateProvider);
return AlertDialog(
backgroundColor: Colors.grey[200],
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
title: const Text("Delete Permanently"),
content: const Text("These items will be permanently deleted from Immich and from your device"),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text(
"Cancel",
style: TextStyle(color: Colors.blueGrey),
),
),
TextButton(
onPressed: () {
ref.watch(assetProvider.notifier).deleteAssets(homePageState.selectedItems);
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
Navigator.of(context).pop();
},
child: Text(
"Delete",
style: TextStyle(color: Colors.red[400]),
),
),
],
);
}
}

View File

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
class DisableMultiSelectButton extends ConsumerWidget {
const DisableMultiSelectButton({
Key? key,
required this.onPressed,
required this.selectedItemCount,
}) : super(key: key);
final Function onPressed;
final int selectedItemCount;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Positioned(
top: 0,
left: 0,
child: Padding(
padding: const EdgeInsets.only(left: 16.0, top: 46),
child: Material(
elevation: 20,
borderRadius: BorderRadius.circular(35),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(35),
color: Colors.grey[100],
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: TextButton.icon(
onPressed: () {
onPressed();
},
icon: const Icon(Icons.close_rounded),
label: Text(
selectedItemCount.toString(),
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18),
)),
),
),
),
),
);
}
}

View File

@@ -1,8 +1,9 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:badges/badges.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/backup_state.model.dart'; import 'package:immich_mobile/shared/models/backup_state.model.dart';
@@ -12,94 +13,98 @@ class ImmichSliverAppBar extends ConsumerWidget {
const ImmichSliverAppBar({ const ImmichSliverAppBar({
Key? key, Key? key,
required this.imageGridGroup, required this.imageGridGroup,
this.onPopBack,
}) : super(key: key); }) : super(key: key);
final List<Widget> imageGridGroup; final List<Widget> imageGridGroup;
final Function? onPopBack;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final BackUpState _backupState = ref.watch(backupProvider); final BackUpState _backupState = ref.watch(backupProvider);
bool _isEnableAutoBackup = ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
return SliverPadding( return SliverAppBar(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 5), centerTitle: true,
sliver: SliverAppBar( floating: true,
centerTitle: true, pinned: false,
floating: true, snap: false,
pinned: false, backgroundColor: Colors.grey[200],
snap: false, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
backgroundColor: Colors.grey[200], leading: Builder(
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))), builder: (BuildContext context) {
leading: Builder( return IconButton(
builder: (BuildContext context) { icon: const Icon(Icons.account_circle_rounded),
return IconButton( onPressed: () {
icon: const Icon(Icons.account_circle_rounded), Scaffold.of(context).openDrawer();
onPressed: () { },
Scaffold.of(context).openDrawer(); tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip,
}, );
tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip, },
);
},
),
title: Text(
'IMMICH',
style: GoogleFonts.snowburstOne(
textStyle: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
color: Theme.of(context).primaryColor,
),
),
),
actions: [
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(),
IconButton(
icon: const Icon(Icons.backup_rounded),
tooltip: 'Backup Controller',
onPressed: () async {
var onPop = await AutoRouter.of(context).push(const BackupControllerRoute());
if (onPop == true) {
// Remove and force getting new widget again if there is not many widget on screen.
// Otherwise do nothing.
if (imageGridGroup.isNotEmpty && imageGridGroup.length < 20) {
print("Get more access");
ref.read(assetProvider.notifier).getMoreAsset();
} else if (imageGridGroup.isEmpty) {
print("get immich asset");
ref.read(assetProvider.notifier).getImmichAssets();
}
}
},
),
_backupState.backupProgress == BackUpProgressEnum.inProgress
? Positioned(
bottom: 5,
child: Text(
_backupState.backingUpAssetCount.toString(),
style: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold),
),
)
: Container()
],
),
],
), ),
title: Text(
'IMMICH',
style: GoogleFonts.snowburstOne(
textStyle: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
color: Theme.of(context).primaryColor,
),
),
),
actions: [
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(),
IconButton(
splashRadius: 25,
iconSize: 30,
icon: _isEnableAutoBackup
? const Icon(Icons.backup_rounded)
: Badge(
padding: const EdgeInsets.all(4),
elevation: 1,
position: BadgePosition.bottomEnd(bottom: -4, end: -4),
badgeColor: Colors.white,
badgeContent: const Icon(
Icons.cloud_off_rounded,
size: 8,
),
child: const Icon(Icons.backup_rounded)),
tooltip: 'Backup Controller',
onPressed: () async {
var onPop = await AutoRouter.of(context).push(const BackupControllerRoute());
if (onPop == true) {
onPopBack!();
}
},
),
_backupState.backupProgress == BackUpProgressEnum.inProgress
? Positioned(
bottom: 5,
child: Text(
_backupState.backingUpAssetCount.toString(),
style: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold),
),
)
: Container()
],
),
],
); );
} }
} }

View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class MonthlyTitleText extends StatelessWidget {
const MonthlyTitleText({
Key? key,
required this.isoDate,
}) : super(key: key);
final String isoDate;
@override
Widget build(BuildContext context) {
var monthTitleText = DateFormat('MMMM y').format(DateTime.parse(isoDate));
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 12.0, top: 32),
child: Text(
monthTitleText,
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
),
);
}
}

View File

@@ -1,12 +1,11 @@
import 'package:auto_route/annotations.dart';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/src/widgets/framework.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; import 'package:immich_mobile/modules/home/providers/asset.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/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/providers/backup.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
class ProfileDrawer extends ConsumerWidget { class ProfileDrawer extends ConsumerWidget {
const ProfileDrawer({Key? key}) : super(key: key); const ProfileDrawer({Key? key}) : super(key: key);
@@ -58,9 +57,11 @@ class ProfileDrawer extends ConsumerWidget {
), ),
onTap: () async { onTap: () async {
bool res = await ref.read(authenticationProvider.notifier).logout(); bool res = await ref.read(authenticationProvider.notifier).logout();
ref.read(assetProvider.notifier).clearAllAsset();
if (res) { if (res) {
ref.watch(backupProvider.notifier).cancelBackup();
ref.watch(assetProvider.notifier).clearAllAsset();
ref.watch(websocketProvider.notifier).disconnect();
AutoRouter.of(context).popUntilRoot(); AutoRouter.of(context).popUntilRoot();
} }
}, },

View File

@@ -1,66 +1,135 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
class ThumbnailImage extends HookWidget { class ThumbnailImage extends HookConsumerWidget {
final ImmichAsset asset; final ImmichAsset asset;
const ThumbnailImage({Key? key, required this.asset}) : super(key: key); const ThumbnailImage({Key? key, required this.asset}) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final cacheKey = useState(1); final cacheKey = useState(1);
var box = Hive.box(userInfoBox); var box = Hive.box(userInfoBox);
var thumbnailRequestUrl = var thumbnailRequestUrl =
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=true'; '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=true';
var selectedAsset = ref.watch(homePageStateProvider).selectedItems;
var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable;
var deviceId = ref.watch(authenticationProvider).deviceId;
Widget _buildSelectionIcon(ImmichAsset asset) {
if (selectedAsset.contains(asset)) {
return Icon(
Icons.check_circle,
color: Theme.of(context).primaryColor,
);
} else {
return const Icon(
Icons.circle_outlined,
color: Colors.white,
);
}
}
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
if (asset.type == 'IMAGE') { debugPrint("View ${asset.id}");
AutoRouter.of(context).push( if (isMultiSelectEnable && selectedAsset.contains(asset) && selectedAsset.length == 1) {
ImageViewerRoute( ref.watch(homePageStateProvider.notifier).disableMultiSelect();
imageUrl: } else if (isMultiSelectEnable && selectedAsset.contains(asset) && selectedAsset.length > 1) {
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false', ref.watch(homePageStateProvider.notifier).removeSingleSelectedItem(asset);
heroTag: asset.id, } else if (isMultiSelectEnable && !selectedAsset.contains(asset)) {
thumbnailUrl: thumbnailRequestUrl, ref.watch(homePageStateProvider.notifier).addSingleSelectedItem(asset);
),
);
} else { } else {
debugPrint("Navigate to video player"); if (asset.type == 'IMAGE') {
AutoRouter.of(context).push(
ImageViewerRoute(
imageUrl:
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false',
heroTag: asset.id,
thumbnailUrl: thumbnailRequestUrl,
asset: asset,
),
);
} else {
debugPrint("Navigate to video player");
AutoRouter.of(context).push( AutoRouter.of(context).push(
VideoViewerRoute( VideoViewerRoute(
videoUrl: '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}', videoUrl: '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}',
), ),
); );
}
} }
}, },
onLongPress: () {}, onLongPress: () {
// Enable multi selecte function
ref.watch(homePageStateProvider.notifier).enableMultiSelect({asset});
HapticFeedback.heavyImpact();
},
child: Hero( child: Hero(
tag: asset.id, tag: asset.id,
child: CachedNetworkImage( child: Stack(
cacheKey: "${asset.id}-${cacheKey.value}", children: [
width: 300, Container(
height: 300, decoration: BoxDecoration(
memCacheHeight: asset.type == 'IMAGE' ? 250 : 400, border: isMultiSelectEnable && selectedAsset.contains(asset)
fit: BoxFit.cover, ? Border.all(color: Theme.of(context).primaryColorLight, width: 10)
imageUrl: thumbnailRequestUrl, : const Border(),
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, ),
fadeInDuration: const Duration(milliseconds: 250), child: CachedNetworkImage(
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale( cacheKey: "${asset.id}-${cacheKey.value}",
scale: 0.2, width: 300,
child: CircularProgressIndicator(value: downloadProgress.progress), height: 300,
), memCacheHeight: asset.type == 'IMAGE' ? 250 : 400,
errorWidget: (context, url, error) { fit: BoxFit.cover,
debugPrint("Error Loading Thumbnail Widget $error"); imageUrl: thumbnailRequestUrl,
cacheKey.value += 1; httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
return const Icon(Icons.error); fadeInDuration: const Duration(milliseconds: 250),
}, progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(value: downloadProgress.progress),
),
errorWidget: (context, url, error) {
return Icon(
Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor,
);
},
),
),
Container(
child: isMultiSelectEnable
? Padding(
padding: const EdgeInsets.all(3.0),
child: Align(
alignment: Alignment.topLeft,
child: _buildSelectionIcon(asset),
),
)
: Container(),
),
Positioned(
right: 10,
bottom: 5,
child: Icon(
(deviceId != asset.deviceId) ? Icons.cloud_done_outlined : Icons.photo_library_rounded,
color: Colors.white,
size: 18,
),
)
],
), ),
), ),
); );

View File

@@ -1,166 +1,119 @@
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/home/providers/home_page_state.provider.dart';
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
import 'package:immich_mobile/modules/home/ui/daily_title_text.dart';
import 'package:immich_mobile/modules/home/ui/disable_multi_select_button.dart';
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart'; import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
import 'package:immich_mobile/modules/home/ui/image_grid.dart'; import 'package:immich_mobile/modules/home/ui/image_grid.dart';
import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart'; import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart';
import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
import 'package:immich_mobile/modules/home/ui/profile_drawer.dart'; import 'package:immich_mobile/modules/home/ui/profile_drawer.dart';
import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
import 'package:intl/intl.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:sliver_tools/sliver_tools.dart';
class HomePage extends HookConsumerWidget { class HomePage extends HookConsumerWidget {
const HomePage({Key? key}) : super(key: key); const HomePage({Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final ValueNotifier<bool> _showBackToTopBtn = useState(false);
ScrollController _scrollController = useScrollController(); ScrollController _scrollController = useScrollController();
var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
List<ImmichAssetGroupByDate> assetGroup = ref.watch(assetProvider); List<Widget> _imageGridGroup = [];
List<Widget> imageGridGroup = []; var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable;
var homePageState = ref.watch(homePageStateProvider);
_scrollControllerCallback() {
var endOfPage = _scrollController.position.maxScrollExtent;
if (_scrollController.offset >= endOfPage - (endOfPage * 0.1) && !_scrollController.position.outOfRange) {
ref.read(assetProvider.notifier).getMoreAsset();
}
if (_scrollController.offset >= 400) {
_showBackToTopBtn.value = true;
} else {
_showBackToTopBtn.value = false;
}
}
useEffect(() { useEffect(() {
ref.read(assetProvider.notifier).getImmichAssets(); ref.read(websocketProvider.notifier).connect();
ref.read(assetProvider.notifier).getAllAsset();
_scrollController.addListener(_scrollControllerCallback); return null;
return () {
_scrollController.removeListener(_scrollControllerCallback);
};
}, []); }, []);
onPopBackFromBackupPage() {
// ref.read(assetProvider.notifier).getAllAsset();
}
Widget _buildBody() { Widget _buildBody() {
if (assetGroup.isNotEmpty) { if (assetGroupByDateTime.isNotEmpty) {
String lastGroupDate = assetGroup[0].date; int? lastMonth;
for (var group in assetGroup) { assetGroupByDateTime.forEach((dateGroup, immichAssetList) {
var dateTitle = group.date; DateTime parseDateGroup = DateTime.parse(dateGroup);
var assetGroup = group.assets; int currentMonth = parseDateGroup.month;
int? currentMonth = DateTime.tryParse(dateTitle)?.month; if (lastMonth != null) {
int? previousMonth = DateTime.tryParse(lastGroupDate)?.month; if (currentMonth - lastMonth! != 0) {
_imageGridGroup.add(
// Add Monthly Title Group if started at the beginning of the month MonthlyTitleText(
if ((currentMonth! - previousMonth!) != 0) { isoDate: dateGroup,
imageGridGroup.add( ),
MonthlyTitleText(isoDate: dateTitle), );
); }
} }
// Add Daily Title Group _imageGridGroup.add(
imageGridGroup.add( DailyTitleText(
DailyTitleText(isoDate: dateTitle), isoDate: dateGroup,
assetGroup: immichAssetList,
),
); );
// Add Image Group _imageGridGroup.add(
imageGridGroup.add( ImageGrid(assetGroup: immichAssetList),
ImageGrid(assetGroup: assetGroup),
); );
//
lastGroupDate = dateTitle; lastMonth = currentMonth;
} });
} }
return SafeArea( return SafeArea(
child: DraggableScrollbar.semicircle( bottom: !isMultiSelectEnable,
backgroundColor: Theme.of(context).primaryColor, top: !isMultiSelectEnable,
controller: _scrollController, child: Stack(
heightScrollThumb: 48.0, children: [
child: CustomScrollView( DraggableScrollbar.semicircle(
controller: _scrollController, backgroundColor: Theme.of(context).primaryColor,
slivers: [ controller: _scrollController,
ImmichSliverAppBar(imageGridGroup: imageGridGroup), heightScrollThumb: 48.0,
...imageGridGroup, child: CustomScrollView(
], controller: _scrollController,
), slivers: [
SliverAnimatedSwitcher(
child: isMultiSelectEnable
? const SliverToBoxAdapter(
child: SizedBox(
height: 70,
child: null,
),
)
: ImmichSliverAppBar(
imageGridGroup: _imageGridGroup,
onPopBack: onPopBackFromBackupPage,
),
duration: const Duration(milliseconds: 350),
),
..._imageGridGroup
],
),
),
isMultiSelectEnable
? DisableMultiSelectButton(
onPressed: ref.watch(homePageStateProvider.notifier).disableMultiSelect,
selectedItemCount: homePageState.selectedItems.length,
)
: Container(),
isMultiSelectEnable ? const ControlBottomAppBar() : Container(),
],
), ),
); );
} }
return Scaffold( return Scaffold(
// key: _scaffoldKey,
drawer: const ProfileDrawer(), drawer: const ProfileDrawer(),
body: _buildBody(), body: _buildBody(),
); );
} }
} }
class MonthlyTitleText extends StatelessWidget {
const MonthlyTitleText({
Key? key,
required this.isoDate,
}) : super(key: key);
final String isoDate;
@override
Widget build(BuildContext context) {
var monthTitleText = DateFormat('MMMM, y').format(DateTime.parse(isoDate));
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 10.0, top: 32),
child: Text(
monthTitleText,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
),
);
}
}
class DailyTitleText extends StatelessWidget {
const DailyTitleText({
Key? key,
required this.isoDate,
}) : super(key: key);
final String isoDate;
@override
Widget build(BuildContext context) {
var currentYear = DateTime.now().year;
var groupYear = DateTime.parse(isoDate).year;
var formatDateTemplate = currentYear == groupYear ? 'E, MMM dd' : 'E, MMM dd, yyyy';
var dateText = DateFormat(formatDateTemplate).format(DateTime.parse(isoDate));
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 24.0, bottom: 24.0, left: 3.0),
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(left: 8.0, bottom: 5.0, top: 5.0),
child: Text(
dateText,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
),
],
),
),
);
}
}

View File

@@ -11,7 +11,7 @@ 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';
class AuthenticationNotifier extends StateNotifier<AuthenticationState> { class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
AuthenticationNotifier() AuthenticationNotifier(this.ref)
: super( : super(
AuthenticationState( AuthenticationState(
deviceId: "", deviceId: "",
@@ -31,6 +31,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
), ),
); );
final Ref ref;
final DeviceInfoService _deviceInfoService = DeviceInfoService(); final DeviceInfoService _deviceInfoService = DeviceInfoService();
final BackupService _backupService = BackupService(); final BackupService _backupService = BackupService();
final NetworkService _networkService = NetworkService(); final NetworkService _networkService = NetworkService();
@@ -126,5 +127,5 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
} }
final authenticationProvider = StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) { final authenticationProvider = StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) {
return AuthenticationNotifier(); return AuthenticationNotifier(ref);
}); });

View File

@@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/providers/backup.provider.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart';
class LoginForm extends HookConsumerWidget { class LoginForm extends HookConsumerWidget {
@@ -18,31 +20,33 @@ class LoginForm extends HookConsumerWidget {
return Center( return Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300), constraints: const BoxConstraints(maxWidth: 300),
child: Wrap( child: SingleChildScrollView(
spacing: 32, child: Wrap(
runSpacing: 32, spacing: 32,
alignment: WrapAlignment.center, runSpacing: 32,
children: [ alignment: WrapAlignment.center,
const Image( children: [
image: AssetImage('assets/immich-logo-no-outline.png'), const Image(
width: 128, image: AssetImage('assets/immich-logo-no-outline.png'),
filterQuality: FilterQuality.high, width: 128,
), filterQuality: FilterQuality.high,
Text( ),
'IMMICH', Text(
style: GoogleFonts.snowburstOne( 'IMMICH',
textStyle: style: GoogleFonts.snowburstOne(
TextStyle(fontWeight: FontWeight.bold, fontSize: 48, color: Theme.of(context).primaryColor)), textStyle:
), TextStyle(fontWeight: FontWeight.bold, fontSize: 48, color: Theme.of(context).primaryColor)),
EmailInput(controller: usernameController), ),
PasswordInput(controller: passwordController), EmailInput(controller: usernameController),
ServerEndpointInput(controller: serverEndpointController), PasswordInput(controller: passwordController),
LoginButton( ServerEndpointInput(controller: serverEndpointController),
emailController: usernameController, LoginButton(
passwordController: passwordController, emailController: usernameController,
serverEndpointController: serverEndpointController, passwordController: passwordController,
), serverEndpointController: serverEndpointController,
], ),
],
),
), ),
), ),
); );
@@ -110,16 +114,21 @@ class LoginButton extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton( return ElevatedButton(
onPressed: () async { onPressed: () async {
// This will remove current cache asset state of previous user login.
ref.watch(assetProvider.notifier).clearAllAsset();
var isAuthenicated = await ref var isAuthenicated = await ref
.read(authenticationProvider.notifier) .read(authenticationProvider.notifier)
.login(emailController.text, passwordController.text, serverEndpointController.text); .login(emailController.text, passwordController.text, serverEndpointController.text);
if (isAuthenicated) { if (isAuthenicated) {
// Resume backup (if enable) then navigate
ref.watch(backupProvider.notifier).resumeBackup();
AutoRouter.of(context).pushNamed("/home-page"); AutoRouter.of(context).pushNamed("/home-page");
} else { } else {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: "Error logging you in, check server url, emald and password!", msg: "Error logging you in, check server url, email and password!",
toastType: ToastType.error); toastType: ToastType.error);
} }
}, },

View File

@@ -1,7 +1,5 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/login/ui/login_form.dart'; import 'package:immich_mobile/modules/login/ui/login_form.dart';
class LoginPage extends HookConsumerWidget { class LoginPage extends HookConsumerWidget {

View File

@@ -3,8 +3,9 @@ import 'package:flutter/widgets.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';
import 'package:immich_mobile/routing/auth_guard.dart'; import 'package:immich_mobile/routing/auth_guard.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/views/backup_controller_page.dart'; import 'package:immich_mobile/shared/views/backup_controller_page.dart';
import 'package:immich_mobile/shared/views/image_viewer_page.dart'; import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
import 'package:immich_mobile/shared/views/video_viewer_page.dart'; import 'package:immich_mobile/shared/views/video_viewer_page.dart';
part 'router.gr.dart'; part 'router.gr.dart';

View File

@@ -41,7 +41,8 @@ class _$AppRouter extends RootStackRouter {
key: args.key, key: args.key,
imageUrl: args.imageUrl, imageUrl: args.imageUrl,
heroTag: args.heroTag, heroTag: args.heroTag,
thumbnailUrl: args.thumbnailUrl)); thumbnailUrl: args.thumbnailUrl,
asset: args.asset));
}, },
VideoViewerRoute.name: (routeData) { VideoViewerRoute.name: (routeData) {
final args = routeData.argsAs<VideoViewerRouteArgs>(); final args = routeData.argsAs<VideoViewerRouteArgs>();
@@ -96,14 +97,16 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
{Key? key, {Key? key,
required String imageUrl, required String imageUrl,
required String heroTag, required String heroTag,
required String thumbnailUrl}) required String thumbnailUrl,
required ImmichAsset asset})
: super(ImageViewerRoute.name, : super(ImageViewerRoute.name,
path: '/image-viewer-page', path: '/image-viewer-page',
args: ImageViewerRouteArgs( args: ImageViewerRouteArgs(
key: key, key: key,
imageUrl: imageUrl, imageUrl: imageUrl,
heroTag: heroTag, heroTag: heroTag,
thumbnailUrl: thumbnailUrl)); thumbnailUrl: thumbnailUrl,
asset: asset));
static const String name = 'ImageViewerRoute'; static const String name = 'ImageViewerRoute';
} }
@@ -113,7 +116,8 @@ class ImageViewerRouteArgs {
{this.key, {this.key,
required this.imageUrl, required this.imageUrl,
required this.heroTag, required this.heroTag,
required this.thumbnailUrl}); required this.thumbnailUrl,
required this.asset});
final Key? key; final Key? key;
@@ -123,9 +127,11 @@ class ImageViewerRouteArgs {
final String thumbnailUrl; final String thumbnailUrl;
final ImmichAsset asset;
@override @override
String toString() { String toString() {
return 'ImageViewerRouteArgs{key: $key, imageUrl: $imageUrl, heroTag: $heroTag, thumbnailUrl: $thumbnailUrl}'; return 'ImageViewerRouteArgs{key: $key, imageUrl: $imageUrl, heroTag: $heroTag, thumbnailUrl: $thumbnailUrl, asset: $asset}';
} }
} }

View File

@@ -0,0 +1,187 @@
import 'dart:convert';
class ImmichExif {
final int? id;
final String? assetId;
final String? make;
final String? model;
final String? imageName;
final int? exifImageWidth;
final int? exifImageHeight;
final int? fileSizeInByte;
final String? orientation;
final String? dateTimeOriginal;
final String? modifyDate;
final String? lensModel;
final double? fNumber;
final double? focalLength;
final int? iso;
final double? exposureTime;
final double? latitude;
final double? longitude;
ImmichExif({
this.id,
this.assetId,
this.make,
this.model,
this.imageName,
this.exifImageWidth,
this.exifImageHeight,
this.fileSizeInByte,
this.orientation,
this.dateTimeOriginal,
this.modifyDate,
this.lensModel,
this.fNumber,
this.focalLength,
this.iso,
this.exposureTime,
this.latitude,
this.longitude,
});
ImmichExif copyWith({
int? id,
String? assetId,
String? make,
String? model,
String? imageName,
int? exifImageWidth,
int? exifImageHeight,
int? fileSizeInByte,
String? orientation,
String? dateTimeOriginal,
String? modifyDate,
String? lensModel,
double? fNumber,
double? focalLength,
int? iso,
double? exposureTime,
double? latitude,
double? longitude,
}) {
return ImmichExif(
id: id ?? this.id,
assetId: assetId ?? this.assetId,
make: make ?? this.make,
model: model ?? this.model,
imageName: imageName ?? this.imageName,
exifImageWidth: exifImageWidth ?? this.exifImageWidth,
exifImageHeight: exifImageHeight ?? this.exifImageHeight,
fileSizeInByte: fileSizeInByte ?? this.fileSizeInByte,
orientation: orientation ?? this.orientation,
dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal,
modifyDate: modifyDate ?? this.modifyDate,
lensModel: lensModel ?? this.lensModel,
fNumber: fNumber ?? this.fNumber,
focalLength: focalLength ?? this.focalLength,
iso: iso ?? this.iso,
exposureTime: exposureTime ?? this.exposureTime,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'assetId': assetId,
'make': make,
'model': model,
'imageName': imageName,
'exifImageWidth': exifImageWidth,
'exifImageHeight': exifImageHeight,
'fileSizeInByte': fileSizeInByte,
'orientation': orientation,
'dateTimeOriginal': dateTimeOriginal,
'modifyDate': modifyDate,
'lensModel': lensModel,
'fNumber': fNumber,
'focalLength': focalLength,
'iso': iso,
'exposureTime': exposureTime,
'latitude': latitude,
'longitude': longitude,
};
}
factory ImmichExif.fromMap(Map<String, dynamic> map) {
return ImmichExif(
id: map['id']?.toInt(),
assetId: map['assetId'],
make: map['make'],
model: map['model'],
imageName: map['imageName'],
exifImageWidth: map['exifImageWidth']?.toInt(),
exifImageHeight: map['exifImageHeight']?.toInt(),
fileSizeInByte: map['fileSizeInByte']?.toInt(),
orientation: map['orientation'],
dateTimeOriginal: map['dateTimeOriginal'],
modifyDate: map['modifyDate'],
lensModel: map['lensModel'],
fNumber: map['fNumber']?.toDouble(),
focalLength: map['focalLength']?.toDouble(),
iso: map['iso']?.toInt(),
exposureTime: map['exposureTime']?.toDouble(),
latitude: map['latitude']?.toDouble(),
longitude: map['longitude']?.toDouble(),
);
}
String toJson() => json.encode(toMap());
factory ImmichExif.fromJson(String source) => ImmichExif.fromMap(json.decode(source));
@override
String toString() {
return 'ImmichExif(id: $id, assetId: $assetId, make: $make, model: $model, imageName: $imageName, exifImageWidth: $exifImageWidth, exifImageHeight: $exifImageHeight, fileSizeInByte: $fileSizeInByte, orientation: $orientation, dateTimeOriginal: $dateTimeOriginal, modifyDate: $modifyDate, lensModel: $lensModel, fNumber: $fNumber, focalLength: $focalLength, iso: $iso, exposureTime: $exposureTime, latitude: $latitude, longitude: $longitude)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ImmichExif &&
other.id == id &&
other.assetId == assetId &&
other.make == make &&
other.model == model &&
other.imageName == imageName &&
other.exifImageWidth == exifImageWidth &&
other.exifImageHeight == exifImageHeight &&
other.fileSizeInByte == fileSizeInByte &&
other.orientation == orientation &&
other.dateTimeOriginal == dateTimeOriginal &&
other.modifyDate == modifyDate &&
other.lensModel == lensModel &&
other.fNumber == fNumber &&
other.focalLength == focalLength &&
other.iso == iso &&
other.exposureTime == exposureTime &&
other.latitude == latitude &&
other.longitude == longitude;
}
@override
int get hashCode {
return id.hashCode ^
assetId.hashCode ^
make.hashCode ^
model.hashCode ^
imageName.hashCode ^
exifImageWidth.hashCode ^
exifImageHeight.hashCode ^
fileSizeInByte.hashCode ^
orientation.hashCode ^
dateTimeOriginal.hashCode ^
modifyDate.hashCode ^
lensModel.hashCode ^
fNumber.hashCode ^
focalLength.hashCode ^
iso.hashCode ^
exposureTime.hashCode ^
latitude.hashCode ^
longitude.hashCode;
}
}

View File

@@ -0,0 +1,133 @@
import 'dart:convert';
import 'package:immich_mobile/shared/models/exif.model.dart';
class ImmichAssetWithExif {
final String id;
final String deviceAssetId;
final String userId;
final String deviceId;
final String type;
final String createdAt;
final String modifiedAt;
final String originalPath;
final bool isFavorite;
final String? duration;
final ImmichExif? exifInfo;
ImmichAssetWithExif({
required this.id,
required this.deviceAssetId,
required this.userId,
required this.deviceId,
required this.type,
required this.createdAt,
required this.modifiedAt,
required this.originalPath,
required this.isFavorite,
this.duration,
this.exifInfo,
});
ImmichAssetWithExif copyWith({
String? id,
String? deviceAssetId,
String? userId,
String? deviceId,
String? type,
String? createdAt,
String? modifiedAt,
String? originalPath,
bool? isFavorite,
String? duration,
ImmichExif? exifInfo,
}) {
return ImmichAssetWithExif(
id: id ?? this.id,
deviceAssetId: deviceAssetId ?? this.deviceAssetId,
userId: userId ?? this.userId,
deviceId: deviceId ?? this.deviceId,
type: type ?? this.type,
createdAt: createdAt ?? this.createdAt,
modifiedAt: modifiedAt ?? this.modifiedAt,
originalPath: originalPath ?? this.originalPath,
isFavorite: isFavorite ?? this.isFavorite,
duration: duration ?? this.duration,
exifInfo: exifInfo ?? this.exifInfo,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'deviceAssetId': deviceAssetId,
'userId': userId,
'deviceId': deviceId,
'type': type,
'createdAt': createdAt,
'modifiedAt': modifiedAt,
'originalPath': originalPath,
'isFavorite': isFavorite,
'duration': duration,
'exifInfo': exifInfo?.toMap(),
};
}
factory ImmichAssetWithExif.fromMap(Map<String, dynamic> map) {
return ImmichAssetWithExif(
id: map['id'] ?? '',
deviceAssetId: map['deviceAssetId'] ?? '',
userId: map['userId'] ?? '',
deviceId: map['deviceId'] ?? '',
type: map['type'] ?? '',
createdAt: map['createdAt'] ?? '',
modifiedAt: map['modifiedAt'] ?? '',
originalPath: map['originalPath'] ?? '',
isFavorite: map['isFavorite'] ?? false,
duration: map['duration'],
exifInfo: map['exifInfo'] != null ? ImmichExif.fromMap(map['exifInfo']) : null,
);
}
String toJson() => json.encode(toMap());
factory ImmichAssetWithExif.fromJson(String source) => ImmichAssetWithExif.fromMap(json.decode(source));
@override
String toString() {
return 'ImmichAssetWithExif(id: $id, deviceAssetId: $deviceAssetId, userId: $userId, deviceId: $deviceId, type: $type, createdAt: $createdAt, modifiedAt: $modifiedAt, originalPath: $originalPath, isFavorite: $isFavorite, duration: $duration, exifInfo: $exifInfo)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ImmichAssetWithExif &&
other.id == id &&
other.deviceAssetId == deviceAssetId &&
other.userId == userId &&
other.deviceId == deviceId &&
other.type == type &&
other.createdAt == createdAt &&
other.modifiedAt == modifiedAt &&
other.originalPath == originalPath &&
other.isFavorite == isFavorite &&
other.duration == duration &&
other.exifInfo == exifInfo;
}
@override
int get hashCode {
return id.hashCode ^
deviceAssetId.hashCode ^
userId.hashCode ^
deviceId.hashCode ^
type.hashCode ^
createdAt.hashCode ^
modifiedAt.hashCode ^
originalPath.hashCode ^
isFavorite.hashCode ^
duration.hashCode ^
exifInfo.hashCode;
}
}

View File

@@ -1,6 +1,11 @@
import 'dart:async';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hive_flutter/hive_flutter.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/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/services/server_info.service.dart'; import 'package:immich_mobile/shared/services/server_info.service.dart';
import 'package:immich_mobile/shared/models/backup_state.model.dart'; import 'package:immich_mobile/shared/models/backup_state.model.dart';
import 'package:immich_mobile/shared/models/server_info.model.dart'; import 'package:immich_mobile/shared/models/server_info.model.dart';
@@ -8,7 +13,7 @@ import 'package:immich_mobile/shared/services/backup.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() BackupNotifier({this.ref})
: super( : super(
BackUpState( BackUpState(
backupProgress: BackUpProgressEnum.idle, backupProgress: BackUpProgressEnum.idle,
@@ -29,21 +34,25 @@ class BackupNotifier extends StateNotifier<BackUpState> {
), ),
); );
Ref? ref;
final BackupService _backupService = BackupService(); final BackupService _backupService = BackupService();
final ServerInfoService _serverInfoService = ServerInfoService(); final ServerInfoService _serverInfoService = ServerInfoService();
final StreamController _onAssetBackupStreamCtrl = StreamController.broadcast();
void getBackupInfo() async { void getBackupInfo() async {
_updateServerInfo(); _updateServerInfo();
List<AssetPathEntity> list = await PhotoManager.getAssetPathList(onlyAll: true, type: RequestType.common); List<AssetPathEntity> list = await PhotoManager.getAssetPathList(onlyAll: true, type: RequestType.common);
List<String> didBackupAsset = await _backupService.getDeviceBackupAsset();
if (list.isEmpty) { if (list.isEmpty) {
debugPrint("No Asset On Device"); debugPrint("No Asset On Device");
state = state.copyWith(
backupProgress: BackUpProgressEnum.idle, totalAssetCount: 0, assetOnDatabase: didBackupAsset.length);
return; return;
} }
int totalAsset = list[0].assetCount; int totalAsset = list[0].assetCount;
List<String> didBackupAsset = await _backupService.getDeviceBackupAsset();
state = state.copyWith(totalAssetCount: totalAsset, assetOnDatabase: didBackupAsset.length); state = state.copyWith(totalAssetCount: totalAsset, assetOnDatabase: didBackupAsset.length);
} }
@@ -61,19 +70,21 @@ class BackupNotifier extends StateNotifier<BackUpState> {
List<AssetPathEntity> list = List<AssetPathEntity> list =
await PhotoManager.getAssetPathList(hasAll: true, onlyAll: true, type: RequestType.common); await PhotoManager.getAssetPathList(hasAll: true, onlyAll: true, type: RequestType.common);
// Get device assets info from database
// Compare and find different assets that has not been backing up
// Backup those assets
List<String> backupAsset = await _backupService.getDeviceBackupAsset();
if (list.isEmpty) { if (list.isEmpty) {
debugPrint("No Asset On Device - Abort Backup Process"); debugPrint("No Asset On Device - Abort Backup Process");
state = state.copyWith(
backupProgress: BackUpProgressEnum.idle, totalAssetCount: 0, assetOnDatabase: backupAsset.length);
return; return;
} }
int totalAsset = list[0].assetCount; int totalAsset = list[0].assetCount;
List<AssetEntity> currentAssets = await list[0].getAssetListRange(start: 0, end: totalAsset); List<AssetEntity> currentAssets = await list[0].getAssetListRange(start: 0, end: totalAsset);
// Get device assets info from database
// Compare and find different assets that has not been backing up
// Backup those assets
List<String> backupAsset = await _backupService.getDeviceBackupAsset();
state = state.copyWith(totalAssetCount: totalAsset, assetOnDatabase: backupAsset.length); state = state.copyWith(totalAssetCount: totalAsset, assetOnDatabase: backupAsset.length);
// Remove item that has already been backed up // Remove item that has already been backed up
for (var backupAssetId in backupAsset) { for (var backupAssetId in backupAsset) {
@@ -96,10 +107,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
void cancelBackup() { void cancelBackup() {
state.cancelToken.cancel('Cancel Backup'); state.cancelToken.cancel('Cancel Backup');
state = state.copyWith(backupProgress: BackUpProgressEnum.idle); state = state.copyWith(backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0);
} }
void _onAssetUploaded() { void _onAssetUploaded(String deviceAssetId, String deviceId) {
state = state =
state.copyWith(backingUpAssetCount: state.backingUpAssetCount - 1, assetOnDatabase: state.assetOnDatabase + 1); state.copyWith(backingUpAssetCount: state.backingUpAssetCount - 1, assetOnDatabase: state.assetOnDatabase + 1);
@@ -130,8 +141,38 @@ class BackupNotifier extends StateNotifier<BackUpState> {
), ),
); );
} }
void resumeBackup() {
var authState = ref?.read(authenticationProvider);
// Check if user is login
var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
// User has been logged out return
if (authState != null) {
if (accessKey == null || !authState.isAuthenticated) {
debugPrint("[resumeBackup] not authenticated - abort");
return;
}
// Check if this device is enable backup by the user
if ((authState.deviceInfo.deviceId == authState.deviceId) && authState.deviceInfo.isAutoBackup) {
// check if backup is alreayd in process - then return
if (state.backupProgress == BackUpProgressEnum.inProgress) {
debugPrint("[resumeBackup] Backup is already in progress - abort");
return;
}
// Run backup
debugPrint("[resumeBackup] Start back up");
startBackupProcess();
}
return;
}
}
} }
final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) { final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
return BackupNotifier(); return BackupNotifier(ref: ref);
}); });

View File

@@ -0,0 +1,113 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:socket_io_client/socket_io_client.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
class WebscoketState {
final Socket? socket;
final bool isConnected;
WebscoketState({
this.socket,
required this.isConnected,
});
WebscoketState copyWith({
Socket? socket,
bool? isConnected,
}) {
return WebscoketState(
socket: socket ?? this.socket,
isConnected: isConnected ?? this.isConnected,
);
}
@override
String toString() => 'WebscoketState(socket: $socket, isConnected: $isConnected)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is WebscoketState && other.socket == socket && other.isConnected == isConnected;
}
@override
int get hashCode => socket.hashCode ^ isConnected.hashCode;
}
class WebsocketNotifier extends StateNotifier<WebscoketState> {
WebsocketNotifier(this.ref) : super(WebscoketState(socket: null, isConnected: false)) {
debugPrint("Init websocket instance");
}
final Ref ref;
connect() {
var authenticationState = ref.watch(authenticationProvider);
if (authenticationState.isAuthenticated) {
var accessToken = Hive.box(userInfoBox).get(accessTokenKey);
var endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
try {
debugPrint("[WEBSOCKET] Attempting to connect to ws");
// Configure socket transports must be sepecified
Socket socket = io(
endpoint,
OptionBuilder()
.setTransports(['websocket'])
.enableReconnection()
.enableForceNew()
.enableForceNewConnection()
.enableAutoConnect()
.setExtraHeaders({"Authorization": "Bearer $accessToken"})
.build(),
);
socket.onConnect((_) {
debugPrint("[WEBSOCKET] Established Websocket Connection");
state = WebscoketState(isConnected: true, socket: socket);
});
socket.onDisconnect((_) {
debugPrint("[WEBSOCKET] Disconnect to Websocket Connection");
state = WebscoketState(isConnected: false, socket: null);
});
socket.on('error', (errorMessage) {
debugPrint("Webcoket Error - $errorMessage");
state = WebscoketState(isConnected: false, socket: null);
});
socket.on('on_upload_success', (data) {
var jsonString = jsonDecode(data.toString());
ImmichAsset newAsset = ImmichAsset.fromMap(jsonString);
ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
});
} catch (e) {
debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
}
}
}
disconnect() {
debugPrint("[WEBSOCKET] Attempting to disconnect");
var socket = state.socket?.disconnect();
if (socket != null) {
if (socket.disconnected) {
state = WebscoketState(isConnected: false, socket: null);
}
}
}
}
final websocketProvider = StateNotifierProvider<WebsocketNotifier, WebscoketState>((ref) {
return WebsocketNotifier(ref);
});

View File

@@ -26,7 +26,7 @@ class BackupService {
return result.cast<String>(); return result.cast<String>();
} }
backupAsset(List<AssetEntity> assetList, CancelToken cancelToken, Function singleAssetDoneCb, backupAsset(List<AssetEntity> assetList, CancelToken cancelToken, Function(String, String) singleAssetDoneCb,
Function(int, int) uploadProgress) async { Function(int, int) uploadProgress) async {
var dio = Dio(); var dio = Dio();
dio.interceptors.add(AuthenticatedRequestInterceptor()); dio.interceptors.add(AuthenticatedRequestInterceptor());
@@ -37,20 +37,12 @@ class BackupService {
for (var entity in assetList) { for (var entity in assetList) {
try { try {
if (entity.type == AssetType.video) { if (entity.type == AssetType.video) {
file = await entity.file; file = await entity.originFile;
} else { } else {
file = await entity.file.timeout(const Duration(seconds: 5)); file = await entity.originFile.timeout(const Duration(seconds: 5));
} }
if (file != null) { if (file != null) {
// reading exif
// var exifData = await readExifFromFile(file);
// for (String key in exifData.keys) {
// debugPrint("- $key (${exifData[key]?.tagType}): ${exifData[key]}");
// }
// debugPrint("------------------");
String originalFileName = await entity.titleAsync; String originalFileName = await entity.titleAsync;
String fileNameWithoutPath = originalFileName.toString().split(".")[0]; String fileNameWithoutPath = originalFileName.toString().split(".")[0];
var fileExtension = p.extension(file.path); var fileExtension = p.extension(file.path);
@@ -85,7 +77,7 @@ class BackupService {
); );
if (res.statusCode == 201) { if (res.statusCode == 201) {
singleAssetDoneCb(); singleAssetDoneCb(entity.id, deviceId);
} }
} }
} on DioError catch (e) { } on DioError catch (e) {

View File

@@ -7,6 +7,24 @@ import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/utils/dio_http_interceptor.dart'; import 'package:immich_mobile/utils/dio_http_interceptor.dart';
class NetworkService { class NetworkService {
Future<dynamic> deleteRequest({required String url, dynamic data}) async {
try {
var dio = Dio();
dio.interceptors.add(AuthenticatedRequestInterceptor());
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
Response res = await dio.delete('$savedEndpoint/$url', data: data);
if (res.statusCode == 200) {
return res;
}
} on DioError catch (e) {
debugPrint("DioError: ${e.response}");
} catch (e) {
debugPrint("ERROR getRequest: ${e.toString()}");
}
}
Future<dynamic> getRequest({required String url}) async { Future<dynamic> getRequest({required String url}) async {
try { try {
var dio = Dio(); var dio = Dio();

View File

@@ -22,6 +22,8 @@ class BackupControllerPage extends HookConsumerWidget {
if (_backupState.backupProgress != BackUpProgressEnum.inProgress) { if (_backupState.backupProgress != BackUpProgressEnum.inProgress) {
ref.read(backupProvider.notifier).getBackupInfo(); ref.read(backupProvider.notifier).getBackupInfo();
} }
return null;
}, []); }, []);
Widget _buildStorageInformation() { Widget _buildStorageInformation() {

View File

@@ -1,64 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart';
class ImageViewerPage extends StatelessWidget {
final String imageUrl;
final String heroTag;
final String thumbnailUrl;
const ImageViewerPage({Key? key, required this.imageUrl, required this.heroTag, required this.thumbnailUrl})
: super(key: key);
@override
Widget build(BuildContext context) {
var box = Hive.box(userInfoBox);
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
toolbarHeight: 60,
backgroundColor: Colors.black,
leading: IconButton(
onPressed: () {
AutoRouter.of(context).pop();
},
icon: const Icon(Icons.arrow_back_ios)),
),
body: Dismissible(
direction: DismissDirection.vertical,
onDismissed: (_) {
AutoRouter.of(context).pop();
},
key: Key(heroTag),
child: Center(
child: Hero(
tag: heroTag,
child: CachedNetworkImage(
fit: BoxFit.cover,
imageUrl: imageUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
fadeInDuration: const Duration(milliseconds: 250),
errorWidget: (context, url, error) => const Icon(Icons.error),
placeholder: (context, url) {
return CachedNetworkImage(
fit: BoxFit.cover,
imageUrl: thumbnailUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
placeholderFadeInDuration: const Duration(milliseconds: 0),
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(value: downloadProgress.progress),
),
errorWidget: (context, url, error) => const Icon(Icons.error),
);
},
),
),
),
),
);
}
}

View File

@@ -1,5 +1,6 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:chewie/chewie.dart'; import 'package:chewie/chewie.dart';
@@ -17,6 +18,7 @@ class VideoViewerPage extends StatelessWidget {
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
appBar: AppBar( appBar: AppBar(
systemOverlayStyle: SystemUiOverlayStyle.light,
backgroundColor: Colors.black, backgroundColor: Colors.black,
leading: IconButton( leading: IconButton(
onPressed: () { onPressed: () {
@@ -24,7 +26,7 @@ class VideoViewerPage extends StatelessWidget {
}, },
icon: const Icon(Icons.arrow_back_ios)), icon: const Icon(Icons.arrow_back_ios)),
), ),
body: Center( body: SafeArea(
child: VideoThumbnailPlayer( child: VideoThumbnailPlayer(
url: videoUrl, url: videoUrl,
jwtToken: jwtToken, jwtToken: jwtToken,
@@ -64,7 +66,6 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
setState(() {}); setState(() {});
} catch (e) { } catch (e) {
debugPrint("ERROR initialize video player"); debugPrint("ERROR initialize video player");
print(e);
} }
} }

View File

@@ -1,9 +1,7 @@
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
class FileHelper { class FileHelper {
static getMimeType(String filePath) { static getMimeType(String filePath) {
debugPrint(filePath);
var fileExtension = p.extension(filePath).split(".")[1]; var fileExtension = p.extension(filePath).split(".")[1];
switch (fileExtension.toLowerCase()) { switch (fileExtension.toLowerCase()) {
@@ -28,6 +26,12 @@ class FileHelper {
case 'avi': case 'avi':
return {"type": "video", "subType": "x-msvideo"}; return {"type": "video", "subType": "x-msvideo"};
case 'heic':
return {"type": "image", "subType": "heic"};
case 'heif':
return {"type": "image", "subType": "heif"};
default: default:
return {"type": "unsupport", "subType": "unsupport"}; return {"type": "unsupport", "subType": "unsupport"};
} }

View File

@@ -2,7 +2,7 @@ build:
flutter packages pub run build_runner build flutter packages pub run build_runner build
watch: watch:
flutter packages pub run build_runner watch flutter packages pub run build_runner watch --delete-conflicting-outputs
create_app_icon: create_app_icon:
flutter pub run flutter_launcher_icons:main flutter pub run flutter_launcher_icons:main

View File

@@ -50,6 +50,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.2.1" version: "3.2.1"
badges:
dependency: "direct main"
description:
name: badges
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@@ -639,6 +646,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.3.10" version: "1.3.10"
photo_view:
dependency: "direct main"
description:
name: photo_view
url: "https://pub.dartlang.org"
source: hosted
version: "0.13.0"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@@ -721,6 +735,27 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.99" version: "0.0.99"
sliver_tools:
dependency: "direct main"
description:
name: sliver_tools
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.5"
socket_io_client:
dependency: "direct main"
description:
name: socket_io_client
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0-beta.4-nullsafety.0"
socket_io_common:
dependency: transitive
description:
name: socket_io_common
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
source_gen: source_gen:
dependency: transitive dependency: transitive
description: description:

View File

@@ -30,6 +30,10 @@ dependencies:
fluttertoast: ^8.0.8 fluttertoast: ^8.0.8
video_player: ^2.2.18 video_player: ^2.2.18
chewie: ^1.2.2 chewie: ^1.2.2
sliver_tools: ^0.2.5
badges: ^2.0.2
photo_view: ^0.13.0
socket_io_client: ^2.0.0-beta.4-nullsafety.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -7,7 +7,7 @@ DB_PASSWORD=postgres
DB_DATABASE_NAME= DB_DATABASE_NAME=
# Upload File Config # Upload File Config
UPLOAD_LOCATION=./upload UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
# JWT SECRET # JWT SECRET
JWT_SECRET= JWT_SECRET=

View File

@@ -1,80 +1,41 @@
FROM ubuntu:20.04 AS development ##################################
# DEVELOPMENT
##################################
FROM node:16-alpine3.14 AS development
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY package.json yarn.lock ./ COPY package.json package-lock.json ./
RUN apt-get update && apt-get install -y --fix-missing --no-install-recommends \ RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg
build-essential \
curl \
git-core \
iputils-ping \
pkg-config \
rsync \
software-properties-common \
unzip \
wget \
ffmpeg
# Install NodeJS RUN npm install
RUN curl --silent --location https://deb.nodesource.com/setup_14.x | bash -
RUN apt-get install --yes nodejs
RUN npm i -g yarn
RUN yarn install
COPY . . COPY . .
RUN yarn build RUN npm run build
# Clean up commands #################################
RUN apt-get autoremove -y && apt-get clean && \ # PRODUCTION
rm -rf /usr/local/src/* #################################
FROM node:16-alpine3.14 AS production
RUN apt-get clean && \
rm -rf /var/lib/apt/lists/*
FROM ubuntu:20.04 as production
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
ARG NODE_ENV=production ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV} ENV NODE_ENV=${NODE_ENV}
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY package.json yarn.lock ./ COPY package.json package-lock.json ./
RUN apt-get update && apt-get install -y --fix-missing --no-install-recommends \ RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg
build-essential \
curl \
git-core \
iputils-ping \
pkg-config \
rsync \
software-properties-common \
unzip \
wget \
ffmpeg
# Install NodeJS RUN npm install --only=production
RUN curl --silent --location https://deb.nodesource.com/setup_14.x | bash -
RUN apt-get install --yes nodejs
RUN npm i -g yarn
RUN yarn install --only=production
COPY . . COPY . .
COPY --from=development /usr/src/app/dist ./dist COPY --from=development /usr/src/app/dist ./dist
# Clean up commands
RUN apt-get autoremove -y && apt-get clean && \
rm -rf /usr/local/src/*
RUN apt-get clean && \
rm -rf /var/lib/apt/lists/*
CMD ["node", "dist/main"] CMD ["node", "dist/main"]

View File

@@ -1,63 +0,0 @@
##################################
# DEVELOPMENT
##################################
FROM node:16-bullseye-slim AS development
ARG DEBIAN_FRONTEND=noninteractive
WORKDIR /usr/src/app
COPY package.json yarn.lock ./
RUN apt-get update
RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y
RUN npm i -g yarn --force
RUN yarn install
COPY . .
RUN yarn build
# Clean up commands
RUN apt-get autoremove -y && apt-get clean && \
rm -rf /usr/local/src/*
RUN apt-get clean && \
rm -rf /var/lib/apt/lists/*
##################################
# PRODUCTION
##################################
FROM node:16-bullseye-slim as production
ARG DEBIAN_FRONTEND=noninteractive
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}
WORKDIR /usr/src/app
COPY package.json yarn.lock ./
RUN apt-get update
RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y
RUN npm i -g yarn --force
RUN yarn install --only=production
COPY . .
COPY --from=development /usr/src/app/dist ./dist
# Clean up commands
RUN apt-get autoremove -y && apt-get clean && \
rm -rf /usr/local/src/*
RUN apt-get clean && \
rm -rf /var/lib/apt/lists/*
CMD ["node", "dist/main"]

View File

@@ -1,13 +1 @@
# IMMICH - Server # Immich Server- NestJs
A self-hosted solution for mobile backup and viewing images/videos.
# Requesquisite
There is a tensorflow module running in the server so some package will be needed when building the Node's modules
```bash
$ apt-get install make cmake gcc g++
```

View File

@@ -2,21 +2,18 @@ version: '3.8'
services: services:
server: immich_server:
container_name: immich_server
image: immich-server-dev:1.0.0 image: immich-server-dev:1.0.0
build: build:
context: . context: .
target: development target: development
dockerfile: ./Dockerfile-minimal dockerfile: ./Dockerfile
command: yarn start:dev command: npm run start:dev
ports: expose:
- "3000:3000" - "3000"
# expose:
# - 3000
volumes: volumes:
- .:/usr/src/app - .:/usr/src/app
- userdata:/usr/src/app/upload - ${UPLOAD_LOCATION}:/usr/src/app/upload
- /usr/src/app/node_modules - /usr/src/app/node_modules
env_file: env_file:
- .env - .env
@@ -62,10 +59,9 @@ services:
networks: networks:
- immich_network - immich_network
depends_on: depends_on:
- server - immich_server
networks: networks:
immich_network: immich_network:
volumes: volumes:
pgdata: pgdata:
userdata:

18495
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -30,17 +30,16 @@
"@nestjs/passport": "^8.1.0", "@nestjs/passport": "^8.1.0",
"@nestjs/platform-express": "^8.0.0", "@nestjs/platform-express": "^8.0.0",
"@nestjs/platform-fastify": "^8.2.6", "@nestjs/platform-fastify": "^8.2.6",
"@nestjs/platform-socket.io": "^8.2.6",
"@nestjs/typeorm": "^8.0.3", "@nestjs/typeorm": "^8.0.3",
"@tensorflow-models/coco-ssd": "^2.2.2", "@nestjs/websockets": "^8.2.6",
"@tensorflow/tfjs": "^3.13.0", "@socket.io/redis-adapter": "^7.1.0",
"@tensorflow/tfjs-converter": "^3.13.0",
"@tensorflow/tfjs-core": "^3.13.0",
"@tensorflow/tfjs-node": "^3.13.0",
"bcrypt": "^5.0.1", "bcrypt": "^5.0.1",
"bull": "^4.4.0", "bull": "^4.4.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.13.2", "class-validator": "^0.13.2",
"dotenv": "^14.2.0", "dotenv": "^14.2.0",
"exifr": "^7.1.3",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"joi": "^17.5.0", "joi": "^17.5.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
@@ -50,7 +49,7 @@
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"rxjs": "^7.2.0", "rxjs": "^7.2.0",
"sharp": "^0.29.3", "sharp": "0.28",
"systeminformation": "^5.11.0", "systeminformation": "^5.11.0",
"typeorm": "^0.2.41" "typeorm": "^0.2.41"
}, },

View File

@@ -1,4 +1,15 @@
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# events {
# worker_connections 1000;
# }
server { server {
client_max_body_size 50000M; client_max_body_size 50000M;
listen 80; listen 80;
@@ -10,11 +21,15 @@ server {
proxy_buffers 64 4k; proxy_buffers 64 4k;
proxy_force_ranges on; proxy_force_ranges on;
proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_pass http://immich_server:3000; proxy_pass http://immich_server:3000;
} }
} }

View File

@@ -12,32 +12,30 @@ import {
Query, Query,
Response, Response,
Headers, Headers,
BadRequestException, Delete,
} 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';
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express'; import { FilesInterceptor } from '@nestjs/platform-express';
import { multerOption } from '../../config/multer-option.config'; import { multerOption } from '../../config/multer-option.config';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { CreateAssetDto } from './dto/create-asset.dto'; import { CreateAssetDto } from './dto/create-asset.dto';
import { createReadStream } from 'fs';
import { ServeFileDto } from './dto/serve-file.dto'; import { ServeFileDto } from './dto/serve-file.dto';
import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service'; import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service';
import { AssetType } from './entities/asset.entity'; import { AssetEntity, AssetType } from './entities/asset.entity';
import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto'; import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto';
import { Response as Res } from 'express'; import { Response as Res } from 'express';
import { promisify } from 'util'; import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto';
import { stat } from 'fs'; import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
import { pipeline } from 'stream'; import { DeleteAssetDto } from './dto/delete-asset.dto';
const fileInfo = promisify(stat);
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('asset') @Controller('asset')
export class AssetController { export class AssetController {
constructor( constructor(
private readonly assetService: AssetService, private assetService: AssetService,
private readonly assetOptimizeService: AssetOptimizeService, private assetOptimizeService: AssetOptimizeService,
private backgroundTaskService: BackgroundTaskService,
) {} ) {}
@Post('upload') @Post('upload')
@@ -52,6 +50,7 @@ export class AssetController {
if (savedAsset && savedAsset.type == AssetType.IMAGE) { if (savedAsset && savedAsset.type == AssetType.IMAGE) {
await this.assetOptimizeService.resizeImage(savedAsset); await this.assetOptimizeService.resizeImage(savedAsset);
await this.backgroundTaskService.extractExif(savedAsset, file.originalname, file.size);
} }
if (savedAsset && savedAsset.type == AssetType.VIDEO) { if (savedAsset && savedAsset.type == AssetType.VIDEO) {
@@ -69,76 +68,12 @@ export class AssetController {
@Response({ passthrough: true }) res: Res, @Response({ passthrough: true }) res: Res,
@Query(ValidationPipe) query: ServeFileDto, @Query(ValidationPipe) query: ServeFileDto,
): Promise<StreamableFile> { ): Promise<StreamableFile> {
let file = null; return this.assetService.serveFile(authUser, query, res, headers);
const asset = await this.assetService.findOne(authUser, query.did, query.aid); }
// Handle Sending Images @Get('/new')
if (asset.type == AssetType.IMAGE || query.isThumb == 'true') { async getNewAssets(@GetAuthUser() authUser: AuthUserDto, @Query(ValidationPipe) query: GetNewAssetQueryDto) {
res.set({ return await this.assetService.getNewAssets(authUser, query.latestDate);
'Content-Type': asset.mimeType,
});
if (query.isThumb === 'false' || !query.isThumb) {
file = createReadStream(asset.originalPath);
} else {
file = createReadStream(asset.resizePath);
}
return new StreamableFile(file);
} else if (asset.type == AssetType.VIDEO) {
// Handle Handling Video
const { size } = await fileInfo(asset.originalPath);
const range = headers.range;
if (range) {
/** Extracting Start and End value from Range Header */
let [start, end] = range.replace(/bytes=/, '').split('-');
start = parseInt(start, 10);
end = end ? parseInt(end, 10) : size - 1;
if (!isNaN(start) && isNaN(end)) {
start = start;
end = size - 1;
}
if (isNaN(start) && !isNaN(end)) {
start = size - end;
end = size - 1;
}
// Handle unavailable range request
if (start >= size || end >= size) {
console.error('Bad Request');
// Return the 416 Range Not Satisfiable.
res.status(416).set({
'Content-Range': `bytes */${size}`,
});
throw new BadRequestException('Bad Request Range');
}
/** Sending Partial Content With HTTP Code 206 */
console.log('Sendinf file with type ', asset.mimeType);
res.status(206).set({
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Type': asset.mimeType,
});
const videoStream = createReadStream(asset.originalPath, { start: start, end: end });
return new StreamableFile(videoStream);
} else {
res.set({
'Content-Type': asset.mimeType,
});
return new StreamableFile(createReadStream(asset.originalPath));
}
}
console.log('SHOULD NOT BE HERE');
} }
@Get('/all') @Get('/all')
@@ -146,8 +81,38 @@ export class AssetController {
return await this.assetService.getAllAssets(authUser, query); return await this.assetService.getAllAssets(authUser, query);
} }
@Get('/')
async getAllAssetsNoPagination(@GetAuthUser() authUser: AuthUserDto) {
return await this.assetService.getAllAssetsNoPagination(authUser);
}
@Get('/:deviceId') @Get('/:deviceId')
async getUserAssetsByDeviceId(@GetAuthUser() authUser: AuthUserDto, @Param('deviceId') deviceId: string) { async getUserAssetsByDeviceId(@GetAuthUser() authUser: AuthUserDto, @Param('deviceId') deviceId: string) {
return await this.assetService.getUserAssetsByDeviceId(authUser, deviceId); return await this.assetService.getUserAssetsByDeviceId(authUser, deviceId);
} }
@Get('/assetById/:assetId')
async getAssetById(@GetAuthUser() authUser: AuthUserDto, @Param('assetId') assetId) {
return this.assetService.getAssetById(authUser, assetId);
}
@Delete('/')
async deleteAssetById(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) assetIds: DeleteAssetDto) {
const deleteAssetList: AssetEntity[] = [];
assetIds.ids.forEach(async (id) => {
const assets = await this.assetService.getAssetById(authUser, id);
deleteAssetList.push(assets);
});
const result = await this.assetService.deleteAssetById(authUser, assetIds);
result.forEach((res) => {
deleteAssetList.filter((a) => a.id == res.id && res.status == 'success');
});
await this.backgroundTaskService.deleteFileOnDisk(deleteAssetList);
return result;
}
} }

View File

@@ -6,6 +6,8 @@ import { AssetEntity } from './entities/asset.entity';
import { ImageOptimizeModule } from '../../modules/image-optimize/image-optimize.module'; import { ImageOptimizeModule } from '../../modules/image-optimize/image-optimize.module';
import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service'; import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { BackgroundTaskModule } from '../../modules/background-task/background-task.module';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
@Module({ @Module({
imports: [ imports: [
@@ -18,7 +20,7 @@ import { BullModule } from '@nestjs/bull';
}, },
}), }),
BullModule.registerQueue({ BullModule.registerQueue({
name: 'machine-learning', name: 'background-task',
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
@@ -27,9 +29,10 @@ import { BullModule } from '@nestjs/bull';
}), }),
TypeOrmModule.forFeature([AssetEntity]), TypeOrmModule.forFeature([AssetEntity]),
ImageOptimizeModule, ImageOptimizeModule,
BackgroundTaskModule,
], ],
controllers: [AssetController], controllers: [AssetController],
providers: [AssetService, AssetOptimizeService], providers: [AssetService, AssetOptimizeService, BackgroundTaskService],
exports: [], exports: [],
}) })
export class AssetModule {} export class AssetModule {}

View File

@@ -1,13 +1,20 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger, StreamableFile } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { MoreThan, 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 { UpdateAssetDto } from './dto/update-asset.dto'; import { UpdateAssetDto } from './dto/update-asset.dto';
import { AssetEntity, AssetType } from './entities/asset.entity'; import { AssetEntity, AssetType } from './entities/asset.entity';
import _ from 'lodash'; import _, { result } from 'lodash';
import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto'; import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto';
import { GetAllAssetReponseDto } from './dto/get-all-asset-response.dto'; import { GetAllAssetReponseDto } from './dto/get-all-asset-response.dto';
import { createReadStream, 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';
const fileInfo = promisify(stat);
@Injectable() @Injectable()
export class AssetService { export class AssetService {
@@ -52,9 +59,21 @@ export class AssetService {
return res; return res;
} }
public async getAllAssets(authUser: AuthUserDto, query: GetAllAssetQueryDto): Promise<GetAllAssetReponseDto> { public async getAllAssetsNoPagination(authUser: AuthUserDto) {
// Each page will take 100 images. try {
const assets = await this.assetRepository
.createQueryBuilder('a')
.where('a."userId" = :userId', { userId: authUser.id })
.orderBy('a."createdAt"::date', 'DESC')
.getMany();
return assets;
} catch (e) {
Logger.error(e, 'getAllAssets');
}
}
public async getAllAssets(authUser: AuthUserDto, query: GetAllAssetQueryDto): Promise<GetAllAssetReponseDto> {
try { try {
const assets = await this.assetRepository const assets = await this.assetRepository
.createQueryBuilder('a') .createQueryBuilder('a')
@@ -63,7 +82,7 @@ export class AssetService {
lastQueryCreatedAt: query.nextPageKey || new Date().toISOString(), lastQueryCreatedAt: query.nextPageKey || new Date().toISOString(),
}) })
.orderBy('a."createdAt"::date', 'DESC') .orderBy('a."createdAt"::date', 'DESC')
// .take(500) .take(5000)
.getMany(); .getMany();
if (assets.length > 0) { if (assets.length > 0) {
@@ -102,4 +121,126 @@ export class AssetService {
return rows[0] as AssetEntity; return rows[0] as AssetEntity;
} }
public async getNewAssets(authUser: AuthUserDto, latestDate: string) {
return await this.assetRepository.find({
where: {
userId: authUser.id,
createdAt: MoreThan(latestDate),
},
order: {
createdAt: 'ASC', // ASC order to add existed asset the latest group first before creating a new date group.
},
});
}
public async getAssetById(authUser: AuthUserDto, assetId: string) {
return await this.assetRepository.findOne({
where: {
userId: authUser.id,
id: assetId,
},
relations: ['exifInfo'],
});
}
public async serveFile(authUser: AuthUserDto, query: ServeFileDto, res: Res, headers: any) {
let file = null;
const asset = await this.findOne(authUser, query.did, query.aid);
// Handle Sending Images
if (asset.type == AssetType.IMAGE || query.isThumb == 'true') {
res.set({
'Content-Type': asset.mimeType,
});
if (query.isThumb === 'false' || !query.isThumb) {
file = createReadStream(asset.originalPath);
} else {
file = 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);
} else if (asset.type == AssetType.VIDEO) {
// Handle Handling Video
const { size } = await fileInfo(asset.originalPath);
const range = headers.range;
if (range) {
/** Extracting Start and End value from Range Header */
let [start, end] = range.replace(/bytes=/, '').split('-');
start = parseInt(start, 10);
end = end ? parseInt(end, 10) : size - 1;
if (!isNaN(start) && isNaN(end)) {
start = start;
end = size - 1;
}
if (isNaN(start) && !isNaN(end)) {
start = size - end;
end = size - 1;
}
// Handle unavailable range request
if (start >= size || end >= size) {
console.error('Bad Request');
// Return the 416 Range Not Satisfiable.
res.status(416).set({
'Content-Range': `bytes */${size}`,
});
throw new BadRequestException('Bad Request Range');
}
/** Sending Partial Content With HTTP Code 206 */
res.status(206).set({
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Type': asset.mimeType,
});
const videoStream = createReadStream(asset.originalPath, { start: start, end: end });
return new StreamableFile(videoStream);
} else {
res.set({
'Content-Type': asset.mimeType,
});
return new StreamableFile(createReadStream(asset.originalPath));
}
}
}
public async deleteAssetById(authUser: AuthUserDto, assetIds: DeleteAssetDto) {
let result = [];
const target = assetIds.ids;
for (let assetId of target) {
const res = await this.assetRepository.delete({
id: assetId,
userId: authUser.id,
});
if (res.affected) {
result.push({
id: assetId,
status: 'success',
});
} else {
result.push({
id: assetId,
status: 'failed',
});
}
}
return result;
}
} }

View File

@@ -0,0 +1,48 @@
import { IsNotEmpty, IsOptional } from 'class-validator';
export class CreateExifDto {
@IsNotEmpty()
assetId: string;
@IsOptional()
make: string;
@IsOptional()
model: string;
@IsOptional()
imageName: string;
@IsOptional()
exifImageWidth: number;
@IsOptional()
exifImageHeight: number;
@IsOptional()
fileSizeInByte: number;
@IsOptional()
orientation: string;
@IsOptional()
dateTimeOriginal: Date;
@IsOptional()
modifiedDate: Date;
@IsOptional()
lensModel: string;
@IsOptional()
fNumber: number;
@IsOptional()
focalLenght: number;
@IsOptional()
iso: number;
@IsOptional()
exposureTime: number;
}

View File

@@ -0,0 +1,6 @@
import { IsNotEmpty } from 'class-validator';
export class DeleteAssetDto {
@IsNotEmpty()
ids: string[];
}

View File

@@ -1,6 +1,6 @@
import { IsNotEmpty } from 'class-validator'; import { IsNotEmpty } from 'class-validator';
class GetAssetDto { export class GetAssetDto {
@IsNotEmpty() @IsNotEmpty()
deviceId: string; deviceId: string;
} }

View File

@@ -0,0 +1,6 @@
import { IsNotEmpty } from 'class-validator';
export class GetNewAssetQueryDto {
@IsNotEmpty()
latestDate: string;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateExifDto } from './create-exif.dto';
export class UpdateExifDto extends PartialType(CreateExifDto) {}

View File

@@ -1,4 +1,5 @@
import { Column, Entity, PrimaryColumn, PrimaryGeneratedColumn, Unique } from 'typeorm'; import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn, PrimaryGeneratedColumn, Unique } from 'typeorm';
import { ExifEntity } from './exif.entity';
@Entity('assets') @Entity('assets')
@Unique(['deviceAssetId', 'userId', 'deviceId']) @Unique(['deviceAssetId', 'userId', 'deviceId'])
@@ -38,6 +39,9 @@ export class AssetEntity {
@Column({ nullable: true }) @Column({ nullable: true })
duration: string; duration: string;
@OneToOne(() => ExifEntity, (exifEntity) => exifEntity.asset)
exifInfo: ExifEntity;
} }
export enum AssetType { export enum AssetType {

View File

@@ -0,0 +1,67 @@
import { Index, JoinColumn, OneToOne } from 'typeorm';
import { Column } from 'typeorm/decorator/columns/Column';
import { PrimaryGeneratedColumn } from 'typeorm/decorator/columns/PrimaryGeneratedColumn';
import { Entity } from 'typeorm/decorator/entity/Entity';
import { AssetEntity } from './asset.entity';
@Entity('exif')
export class ExifEntity {
@PrimaryGeneratedColumn()
id: string;
@Index({ unique: true })
@Column({ type: 'uuid' })
assetId: string;
@Column({ nullable: true })
make: string;
@Column({ nullable: true })
model: string;
@Column({ nullable: true })
imageName: string;
@Column({ nullable: true })
exifImageWidth: number;
@Column({ nullable: true })
exifImageHeight: number;
@Column({ nullable: true })
fileSizeInByte: number;
@Column({ nullable: true })
orientation: string;
@Column({ type: 'timestamptz', nullable: true })
dateTimeOriginal: Date;
@Column({ type: 'timestamptz', nullable: true })
modifyDate: Date;
@Column({ nullable: true })
lensModel: string;
@Column({ type: 'float8', nullable: true })
fNumber: number;
@Column({ type: 'float8', nullable: true })
focalLength: number;
@Column({ nullable: true })
iso: number;
@Column({ type: 'float', nullable: true })
exposureTime: number;
@Column({ type: 'float', nullable: true })
latitude: number;
@Column({ type: 'float', nullable: true })
longitude: number;
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
asset: ExifEntity;
}

View File

@@ -0,0 +1,47 @@
import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { CommunicationService } from './communication.service';
import { Socket, Server } from 'socket.io';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { UserEntity } from '../user/entities/user.entity';
import { Repository } from 'typeorm';
@WebSocketGateway()
export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisconnect {
constructor(
private immichJwtService: ImmichJwtService,
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
) {}
@WebSocketServer() server: Server;
handleDisconnect(client: Socket) {
client.leave(client.nsp.name);
Logger.log(`Client ${client.id} disconnected`);
}
async handleConnection(client: Socket, ...args: any[]) {
Logger.log(`New websocket connection: ${client.id}`, 'NewWebSocketConnection');
const accessToken = client.handshake.headers.authorization.split(' ')[1];
const res = await this.immichJwtService.validateToken(accessToken);
if (!res.status) {
client.emit('error', 'unauthorized');
client.disconnect();
return;
}
const user = await this.userRepository.findOne({ where: { id: res.userId } });
if (!user) {
client.emit('error', 'unauthorized');
client.disconnect();
return;
}
client.join(user.id);
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { CommunicationService } from './communication.service';
import { CommunicationGateway } from './communication.gateway';
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { JwtModule } from '@nestjs/jwt';
import { jwtConfig } from '../../config/jwt.config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '../user/entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)],
providers: [CommunicationGateway, CommunicationService, ImmichJwtService],
exports: [CommunicationGateway],
})
export class CommunicationModule {}

View File

@@ -0,0 +1,4 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class CommunicationService {}

View File

@@ -16,16 +16,7 @@ export class UserService {
return 'This action adds a new user'; return 'This action adds a new user';
} }
async findAll() { async findAll() {}
try {
return 'welcome';
// return await this.userRepository.find();
// return await this.userRepository.query('select * from users');
} catch (e) {
console.log(e);
}
// return 'helloworld';
}
findOne(id: number) { findOne(id: number) {
return `This action returns a #${id} user`; return `This action returns a #${id} user`;

View File

@@ -13,6 +13,8 @@ import { immichAppConfig } from './config/app.config';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { ImageOptimizeModule } from './modules/image-optimize/image-optimize.module'; import { ImageOptimizeModule } from './modules/image-optimize/image-optimize.module';
import { ServerInfoModule } from './api-v1/server-info/server-info.module'; import { ServerInfoModule } from './api-v1/server-info/server-info.module';
import { BackgroundTaskModule } from './modules/background-task/background-task.module';
import { CommunicationModule } from './api-v1/communication/communication.module';
@Module({ @Module({
imports: [ imports: [
@@ -29,7 +31,6 @@ import { ServerInfoModule } from './api-v1/server-info/server-info.module';
redis: { redis: {
host: 'immich_redis', host: 'immich_redis',
port: 6379, port: 6379,
// password: configService.get('REDIS_PASSWORD'),
}, },
}), }),
inject: [ConfigService], inject: [ConfigService],
@@ -38,12 +39,16 @@ import { ServerInfoModule } from './api-v1/server-info/server-info.module';
ImageOptimizeModule, ImageOptimizeModule,
ServerInfoModule, ServerInfoModule,
BackgroundTaskModule,
CommunicationModule,
], ],
controllers: [], controllers: [],
providers: [], providers: [],
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void { configure(consumer: MiddlewareConsumer): void {
consumer.apply(AppLoggerMiddleware).forRoutes('*'); // consumer.apply(AppLoggerMiddleware).forRoutes('*');
} }
} }

View File

@@ -4,14 +4,15 @@ import { existsSync, mkdirSync } from 'fs';
import { diskStorage } from 'multer'; import { diskStorage } from 'multer';
import { extname } from 'path'; import { extname } from 'path';
import { Request } from 'express'; import { Request } from 'express';
import { APP_UPLOAD_LOCATION } from '../constants/upload_location.constant';
export const multerConfig = { export const multerConfig = {
dest: process.env.UPLOAD_LOCATION, dest: APP_UPLOAD_LOCATION,
}; };
export const multerOption: MulterOptions = { export const multerOption: MulterOptions = {
fileFilter: (req: Request, file: any, cb: any) => { fileFilter: (req: Request, file: any, cb: any) => {
if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime)$/)) { if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif)$/)) {
cb(null, true); cb(null, true);
} else { } else {
cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false); cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false);

View File

@@ -0,0 +1 @@
export const APP_UPLOAD_LOCATION = './upload';

View File

@@ -1,11 +1,15 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express'; import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule); const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.set('trust proxy'); app.set('trust proxy');
app.useWebSocketAdapter(new RedisIoAdapter(app));
await app.listen(3000); await app.listen(3000);
} }
bootstrap(); bootstrap();

View File

@@ -0,0 +1,15 @@
import { IoAdapter } from '@nestjs/platform-socket.io';
import { RedisClient, createClient } from 'redis';
import { ServerOptions } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
const pubClient = createClient({ url: 'redis://immich_redis:6379' });
const subClient = pubClient.duplicate();
export class RedisIoAdapter extends IoAdapter {
createIOServer(port: number, options?: ServerOptions): any {
const server = super.createIOServer(port, options);
server.adapter(createAdapter(pubClient, subClient));
return server;
}
}

View File

@@ -0,0 +1,24 @@
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import { ExifEntity } from '../../api-v1/asset/entities/exif.entity';
import { BackgroundTaskProcessor } from './background-task.processor';
import { BackgroundTaskService } from './background-task.service';
@Module({
imports: [
BullModule.registerQueue({
name: 'background-task',
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
],
providers: [BackgroundTaskService, BackgroundTaskProcessor],
exports: [BackgroundTaskService],
})
export class BackgroundTaskModule {}

View File

@@ -0,0 +1,79 @@
import { InjectQueue, Process, Processor } from '@nestjs/bull';
import { InjectRepository } from '@nestjs/typeorm';
import { Job, Queue } from 'bull';
import { Repository } from 'typeorm';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import { ConfigService } from '@nestjs/config';
import exifr from 'exifr';
import { readFile } from 'fs/promises';
import fs from 'fs';
import { Logger } from '@nestjs/common';
import { ExifEntity } from '../../api-v1/asset/entities/exif.entity';
@Processor('background-task')
export class BackgroundTaskProcessor {
constructor(
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
@InjectRepository(ExifEntity)
private exifRepository: Repository<ExifEntity>,
private configService: ConfigService,
) {}
@Process('extract-exif')
async extractExif(job: Job) {
const { savedAsset, fileName, fileSize }: { savedAsset: AssetEntity; fileName: string; fileSize: number } =
job.data;
const fileBuffer = await readFile(savedAsset.originalPath);
const exifData = await exifr.parse(fileBuffer);
const newExif = new ExifEntity();
newExif.assetId = savedAsset.id;
newExif.make = exifData['Make'] || null;
newExif.model = exifData['Model'] || null;
newExif.imageName = fileName || null;
newExif.exifImageHeight = exifData['ExifImageHeight'] || null;
newExif.exifImageWidth = exifData['ExifImageWidth'] || null;
newExif.fileSizeInByte = fileSize || null;
newExif.orientation = exifData['Orientation'] || null;
newExif.dateTimeOriginal = exifData['DateTimeOriginal'] || null;
newExif.modifyDate = exifData['ModifyDate'] || null;
newExif.lensModel = exifData['LensModel'] || null;
newExif.fNumber = exifData['FNumber'] || null;
newExif.focalLength = exifData['FocalLength'] || null;
newExif.iso = exifData['ISO'] || null;
newExif.exposureTime = exifData['ExposureTime'] || null;
newExif.latitude = exifData['latitude'] || null;
newExif.longitude = exifData['longitude'] || null;
await this.exifRepository.save(newExif);
try {
} catch (e) {
Logger.error(`Error extracting EXIF ${e.toString()}`, 'extractExif');
}
}
@Process('delete-file-on-disk')
async deleteFileOnDisk(job) {
const { assets }: { assets: AssetEntity[] } = job.data;
assets.forEach(async (asset) => {
fs.unlink(asset.originalPath, (err) => {
if (err) {
console.log('error deleting ', asset.originalPath);
}
});
fs.unlink(asset.resizePath, (err) => {
if (err) {
console.log('error deleting ', asset.originalPath);
}
});
});
}
}

View File

@@ -0,0 +1,35 @@
import { InjectQueue } from '@nestjs/bull/dist/decorators';
import { Injectable } from '@nestjs/common';
import { Queue } from 'bull';
import { randomUUID } from 'node:crypto';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
@Injectable()
export class BackgroundTaskService {
constructor(
@InjectQueue('background-task')
private backgroundTaskQueue: Queue,
) {}
async extractExif(savedAsset: AssetEntity, fileName: string, fileSize: number) {
await this.backgroundTaskQueue.add(
'extract-exif',
{
savedAsset,
fileName,
fileSize,
},
{ jobId: randomUUID() },
);
}
async deleteFileOnDisk(assets: AssetEntity[]) {
await this.backgroundTaskQueue.add(
'delete-file-on-disk',
{
assets,
},
{ jobId: randomUUID() },
);
}
}

View File

@@ -5,12 +5,16 @@ import { join } from 'path';
import { AssetModule } from '../../api-v1/asset/asset.module'; import { AssetModule } from '../../api-v1/asset/asset.module';
import { AssetService } from '../../api-v1/asset/asset.service'; import { AssetService } from '../../api-v1/asset/asset.service';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity'; import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import { CommunicationGateway } from '../../api-v1/communication/communication.gateway';
import { CommunicationModule } from '../../api-v1/communication/communication.module';
import { UserEntity } from '../../api-v1/user/entities/user.entity';
import { ImmichJwtModule } from '../immich-jwt/immich-jwt.module';
import { ImageOptimizeProcessor } from './image-optimize.processor'; import { ImageOptimizeProcessor } from './image-optimize.processor';
import { AssetOptimizeService } from './image-optimize.service'; import { AssetOptimizeService } from './image-optimize.service';
import { MachineLearningProcessor } from './machine-learning.processor';
@Module({ @Module({
imports: [ imports: [
CommunicationModule,
BullModule.registerQueue({ BullModule.registerQueue({
name: 'optimize', name: 'optimize',
defaultJobOptions: { defaultJobOptions: {
@@ -19,18 +23,10 @@ import { MachineLearningProcessor } from './machine-learning.processor';
removeOnFail: false, removeOnFail: false,
}, },
}), }),
BullModule.registerQueue({
name: 'machine-learning',
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
TypeOrmModule.forFeature([AssetEntity]), TypeOrmModule.forFeature([AssetEntity]),
], ],
providers: [AssetOptimizeService, ImageOptimizeProcessor, MachineLearningProcessor], providers: [AssetOptimizeService, ImageOptimizeProcessor],
exports: [AssetOptimizeService], exports: [AssetOptimizeService],
}) })
export class ImageOptimizeModule {} export class ImageOptimizeModule {}

View File

@@ -4,24 +4,27 @@ import { Job, Queue } from 'bull';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity'; import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import sharp from 'sharp'; import sharp from 'sharp';
import fs, { existsSync, mkdirSync } from 'fs'; import { existsSync, mkdirSync, readFile } from 'fs';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import ffmpeg from 'fluent-ffmpeg'; import ffmpeg from 'fluent-ffmpeg';
import { Logger } from '@nestjs/common'; import { APP_UPLOAD_LOCATION } from '../../constants/upload_location.constant';
import { WebSocketServer } from '@nestjs/websockets';
import { Socket, Server as SocketIoServer } from 'socket.io';
import { CommunicationGateway } from '../../api-v1/communication/communication.gateway';
@Processor('optimize') @Processor('optimize')
export class ImageOptimizeProcessor { export class ImageOptimizeProcessor {
constructor( constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>, private wsCommunicateionGateway: CommunicationGateway,
@InjectQueue('machine-learning') private machineLearningQueue: Queue, @InjectRepository(AssetEntity)
private configService: ConfigService, private assetRepository: Repository<AssetEntity>,
) {} ) {}
@Process('resize-image') @Process('resize-image')
async resizeUploadedImage(job: Job) { async resizeUploadedImage(job: Job) {
const { savedAsset }: { savedAsset: AssetEntity } = job.data; const { savedAsset }: { savedAsset: AssetEntity } = job.data;
const basePath = this.configService.get('UPLOAD_LOCATION'); const basePath = APP_UPLOAD_LOCATION;
const resizePath = savedAsset.originalPath.replace('/original/', '/thumb/'); const resizePath = savedAsset.originalPath.replace('/original/', '/thumb/');
// Create folder for thumb image if not exist // Create folder for thumb image if not exist
@@ -32,29 +35,52 @@ export class ImageOptimizeProcessor {
mkdirSync(resizeDir, { recursive: true }); mkdirSync(resizeDir, { recursive: true });
} }
fs.readFile(savedAsset.originalPath, (err, data) => { readFile(savedAsset.originalPath, async (err, data) => {
if (err) { if (err) {
console.error('Error Reading File'); console.error('Error Reading File');
} }
sharp(data) if (savedAsset.mimeType == 'image/heic' || savedAsset.mimeType == 'image/heif') {
.resize(512, 512, { fit: 'outside' }) let desitnation = '';
.toFile(resizePath, async (err, info) => { if (savedAsset.mimeType == 'image/heic') {
if (err) { desitnation = resizePath.replace('.HEIC', '.jpeg');
console.error('Error resizing file ', err); } else {
} desitnation = resizePath.replace('.HEIF', '.jpeg');
}
await this.assetRepository.update(savedAsset, { resizePath: resizePath }); sharp(data)
.toFormat('jpeg')
.resize(512, 512, { fit: 'outside' })
.toFile(desitnation, async (err, info) => {
if (err) {
console.error('Error resizing file ', err);
return;
}
// Send file to object detection after resizing const res = await this.assetRepository.update(savedAsset, { resizePath: desitnation });
// const detectionJob = await this.machineLearningQueue.add( if (res.affected) {
// 'object-detection', this.wsCommunicateionGateway.server
// { .to(savedAsset.userId)
// resizePath, .emit('on_upload_success', JSON.stringify(savedAsset));
// }, }
// { jobId: randomUUID() }, });
// ); } else {
}); sharp(data)
.resize(512, 512, { fit: 'outside' })
.toFile(resizePath, async (err, info) => {
if (err) {
console.error('Error resizing file ', err);
return;
}
const res = await this.assetRepository.update(savedAsset, { resizePath: resizePath });
if (res.affected) {
this.wsCommunicateionGateway.server
.to(savedAsset.userId)
.emit('on_upload_success', JSON.stringify(savedAsset));
}
});
}
}); });
return 'ok'; return 'ok';
@@ -64,9 +90,8 @@ export class ImageOptimizeProcessor {
async resizeUploadedVideo(job: Job) { async resizeUploadedVideo(job: Job) {
const { savedAsset, filename }: { savedAsset: AssetEntity; filename: String } = job.data; const { savedAsset, filename }: { savedAsset: AssetEntity; filename: String } = job.data;
const basePath = this.configService.get('UPLOAD_LOCATION'); const basePath = APP_UPLOAD_LOCATION;
// const resizePath = savedAsset.originalPath.replace('/original/', '/thumb/'); // const resizePath = savedAsset.originalPath.replace('/original/', '/thumb/');
console.log(filename);
// Create folder for thumb image if not exist // Create folder for thumb image if not exist
const resizeDir = `${basePath}/${savedAsset.userId}/thumb/${savedAsset.deviceId}`; const resizeDir = `${basePath}/${savedAsset.userId}/thumb/${savedAsset.deviceId}`;
@@ -82,7 +107,12 @@ export class ImageOptimizeProcessor {
filename: `${filename}.png`, filename: `${filename}.png`,
}) })
.on('end', async (a) => { .on('end', async (a) => {
await this.assetRepository.update(savedAsset, { resizePath: `${resizeDir}/${filename}.png` }); const res = await this.assetRepository.update(savedAsset, { resizePath: `${resizeDir}/${filename}.png` });
if (res.affected) {
this.wsCommunicateionGateway.server
.to(savedAsset.userId)
.emit('on_upload_success', JSON.stringify(savedAsset));
}
}); });
return 'ok'; return 'ok';

View File

@@ -22,7 +22,7 @@ export class AssetOptimizeService {
}; };
} }
public async getVideoThumbnail(savedAsset: AssetEntity, filename: String) { public async getVideoThumbnail(savedAsset: AssetEntity, filename: string) {
const job = await this.optimizeQueue.add( const job = await this.optimizeQueue.add(
'get-video-thumbnail', 'get-video-thumbnail',
{ {

View File

@@ -1,38 +0,0 @@
import { Process, Processor } from '@nestjs/bull';
import { InjectRepository } from '@nestjs/typeorm';
import { Job } from 'bull';
import { Repository } from 'typeorm';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import fs from 'fs';
import { ConfigService } from '@nestjs/config';
import * as tfnode from '@tensorflow/tfjs-node';
import * as cocoSsd from '@tensorflow-models/coco-ssd';
@Processor('machine-learning')
export class MachineLearningProcessor {
constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
private configService: ConfigService,
) {}
@Process('object-detection')
async handleOptimization(job: Job) {
try {
const { resizePath }: { resizePath: string } = job.data;
const image = fs.readFileSync(resizePath);
const decodedImage = tfnode.node.decodeImage(image, 3) as tfnode.Tensor3D;
const model = await cocoSsd.load();
const predictions = await model.detect(decodedImage);
console.log('\n\nstart predictions ------------------ ');
for (var result of predictions) {
console.log(`Found ${result.class} with score ${result.score}`);
}
console.log('end predictions ------------------\n\n');
return 'ok';
} catch (e) {
console.log('Error object detection ', e);
}
}
}

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { JwtPayloadDto } from '../../api-v1/auth/dto/jwt-payload.dto'; import { JwtPayloadDto } from '../../api-v1/auth/dto/jwt-payload.dto';
import { jwtSecret } from '../../constants/jwt.constant';
@Injectable() @Injectable()
export class ImmichJwtService { export class ImmichJwtService {
@@ -11,4 +12,20 @@ export class ImmichJwtService {
...payload, ...payload,
}); });
} }
public async validateToken(accessToken: string) {
try {
const payload = await this.jwtService.verify(accessToken, { secret: jwtSecret });
return {
userId: payload['userId'],
status: true,
};
} catch (e) {
Logger.error('Error validating token from websocket request', 'ValidateWebsocketToken');
return {
userId: null,
status: false,
};
}
}
} }

File diff suppressed because it is too large Load Diff