Compare commits

..

11 Commits

Author SHA1 Message Date
Alex
5990a28870 Implemented search result page (#37) 2022-03-02 16:44:24 -06:00
Alex Tran
bd34be92e6 resolve merge conflict 2022-02-27 12:49:18 -06:00
Alex Tran
e608c61ba5 Resolve merge conflict 2022-02-27 12:46:19 -06:00
Alex Tran
d2edc0bffe ok 2022-02-27 12:45:12 -06:00
Alex
bfde308492 Added Tab Bar, search bar and suggested search terms from assets metadata, tags (#35) 2022-02-27 12:43:29 -06:00
Alex
f181dba964 Refactor Python Modules (#29) 2022-02-20 21:17:36 -06:00
Alex Tran
c894e36855 update readme 2022-02-19 23:26:48 -06:00
Alex Tran
01e906e99c update readme 2022-02-19 23:25:55 -06:00
Alex Tran
352800223e remove GPU build on Python server 2022-02-19 22:57:11 -06:00
Alex
619735fea0 Implemented image tagging using TensorFlow InceptionV3 (#28)
* Refactor docker-compose to its own folder
* Added FastAPI development environment
* Added support for GPU in docker file
* Added image classification
* creating endpoint for smart Image info
* added logo with white background on ios
* Added endpoint and trigger for image tagging
* Classify image and save into database
* Update readme
2022-02-19 22:42:10 -06:00
Alex Tran
75b1ed08b4 Fixed problem of not disconnecting Websocket when logging out 2022-02-14 14:42:06 -06:00
80 changed files with 2675 additions and 10765 deletions

View File

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

View File

@@ -35,6 +35,7 @@ This project is under heavy development, there will be continous functions, feat
- Support HEIC/HEIF Backup.
- Extract and display EXIF info.
- Real-time render from multi-device upload event.
- Image Classification based on ImageNet Dataset
# Development
@@ -44,13 +45,15 @@ You can use docker compose for development, there are several services that comp
2. PostgreSQL
3. Redis
4. Nginx
5. TensorFlow and Keras
## Populate .env file
Navigate to `server` directory and run
Navigate to `docker` directory and run
````
```
cp .env.example .env
```
Then populate the value in there.
@@ -59,13 +62,13 @@ Pay attention to the key `UPLOAD_LOCATION`, this directory must exist and is own
To start, run
```bash
docker-compose -f ./server/docker-compose.yml up
````
docker-compose -f ./docker/docker-compose.yml up
```
To force rebuild node modules after installing new packages
```bash
docker-compose -f ./server/docker-compose.yml up --build -V
docker-compose -f ./docker/docker-compose.yml up --build -V
```
The server will be running at `http://your-ip:2283` through `Nginx`

1
docker/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.env

View File

@@ -0,0 +1,95 @@
version: "3.8"
services:
immich_server:
image: immich-server-dev:1.0.0
build:
context: ../server
target: development
dockerfile: ../server/Dockerfile
command: npm run start:dev
expose:
- "3000"
volumes:
- ../server:/usr/src/app
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /usr/src/app/node_modules
env_file:
- .env
depends_on:
- redis
- database
networks:
- immich_network
redis:
container_name: immich_redis
image: redis:6.2
networks:
- immich_network
database:
container_name: immich_postgres
image: postgres:14
env_file:
- .env
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
PG_DATA: /var/lib/postgresql/data
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- 5432:5432
networks:
- immich_network
nginx:
container_name: proxy_nginx
image: nginx:latest
volumes:
- ./settings/nginx-conf:/etc/nginx/conf.d
ports:
- 2283:80
- 2284:443
logging:
driver: none
networks:
- immich_network
depends_on:
- immich_server
immich_tf_fastapi:
container_name: immich_tf_fastapi
image: tensor_flow_fastapi:1.0.0
restart: always
command: uvicorn app.main:app --proxy-headers --host 0.0.0.0 --port 8000 --reload
build:
context: ../machine_learning
target: gpu
dockerfile: ../machine_learning/Dockerfile
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
volumes:
- ../machine_learning/app:/code/app
- ${UPLOAD_LOCATION}:/code/app/upload
ports:
- 2285:8000
expose:
- "8000"
depends_on:
- database
networks:
- immich_network
networks:
immich_network:
volumes:
pgdata:

View File

@@ -1,18 +1,18 @@
version: '3.8'
version: "3.8"
services:
immich_server:
image: immich-server-dev:1.0.0
build:
context: .
context: ../server
target: development
dockerfile: ./Dockerfile
command: npm run start:dev
expose:
dockerfile: ../server/Dockerfile
entrypoint: ["/bin/sh", "./entrypoint.sh"]
# command: npm run start:dev
expose:
- "3000"
volumes:
- .:/usr/src/app
- ../server:/usr/src/app
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /usr/src/app/node_modules
env_file:
@@ -27,7 +27,7 @@ services:
container_name: immich_redis
image: redis:6.2
networks:
- immich_network
- immich_network
database:
container_name: immich_postgres
@@ -44,7 +44,7 @@ services:
ports:
- 5432:5432
networks:
- immich_network
- immich_network
nginx:
container_name: proxy_nginx
@@ -61,7 +61,28 @@ services:
depends_on:
- immich_server
immich_tf_fastapi:
container_name: immich_tf_fastapi
image: tensor_flow_fastapi:1.0.0
restart: always
command: uvicorn app.main:app --proxy-headers --host 0.0.0.0 --port 8000 --reload
build:
context: ../machine_learning
target: cpu
dockerfile: ../machine_learning/Dockerfile
volumes:
- ../machine_learning/app:/code/app
- ${UPLOAD_LOCATION}:/code/app/upload
ports:
- 2285:8000
expose:
- "8000"
depends_on:
- database
networks:
- immich_network
networks:
immich_network:
volumes:
pgdata:
pgdata:

View File

@@ -0,0 +1,35 @@
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# events {
# worker_connections 1000;
# }
server {
client_max_body_size 50000M;
listen 80;
location / {
proxy_buffering off;
proxy_buffer_size 16k;
proxy_busy_buffers_size 24k;
proxy_buffers 64 4k;
proxy_force_ranges on;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
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;
}
}

View File

@@ -0,0 +1 @@
devenv/

3
machine_learning/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
__pycache__/
devenv/
app/upload

View File

@@ -0,0 +1,22 @@
## GPU Build
# FROM tensorflow/tensorflow:latest-gpu as gpu
# WORKDIR /code
# COPY ./requirements.txt /code/requirements.txt
# RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
# COPY ./app /code/app
## CPU BUILD
FROM python:3.8 as cpu
WORKDIR /code
COPY ./requirements.txt /code/requirements.txt
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
COPY ./app /code/app

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

View File

@@ -0,0 +1,32 @@
from tensorflow.keras.applications import InceptionV3
from tensorflow.keras.applications.inception_v3 import preprocess_input, decode_predictions
from tensorflow.keras.preprocessing import image
import numpy as np
IMG_SIZE = 299
PREDICTION_MODEL = InceptionV3(weights='imagenet')
def classify_image(image_path: str):
img_path = f'./app/{image_path}'
img = image.load_img(img_path, target_size=(IMG_SIZE, IMG_SIZE))
x = image.img_to_array(img)
x = np.expand_dims(x, axis=0)
x = preprocess_input(x)
preds = PREDICTION_MODEL.predict(x)
result = decode_predictions(preds, top=3)[0]
payload = []
for _, value, _ in result:
payload.append(value)
return payload
def warm_up():
img_path = f'./app/test.png'
img = image.load_img(img_path, target_size=(IMG_SIZE, IMG_SIZE))
x = image.img_to_array(img)
x = np.expand_dims(x, axis=0)
x = preprocess_input(x)
PREDICTION_MODEL.predict(x)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
from pydantic import BaseModel
from fastapi import FastAPI
from .object_detection import object_detection
from .image_classifier import image_classifier
from tf2_yolov4.anchors import YOLOV4_ANCHORS
from tf2_yolov4.model import YOLOv4
HEIGHT, WIDTH = (640, 960)
# Warm up model
image_classifier.warm_up()
app = FastAPI()
class TagImagePayload(BaseModel):
thumbnail_path: str
@app.post("/tagImage")
async def post_root(payload: TagImagePayload):
image_path = payload.thumbnail_path
if image_path[0] == '.':
image_path = image_path[2:]
return image_classifier.classify_image(image_path=image_path)
@app.get("/")
async def test():
object_detection.run_detection()
# image = tf.io.read_file("./app/cars.jpg")
# image = tf.image.decode_image(image)
# image = tf.image.resize(image, (HEIGHT, WIDTH))
# images = tf.expand_dims(image, axis=0) / 255.0
# model = YOLOv4(
# (HEIGHT, WIDTH, 3),
# 80,
# YOLOV4_ANCHORS,
# "darknet",
# )

View File

@@ -0,0 +1,4 @@
def run_detection():
print("run detection")

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

View File

@@ -0,0 +1,7 @@
fastapi>=0.68.0,<0.69.0
pydantic>=1.8.0,<2.0.0
uvicorn>=0.15.0,<0.16.0
tensorflow==2.8.0
numpy==1.22.2
pillow==9.0.1
tf2_yolov4==0.1.0

View File

@@ -1,122 +1,158 @@
{
"images" : [
{
"size" : "20x20",
"filename" : "immich-logo-1024-20@2x.png",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
"scale" : "2x",
"size" : "20x20"
},
{
"size" : "20x20",
"filename" : "immich-logo-1024-20@3x.png",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
"scale" : "3x",
"size" : "20x20"
},
{
"size" : "29x29",
"filename" : "immich-logo-1024-29.png",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
"scale" : "1x",
"size" : "29x29"
},
{
"size" : "29x29",
"filename" : "immich-logo-1024-29@2x.png",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
"scale" : "2x",
"size" : "29x29"
},
{
"size" : "29x29",
"filename" : "immich-logo-1024-29@3x.png",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
"scale" : "3x",
"size" : "29x29"
},
{
"size" : "40x40",
"filename" : "immich-logo-1024-40@2x.png",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
"scale" : "2x",
"size" : "40x40"
},
{
"size" : "40x40",
"filename" : "immich-logo-1024-40@3x.png",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
"scale" : "3x",
"size" : "40x40"
},
{
"size" : "60x60",
"filename" : "immich-logo-1024-60@2x.png",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
"scale" : "2x",
"size" : "60x60"
},
{
"size" : "60x60",
"filename" : "immich-logo-1024-60@3x.png",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
"scale" : "3x",
"size" : "60x60"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
"idiom" : "ipad",
"scale" : "1x",
"size" : "20x20"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
"idiom" : "ipad",
"scale" : "2x",
"size" : "20x20"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
"idiom" : "ipad",
"scale" : "1x",
"size" : "29x29"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
"idiom" : "ipad",
"scale" : "2x",
"size" : "29x29"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
"idiom" : "ipad",
"scale" : "1x",
"size" : "40x40"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
"idiom" : "ipad",
"scale" : "2x",
"size" : "40x40"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
"idiom" : "ipad",
"scale" : "1x",
"size" : "76x76"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
"scale" : "2x",
"size" : "76x76"
},
{
"filename" : "Icon-App-83.5x83.5@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "83.5x83.5"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"
},
{
"filename" : "immich-logo-1024-20.png",
"idiom" : "universal",
"scale" : "1x",
"size" : "20x20"
},
{
"filename" : "immich-logo-1024-40.png",
"idiom" : "universal",
"scale" : "1x",
"size" : "40x40"
},
{
"filename" : "immich-logo-1024-76.png",
"idiom" : "universal",
"scale" : "1x",
"size" : "76x76"
},
{
"filename" : "immich-logo-1024-76@2x.png",
"idiom" : "universal",
"scale" : "2x",
"size" : "76x76"
},
{
"filename" : "immich-logo-1024-83.5@2x.png",
"idiom" : "universal",
"scale" : "2x",
"size" : "83.5x83.5"
},
{
"filename" : "immich-logo-1024-1024.png",
"idiom" : "universal",
"scale" : "1x",
"size" : "1024x1024"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -51,56 +51,55 @@ class ImageViewerPage extends HookConsumerWidget {
});
},
),
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(),
body: SafeArea(
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) => 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: 12, color: Colors.grey[400]),
style: TextStyle(fontSize: 16, color: Colors.white),
),
),
],
SingleChildScrollView(
child: Text(
error.toString(),
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
),
),
],
),
),
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],
),
);
},
),
// 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

