Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5990a28870 | ||
|
|
bd34be92e6 | ||
|
|
e608c61ba5 | ||
|
|
d2edc0bffe | ||
|
|
bfde308492 | ||
|
|
f181dba964 | ||
|
|
c894e36855 | ||
|
|
01e906e99c | ||
|
|
352800223e | ||
|
|
619735fea0 | ||
|
|
75b1ed08b4 |
6
Makefile
@@ -1,8 +1,8 @@
|
|||||||
dev:
|
dev:
|
||||||
docker-compose -f ./server/docker-compose.yml up
|
docker-compose -f ./docker/docker-compose.yml up
|
||||||
|
|
||||||
dev-update:
|
dev-update:
|
||||||
docker-compose -f ./server/docker-compose.yml up --build -V
|
docker-compose -f ./docker/docker-compose.yml up --build -V
|
||||||
|
|
||||||
dev-scale:
|
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
|
||||||
|
|||||||
13
README.md
@@ -35,6 +35,7 @@ This project is under heavy development, there will be continous functions, feat
|
|||||||
- Support HEIC/HEIF Backup.
|
- Support HEIC/HEIF Backup.
|
||||||
- Extract and display EXIF info.
|
- Extract and display EXIF info.
|
||||||
- Real-time render from multi-device upload event.
|
- Real-time render from multi-device upload event.
|
||||||
|
- Image Classification based on ImageNet Dataset
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
|
|
||||||
@@ -44,13 +45,15 @@ You can use docker compose for development, there are several services that comp
|
|||||||
2. PostgreSQL
|
2. PostgreSQL
|
||||||
3. Redis
|
3. Redis
|
||||||
4. Nginx
|
4. Nginx
|
||||||
|
5. TensorFlow and Keras
|
||||||
|
|
||||||
## Populate .env file
|
## Populate .env file
|
||||||
|
|
||||||
Navigate to `server` directory and run
|
Navigate to `docker` directory and run
|
||||||
|
|
||||||
````
|
```
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
Then populate the value in there.
|
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
|
To start, run
|
||||||
|
|
||||||
```bash
|
```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
|
To force rebuild node modules after installing new packages
|
||||||
|
|
||||||
```bash
|
```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`
|
The server will be running at `http://your-ip:2283` through `Nginx`
|
||||||
|
|||||||
1
docker/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.env
|
||||||
95
docker/docker-compose.gpu.yml
Normal 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:
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
version: '3.8'
|
version: "3.8"
|
||||||
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
immich_server:
|
immich_server:
|
||||||
image: immich-server-dev:1.0.0
|
image: immich-server-dev:1.0.0
|
||||||
build:
|
build:
|
||||||
context: .
|
context: ../server
|
||||||
target: development
|
target: development
|
||||||
dockerfile: ./Dockerfile
|
dockerfile: ../server/Dockerfile
|
||||||
command: npm run start:dev
|
entrypoint: ["/bin/sh", "./entrypoint.sh"]
|
||||||
|
# command: npm run start:dev
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/usr/src/app
|
- ../server:/usr/src/app
|
||||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||||
- /usr/src/app/node_modules
|
- /usr/src/app/node_modules
|
||||||
env_file:
|
env_file:
|
||||||
@@ -61,6 +61,27 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- immich_server
|
- 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:
|
networks:
|
||||||
immich_network:
|
immich_network:
|
||||||
volumes:
|
volumes:
|
||||||
35
docker/settings/nginx-conf/nginx.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
machine_learning/.dockerignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
devenv/
|
||||||
3
machine_learning/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
__pycache__/
|
||||||
|
devenv/
|
||||||
|
app/upload
|
||||||
22
machine_learning/Dockerfile
Normal 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
|
||||||
0
machine_learning/app/__init__.py
Normal file
BIN
machine_learning/app/cars.jpg
Normal file
|
After Width: | Height: | Size: 193 KiB |
0
machine_learning/app/image_classifier/__init__.py
Normal file
32
machine_learning/app/image_classifier/image_classifier.py
Normal 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)
|
||||||
1002
machine_learning/app/imagenet_class_index.json
Normal file
46
machine_learning/app/main.py
Normal 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",
|
||||||
|
# )
|
||||||
0
machine_learning/app/object_detection/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
def run_detection():
|
||||||
|
print("run detection")
|
||||||
BIN
machine_learning/app/test.png
Normal file
|
After Width: | Height: | Size: 345 KiB |
7
machine_learning/requirements.txt
Normal 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
|
||||||
@@ -1,122 +1,158 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"size" : "20x20",
|
"filename" : "immich-logo-1024-20@2x.png",
|
||||||
"idiom" : "iphone",
|
"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",
|
"idiom" : "iphone",
|
||||||
"filename" : "Icon-App-20x20@3x.png",
|
"scale" : "3x",
|
||||||
"scale" : "3x"
|
"size" : "20x20"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size" : "29x29",
|
"filename" : "immich-logo-1024-29.png",
|
||||||
"idiom" : "iphone",
|
"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",
|
"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",
|
"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",
|
"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",
|
"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",
|
"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",
|
"idiom" : "iphone",
|
||||||
"filename" : "Icon-App-60x60@3x.png",
|
"scale" : "3x",
|
||||||
"scale" : "3x"
|
"size" : "60x60"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@1x.png",
|
"filename" : "Icon-App-20x20@1x.png",
|
||||||
"scale" : "1x"
|
"idiom" : "ipad",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "20x20"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@2x.png",
|
"filename" : "Icon-App-20x20@2x.png",
|
||||||
"scale" : "2x"
|
"idiom" : "ipad",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "20x20"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@1x.png",
|
"filename" : "Icon-App-29x29@1x.png",
|
||||||
"scale" : "1x"
|
"idiom" : "ipad",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "29x29"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@2x.png",
|
"filename" : "Icon-App-29x29@2x.png",
|
||||||
"scale" : "2x"
|
"idiom" : "ipad",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "29x29"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@1x.png",
|
"filename" : "Icon-App-40x40@1x.png",
|
||||||
"scale" : "1x"
|
"idiom" : "ipad",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "40x40"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@2x.png",
|
"filename" : "Icon-App-40x40@2x.png",
|
||||||
"scale" : "2x"
|
"idiom" : "ipad",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "40x40"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@1x.png",
|
"filename" : "Icon-App-76x76@1x.png",
|
||||||
"scale" : "1x"
|
"idiom" : "ipad",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "76x76"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@2x.png",
|
"filename" : "Icon-App-76x76@2x.png",
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "83.5x83.5",
|
|
||||||
"idiom" : "ipad",
|
"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",
|
"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" : {
|
"info" : {
|
||||||
"version" : 1,
|
"author" : "xcode",
|
||||||
"author" : "xcode"
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -51,56 +51,55 @@ class ImageViewerPage extends HookConsumerWidget {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
body: Center(
|
body: SafeArea(
|
||||||
child: Hero(
|
child: Center(
|
||||||
tag: heroTag,
|
child: Hero(
|
||||||
child: CachedNetworkImage(
|
tag: heroTag,
|
||||||
fit: BoxFit.cover,
|
child: CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
fit: BoxFit.cover,
|
||||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
imageUrl: imageUrl,
|
||||||
fadeInDuration: const Duration(milliseconds: 250),
|
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||||
errorWidget: (context, url, error) => ConstrainedBox(
|
fadeInDuration: const Duration(milliseconds: 250),
|
||||||
constraints: const BoxConstraints(maxWidth: 300),
|
errorWidget: (context, url, error) => ConstrainedBox(
|
||||||
child: Wrap(
|
constraints: const BoxConstraints(maxWidth: 300),
|
||||||
spacing: 32,
|
child: Wrap(
|
||||||
runSpacing: 32,
|
spacing: 32,
|
||||||
alignment: WrapAlignment.center,
|
runSpacing: 32,
|
||||||
children: [
|
alignment: WrapAlignment.center,
|
||||||
const Text(
|
children: [
|
||||||
"Failed To Render Image - Possibly Corrupted Data",
|
const Text(
|
||||||
textAlign: TextAlign.center,
|
"Failed To Render Image - Possibly Corrupted Data",
|
||||||
style: TextStyle(fontSize: 16, color: Colors.white),
|
|
||||||
),
|
|
||||||
SingleChildScrollView(
|
|
||||||
child: Text(
|
|
||||||
error.toString(),
|
|
||||||
textAlign: TextAlign.center,
|
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],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -12,11 +12,9 @@ import 'package:immich_mobile/shared/providers/backup.provider.dart';
|
|||||||
class ImmichSliverAppBar extends ConsumerWidget {
|
class ImmichSliverAppBar extends ConsumerWidget {
|
||||||
const ImmichSliverAppBar({
|
const ImmichSliverAppBar({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.imageGridGroup,
|
|
||||||
this.onPopBack,
|
this.onPopBack,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
final List<Widget> imageGridGroup;
|
|
||||||
final Function? onPopBack;
|
final Function? onPopBack;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -46,7 +44,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
|||||||
style: GoogleFonts.snowburstOne(
|
style: GoogleFonts.snowburstOne(
|
||||||
textStyle: TextStyle(
|
textStyle: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 18,
|
fontSize: 22,
|
||||||
color: Theme.of(context).primaryColor,
|
color: Theme.of(context).primaryColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -85,12 +83,8 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: const Icon(Icons.backup_rounded)),
|
child: const Icon(Icons.backup_rounded)),
|
||||||
tooltip: 'Backup Controller',
|
tooltip: 'Backup Controller',
|
||||||
onPressed: () async {
|
onPressed: () {
|
||||||
var onPop = await AutoRouter.of(context).push(const BackupControllerRoute());
|
AutoRouter.of(context).push(const BackupControllerRoute());
|
||||||
|
|
||||||
if (onPop == true) {
|
|
||||||
onPopBack!();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_backupState.backupProgress == BackUpProgressEnum.inProgress
|
_backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||||
|
|||||||
@@ -31,10 +31,6 @@ class HomePage extends HookConsumerWidget {
|
|||||||
return null;
|
return null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
onPopBackFromBackupPage() {
|
|
||||||
// ref.read(assetProvider.notifier).getAllAsset();
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildBody() {
|
Widget _buildBody() {
|
||||||
if (assetGroupByDateTime.isNotEmpty) {
|
if (assetGroupByDateTime.isNotEmpty) {
|
||||||
int? lastMonth;
|
int? lastMonth;
|
||||||
@@ -88,10 +84,7 @@ class HomePage extends HookConsumerWidget {
|
|||||||
child: null,
|
child: null,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: ImmichSliverAppBar(
|
: const ImmichSliverAppBar(),
|
||||||
imageGridGroup: _imageGridGroup,
|
|
||||||
onPopBack: onPopBackFromBackupPage,
|
|
||||||
),
|
|
||||||
duration: const Duration(milliseconds: 350),
|
duration: const Duration(milliseconds: 350),
|
||||||
),
|
),
|
||||||
..._imageGridGroup
|
..._imageGridGroup
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final usernameController = useTextEditingController(text: 'testuser@email.com');
|
final usernameController = useTextEditingController(text: 'testuser@email.com');
|
||||||
final passwordController = useTextEditingController(text: 'password');
|
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(
|
return Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
@@ -124,7 +124,8 @@ class LoginButton extends ConsumerWidget {
|
|||||||
if (isAuthenicated) {
|
if (isAuthenicated) {
|
||||||
// Resume backup (if enable) then navigate
|
// Resume backup (if enable) then navigate
|
||||||
ref.watch(backupProvider.notifier).resumeBackup();
|
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 {
|
} else {
|
||||||
ImmichToast.show(
|
ImmichToast.show(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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)));
|
||||||
|
});
|
||||||
39
mobile/lib/modules/search/services/search.service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
mobile/lib/modules/search/ui/search_bar.dart
Normal 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);
|
||||||
|
}
|
||||||
36
mobile/lib/modules/search/ui/search_suggestion_list.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
mobile/lib/modules/search/views/search_page.dart
Normal 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(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
197
mobile/lib/modules/search/views/search_result_page.dart
Normal 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(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:immich_mobile/shared/services/network.service.dart';
|
import 'package:immich_mobile/shared/services/network.service.dart';
|
||||||
|
|
||||||
class AuthGuard extends AutoRouteGuard {
|
class AuthGuard extends AutoRouteGuard {
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import 'package:auto_route/auto_route.dart';
|
|||||||
import 'package:flutter/widgets.dart';
|
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/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/routing/auth_guard.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/shared/views/backup_controller_page.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/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';
|
import 'package:immich_mobile/shared/views/video_viewer_page.dart';
|
||||||
|
|
||||||
part 'router.gr.dart';
|
part 'router.gr.dart';
|
||||||
@@ -14,10 +17,18 @@ part 'router.gr.dart';
|
|||||||
replaceInRouteName: 'Page,Route',
|
replaceInRouteName: 'Page,Route',
|
||||||
routes: <AutoRoute>[
|
routes: <AutoRoute>[
|
||||||
AutoRoute(page: LoginPage, initial: true),
|
AutoRoute(page: LoginPage, initial: true),
|
||||||
AutoRoute(page: HomePage, guards: [AuthGuard]),
|
AutoRoute(
|
||||||
AutoRoute(page: BackupControllerPage, guards: [AuthGuard]),
|
page: TabControllerPage,
|
||||||
|
guards: [AuthGuard],
|
||||||
|
children: [
|
||||||
|
AutoRoute(page: HomePage, guards: [AuthGuard]),
|
||||||
|
AutoRoute(page: SearchPage, guards: [AuthGuard])
|
||||||
|
],
|
||||||
|
),
|
||||||
AutoRoute(page: ImageViewerPage, guards: [AuthGuard]),
|
AutoRoute(page: ImageViewerPage, guards: [AuthGuard]),
|
||||||
AutoRoute(page: VideoViewerPage, guards: [AuthGuard]),
|
AutoRoute(page: VideoViewerPage, guards: [AuthGuard]),
|
||||||
|
AutoRoute(page: BackupControllerPage, guards: [AuthGuard]),
|
||||||
|
AutoRoute(page: SearchResultPage, guards: [AuthGuard]),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class AppRouter extends _$AppRouter {
|
class AppRouter extends _$AppRouter {
|
||||||
|
|||||||
@@ -25,13 +25,9 @@ class _$AppRouter extends RootStackRouter {
|
|||||||
return MaterialPageX<dynamic>(
|
return MaterialPageX<dynamic>(
|
||||||
routeData: routeData, child: const LoginPage());
|
routeData: routeData, child: const LoginPage());
|
||||||
},
|
},
|
||||||
HomeRoute.name: (routeData) {
|
TabControllerRoute.name: (routeData) {
|
||||||
return MaterialPageX<dynamic>(
|
return MaterialPageX<dynamic>(
|
||||||
routeData: routeData, child: const HomePage());
|
routeData: routeData, child: const TabControllerPage());
|
||||||
},
|
|
||||||
BackupControllerRoute.name: (routeData) {
|
|
||||||
return MaterialPageX<dynamic>(
|
|
||||||
routeData: routeData, child: const BackupControllerPage());
|
|
||||||
},
|
},
|
||||||
ImageViewerRoute.name: (routeData) {
|
ImageViewerRoute.name: (routeData) {
|
||||||
final args = routeData.argsAs<ImageViewerRouteArgs>();
|
final args = routeData.argsAs<ImageViewerRouteArgs>();
|
||||||
@@ -49,19 +45,55 @@ class _$AppRouter extends RootStackRouter {
|
|||||||
return MaterialPageX<dynamic>(
|
return MaterialPageX<dynamic>(
|
||||||
routeData: routeData,
|
routeData: routeData,
|
||||||
child: VideoViewerPage(key: args.key, videoUrl: args.videoUrl));
|
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
|
@override
|
||||||
List<RouteConfig> get routes => [
|
List<RouteConfig> get routes => [
|
||||||
RouteConfig(LoginRoute.name, path: '/'),
|
RouteConfig(LoginRoute.name, path: '/'),
|
||||||
RouteConfig(HomeRoute.name, path: '/home-page', guards: [authGuard]),
|
RouteConfig(TabControllerRoute.name,
|
||||||
RouteConfig(BackupControllerRoute.name,
|
path: '/tab-controller-page',
|
||||||
path: '/backup-controller-page', guards: [authGuard]),
|
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,
|
RouteConfig(ImageViewerRoute.name,
|
||||||
path: '/image-viewer-page', guards: [authGuard]),
|
path: '/image-viewer-page', guards: [authGuard]),
|
||||||
RouteConfig(VideoViewerRoute.name,
|
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
|
/// generated route for
|
||||||
/// [HomePage]
|
/// [TabControllerPage]
|
||||||
class HomeRoute extends PageRouteInfo<void> {
|
class TabControllerRoute extends PageRouteInfo<void> {
|
||||||
const HomeRoute() : super(HomeRoute.name, path: '/home-page');
|
const TabControllerRoute({List<PageRouteInfo>? children})
|
||||||
|
: super(TabControllerRoute.name,
|
||||||
|
path: '/tab-controller-page', initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'HomeRoute';
|
static const String name = 'TabControllerRoute';
|
||||||
}
|
|
||||||
|
|
||||||
/// 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
|
/// generated route for
|
||||||
@@ -158,3 +183,65 @@ class VideoViewerRouteArgs {
|
|||||||
return 'VideoViewerRouteArgs{key: $key, videoUrl: $videoUrl}';
|
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}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
|
|||||||
final Ref ref;
|
final Ref ref;
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
var authenticationState = ref.watch(authenticationProvider);
|
var authenticationState = ref.read(authenticationProvider);
|
||||||
|
|
||||||
if (authenticationState.isAuthenticated) {
|
if (authenticationState.isAuthenticated) {
|
||||||
var accessToken = Hive.box(userInfoBox).get(accessTokenKey);
|
var accessToken = Hive.box(userInfoBox).get(accessTokenKey);
|
||||||
|
|||||||
44
mobile/lib/shared/views/tab_controller_page.dart
Normal 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
@@ -0,0 +1,2 @@
|
|||||||
|
# npm run typeorm migration:run
|
||||||
|
npm run start:dev
|
||||||
10598
server/package-lock.json
generated
@@ -18,7 +18,8 @@
|
|||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/bull": "^0.4.2",
|
"@nestjs/bull": "^0.4.2",
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
"@nestjs/typeorm": "^8.0.3",
|
"@nestjs/typeorm": "^8.0.3",
|
||||||
"@nestjs/websockets": "^8.2.6",
|
"@nestjs/websockets": "^8.2.6",
|
||||||
"@socket.io/redis-adapter": "^7.1.0",
|
"@socket.io/redis-adapter": "^7.1.0",
|
||||||
|
"axios": "^0.26.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",
|
||||||
@@ -46,10 +48,12 @@
|
|||||||
"passport": "^0.5.2",
|
"passport": "^0.5.2",
|
||||||
"passport-jwt": "^4.0.0",
|
"passport-jwt": "^4.0.0",
|
||||||
"pg": "^8.7.1",
|
"pg": "^8.7.1",
|
||||||
|
"redis": "^3.1.2",
|
||||||
"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.28",
|
"sharp": "0.28",
|
||||||
|
"socket.io-redis": "^6.1.1",
|
||||||
"systeminformation": "^5.11.0",
|
"systeminformation": "^5.11.0",
|
||||||
"typeorm": "^0.2.41"
|
"typeorm": "^0.2.41"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { Response as Res } from 'express';
|
|||||||
import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto';
|
import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto';
|
||||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||||
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
||||||
|
import { SearchAssetDto } from './dto/search-asset.dto';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('asset')
|
@Controller('asset')
|
||||||
@@ -71,6 +72,16 @@ export class AssetController {
|
|||||||
return this.assetService.serveFile(authUser, query, res, headers);
|
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')
|
@Get('/new')
|
||||||
async getNewAssets(@GetAuthUser() authUser: AuthUserDto, @Query(ValidationPipe) query: GetNewAssetQueryDto) {
|
async getNewAssets(@GetAuthUser() authUser: AuthUserDto, @Query(ValidationPipe) query: GetNewAssetQueryDto) {
|
||||||
return await this.assetService.getNewAssets(authUser, query.latestDate);
|
return await this.assetService.getNewAssets(authUser, query.latestDate);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { ServeFileDto } from './dto/serve-file.dto';
|
|||||||
import { Response as Res } from 'express';
|
import { Response as Res } from 'express';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
||||||
|
import { SearchAssetDto } from './dto/search-asset.dto';
|
||||||
|
|
||||||
const fileInfo = promisify(stat);
|
const fileInfo = promisify(stat);
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ export class AssetService {
|
|||||||
.orderBy('a."createdAt"::date', 'DESC')
|
.orderBy('a."createdAt"::date', 'DESC')
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|
||||||
return assets;
|
return assets;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Logger.error(e, 'getAllAssets');
|
Logger.error(e, 'getAllAssets');
|
||||||
}
|
}
|
||||||
@@ -243,4 +244,58 @@ export class AssetService {
|
|||||||
|
|
||||||
return result;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
server/src/api-v1/asset/dto/search-asset.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
|
export class SearchAssetDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
searchTerm: string;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn, PrimaryGeneratedColumn, Unique } from 'typeorm';
|
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn, PrimaryGeneratedColumn, Unique } from 'typeorm';
|
||||||
import { ExifEntity } from './exif.entity';
|
import { ExifEntity } from './exif.entity';
|
||||||
|
import { SmartInfoEntity } from './smart-info.entity';
|
||||||
|
|
||||||
@Entity('assets')
|
@Entity('assets')
|
||||||
@Unique(['deviceAssetId', 'userId', 'deviceId'])
|
@Unique(['deviceAssetId', 'userId', 'deviceId'])
|
||||||
@@ -42,6 +43,9 @@ export class AssetEntity {
|
|||||||
|
|
||||||
@OneToOne(() => ExifEntity, (exifEntity) => exifEntity.asset)
|
@OneToOne(() => ExifEntity, (exifEntity) => exifEntity.asset)
|
||||||
exifInfo: ExifEntity;
|
exifInfo: ExifEntity;
|
||||||
|
|
||||||
|
@OneToOne(() => SmartInfoEntity, (smartInfoEntity) => smartInfoEntity.asset)
|
||||||
|
smartInfo: SmartInfoEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AssetType {
|
export enum AssetType {
|
||||||
|
|||||||
19
server/src/api-v1/asset/entities/smart-info.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||||
import dotenv from 'dotenv';
|
// import dotenv from 'dotenv';
|
||||||
|
|
||||||
const result = dotenv.config();
|
// const result = dotenv.config();
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
console.log(result.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// if (result.error) {
|
||||||
|
// console.log(result.error);
|
||||||
|
// }
|
||||||
export const databaseConfig: TypeOrmModuleOptions = {
|
export const databaseConfig: TypeOrmModuleOptions = {
|
||||||
type: 'postgres',
|
type: 'postgres',
|
||||||
host: 'immich_postgres',
|
host: 'immich_postgres',
|
||||||
@@ -15,13 +14,10 @@ export const databaseConfig: TypeOrmModuleOptions = {
|
|||||||
password: process.env.DB_PASSWORD,
|
password: process.env.DB_PASSWORD,
|
||||||
database: process.env.DB_DATABASE_NAME,
|
database: process.env.DB_DATABASE_NAME,
|
||||||
entities: [__dirname + '/../**/*.entity.{js,ts}'],
|
entities: [__dirname + '/../**/*.entity.{js,ts}'],
|
||||||
synchronize: true,
|
synchronize: false,
|
||||||
// logging: true,
|
migrations: [__dirname + '/../migration/*.js'],
|
||||||
// logger: 'advanced-console',
|
cli: {
|
||||||
// ssl: process.env.NODE_ENV == 'production',
|
migrationsDir: __dirname + '/../migration',
|
||||||
// extra: {
|
},
|
||||||
// ssl: {
|
migrationsRun: true,
|
||||||
// rejectUnauthorized: false,
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import { IoAdapter } from '@nestjs/platform-socket.io';
|
import { IoAdapter } from '@nestjs/platform-socket.io';
|
||||||
import { RedisClient, createClient } from 'redis';
|
import { RedisClient } from 'redis';
|
||||||
import { ServerOptions } from 'socket.io';
|
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 subClient = pubClient.duplicate();
|
||||||
|
const redisAdapter = createAdapter({ pubClient, subClient });
|
||||||
|
|
||||||
export class RedisIoAdapter extends IoAdapter {
|
export class RedisIoAdapter extends IoAdapter {
|
||||||
createIOServer(port: number, options?: ServerOptions): any {
|
createIOServer(port: number, options?: ServerOptions): any {
|
||||||
const server = super.createIOServer(port, options);
|
const server = super.createIOServer(port, options);
|
||||||
server.adapter(createAdapter(pubClient, subClient));
|
server.adapter(redisAdapter);
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
server/src/migration/1645130759468-CreateUserTable.ts
Normal 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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
server/src/migration/1645130777674-CreateDeviceInfoTable.ts
Normal 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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
server/src/migration/1645130805273-CreateAssetsTable.ts
Normal 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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
server/src/migration/1645130817965-CreateExifTable.ts
Normal 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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
server/src/migration/1645130870184-CreateSmartInfoTable.ts
Normal 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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
|
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
|
||||||
import { ExifEntity } from '../../api-v1/asset/entities/exif.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 { BackgroundTaskProcessor } from './background-task.processor';
|
||||||
import { BackgroundTaskService } from './background-task.service';
|
import { BackgroundTaskService } from './background-task.service';
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ import { BackgroundTaskService } from './background-task.service';
|
|||||||
removeOnFail: false,
|
removeOnFail: false,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
|
TypeOrmModule.forFeature([AssetEntity, ExifEntity, SmartInfoEntity]),
|
||||||
],
|
],
|
||||||
providers: [BackgroundTaskService, BackgroundTaskProcessor],
|
providers: [BackgroundTaskService, BackgroundTaskProcessor],
|
||||||
exports: [BackgroundTaskService],
|
exports: [BackgroundTaskService],
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { readFile } from 'fs/promises';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { ExifEntity } from '../../api-v1/asset/entities/exif.entity';
|
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')
|
@Processor('background-task')
|
||||||
export class BackgroundTaskProcessor {
|
export class BackgroundTaskProcessor {
|
||||||
@@ -16,6 +18,9 @@ export class BackgroundTaskProcessor {
|
|||||||
@InjectRepository(AssetEntity)
|
@InjectRepository(AssetEntity)
|
||||||
private assetRepository: Repository<AssetEntity>,
|
private assetRepository: Repository<AssetEntity>,
|
||||||
|
|
||||||
|
@InjectRepository(SmartInfoEntity)
|
||||||
|
private smartInfoRepository: Repository<SmartInfoEntity>,
|
||||||
|
|
||||||
@InjectRepository(ExifEntity)
|
@InjectRepository(ExifEntity)
|
||||||
private exifRepository: Repository<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,4 +32,15 @@ export class BackgroundTaskService {
|
|||||||
{ jobId: randomUUID() },
|
{ jobId: randomUUID() },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async tagImage(thumbnailPath: string, asset: AssetEntity) {
|
||||||
|
await this.backgroundTaskQueue.add(
|
||||||
|
'tag-image',
|
||||||
|
{
|
||||||
|
thumbnailPath,
|
||||||
|
asset,
|
||||||
|
},
|
||||||
|
{ jobId: randomUUID() },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
import { BullModule } from '@nestjs/bull';
|
import { BullModule } from '@nestjs/bull';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
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 { 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 { CommunicationModule } from '../../api-v1/communication/communication.module';
|
||||||
import { UserEntity } from '../../api-v1/user/entities/user.entity';
|
import { BackgroundTaskModule } from '../background-task/background-task.module';
|
||||||
import { ImmichJwtModule } from '../immich-jwt/immich-jwt.module';
|
import { BackgroundTaskService } from '../background-task/background-task.service';
|
||||||
import { ImageOptimizeProcessor } from './image-optimize.processor';
|
import { ImageOptimizeProcessor } from './image-optimize.processor';
|
||||||
import { AssetOptimizeService } from './image-optimize.service';
|
import { AssetOptimizeService } from './image-optimize.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
CommunicationModule,
|
CommunicationModule,
|
||||||
|
BackgroundTaskModule,
|
||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
name: 'optimize',
|
name: 'optimize',
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
@@ -23,10 +20,17 @@ import { AssetOptimizeService } from './image-optimize.service';
|
|||||||
removeOnFail: false,
|
removeOnFail: false,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
BullModule.registerQueue({
|
||||||
|
name: 'background-task',
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 3,
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
TypeOrmModule.forFeature([AssetEntity]),
|
TypeOrmModule.forFeature([AssetEntity]),
|
||||||
],
|
],
|
||||||
providers: [AssetOptimizeService, ImageOptimizeProcessor],
|
providers: [AssetOptimizeService, ImageOptimizeProcessor, BackgroundTaskService],
|
||||||
exports: [AssetOptimizeService],
|
exports: [AssetOptimizeService],
|
||||||
})
|
})
|
||||||
export class ImageOptimizeModule {}
|
export class ImageOptimizeModule {}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { APP_UPLOAD_LOCATION } from '../../constants/upload_location.constant';
|
|||||||
import { WebSocketServer } from '@nestjs/websockets';
|
import { WebSocketServer } from '@nestjs/websockets';
|
||||||
import { Socket, Server as SocketIoServer } from 'socket.io';
|
import { Socket, Server as SocketIoServer } from 'socket.io';
|
||||||
import { CommunicationGateway } from '../../api-v1/communication/communication.gateway';
|
import { CommunicationGateway } from '../../api-v1/communication/communication.gateway';
|
||||||
|
import { BackgroundTaskService } from '../background-task/background-task.service';
|
||||||
|
|
||||||
@Processor('optimize')
|
@Processor('optimize')
|
||||||
export class ImageOptimizeProcessor {
|
export class ImageOptimizeProcessor {
|
||||||
@@ -18,6 +19,8 @@ export class ImageOptimizeProcessor {
|
|||||||
private wsCommunicateionGateway: CommunicationGateway,
|
private wsCommunicateionGateway: CommunicationGateway,
|
||||||
@InjectRepository(AssetEntity)
|
@InjectRepository(AssetEntity)
|
||||||
private assetRepository: Repository<AssetEntity>,
|
private assetRepository: Repository<AssetEntity>,
|
||||||
|
|
||||||
|
private backgroundTaskService: BackgroundTaskService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Process('resize-image')
|
@Process('resize-image')
|
||||||
@@ -58,11 +61,15 @@ export class ImageOptimizeProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const res = await this.assetRepository.update(savedAsset, { resizePath: desitnation });
|
const res = await this.assetRepository.update(savedAsset, { resizePath: desitnation });
|
||||||
|
|
||||||
if (res.affected) {
|
if (res.affected) {
|
||||||
this.wsCommunicateionGateway.server
|
this.wsCommunicateionGateway.server
|
||||||
.to(savedAsset.userId)
|
.to(savedAsset.userId)
|
||||||
.emit('on_upload_success', JSON.stringify(savedAsset));
|
.emit('on_upload_success', JSON.stringify(savedAsset));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tag Image
|
||||||
|
this.backgroundTaskService.tagImage(desitnation, savedAsset);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
sharp(data)
|
sharp(data)
|
||||||
@@ -79,6 +86,9 @@ export class ImageOptimizeProcessor {
|
|||||||
.to(savedAsset.userId)
|
.to(savedAsset.userId)
|
||||||
.emit('on_upload_success', JSON.stringify(savedAsset));
|
.emit('on_upload_success', JSON.stringify(savedAsset));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tag Image
|
||||||
|
this.backgroundTaskService.tagImage(resizePath, savedAsset);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -107,12 +117,18 @@ export class ImageOptimizeProcessor {
|
|||||||
filename: `${filename}.png`,
|
filename: `${filename}.png`,
|
||||||
})
|
})
|
||||||
.on('end', async (a) => {
|
.on('end', async (a) => {
|
||||||
|
const thumbnailPath = `${resizeDir}/${filename}.png`;
|
||||||
|
|
||||||
const res = await this.assetRepository.update(savedAsset, { resizePath: `${resizeDir}/${filename}.png` });
|
const res = await this.assetRepository.update(savedAsset, { resizePath: `${resizeDir}/${filename}.png` });
|
||||||
|
|
||||||
if (res.affected) {
|
if (res.affected) {
|
||||||
this.wsCommunicateionGateway.server
|
this.wsCommunicateionGateway.server
|
||||||
.to(savedAsset.userId)
|
.to(savedAsset.userId)
|
||||||
.emit('on_upload_success', JSON.stringify(savedAsset));
|
.emit('on_upload_success', JSON.stringify(savedAsset));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tag Image
|
||||||
|
this.backgroundTaskService.tagImage(thumbnailPath, savedAsset);
|
||||||
});
|
});
|
||||||
|
|
||||||
return 'ok';
|
return 'ok';
|
||||||
|
|||||||