@@ -12,11 +12,9 @@ import 'package:immich_mobile/shared/providers/backup.provider.dart';
class ImmichSliverAppBar extends ConsumerWidget {
const ImmichSliverAppBar({
Key? key,
required this.imageGridGroup,
this.onPopBack,
}) : super(key: key);
final List<Widget> imageGridGroup;
final Function? onPopBack;
@override
@@ -46,7 +44,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
style: GoogleFonts.snowburstOne(
textStyle: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
fontSize: 22,
color: Theme.of(context).primaryColor,
),
),
@@ -85,12 +83,8 @@ class ImmichSliverAppBar extends ConsumerWidget {
),
child: const Icon(Icons.backup_rounded)),
tooltip: 'Backup Controller',
onPressed: () async {
var onPop = await AutoRouter.of(context).push(const BackupControllerRoute());
if (onPop == true) {
onPopBack!();
}
onPressed: () {
AutoRouter.of(context).push(const BackupControllerRoute());
},
),
_backupState.backupProgress == BackUpProgressEnum.inProgress

View File

@@ -31,10 +31,6 @@ class HomePage extends HookConsumerWidget {
return null;
}, []);
onPopBackFromBackupPage() {
// ref.read(assetProvider.notifier).getAllAsset();
}
Widget _buildBody() {
if (assetGroupByDateTime.isNotEmpty) {
int? lastMonth;
@@ -88,10 +84,7 @@ class HomePage extends HookConsumerWidget {
child: null,
),
)
: ImmichSliverAppBar(
imageGridGroup: _imageGridGroup,
onPopBack: onPopBackFromBackupPage,
),
: const ImmichSliverAppBar(),
duration: const Duration(milliseconds: 350),
),
..._imageGridGroup

View File

@@ -15,7 +15,7 @@ class LoginForm extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final usernameController = useTextEditingController(text: 'testuser@email.com');
final passwordController = useTextEditingController(text: 'password');
final serverEndpointController = useTextEditingController(text: 'http://192.168.1.103:2283');
final serverEndpointController = useTextEditingController(text: 'http://192.168.1.216:2283');
return Center(
child: ConstrainedBox(
@@ -124,7 +124,8 @@ class LoginButton extends ConsumerWidget {
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");
AutoRouter.of(context).pushNamed("/tab-controller-page");
} else {
ImmichToast.show(
context: context,

View File

@@ -0,0 +1,131 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/search/services/search.service.dart';
class SearchPageState {
final String searchTerm;
final bool isSearchEnabled;
final List<String> searchSuggestion;
final List<String> userSuggestedSearchTerms;
SearchPageState({
required this.searchTerm,
required this.isSearchEnabled,
required this.searchSuggestion,
required this.userSuggestedSearchTerms,
});
SearchPageState copyWith({
String? searchTerm,
bool? isSearchEnabled,
List<String>? searchSuggestion,
List<String>? userSuggestedSearchTerms,
}) {
return SearchPageState(
searchTerm: searchTerm ?? this.searchTerm,
isSearchEnabled: isSearchEnabled ?? this.isSearchEnabled,
searchSuggestion: searchSuggestion ?? this.searchSuggestion,
userSuggestedSearchTerms: userSuggestedSearchTerms ?? this.userSuggestedSearchTerms,
);
}
Map<String, dynamic> toMap() {
return {
'searchTerm': searchTerm,
'isSearchEnabled': isSearchEnabled,
'searchSuggestion': searchSuggestion,
'userSuggestedSearchTerms': userSuggestedSearchTerms,
};
}
factory SearchPageState.fromMap(Map<String, dynamic> map) {
return SearchPageState(
searchTerm: map['searchTerm'] ?? '',
isSearchEnabled: map['isSearchEnabled'] ?? false,
searchSuggestion: List<String>.from(map['searchSuggestion']),
userSuggestedSearchTerms: List<String>.from(map['userSuggestedSearchTerms']),
);
}
String toJson() => json.encode(toMap());
factory SearchPageState.fromJson(String source) => SearchPageState.fromMap(json.decode(source));
@override
String toString() {
return 'SearchPageState(searchTerm: $searchTerm, isSearchEnabled: $isSearchEnabled, searchSuggestion: $searchSuggestion, userSuggestedSearchTerms: $userSuggestedSearchTerms)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return other is SearchPageState &&
other.searchTerm == searchTerm &&
other.isSearchEnabled == isSearchEnabled &&
listEquals(other.searchSuggestion, searchSuggestion) &&
listEquals(other.userSuggestedSearchTerms, userSuggestedSearchTerms);
}
@override
int get hashCode {
return searchTerm.hashCode ^
isSearchEnabled.hashCode ^
searchSuggestion.hashCode ^
userSuggestedSearchTerms.hashCode;
}
}
class SearchPageStateNotifier extends StateNotifier<SearchPageState> {
SearchPageStateNotifier()
: super(
SearchPageState(
searchTerm: "",
isSearchEnabled: false,
searchSuggestion: [],
userSuggestedSearchTerms: [],
),
);
final SearchService _searchService = SearchService();
void enableSearch() {
state = state.copyWith(isSearchEnabled: true);
}
void disableSearch() {
state = state.copyWith(isSearchEnabled: false);
}
void setSearchTerm(String value) {
state = state.copyWith(searchTerm: value);
_getSearchSuggestion(state.searchTerm);
}
void _getSearchSuggestion(String searchTerm) {
var searchList = state.userSuggestedSearchTerms;
var newList = searchList.where((e) => e.toLowerCase().contains(searchTerm));
state = state.copyWith(searchSuggestion: [...newList]);
if (searchTerm.isEmpty) {
state = state.copyWith(searchSuggestion: []);
}
}
void getSuggestedSearchTerms() async {
var userSuggestedSearchTerms = await _searchService.getUserSuggestedSearchTerms();
state = state.copyWith(userSuggestedSearchTerms: userSuggestedSearchTerms);
}
}
final searchPageStateProvider = StateNotifierProvider<SearchPageStateNotifier, SearchPageState>((ref) {
return SearchPageStateNotifier();
});

View File

@@ -0,0 +1,111 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/search/services/search.service.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:intl/intl.dart';
class SearchresultPageState {
final bool isLoading;
final bool isSuccess;
final bool isError;
final List<ImmichAsset> searchResult;
SearchresultPageState({
required this.isLoading,
required this.isSuccess,
required this.isError,
required this.searchResult,
});
SearchresultPageState copyWith({
bool? isLoading,
bool? isSuccess,
bool? isError,
List<ImmichAsset>? searchResult,
}) {
return SearchresultPageState(
isLoading: isLoading ?? this.isLoading,
isSuccess: isSuccess ?? this.isSuccess,
isError: isError ?? this.isError,
searchResult: searchResult ?? this.searchResult,
);
}
Map<String, dynamic> toMap() {
return {
'isLoading': isLoading,
'isSuccess': isSuccess,
'isError': isError,
'searchResult': searchResult.map((x) => x.toMap()).toList(),
};
}
factory SearchresultPageState.fromMap(Map<String, dynamic> map) {
return SearchresultPageState(
isLoading: map['isLoading'] ?? false,
isSuccess: map['isSuccess'] ?? false,
isError: map['isError'] ?? false,
searchResult: List<ImmichAsset>.from(map['searchResult']?.map((x) => ImmichAsset.fromMap(x))),
);
}
String toJson() => json.encode(toMap());
factory SearchresultPageState.fromJson(String source) => SearchresultPageState.fromMap(json.decode(source));
@override
String toString() {
return 'SearchresultPageState(isLoading: $isLoading, isSuccess: $isSuccess, isError: $isError, searchResult: $searchResult)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return other is SearchresultPageState &&
other.isLoading == isLoading &&
other.isSuccess == isSuccess &&
other.isError == isError &&
listEquals(other.searchResult, searchResult);
}
@override
int get hashCode {
return isLoading.hashCode ^ isSuccess.hashCode ^ isError.hashCode ^ searchResult.hashCode;
}
}
class SearchResultPageStateNotifier extends StateNotifier<SearchresultPageState> {
SearchResultPageStateNotifier()
: super(SearchresultPageState(searchResult: [], isError: false, isLoading: true, isSuccess: false));
final SearchService _searchService = SearchService();
search(String searchTerm) async {
state = state.copyWith(searchResult: [], isError: false, isLoading: true, isSuccess: false);
List<ImmichAsset>? assets = await _searchService.searchAsset(searchTerm);
if (assets != null) {
state = state.copyWith(searchResult: assets, isError: false, isLoading: false, isSuccess: true);
} else {
state = state.copyWith(searchResult: [], isError: true, isLoading: false, isSuccess: false);
}
}
}
final searchResultPageStateProvider =
StateNotifierProvider<SearchResultPageStateNotifier, SearchresultPageState>((ref) {
return SearchResultPageStateNotifier();
});
final searchResultGroupByDateTimeProvider = StateProvider((ref) {
var assets = ref.watch(searchResultPageStateProvider).searchResult;
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,39 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/services/network.service.dart';
class SearchService {
final NetworkService _networkService = NetworkService();
Future<List<String>?> getUserSuggestedSearchTerms() async {
try {
var res = await _networkService.getRequest(url: "asset/searchTerm");
List<dynamic> decodedData = jsonDecode(res.toString());
return List.from(decodedData);
} catch (e) {
debugPrint("[ERROR] [getUserSuggestedSearchTerms] ${e.toString()}");
return [];
}
}
Future<List<ImmichAsset>?> searchAsset(String searchTerm) async {
try {
var res = await _networkService.postRequest(
url: "asset/search",
data: {"searchTerm": searchTerm},
);
List<dynamic> decodedData = jsonDecode(res.toString());
List<ImmichAsset> result = List.from(decodedData.map((a) => ImmichAsset.fromMap(a)));
return result;
} catch (e) {
debugPrint("[ERROR] [searchAsset] ${e.toString()}");
return null;
}
}
}

View File

@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
class SearchBar extends HookConsumerWidget with PreferredSizeWidget {
SearchBar({Key? key, required this.searchFocusNode, required this.onSubmitted}) : super(key: key);
final FocusNode searchFocusNode;
final Function(String) onSubmitted;
@override
Widget build(BuildContext context, WidgetRef ref) {
final searchTermController = useTextEditingController(text: "");
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
return AppBar(
automaticallyImplyLeading: false,
leading: isSearchEnabled
? IconButton(
onPressed: () {
searchFocusNode.unfocus();
ref.watch(searchPageStateProvider.notifier).disableSearch();
searchTermController.clear();
},
icon: const Icon(Icons.arrow_back_ios_rounded))
: const Icon(Icons.search_rounded),
title: TextField(
controller: searchTermController,
focusNode: searchFocusNode,
autofocus: false,
onTap: () {
searchTermController.clear();
ref.watch(searchPageStateProvider.notifier).getSuggestedSearchTerms();
ref.watch(searchPageStateProvider.notifier).enableSearch();
ref.watch(searchPageStateProvider.notifier).setSearchTerm("");
searchFocusNode.requestFocus();
},
onSubmitted: (searchTerm) {
onSubmitted(searchTerm);
searchTermController.clear();
ref.watch(searchPageStateProvider.notifier).setSearchTerm("");
},
onChanged: (value) {
ref.watch(searchPageStateProvider.notifier).setSearchTerm(value);
},
decoration: const InputDecoration(
hintText: 'Search your photos',
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.transparent),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.transparent),
),
),
),
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
class SearchSuggestionList extends ConsumerWidget {
const SearchSuggestionList({Key? key, required this.onSubmitted}) : super(key: key);
final Function(String) onSubmitted;
@override
Widget build(BuildContext context, WidgetRef ref) {
final searchTerm = ref.watch(searchPageStateProvider).searchTerm;
final searchSuggestion = ref.watch(searchPageStateProvider).searchSuggestion;
return Container(
color: searchTerm.isEmpty ? Colors.black.withOpacity(0.5) : Theme.of(context).scaffoldBackgroundColor,
child: CustomScrollView(
slivers: [
SliverFillRemaining(
hasScrollBody: true,
child: ListView.builder(
itemBuilder: ((context, index) {
return ListTile(
onTap: () {
onSubmitted(searchSuggestion[index]);
},
title: Text(searchSuggestion[index]),
);
}),
itemCount: searchSuggestion.length,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,78 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
import 'package:immich_mobile/modules/search/ui/search_bar.dart';
import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
import 'package:immich_mobile/routing/router.dart';
// ignore: must_be_immutable
class SearchPage extends HookConsumerWidget {
SearchPage({Key? key}) : super(key: key);
late FocusNode searchFocusNode;
@override
Widget build(BuildContext context, WidgetRef ref) {
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
useEffect(() {
searchFocusNode = FocusNode();
return () => searchFocusNode.dispose();
}, []);
_onSearchSubmitted(String searchTerm) async {
searchFocusNode.unfocus();
ref.watch(searchPageStateProvider.notifier).disableSearch();
AutoRouter.of(context).push(SearchResultRoute(searchTerm: searchTerm));
}
return Scaffold(
appBar: SearchBar(
searchFocusNode: searchFocusNode,
onSubmitted: _onSearchSubmitted,
),
body: GestureDetector(
onTap: () {
searchFocusNode.unfocus();
ref.watch(searchPageStateProvider.notifier).disableSearch();
},
child: Stack(
children: [
ListView(
children: [
Container(
height: 300,
color: Colors.blue,
),
Container(
height: 300,
color: Colors.red,
),
Container(
height: 300,
color: Colors.green,
),
Container(
height: 300,
color: Colors.blue,
),
Container(
height: 300,
color: Colors.red,
),
Container(
height: 300,
color: Colors.green,
),
],
),
isSearchEnabled ? SearchSuggestionList(onSubmitted: _onSearchSubmitted) : Container(),
],
),
),
);
}
}

View File

@@ -0,0 +1,197 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/daily_title_text.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/monthly_title_text.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
import 'package:immich_mobile/modules/search/providers/search_result_page_state.provider.dart';
import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
class SearchResultPage extends HookConsumerWidget {
SearchResultPage({Key? key, required this.searchTerm}) : super(key: key);
final String searchTerm;
late FocusNode searchFocusNode;
@override
Widget build(BuildContext context, WidgetRef ref) {
ScrollController _scrollController = useScrollController();
final searchTermController = useTextEditingController(text: "");
final isNewSearch = useState(false);
final currentSearchTerm = useState(searchTerm);
List<Widget> _imageGridGroup = [];
useEffect(() {
searchFocusNode = FocusNode();
Future.delayed(Duration.zero, () => ref.read(searchResultPageStateProvider.notifier).search(searchTerm));
return () => searchFocusNode.dispose();
}, []);
_onSearchSubmitted(String newSearchTerm) {
debugPrint("Re-Search with $newSearchTerm");
searchFocusNode.unfocus();
isNewSearch.value = false;
currentSearchTerm.value = newSearchTerm;
ref.watch(searchResultPageStateProvider.notifier).search(newSearchTerm);
}
_buildTextField() {
return TextField(
controller: searchTermController,
focusNode: searchFocusNode,
autofocus: false,
onTap: () {
searchTermController.clear();
ref.watch(searchPageStateProvider.notifier).setSearchTerm("");
searchFocusNode.requestFocus();
},
textInputAction: TextInputAction.search,
onSubmitted: (searchTerm) {
if (searchTerm.isNotEmpty) {
searchTermController.clear();
_onSearchSubmitted(searchTerm);
} else {
isNewSearch.value = false;
}
},
onChanged: (value) {
ref.watch(searchPageStateProvider.notifier).setSearchTerm(value);
},
decoration: const InputDecoration(
hintText: 'New Search',
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.transparent),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.transparent),
),
),
);
}
_buildChip() {
return Chip(
label: Wrap(
spacing: 5,
children: [
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: Text(
currentSearchTerm.value,
style: TextStyle(color: Theme.of(context).primaryColor),
maxLines: 1,
),
),
Icon(
Icons.close_rounded,
color: Theme.of(context).primaryColor,
size: 20,
),
],
),
backgroundColor: Theme.of(context).primaryColor.withAlpha(50),
);
}
_buildSearchResult() {
var searchResultPageState = ref.watch(searchResultPageStateProvider);
var assetGroupByDateTime = ref.watch(searchResultGroupByDateTimeProvider);
if (searchResultPageState.isError) {
return const Text("Error");
}
if (searchResultPageState.isLoading) {
return const CircularProgressIndicator.adaptive();
}
if (searchResultPageState.isSuccess) {
if (searchResultPageState.searchResult.isNotEmpty) {
int? lastMonth;
assetGroupByDateTime.forEach((dateGroup, immichAssetList) {
DateTime parseDateGroup = DateTime.parse(dateGroup);
int currentMonth = parseDateGroup.month;
if (lastMonth != null) {
if (currentMonth - lastMonth! != 0) {
_imageGridGroup.add(
MonthlyTitleText(
isoDate: dateGroup,
),
);
}
}
_imageGridGroup.add(
DailyTitleText(
isoDate: dateGroup,
assetGroup: immichAssetList,
),
);
_imageGridGroup.add(
ImageGrid(assetGroup: immichAssetList),
);
lastMonth = currentMonth;
});
return DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).primaryColor,
controller: _scrollController,
heightScrollThumb: 48.0,
child: CustomScrollView(
controller: _scrollController,
slivers: [..._imageGridGroup],
),
);
} else {
return const Text("No assets found");
}
}
return Container();
}
return Scaffold(
appBar: AppBar(
leading: IconButton(
splashRadius: 20,
onPressed: () {
if (isNewSearch.value) {
isNewSearch.value = false;
} else {
AutoRouter.of(context).pop(true);
}
},
icon: const Icon(Icons.arrow_back_ios_rounded),
),
title: GestureDetector(
onTap: () {
isNewSearch.value = true;
searchFocusNode.requestFocus();
},
child: isNewSearch.value ? _buildTextField() : _buildChip()),
centerTitle: false,
),
body: GestureDetector(
onTap: () {
searchFocusNode.unfocus();
ref.watch(searchPageStateProvider.notifier).disableSearch();
},
child: Stack(
children: [
_buildSearchResult(),
isNewSearch.value ? SearchSuggestionList(onSubmitted: _onSearchSubmitted) : Container(),
],
),
),
);
}
}

View File

@@ -1,7 +1,6 @@
import 'dart:convert';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/cupertino.dart';
import 'package:immich_mobile/shared/services/network.service.dart';
class AuthGuard extends AutoRouteGuard {

View File

@@ -2,10 +2,13 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.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/search/views/search_page.dart';
import 'package:immich_mobile/modules/search/views/search_result_page.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/modules/asset_viewer/views/image_viewer_page.dart';
import 'package:immich_mobile/shared/views/tab_controller_page.dart';
import 'package:immich_mobile/shared/views/video_viewer_page.dart';
part 'router.gr.dart';
@@ -14,10 +17,18 @@ part 'router.gr.dart';
replaceInRouteName: 'Page,Route',
routes: <AutoRoute>[
AutoRoute(page: LoginPage, initial: true),
AutoRoute(page: HomePage, guards: [AuthGuard]),
AutoRoute(page: BackupControllerPage, guards: [AuthGuard]),
AutoRoute(
page: TabControllerPage,
guards: [AuthGuard],
children: [
AutoRoute(page: HomePage, guards: [AuthGuard]),
AutoRoute(page: SearchPage, guards: [AuthGuard])
],
),
AutoRoute(page: ImageViewerPage, guards: [AuthGuard]),
AutoRoute(page: VideoViewerPage, guards: [AuthGuard]),
AutoRoute(page: BackupControllerPage, guards: [AuthGuard]),
AutoRoute(page: SearchResultPage, guards: [AuthGuard]),
],
)
class AppRouter extends _$AppRouter {

View File

@@ -25,13 +25,9 @@ class _$AppRouter extends RootStackRouter {
return MaterialPageX<dynamic>(
routeData: routeData, child: const LoginPage());
},
HomeRoute.name: (routeData) {
TabControllerRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData, child: const HomePage());
},
BackupControllerRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData, child: const BackupControllerPage());
routeData: routeData, child: const TabControllerPage());
},
ImageViewerRoute.name: (routeData) {
final args = routeData.argsAs<ImageViewerRouteArgs>();
@@ -49,19 +45,55 @@ class _$AppRouter extends RootStackRouter {
return MaterialPageX<dynamic>(
routeData: routeData,
child: VideoViewerPage(key: args.key, videoUrl: args.videoUrl));
},
BackupControllerRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData, child: const BackupControllerPage());
},
SearchResultRoute.name: (routeData) {
final args = routeData.argsAs<SearchResultRouteArgs>();
return MaterialPageX<dynamic>(
routeData: routeData,
child: SearchResultPage(key: args.key, searchTerm: args.searchTerm));
},
HomeRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData, child: const HomePage());
},
SearchRoute.name: (routeData) {
final args = routeData.argsAs<SearchRouteArgs>(
orElse: () => const SearchRouteArgs());
return MaterialPageX<dynamic>(
routeData: routeData, child: SearchPage(key: args.key));
}
};
@override
List<RouteConfig> get routes => [
RouteConfig(LoginRoute.name, path: '/'),
RouteConfig(HomeRoute.name, path: '/home-page', guards: [authGuard]),
RouteConfig(BackupControllerRoute.name,
path: '/backup-controller-page', guards: [authGuard]),
RouteConfig(TabControllerRoute.name,
path: '/tab-controller-page',
guards: [
authGuard
],
children: [
RouteConfig(HomeRoute.name,
path: 'home-page',
parent: TabControllerRoute.name,
guards: [authGuard]),
RouteConfig(SearchRoute.name,
path: 'search-page',
parent: TabControllerRoute.name,
guards: [authGuard])
]),
RouteConfig(ImageViewerRoute.name,
path: '/image-viewer-page', guards: [authGuard]),
RouteConfig(VideoViewerRoute.name,
path: '/video-viewer-page', guards: [authGuard])
path: '/video-viewer-page', guards: [authGuard]),
RouteConfig(BackupControllerRoute.name,
path: '/backup-controller-page', guards: [authGuard]),
RouteConfig(SearchResultRoute.name,
path: '/search-result-page', guards: [authGuard])
];
}
@@ -74,20 +106,13 @@ class LoginRoute extends PageRouteInfo<void> {
}
/// generated route for
/// [HomePage]
class HomeRoute extends PageRouteInfo<void> {
const HomeRoute() : super(HomeRoute.name, path: '/home-page');
/// [TabControllerPage]
class TabControllerRoute extends PageRouteInfo<void> {
const TabControllerRoute({List<PageRouteInfo>? children})
: super(TabControllerRoute.name,
path: '/tab-controller-page', initialChildren: children);
static const String name = 'HomeRoute';
}
/// generated route for
/// [BackupControllerPage]
class BackupControllerRoute extends PageRouteInfo<void> {
const BackupControllerRoute()
: super(BackupControllerRoute.name, path: '/backup-controller-page');
static const String name = 'BackupControllerRoute';
static const String name = 'TabControllerRoute';
}
/// generated route for
@@ -158,3 +183,65 @@ class VideoViewerRouteArgs {
return 'VideoViewerRouteArgs{key: $key, videoUrl: $videoUrl}';
}
}
/// generated route for
/// [BackupControllerPage]
class BackupControllerRoute extends PageRouteInfo<void> {
const BackupControllerRoute()
: super(BackupControllerRoute.name, path: '/backup-controller-page');
static const String name = 'BackupControllerRoute';
}
/// generated route for
/// [SearchResultPage]
class SearchResultRoute extends PageRouteInfo<SearchResultRouteArgs> {
SearchResultRoute({Key? key, required String searchTerm})
: super(SearchResultRoute.name,
path: '/search-result-page',
args: SearchResultRouteArgs(key: key, searchTerm: searchTerm));
static const String name = 'SearchResultRoute';
}
class SearchResultRouteArgs {
const SearchResultRouteArgs({this.key, required this.searchTerm});
final Key? key;
final String searchTerm;
@override
String toString() {
return 'SearchResultRouteArgs{key: $key, searchTerm: $searchTerm}';
}
}
/// generated route for
/// [HomePage]
class HomeRoute extends PageRouteInfo<void> {
const HomeRoute() : super(HomeRoute.name, path: 'home-page');
static const String name = 'HomeRoute';
}
/// generated route for
/// [SearchPage]
class SearchRoute extends PageRouteInfo<SearchRouteArgs> {
SearchRoute({Key? key})
: super(SearchRoute.name,
path: 'search-page', args: SearchRouteArgs(key: key));
static const String name = 'SearchRoute';
}
class SearchRouteArgs {
const SearchRouteArgs({this.key});
final Key? key;
@override
String toString() {
return 'SearchRouteArgs{key: $key}';
}
}

View File

@@ -51,7 +51,7 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
final Ref ref;
connect() {
var authenticationState = ref.watch(authenticationProvider);
var authenticationState = ref.read(authenticationProvider);
if (authenticationState.isAuthenticated) {
var accessToken = Hive.box(userInfoBox).get(accessTokenKey);

View File

@@ -0,0 +1,44 @@
import 'package:auto_route/auto_route.dart';
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/routing/router.dart';
class TabControllerPage extends ConsumerWidget {
const TabControllerPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable;
return AutoTabsRouter(
routes: [
const HomeRoute(),
SearchRoute(),
],
builder: (context, child, animation) {
final tabsRouter = AutoTabsRouter.of(context);
return Scaffold(
body: FadeTransition(
opacity: animation,
child: child,
),
bottomNavigationBar: isMultiSelectEnable
? null
: BottomNavigationBar(
selectedLabelStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
unselectedLabelStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
currentIndex: tabsRouter.activeIndex,
onTap: (index) {
tabsRouter.setActiveIndex(index);
},
items: const [
BottomNavigationBarItem(label: 'Photos', icon: Icon(Icons.photo)),
BottomNavigationBarItem(label: 'Seach', icon: Icon(Icons.search)),
],
),
);
},
);
}
}

2
server/entrypoint.sh Normal file
View File

@@ -0,0 +1,2 @@
# npm run typeorm migration:run
npm run start:dev

10598
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,8 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test:e2e": "jest --config ./test/jest-e2e.json",
"typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js"
},
"dependencies": {
"@nestjs/bull": "^0.4.2",
@@ -34,6 +35,7 @@
"@nestjs/typeorm": "^8.0.3",
"@nestjs/websockets": "^8.2.6",
"@socket.io/redis-adapter": "^7.1.0",
"axios": "^0.26.0",
"bcrypt": "^5.0.1",
"bull": "^4.4.0",
"class-transformer": "^0.5.1",
@@ -46,10 +48,12 @@
"passport": "^0.5.2",
"passport-jwt": "^4.0.0",
"pg": "^8.7.1",
"redis": "^3.1.2",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0",
"sharp": "0.28",
"socket.io-redis": "^6.1.1",
"systeminformation": "^5.11.0",
"typeorm": "^0.2.41"
},

View File

@@ -28,6 +28,7 @@ import { Response as Res } from 'express';
import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-asset.dto';
@UseGuards(JwtAuthGuard)
@Controller('asset')
@@ -71,6 +72,16 @@ export class AssetController {
return this.assetService.serveFile(authUser, query, res, headers);
}
@Get('/searchTerm')
async getAssetSearchTerm(@GetAuthUser() authUser: AuthUserDto) {
return this.assetService.getAssetSearchTerm(authUser);
}
@Post('/search')
async searchAsset(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) searchAssetDto: SearchAssetDto) {
return this.assetService.searchAsset(authUser, searchAssetDto);
}
@Get('/new')
async getNewAssets(@GetAuthUser() authUser: AuthUserDto, @Query(ValidationPipe) query: GetNewAssetQueryDto) {
return await this.assetService.getNewAssets(authUser, query.latestDate);

View File

@@ -13,6 +13,7 @@ import { ServeFileDto } from './dto/serve-file.dto';
import { Response as Res } from 'express';
import { promisify } from 'util';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-asset.dto';
const fileInfo = promisify(stat);
@@ -67,7 +68,7 @@ export class AssetService {
.orderBy('a."createdAt"::date', 'DESC')
.getMany();
return assets;
return assets;
} catch (e) {
Logger.error(e, 'getAllAssets');
}
@@ -243,4 +244,58 @@ export class AssetService {
return result;
}
async getAssetSearchTerm(authUser: AuthUserDto): Promise<String[]> {
const possibleSearchTerm = new Set<String>();
const rows = await this.assetRepository.query(
`
select distinct si.tags, e.orientation, e."lensModel", e.make, e.model , a.type
from assets a
left join exif e on a.id = e."assetId"
left join smart_info si on a.id = si."assetId"
where a."userId" = $1;
`,
[authUser.id],
);
rows.forEach((row) => {
// tags
row['tags']?.map((tag) => possibleSearchTerm.add(tag?.toLowerCase()));
// asset's tyoe
possibleSearchTerm.add(row['type']?.toLowerCase());
// image orientation
possibleSearchTerm.add(row['orientation']?.toLowerCase());
// Lens model
possibleSearchTerm.add(row['lensModel']?.toLowerCase());
// Make and model
possibleSearchTerm.add(row['make']?.toLowerCase());
possibleSearchTerm.add(row['model']?.toLowerCase());
});
return Array.from(possibleSearchTerm).filter((x) => x != null);
}
async searchAsset(authUser: AuthUserDto, searchAssetDto: SearchAssetDto) {
const query = `
SELECT a.*
FROM assets a
LEFT JOIN smart_info si ON a.id = si."assetId"
LEFT JOIN exif e ON a.id = e."assetId"
WHERE a."userId" = $1
AND
(
TO_TSVECTOR('english', ARRAY_TO_STRING(si.tags, ',')) @@ PLAINTO_TSQUERY('english', $2) OR
e.exif_text_searchable_column @@ PLAINTO_TSQUERY('english', $2)
);
`;
const rows = await this.assetRepository.query(query, [authUser.id, searchAssetDto.searchTerm]);
return rows;
}
}

View File

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

View File

@@ -1,5 +1,6 @@
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn, PrimaryGeneratedColumn, Unique } from 'typeorm';
import { ExifEntity } from './exif.entity';
import { SmartInfoEntity } from './smart-info.entity';
@Entity('assets')
@Unique(['deviceAssetId', 'userId', 'deviceId'])
@@ -42,6 +43,9 @@ export class AssetEntity {
@OneToOne(() => ExifEntity, (exifEntity) => exifEntity.asset)
exifInfo: ExifEntity;
@OneToOne(() => SmartInfoEntity, (smartInfoEntity) => smartInfoEntity.asset)
smartInfo: SmartInfoEntity;
}
export enum AssetType {

View File

@@ -0,0 +1,19 @@
import { Column, Entity, Index, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
import { AssetEntity } from './asset.entity';
@Entity('smart_info')
export class SmartInfoEntity {
@PrimaryGeneratedColumn()
id: string;
@Index({ unique: true })
@Column({ type: 'uuid' })
assetId: string;
@Column({ type: 'text', array: true, nullable: true })
tags: string[];
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
asset: SmartInfoEntity;
}

View File

@@ -1,12 +1,11 @@
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import dotenv from 'dotenv';
// import dotenv from 'dotenv';
const result = dotenv.config();
if (result.error) {
console.log(result.error);
}
// const result = dotenv.config();
// if (result.error) {
// console.log(result.error);
// }
export const databaseConfig: TypeOrmModuleOptions = {
type: 'postgres',
host: 'immich_postgres',
@@ -15,13 +14,10 @@ export const databaseConfig: TypeOrmModuleOptions = {
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE_NAME,
entities: [__dirname + '/../**/*.entity.{js,ts}'],
synchronize: true,
// logging: true,
// logger: 'advanced-console',
// ssl: process.env.NODE_ENV == 'production',
// extra: {
// ssl: {
// rejectUnauthorized: false,
// },
// },
synchronize: false,
migrations: [__dirname + '/../migration/*.js'],
cli: {
migrationsDir: __dirname + '/../migration',
},
migrationsRun: true,
};

View File

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

View File

@@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateUserTable1645130759468 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
create table if not exists users
(
id uuid default uuid_generate_v4() not null
constraint "PK_a3ffb1c0c8416b9fc6f907b7433"
primary key,
email varchar not null,
password varchar not null,
salt varchar not null,
"createdAt" timestamp default now() not null
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`drop table users`);
}
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateDeviceInfoTable1645130777674 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
create table if not exists device_info
(
id serial
constraint "PK_b1c15a80b0a4e5f4eebadbdd92c"
primary key,
"userId" varchar not null,
"deviceId" varchar not null,
"deviceType" varchar not null,
"notificationToken" varchar,
"createdAt" timestamp default now() not null,
"isAutoBackup" boolean default false not null,
constraint "UQ_ebad78f36b10d15fbea8560e107"
unique ("userId", "deviceId")
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`drop table device_info`);
}
}

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateAssetsTable1645130805273 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
create table if not exists assets
(
id uuid default uuid_generate_v4() not null
constraint "PK_da96729a8b113377cfb6a62439c"
primary key,
"deviceAssetId" varchar not null,
"userId" varchar not null,
"deviceId" varchar not null,
type varchar not null,
"originalPath" varchar not null,
"resizePath" varchar,
"createdAt" varchar not null,
"modifiedAt" varchar not null,
"isFavorite" boolean default false not null,
"mimeType" varchar,
duration varchar,
constraint "UQ_b599ab0bd9574958acb0b30a90e"
unique ("deviceAssetId", "userId", "deviceId")
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`drop table assets`);
}
}

View File

@@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateExifTable1645130817965 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
create table if not exists exif
(
id serial
constraint "PK_28663352d85078ad0046dafafaa"
primary key,
"assetId" uuid not null
constraint "REL_c0117fdbc50b917ef9067740c4"
unique
constraint "FK_c0117fdbc50b917ef9067740c44"
references assets
on delete cascade,
make varchar,
model varchar,
"imageName" varchar,
"exifImageWidth" integer,
"exifImageHeight" integer,
"fileSizeInByte" integer,
orientation varchar,
"dateTimeOriginal" timestamp with time zone,
"modifyDate" timestamp with time zone,
"lensModel" varchar,
"fNumber" double precision,
"focalLength" double precision,
iso integer,
"exposureTime" double precision,
latitude double precision,
longitude double precision
);
create unique index if not exists "IDX_c0117fdbc50b917ef9067740c4" on exif ("assetId");
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`drop table exif`);
}
}

View File

@@ -0,0 +1,30 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateSmartInfoTable1645130870184 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
create table if not exists smart_info
(
id serial
constraint "PK_0beace66440e9713f5c40470e46"
primary key,
"assetId" uuid not null
constraint "UQ_5e3753aadd956110bf3ec0244ac"
unique
constraint "FK_5e3753aadd956110bf3ec0244ac"
references assets
on delete cascade,
tags text[]
);
create unique index if not exists "IDX_5e3753aadd956110bf3ec0244a"
on smart_info ("assetId");
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
drop table smart_info;
`);
}
}

View File

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

View File

@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateExifTextSearchIndex1646249734844 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE INDEX exif_text_searchable_idx
ON exif
USING GIN (exif_text_searchable_column);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS exif_text_searchable_idx ON exif;
`);
}
}

View File

@@ -3,6 +3,7 @@ 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 { SmartInfoEntity } from '../../api-v1/asset/entities/smart-info.entity';
import { BackgroundTaskProcessor } from './background-task.processor';
import { BackgroundTaskService } from './background-task.service';
@@ -16,7 +17,7 @@ import { BackgroundTaskService } from './background-task.service';
removeOnFail: false,
},
}),
TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
TypeOrmModule.forFeature([AssetEntity, ExifEntity, SmartInfoEntity]),
],
providers: [BackgroundTaskService, BackgroundTaskProcessor],
exports: [BackgroundTaskService],

View File

@@ -9,6 +9,8 @@ import { readFile } from 'fs/promises';
import fs from 'fs';
import { Logger } from '@nestjs/common';
import { ExifEntity } from '../../api-v1/asset/entities/exif.entity';
import axios from 'axios';
import { SmartInfoEntity } from '../../api-v1/asset/entities/smart-info.entity';
@Processor('background-task')
export class BackgroundTaskProcessor {
@@ -16,6 +18,9 @@ export class BackgroundTaskProcessor {
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
@InjectRepository(SmartInfoEntity)
private smartInfoRepository: Repository<SmartInfoEntity>,
@InjectRepository(ExifEntity)
private exifRepository: Repository<ExifEntity>,
@@ -76,4 +81,18 @@ export class BackgroundTaskProcessor {
});
});
}
@Process('tag-image')
async tagImage(job) {
const { thumbnailPath, asset }: { thumbnailPath: string; asset: AssetEntity } = job.data;
const res = await axios.post('http://immich_tf_fastapi:8000/tagImage', { thumbnail_path: thumbnailPath });
if (res.status == 200) {
const smartInfo = new SmartInfoEntity();
smartInfo.assetId = asset.id;
smartInfo.tags = [...res.data];
this.smartInfoRepository.save(smartInfo);
}
}
}

View File

@@ -32,4 +32,15 @@ export class BackgroundTaskService {
{ jobId: randomUUID() },
);
}
async tagImage(thumbnailPath: string, asset: AssetEntity) {
await this.backgroundTaskQueue.add(
'tag-image',
{
thumbnailPath,
asset,
},
{ jobId: randomUUID() },
);
}
}

View File

@@ -1,20 +1,17 @@
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { join } from 'path';
import { AssetModule } from '../../api-v1/asset/asset.module';
import { AssetService } from '../../api-v1/asset/asset.service';
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 { BackgroundTaskModule } from '../background-task/background-task.module';
import { BackgroundTaskService } from '../background-task/background-task.service';
import { ImageOptimizeProcessor } from './image-optimize.processor';
import { AssetOptimizeService } from './image-optimize.service';
@Module({
imports: [
CommunicationModule,
BackgroundTaskModule,
BullModule.registerQueue({
name: 'optimize',
defaultJobOptions: {
@@ -23,10 +20,17 @@ import { AssetOptimizeService } from './image-optimize.service';
removeOnFail: false,
},
}),
BullModule.registerQueue({
name: 'background-task',
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
TypeOrmModule.forFeature([AssetEntity]),
],
providers: [AssetOptimizeService, ImageOptimizeProcessor],
providers: [AssetOptimizeService, ImageOptimizeProcessor, BackgroundTaskService],
exports: [AssetOptimizeService],
})
export class ImageOptimizeModule {}

View File

@@ -11,6 +11,7 @@ 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';
import { BackgroundTaskService } from '../background-task/background-task.service';
@Processor('optimize')
export class ImageOptimizeProcessor {
@@ -18,6 +19,8 @@ export class ImageOptimizeProcessor {
private wsCommunicateionGateway: CommunicationGateway,
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
private backgroundTaskService: BackgroundTaskService,
) {}
@Process('resize-image')
@@ -58,11 +61,15 @@ export class ImageOptimizeProcessor {
}
const res = await this.assetRepository.update(savedAsset, { resizePath: desitnation });
if (res.affected) {
this.wsCommunicateionGateway.server
.to(savedAsset.userId)
.emit('on_upload_success', JSON.stringify(savedAsset));
}
// Tag Image
this.backgroundTaskService.tagImage(desitnation, savedAsset);
});
} else {
sharp(data)
@@ -79,6 +86,9 @@ export class ImageOptimizeProcessor {
.to(savedAsset.userId)
.emit('on_upload_success', JSON.stringify(savedAsset));
}
// Tag Image
this.backgroundTaskService.tagImage(resizePath, savedAsset);
});
}
});
@@ -107,12 +117,18 @@ export class ImageOptimizeProcessor {
filename: `${filename}.png`,
})
.on('end', async (a) => {
const thumbnailPath = `${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));
}
// Tag Image
this.backgroundTaskService.tagImage(thumbnailPath, savedAsset);
});
return 'ok';