mirror of
https://github.com/immich-app/immich.git
synced 2025-12-06 21:01:24 -08:00
Compare commits
27 Commits
v0.6-dev
...
v1.4.0-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cbd5d1b0c | ||
|
|
80fd664cc8 | ||
|
|
041c711cb9 | ||
|
|
dd9c5244fd | ||
|
|
fe693db84f | ||
|
|
5c9d3cd08b | ||
|
|
725ab5622f | ||
|
|
e9acd21733 | ||
|
|
ce1ab1ed50 | ||
|
|
3e918ffd18 | ||
|
|
678ce23c16 | ||
|
|
31c18ff34c | ||
|
|
e407a4fa13 | ||
|
|
be72df70fe | ||
|
|
dbd79f4797 | ||
|
|
f790315d3f | ||
|
|
f1ab700334 | ||
|
|
afc29a67d2 | ||
|
|
ba816babee | ||
|
|
6e0ac79eae | ||
|
|
94514cfeea | ||
|
|
8c7080eaef | ||
|
|
348d395b21 | ||
|
|
2e7e97ea13 | ||
|
|
d71e7ebff1 | ||
|
|
9755936950 | ||
|
|
347052f82f |
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: alextran1502
|
||||
35
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
35
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG]"
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
2
.github/workflows/build_push_server.yml
vendored
2
.github/workflows/build_push_server.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
# https://github.com/docker/build-push-action#multi-platform-image
|
||||
- name: Build and push Immich
|
||||
uses: docker/build-push-action@v2.9.0
|
||||
uses: docker/build-push-action@v2.10.0
|
||||
with:
|
||||
context: ./server
|
||||
file: ./server/Dockerfile
|
||||
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.DS_Store
|
||||
.vscode
|
||||
.idea
|
||||
6
Makefile
6
Makefile
@@ -1,8 +1,8 @@
|
||||
dev:
|
||||
docker-compose -f ./docker/docker-compose.yml up
|
||||
docker-compose -f ./docker/docker-compose.dev.yml up --remove-orphans
|
||||
|
||||
dev-update:
|
||||
docker-compose -f ./docker/docker-compose.yml up --build -V
|
||||
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
|
||||
|
||||
dev-scale:
|
||||
docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich_server=3 --remove-orphans
|
||||
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich_server=3 --remove-orphans
|
||||
|
||||
13
PR_CHECKLIST.md
Normal file
13
PR_CHECKLIST.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Deployment checklist for iOS/Android/Server
|
||||
|
||||
[] Up version in [mobile/pubspec.yml](/mobile/pubspec.yaml)
|
||||
|
||||
[] Up version in [docker/docker-compose.yml](/docker/docker-compose.yml) for `immich_server` service
|
||||
|
||||
[] Up version in [docker/docker-compose.gpu.yml](/docker/docker-compose.gpu.yml) for `immich_server` service
|
||||
|
||||
[] Up version in [server/src/constants/server_version.constant.ts](/server/src/constants/server_version.constant.ts)
|
||||
|
||||
[] Up version in iOS Fastlane [/mobile/ios/fastlane/Fastfile](/mobile/ios/fastlane/Fastfile)
|
||||
|
||||
All of the version should be the same.
|
||||
30
README.md
30
README.md
@@ -1,5 +1,24 @@
|
||||
<p align="center">
|
||||
<img src="design/immich-logo.svg" width="150" title="hover text">
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: MIT"></a>
|
||||
<a href="https://github.com/alextran1502/immich"><img src="https://img.shields.io/github/stars/alextran1502/immich.svg?style=for-the-badge&logo=github&color=3F51B5&label=Stars&logoColor=000000&labelColor=ececec" alt="Star on Github"></a>
|
||||
<a href="https://immichci.little-home.net/viewType.html?buildTypeId=Immich_BuildAndroidAndGetArtifact&guest=1">
|
||||
<img src="https://img.shields.io/teamcity/http/immichci.little-home.net/s/Immich_BuildAndroidAndGetArtifact.svg?style=for-the-badge&label=Android&logo=teamcity&logoColor=000000&labelColor=ececec" alt="Android Build"/>
|
||||
</a>
|
||||
<a href="https://immichci.little-home.net/viewType.html?buildTypeId=Immich_BuildAndPublishIOSToTestFlight&guest=1">
|
||||
<img src="https://img.shields.io/teamcity/http/immichci.little-home.net/s/Immich_BuildAndPublishIOSToTestFlight.svg?style=for-the-badge&label=iOS&logo=teamcity&logoColor=000000&labelColor=ececec" alt="iOS Build"/>
|
||||
</a>
|
||||
<a href="https://actions-badge.atrox.dev/alextran1502/immich/goto?ref=main">
|
||||
<img alt="Build Status" src="https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Falextran1502%2Fimmich%2Fbadge%3Fref%3Dmain&style=for-the-badge&label=Server Docker&logo=docker&labelColor=ececec" />
|
||||
</a>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
<p align="center">
|
||||
<img src="design/immich-logo.svg" width="200" title="Immich Logo">
|
||||
</p>
|
||||
</p>
|
||||
|
||||
# Immich
|
||||
@@ -28,18 +47,21 @@ This project is under heavy development, there will be continous functions, feat
|
||||
|
||||
# Features
|
||||
|
||||
- Upload assets(videos/images).
|
||||
- View assets.
|
||||
- Upload and view assets(videos/images).
|
||||
- Multi-user supported.
|
||||
- Quick navigation with drag scroll bar.
|
||||
- Auto Backup.
|
||||
- Support HEIC/HEIF Backup.
|
||||
- Extract and display EXIF info.
|
||||
- Real-time render from multi-device upload event.
|
||||
- Image Tagging/Classification based on ImageNet dataset
|
||||
- Object detection based on COCO SSD.
|
||||
- Search assets based on tags and exif data (lens, make, model, orientation)
|
||||
- Upload assets from your local computer/server using [immich cli tools](https://www.npmjs.com/package/immich)
|
||||
- [Optional] Reserve geocoding using Mapbox (Generous free-tier of 100,000 search/month)
|
||||
- Show asset's location information on map (OpenStreetMap).
|
||||
- Show curated places on the search page
|
||||
- Show curated objects on the search page
|
||||
|
||||
# Development
|
||||
|
||||
@@ -49,7 +71,7 @@ You can use docker compose for development, there are several services that comp
|
||||
2. PostgreSQL
|
||||
3. Redis
|
||||
4. Nginx
|
||||
5. TensorFlow and Keras
|
||||
5. TensorFlow
|
||||
|
||||
## Populate .env file
|
||||
|
||||
|
||||
87
docker/docker-compose.dev.yml
Normal file
87
docker/docker-compose.dev.yml
Normal file
@@ -0,0 +1,87 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
immich_server:
|
||||
image: immich-server-dev:1.3.2
|
||||
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
|
||||
|
||||
immich_microservices:
|
||||
image: immich-microservices-dev:1.3.2
|
||||
build:
|
||||
context: ../microservices
|
||||
target: development
|
||||
dockerfile: ../microservices/Dockerfile
|
||||
command: npm run start:dev
|
||||
expose:
|
||||
- "3001"
|
||||
volumes:
|
||||
- ../microservices:/usr/src/app
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- /usr/src/app/node_modules
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- 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
|
||||
|
||||
networks:
|
||||
immich_network:
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -2,7 +2,7 @@ version: "3.8"
|
||||
|
||||
services:
|
||||
immich_server:
|
||||
image: immich-server-dev:1.0.0
|
||||
image: immich-server-dev:1.4.0
|
||||
build:
|
||||
context: ../server
|
||||
target: development
|
||||
@@ -22,6 +22,34 @@ services:
|
||||
networks:
|
||||
- immich_network
|
||||
|
||||
immich_microservices:
|
||||
image: immich-microservices-dev:1.4.0
|
||||
build:
|
||||
context: ../microservices
|
||||
target: development
|
||||
dockerfile: ../microservices/Dockerfile
|
||||
command: npm run start:dev
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [ gpu ]
|
||||
expose:
|
||||
- "3001"
|
||||
volumes:
|
||||
- ../microservices:/usr/src/app
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- /usr/src/app/node_modules
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- database
|
||||
- immich_server
|
||||
networks:
|
||||
- immich_network
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: redis:6.2
|
||||
@@ -60,35 +88,6 @@ services:
|
||||
depends_on:
|
||||
- immich_server
|
||||
|
||||
immich_tf_fastapi:
|
||||
container_name: immich_tf_fastapi
|
||||
image: tensor_flow_fastapi:1.0.0
|
||||
restart: always
|
||||
command: uvicorn app.main:app --proxy-headers --host 0.0.0.0 --port 8000 --reload
|
||||
build:
|
||||
context: ../machine_learning
|
||||
target: 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:
|
||||
|
||||
@@ -2,13 +2,12 @@ version: "3.8"
|
||||
|
||||
services:
|
||||
immich_server:
|
||||
image: immich-server-dev:1.0.0
|
||||
image: immich-server:1.4.0
|
||||
build:
|
||||
context: ../server
|
||||
target: development
|
||||
target: production
|
||||
dockerfile: ../server/Dockerfile
|
||||
entrypoint: ["/bin/sh", "./entrypoint.sh"]
|
||||
# command: npm run start:dev
|
||||
command: npm run start:prod
|
||||
expose:
|
||||
- "3000"
|
||||
volumes:
|
||||
@@ -23,6 +22,27 @@ services:
|
||||
networks:
|
||||
- immich_network
|
||||
|
||||
immich_microservices:
|
||||
image: immich-microservices:1.4.0
|
||||
build:
|
||||
context: ../microservices
|
||||
target: production
|
||||
dockerfile: ../microservices/Dockerfile
|
||||
command: npm run start:prod
|
||||
expose:
|
||||
- "3001"
|
||||
volumes:
|
||||
- ../microservices:/usr/src/app
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- /usr/src/app/node_modules
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- database
|
||||
networks:
|
||||
- immich_network
|
||||
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: redis:6.2
|
||||
@@ -61,26 +81,26 @@ services:
|
||||
depends_on:
|
||||
- immich_server
|
||||
|
||||
immich_tf_fastapi:
|
||||
container_name: immich_tf_fastapi
|
||||
image: tensor_flow_fastapi:1.0.0
|
||||
restart: always
|
||||
command: uvicorn app.main:app --proxy-headers --host 0.0.0.0 --port 8000 --reload
|
||||
build:
|
||||
context: ../machine_learning
|
||||
target: cpu
|
||||
dockerfile: ../machine_learning/Dockerfile
|
||||
volumes:
|
||||
- ../machine_learning/app:/code/app
|
||||
- ${UPLOAD_LOCATION}:/code/app/upload
|
||||
ports:
|
||||
- 2285:8000
|
||||
expose:
|
||||
- "8000"
|
||||
depends_on:
|
||||
- database
|
||||
networks:
|
||||
- immich_network
|
||||
# immich_tf_fastapi:
|
||||
# container_name: immich_tf_fastapi
|
||||
# image: tensor_flow_fastapi:1.0.0
|
||||
# restart: always
|
||||
# command: uvicorn app.main:app --proxy-headers --host 0.0.0.0 --port 8000 --reload
|
||||
# build:
|
||||
# context: ../machine_learning
|
||||
# target: cpu
|
||||
# dockerfile: ../machine_learning/Dockerfile
|
||||
# volumes:
|
||||
# - ../machine_learning/app:/code/app
|
||||
# - ${UPLOAD_LOCATION}:/code/app/upload
|
||||
# ports:
|
||||
# - 2285:8000
|
||||
# expose:
|
||||
# - "8000"
|
||||
# depends_on:
|
||||
# - database
|
||||
# networks:
|
||||
# - immich_network
|
||||
|
||||
networks:
|
||||
immich_network:
|
||||
|
||||
@@ -13,7 +13,7 @@ server {
|
||||
client_max_body_size 50000M;
|
||||
|
||||
listen 80;
|
||||
|
||||
access_log off;
|
||||
location / {
|
||||
proxy_buffering off;
|
||||
proxy_buffer_size 16k;
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
## CPU BUILD
|
||||
FROM python:3.8 as cpu
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-get install ffmpeg libsm6 libxext6 -y
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
COPY ./requirements.txt /code/requirements.txt
|
||||
|
||||
@@ -2,15 +2,20 @@ 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
|
||||
|
||||
from PIL import Image
|
||||
import cv2
|
||||
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)
|
||||
# img = image.load_img(img_path, target_size=(IMG_SIZE, IMG_SIZE))
|
||||
|
||||
target_image = cv2.imread(img_path, cv2.IMREAD_UNCHANGED)
|
||||
resized_target_image = cv2.resize(target_image, (IMG_SIZE, IMG_SIZE))
|
||||
|
||||
x = image.img_to_array(resized_target_image)
|
||||
x = np.expand_dims(x, axis=0)
|
||||
x = preprocess_input(x)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
opencv-python==4.5.5.64
|
||||
fastapi>=0.68.0,<0.69.0
|
||||
pydantic>=1.8.0,<2.0.0
|
||||
uvicorn>=0.15.0,<0.16.0
|
||||
|
||||
4
microservices/.dockerignore
Normal file
4
microservices/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
upload/
|
||||
dist/
|
||||
|
||||
24
microservices/.eslintrc.js
Normal file
24
microservices/.eslintrc.js
Normal file
@@ -0,0 +1,24 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
};
|
||||
35
microservices/.gitignore
vendored
Normal file
35
microservices/.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
4
microservices/.prettierrc
Normal file
4
microservices/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
43
microservices/Dockerfile
Normal file
43
microservices/Dockerfile
Normal file
@@ -0,0 +1,43 @@
|
||||
##################################
|
||||
# DEVELOPMENT
|
||||
##################################
|
||||
FROM node:16-bullseye-slim AS development
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
#################################
|
||||
# PRODUCTION
|
||||
#################################
|
||||
FROM node:16-bullseye-slim AS production
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG NODE_ENV=production
|
||||
ENV NODE_ENV=${NODE_ENV}
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y
|
||||
|
||||
RUN npm install --only=production
|
||||
|
||||
COPY . .
|
||||
|
||||
COPY --from=development /usr/src/app/dist ./dist
|
||||
|
||||
CMD ["node", "dist/main"]
|
||||
4
microservices/README.md
Normal file
4
microservices/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
# Microservices for Immich
|
||||
|
||||
## Image Classifier
|
||||
4
microservices/nest-cli.json
Normal file
4
microservices/nest-cli.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
17323
microservices/package-lock.json
generated
Normal file
17323
microservices/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
83
microservices/package.json
Normal file
83
microservices/package.json
Normal file
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"name": "nest_microservices",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"prebuild": "rimraf dist",
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^8.0.0",
|
||||
"@nestjs/core": "^8.0.0",
|
||||
"@nestjs/mapped-types": "^1.0.1",
|
||||
"@nestjs/platform-express": "^8.0.0",
|
||||
"@nestjs/typeorm": "^8.0.3",
|
||||
"@tensorflow-models/coco-ssd": "^2.2.2",
|
||||
"@tensorflow-models/mobilenet": "^2.1.0",
|
||||
"@tensorflow/tfjs": "^3.15.0",
|
||||
"@tensorflow/tfjs-converter": "^3.15.0",
|
||||
"@tensorflow/tfjs-core": "^3.15.0",
|
||||
"@tensorflow/tfjs-node": "^3.15.0",
|
||||
"@tensorflow/tfjs-node-gpu": "^3.15.0",
|
||||
"@trpc/server": "^9.20.3",
|
||||
"pg": "^8.7.3",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"rxjs": "^7.2.0",
|
||||
"typeorm": "^0.2.45"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^8.2.4",
|
||||
"@nestjs/schematics": "^8.0.0",
|
||||
"@nestjs/testing": "^8.0.0",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/jest": "27.4.1",
|
||||
"@types/node": "^16.0.0",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
"eslint": "^8.0.1",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"jest": "^27.2.5",
|
||||
"prettier": "^2.3.2",
|
||||
"source-map-support": "^0.5.20",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "^27.0.3",
|
||||
"ts-loader": "^9.2.3",
|
||||
"ts-node": "^10.0.0",
|
||||
"tsconfig-paths": "^3.10.1",
|
||||
"typescript": "^4.3.5"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
16
microservices/src/app.module.ts
Normal file
16
microservices/src/app.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ImageClassifierModule } from './image-classifier/image-classifier.module';
|
||||
import { databaseConfig } from './config/database.config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ObjectDetectionModule } from './object-detection/object-detection.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forRoot(databaseConfig),
|
||||
ImageClassifierModule,
|
||||
ObjectDetectionModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [],
|
||||
})
|
||||
export class AppModule {}
|
||||
11
microservices/src/config/database.config.ts
Normal file
11
microservices/src/config/database.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
|
||||
export const databaseConfig: TypeOrmModuleOptions = {
|
||||
type: 'postgres',
|
||||
host: 'immich_postgres',
|
||||
port: 5432,
|
||||
username: process.env.DB_USERNAME,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_DATABASE_NAME,
|
||||
synchronize: false,
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { ImageClassifierService } from './image-classifier.service';
|
||||
|
||||
@Controller('image-classifier')
|
||||
export class ImageClassifierController {
|
||||
constructor(
|
||||
private readonly imageClassifierService: ImageClassifierService,
|
||||
) {}
|
||||
|
||||
@Post('/tagImage')
|
||||
async tagImage(@Body('thumbnailPath') thumbnailPath: string) {
|
||||
return await this.imageClassifierService.tagImage(thumbnailPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ImageClassifierService } from './image-classifier.service';
|
||||
import { ImageClassifierController } from './image-classifier.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [ImageClassifierController],
|
||||
providers: [ImageClassifierService],
|
||||
})
|
||||
export class ImageClassifierModule {}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as mobilenet from '@tensorflow-models/mobilenet';
|
||||
import * as cocoSsd from '@tensorflow-models/coco-ssd';
|
||||
import * as tf from '@tensorflow/tfjs-node';
|
||||
import * as fs from 'fs';
|
||||
|
||||
@Injectable()
|
||||
export class ImageClassifierService {
|
||||
private readonly MOBILENET_VERSION = 2;
|
||||
private readonly MOBILENET_ALPHA = 1.0;
|
||||
|
||||
private mobileNetModel: mobilenet.MobileNet;
|
||||
|
||||
constructor() {
|
||||
Logger.log(
|
||||
`Running Node TensorFlow Version : ${tf.version['tfjs']}`,
|
||||
'ImageClassifier',
|
||||
);
|
||||
mobilenet
|
||||
.load({
|
||||
version: this.MOBILENET_VERSION,
|
||||
alpha: this.MOBILENET_ALPHA,
|
||||
})
|
||||
.then((mobilenetModel) => (this.mobileNetModel = mobilenetModel));
|
||||
}
|
||||
|
||||
async tagImage(thumbnailPath: string) {
|
||||
try {
|
||||
const isExist = fs.existsSync(thumbnailPath);
|
||||
if (isExist) {
|
||||
const tags = [];
|
||||
const image = fs.readFileSync(thumbnailPath);
|
||||
const decodedImage = tf.node.decodeImage(image, 3) as tf.Tensor3D;
|
||||
const predictions = await this.mobileNetModel.classify(decodedImage);
|
||||
|
||||
for (const prediction of predictions) {
|
||||
if (prediction.probability >= 0.1) {
|
||||
tags.push(...prediction.className.split(',').map((e) => e.trim()));
|
||||
}
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Error reading file ', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
microservices/src/main.ts
Normal file
10
microservices/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
await app.listen(3001);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { ObjectDetectionService } from './object-detection.service';
|
||||
|
||||
@Controller('object-detection')
|
||||
export class ObjectDetectionController {
|
||||
constructor(
|
||||
private readonly objectDetectionService: ObjectDetectionService,
|
||||
) {}
|
||||
|
||||
@Post('/detectObject')
|
||||
async detectObject(@Body('thumbnailPath') thumbnailPath: string) {
|
||||
return await this.objectDetectionService.detectObject(thumbnailPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ObjectDetectionService } from './object-detection.service';
|
||||
import { ObjectDetectionController } from './object-detection.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [ObjectDetectionController],
|
||||
providers: [ObjectDetectionService],
|
||||
})
|
||||
export class ObjectDetectionModule {}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as cocoSsd from '@tensorflow-models/coco-ssd';
|
||||
import * as tf from '@tensorflow/tfjs-node';
|
||||
import * as fs from 'fs';
|
||||
|
||||
@Injectable()
|
||||
export class ObjectDetectionService {
|
||||
private cocoSsdModel: cocoSsd.ObjectDetection;
|
||||
|
||||
constructor() {
|
||||
Logger.log(
|
||||
`Running Node TensorFlow Version : ${tf.version['tfjs']}`,
|
||||
'ObjectDetection',
|
||||
);
|
||||
cocoSsd.load().then((model) => (this.cocoSsdModel = model));
|
||||
}
|
||||
async detectObject(thumbnailPath: string) {
|
||||
try {
|
||||
const isExist = fs.existsSync(thumbnailPath);
|
||||
if (isExist) {
|
||||
const tags = new Set();
|
||||
const image = fs.readFileSync(thumbnailPath);
|
||||
const decodedImage = tf.node.decodeImage(image, 3) as tf.Tensor3D;
|
||||
const predictions = await this.cocoSsdModel.detect(decodedImage);
|
||||
|
||||
for (const result of predictions) {
|
||||
if (result.score > 0.5) {
|
||||
tags.add(result.class);
|
||||
}
|
||||
}
|
||||
|
||||
return [...tags];
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Error reading file ', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
microservices/test/app.e2e-spec.ts
Normal file
25
microservices/test/app.e2e-spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as request from 'supertest';
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
// End to End test
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
});
|
||||
});
|
||||
9
microservices/test/jest-e2e.json
Normal file
9
microservices/test/jest-e2e.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
4
microservices/tsconfig.build.json
Normal file
4
microservices/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
21
microservices/tsconfig.json
Normal file
21
microservices/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "es2017",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
PODS:
|
||||
- device_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- flutter_udid (0.0.1):
|
||||
- Flutter
|
||||
@@ -11,6 +9,8 @@ PODS:
|
||||
- FMDB (2.7.5):
|
||||
- FMDB/standard (= 2.7.5)
|
||||
- FMDB/standard (2.7.5)
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_ios (0.0.1):
|
||||
- Flutter
|
||||
- photo_manager (1.0.0):
|
||||
@@ -27,10 +27,10 @@ PODS:
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
|
||||
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
|
||||
- photo_manager (from `.symlinks/plugins/photo_manager/ios`)
|
||||
- sqflite (from `.symlinks/plugins/sqflite/ios`)
|
||||
@@ -44,14 +44,14 @@ SPEC REPOS:
|
||||
- Toast
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
device_info_plus:
|
||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_udid:
|
||||
:path: ".symlinks/plugins/flutter_udid/ios"
|
||||
fluttertoast:
|
||||
:path: ".symlinks/plugins/fluttertoast/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_ios:
|
||||
:path: ".symlinks/plugins/path_provider_ios/ios"
|
||||
photo_manager:
|
||||
@@ -64,11 +64,11 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/wakelock/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed
|
||||
Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a
|
||||
flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c
|
||||
fluttertoast: 6122fa75143e992b1d3470f61000f591a798cc58
|
||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
|
||||
path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5
|
||||
photo_manager: 84fa94fbeb82e607333ea9a13c43b58e0903a463
|
||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||
@@ -79,4 +79,4 @@ SPEC CHECKSUMS:
|
||||
|
||||
PODFILE CHECKSUM: 05c3056158482c567a3e0cdab1351ceeee238a07
|
||||
|
||||
COCOAPODS: 1.10.1
|
||||
COCOAPODS: 1.11.3
|
||||
|
||||
@@ -341,7 +341,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
NEW_SETTING = "";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -360,7 +360,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -425,7 +425,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
NEW_SETTING = "";
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
@@ -475,7 +475,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
NEW_SETTING = "";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -495,7 +495,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -522,7 +522,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<string>2</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||
@@ -60,5 +60,7 @@
|
||||
<true/>
|
||||
<key>io.flutter.embedded_views_preview</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -16,8 +16,14 @@
|
||||
default_platform(:ios)
|
||||
|
||||
platform :ios do
|
||||
desc "iOS deployment"
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.4.0"
|
||||
)
|
||||
increment_build_number({
|
||||
build_number: 0
|
||||
})
|
||||
build_app(scheme: "Runner",
|
||||
workspace: "Runner.xcworkspace",
|
||||
xcargs: "-allowProvisioningUpdates")
|
||||
@@ -25,4 +31,5 @@ platform :ios do
|
||||
skip_waiting_for_build_processing: true
|
||||
)
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -5,17 +5,27 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.001066">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000332">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: build_app" time="71.433647">
|
||||
<testcase classname="fastlane.lanes" name="1: latest_testflight_build_number" time="4.608292">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_testflight" time="104.299383">
|
||||
<testcase classname="fastlane.lanes" name="2: increment_build_number" time="0.747162">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: build_app" time="88.727281">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="4: upload_to_testflight" time="7.79397">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
|
||||
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||
import 'constants/hive_box.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
@@ -42,7 +44,10 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
|
||||
ref.watch(backupProvider.notifier).resumeBackup();
|
||||
ref.watch(websocketProvider.notifier).connect();
|
||||
ref.watch(assetProvider.notifier).getAllAsset();
|
||||
ref.watch(serverInfoProvider.notifier).getServerVersion();
|
||||
|
||||
break;
|
||||
|
||||
case AppLifecycleState.inactive:
|
||||
debugPrint("[APP STATE] inactive");
|
||||
ref.watch(appStateProvider.notifier).state = AppStateEnum.inactive;
|
||||
@@ -50,10 +55,12 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
|
||||
ref.watch(backupProvider.notifier).cancelBackup();
|
||||
|
||||
break;
|
||||
|
||||
case AppLifecycleState.paused:
|
||||
debugPrint("[APP STATE] paused");
|
||||
ref.watch(appStateProvider.notifier).state = AppStateEnum.paused;
|
||||
break;
|
||||
|
||||
case AppLifecycleState.detached:
|
||||
debugPrint("[APP STATE] detached");
|
||||
ref.watch(appStateProvider.notifier).state = AppStateEnum.detached;
|
||||
@@ -100,7 +107,7 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
|
||||
),
|
||||
),
|
||||
routeInformationParser: _immichRouter.defaultRouteParser(),
|
||||
routerDelegate: _immichRouter.delegate(),
|
||||
routerDelegate: _immichRouter.delegate(navigatorObservers: () => [TabNavigationObserver(ref: ref)]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import 'package:immich_mobile/modules/login/providers/authentication.provider.da
|
||||
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/backup_state.model.dart';
|
||||
import 'package:immich_mobile/shared/models/server_info_state.model.dart';
|
||||
import 'package:immich_mobile/shared/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
|
||||
class ImmichSliverAppBar extends ConsumerWidget {
|
||||
const ImmichSliverAppBar({
|
||||
@@ -21,6 +23,8 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final BackUpState _backupState = ref.watch(backupProvider);
|
||||
bool _isEnableAutoBackup = ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
|
||||
final ServerInfoState _serverInfoState = ref.watch(serverInfoProvider);
|
||||
|
||||
return SliverAppBar(
|
||||
centerTitle: true,
|
||||
floating: true,
|
||||
@@ -30,12 +34,46 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
|
||||
leading: Builder(
|
||||
builder: (BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.account_circle_rounded),
|
||||
onPressed: () {
|
||||
Scaffold.of(context).openDrawer();
|
||||
},
|
||||
tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip,
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: 5,
|
||||
child: IconButton(
|
||||
splashRadius: 25,
|
||||
icon: const Icon(
|
||||
Icons.account_circle_rounded,
|
||||
size: 30,
|
||||
),
|
||||
onPressed: () {
|
||||
Scaffold.of(context).openDrawer();
|
||||
},
|
||||
),
|
||||
),
|
||||
_serverInfoState.isVersionMismatch
|
||||
? Positioned(
|
||||
bottom: 12,
|
||||
right: 12,
|
||||
child: GestureDetector(
|
||||
onTap: () => Scaffold.of(context).openDrawer(),
|
||||
child: Material(
|
||||
color: Colors.grey[200],
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(50.0),
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(2.0),
|
||||
child: Icon(
|
||||
Icons.info,
|
||||
color: Color.fromARGB(255, 243, 188, 106),
|
||||
size: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,18 +1,40 @@
|
||||
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/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/server_info_state.model.dart';
|
||||
import 'package:immich_mobile/shared/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
class ProfileDrawer extends ConsumerWidget {
|
||||
class ProfileDrawer extends HookConsumerWidget {
|
||||
const ProfileDrawer({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
AuthenticationState _authState = ref.watch(authenticationProvider);
|
||||
ServerInfoState _serverInfoState = ref.watch(serverInfoProvider);
|
||||
|
||||
final appInfo = useState({});
|
||||
|
||||
_getPackageInfo() async {
|
||||
PackageInfo packageInfo = await PackageInfo.fromPlatform();
|
||||
|
||||
appInfo.value = {
|
||||
"version": packageInfo.version,
|
||||
"buildNumber": packageInfo.buildNumber,
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
_getPackageInfo();
|
||||
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
return Drawer(
|
||||
shape: const RoundedRectangleBorder(
|
||||
@@ -21,50 +43,125 @@ class ProfileDrawer extends ConsumerWidget {
|
||||
bottomRight: Radius.circular(5),
|
||||
),
|
||||
),
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
DrawerHeader(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Image(
|
||||
image: AssetImage('assets/immich-logo-no-outline.png'),
|
||||
width: 50,
|
||||
filterQuality: FilterQuality.high,
|
||||
ListView(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
DrawerHeader(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
const Padding(padding: EdgeInsets.all(8)),
|
||||
Text(
|
||||
_authState.userEmail,
|
||||
style: TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
tileColor: Colors.grey[100],
|
||||
leading: const Icon(
|
||||
Icons.logout_rounded,
|
||||
color: Colors.black54,
|
||||
),
|
||||
title: const Text(
|
||||
"Sign Out",
|
||||
style: TextStyle(color: Colors.black54, fontSize: 14),
|
||||
),
|
||||
onTap: () async {
|
||||
bool res = await ref.read(authenticationProvider.notifier).logout();
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Image(
|
||||
image: AssetImage('assets/immich-logo-no-outline.png'),
|
||||
width: 50,
|
||||
filterQuality: FilterQuality.high,
|
||||
),
|
||||
const Padding(padding: EdgeInsets.all(8)),
|
||||
Text(
|
||||
_authState.userEmail,
|
||||
style: TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
tileColor: Colors.grey[100],
|
||||
leading: const Icon(
|
||||
Icons.logout_rounded,
|
||||
color: Colors.black54,
|
||||
),
|
||||
title: const Text(
|
||||
"Sign Out",
|
||||
style: TextStyle(color: Colors.black54, fontSize: 14),
|
||||
),
|
||||
onTap: () async {
|
||||
bool res = await ref.read(authenticationProvider.notifier).logout();
|
||||
|
||||
if (res) {
|
||||
ref.watch(backupProvider.notifier).cancelBackup();
|
||||
ref.watch(assetProvider.notifier).clearAllAsset();
|
||||
ref.watch(websocketProvider.notifier).disconnect();
|
||||
AutoRouter.of(context).popUntilRoot();
|
||||
}
|
||||
},
|
||||
if (res) {
|
||||
ref.watch(backupProvider.notifier).cancelBackup();
|
||||
ref.watch(assetProvider.notifier).clearAllAsset();
|
||||
ref.watch(websocketProvider.notifier).disconnect();
|
||||
AutoRouter.of(context).popUntilRoot();
|
||||
}
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Card(
|
||||
color: Colors.grey[100],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
_serverInfoState.isVersionMismatch
|
||||
? _serverInfoState.versionMismatchErrorMessage
|
||||
: "Client and Server are up-to-date",
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
TextStyle(fontSize: 11, color: Theme.of(context).primaryColor, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"App Version",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[500],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"${appInfo.value["version"]} build.${appInfo.value["buildNumber"]}",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[500],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"Server Version",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[500],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"${_serverInfoState.serverVersion.major}.${_serverInfoState.serverVersion.minor}.${_serverInfoState.serverVersion.patch}",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[500],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/profile_drawer.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
|
||||
@@ -28,6 +29,7 @@ class HomePage extends HookConsumerWidget {
|
||||
useEffect(() {
|
||||
ref.read(websocketProvider.notifier).connect();
|
||||
ref.read(assetProvider.notifier).getAllAsset();
|
||||
ref.watch(serverInfoProvider.notifier).getServerVersion();
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final usernameController = useTextEditingController(text: 'testuser@email.com');
|
||||
final passwordController = useTextEditingController(text: 'password');
|
||||
final serverEndpointController = useTextEditingController(text: 'http://192.168.1.103:2283');
|
||||
final serverEndpointController = useTextEditingController(text: 'http://192.168.1.216:2283');
|
||||
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
@@ -78,7 +78,7 @@ class EmailInput extends StatelessWidget {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'email', border: OutlineInputBorder(), hintText: 'youremail@email.com'),
|
||||
const InputDecoration(labelText: 'Email', border: OutlineInputBorder(), hintText: 'youremail@email.com'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
79
mobile/lib/modules/search/models/curated_location.model.dart
Normal file
79
mobile/lib/modules/search/models/curated_location.model.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class CuratedLocation {
|
||||
final String id;
|
||||
final String city;
|
||||
final String resizePath;
|
||||
final String deviceAssetId;
|
||||
final String deviceId;
|
||||
|
||||
CuratedLocation({
|
||||
required this.id,
|
||||
required this.city,
|
||||
required this.resizePath,
|
||||
required this.deviceAssetId,
|
||||
required this.deviceId,
|
||||
});
|
||||
|
||||
CuratedLocation copyWith({
|
||||
String? id,
|
||||
String? city,
|
||||
String? resizePath,
|
||||
String? deviceAssetId,
|
||||
String? deviceId,
|
||||
}) {
|
||||
return CuratedLocation(
|
||||
id: id ?? this.id,
|
||||
city: city ?? this.city,
|
||||
resizePath: resizePath ?? this.resizePath,
|
||||
deviceAssetId: deviceAssetId ?? this.deviceAssetId,
|
||||
deviceId: deviceId ?? this.deviceId,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'city': city,
|
||||
'resizePath': resizePath,
|
||||
'deviceAssetId': deviceAssetId,
|
||||
'deviceId': deviceId,
|
||||
};
|
||||
}
|
||||
|
||||
factory CuratedLocation.fromMap(Map<String, dynamic> map) {
|
||||
return CuratedLocation(
|
||||
id: map['id'] ?? '',
|
||||
city: map['city'] ?? '',
|
||||
resizePath: map['resizePath'] ?? '',
|
||||
deviceAssetId: map['deviceAssetId'] ?? '',
|
||||
deviceId: map['deviceId'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory CuratedLocation.fromJson(String source) => CuratedLocation.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'CuratedLocation(id: $id, city: $city, resizePath: $resizePath, deviceAssetId: $deviceAssetId, deviceId: $deviceId)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is CuratedLocation &&
|
||||
other.id == id &&
|
||||
other.city == city &&
|
||||
other.resizePath == resizePath &&
|
||||
other.deviceAssetId == deviceAssetId &&
|
||||
other.deviceId == deviceId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^ city.hashCode ^ resizePath.hashCode ^ deviceAssetId.hashCode ^ deviceId.hashCode;
|
||||
}
|
||||
}
|
||||
80
mobile/lib/modules/search/models/curated_object.model.dart
Normal file
80
mobile/lib/modules/search/models/curated_object.model.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class CuratedObject {
|
||||
final String id;
|
||||
final String object;
|
||||
final String resizePath;
|
||||
final String deviceAssetId;
|
||||
final String deviceId;
|
||||
CuratedObject({
|
||||
required this.id,
|
||||
required this.object,
|
||||
required this.resizePath,
|
||||
required this.deviceAssetId,
|
||||
required this.deviceId,
|
||||
});
|
||||
|
||||
CuratedObject copyWith({
|
||||
String? id,
|
||||
String? object,
|
||||
String? resizePath,
|
||||
String? deviceAssetId,
|
||||
String? deviceId,
|
||||
}) {
|
||||
return CuratedObject(
|
||||
id: id ?? this.id,
|
||||
object: object ?? this.object,
|
||||
resizePath: resizePath ?? this.resizePath,
|
||||
deviceAssetId: deviceAssetId ?? this.deviceAssetId,
|
||||
deviceId: deviceId ?? this.deviceId,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
final result = <String, dynamic>{};
|
||||
|
||||
result.addAll({'id': id});
|
||||
result.addAll({'object': object});
|
||||
result.addAll({'resizePath': resizePath});
|
||||
result.addAll({'deviceAssetId': deviceAssetId});
|
||||
result.addAll({'deviceId': deviceId});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
factory CuratedObject.fromMap(Map<String, dynamic> map) {
|
||||
return CuratedObject(
|
||||
id: map['id'] ?? '',
|
||||
object: map['object'] ?? '',
|
||||
resizePath: map['resizePath'] ?? '',
|
||||
deviceAssetId: map['deviceAssetId'] ?? '',
|
||||
deviceId: map['deviceId'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory CuratedObject.fromJson(String source) => CuratedObject.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'CuratedObject(id: $id, object: $object, resizePath: $resizePath, deviceAssetId: $deviceAssetId, deviceId: $deviceId)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is CuratedObject &&
|
||||
other.id == id &&
|
||||
other.object == object &&
|
||||
other.resizePath == resizePath &&
|
||||
other.deviceAssetId == deviceAssetId &&
|
||||
other.deviceId == deviceId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^ object.hashCode ^ resizePath.hashCode ^ deviceAssetId.hashCode ^ deviceId.hashCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.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;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,28 @@
|
||||
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 {
|
||||
class SearchResultPageState {
|
||||
final bool isLoading;
|
||||
final bool isSuccess;
|
||||
final bool isError;
|
||||
final List<ImmichAsset> searchResult;
|
||||
|
||||
SearchresultPageState({
|
||||
SearchResultPageState({
|
||||
required this.isLoading,
|
||||
required this.isSuccess,
|
||||
required this.isError,
|
||||
required this.searchResult,
|
||||
});
|
||||
|
||||
SearchresultPageState copyWith({
|
||||
SearchResultPageState copyWith({
|
||||
bool? isLoading,
|
||||
bool? isSuccess,
|
||||
bool? isError,
|
||||
List<ImmichAsset>? searchResult,
|
||||
}) {
|
||||
return SearchresultPageState(
|
||||
return SearchResultPageState(
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isSuccess: isSuccess ?? this.isSuccess,
|
||||
isError: isError ?? this.isError,
|
||||
@@ -43,8 +39,8 @@ class SearchresultPageState {
|
||||
};
|
||||
}
|
||||
|
||||
factory SearchresultPageState.fromMap(Map<String, dynamic> map) {
|
||||
return SearchresultPageState(
|
||||
factory SearchResultPageState.fromMap(Map<String, dynamic> map) {
|
||||
return SearchResultPageState(
|
||||
isLoading: map['isLoading'] ?? false,
|
||||
isSuccess: map['isSuccess'] ?? false,
|
||||
isError: map['isError'] ?? false,
|
||||
@@ -54,7 +50,7 @@ class SearchresultPageState {
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory SearchresultPageState.fromJson(String source) => SearchresultPageState.fromMap(json.decode(source));
|
||||
factory SearchResultPageState.fromJson(String source) => SearchResultPageState.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
@@ -66,7 +62,7 @@ class SearchresultPageState {
|
||||
if (identical(this, other)) return true;
|
||||
final listEquals = const DeepCollectionEquality().equals;
|
||||
|
||||
return other is SearchresultPageState &&
|
||||
return other is SearchResultPageState &&
|
||||
other.isLoading == isLoading &&
|
||||
other.isSuccess == isSuccess &&
|
||||
other.isError == isError &&
|
||||
@@ -78,34 +74,3 @@ class SearchresultPageState {
|
||||
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)));
|
||||
});
|
||||
@@ -1,85 +1,10 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/search/models/curated_location.model.dart';
|
||||
import 'package:immich_mobile/modules/search/models/curated_object.model.dart';
|
||||
import 'package:immich_mobile/modules/search/models/search_page_state.model.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(
|
||||
@@ -129,3 +54,25 @@ class SearchPageStateNotifier extends StateNotifier<SearchPageState> {
|
||||
final searchPageStateProvider = StateNotifierProvider<SearchPageStateNotifier, SearchPageState>((ref) {
|
||||
return SearchPageStateNotifier();
|
||||
});
|
||||
|
||||
final getCuratedLocationProvider = FutureProvider.autoDispose<List<CuratedLocation>>((ref) async {
|
||||
final SearchService _searchService = SearchService();
|
||||
|
||||
var curatedLocation = await _searchService.getCuratedLocation();
|
||||
if (curatedLocation != null) {
|
||||
return curatedLocation;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
final getCuratedObjectProvider = FutureProvider.autoDispose<List<CuratedObject>>((ref) async {
|
||||
final SearchService _searchService = SearchService();
|
||||
|
||||
var curatedObject = await _searchService.getCuratedObjects();
|
||||
if (curatedObject != null) {
|
||||
return curatedObject;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/search/models/search_result_page_state.model.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 SearchResultPageNotifier extends StateNotifier<SearchResultPageState> {
|
||||
SearchResultPageNotifier()
|
||||
: super(SearchResultPageState(searchResult: [], isError: false, isLoading: true, isSuccess: false));
|
||||
|
||||
final SearchService _searchService = SearchService();
|
||||
|
||||
void 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 searchResultPageProvider = StateNotifierProvider<SearchResultPageNotifier, SearchResultPageState>((ref) {
|
||||
return SearchResultPageNotifier();
|
||||
});
|
||||
|
||||
final searchResultGroupByDateTimeProvider = StateProvider((ref) {
|
||||
var assets = ref.watch(searchResultPageProvider).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)));
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/modules/search/models/curated_location.model.dart';
|
||||
import 'package:immich_mobile/modules/search/models/curated_object.model.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
|
||||
import 'package:immich_mobile/shared/services/network.service.dart';
|
||||
|
||||
@@ -36,4 +38,34 @@ class SearchService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<CuratedLocation>?> getCuratedLocation() async {
|
||||
try {
|
||||
var res = await _networkService.getRequest(url: "asset/allLocation");
|
||||
|
||||
List<dynamic> decodedData = jsonDecode(res.toString());
|
||||
|
||||
List<CuratedLocation> result = List.from(decodedData.map((a) => CuratedLocation.fromMap(a)));
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
debugPrint("[ERROR] [getCuratedLocation] ${e.toString()}");
|
||||
throw Error();
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<CuratedObject>?> getCuratedObjects() async {
|
||||
try {
|
||||
var res = await _networkService.getRequest(url: "asset/allObjects");
|
||||
|
||||
List<dynamic> decodedData = jsonDecode(res.toString());
|
||||
|
||||
List<CuratedObject> result = List.from(decodedData.map((a) => CuratedObject.fromMap(a)));
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
debugPrint("[ERROR] [CuratedObject] ${e.toString()}");
|
||||
throw Error();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/search/models/curated_location.model.dart';
|
||||
import 'package:immich_mobile/modules/search/models/curated_object.model.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';
|
||||
import 'package:immich_mobile/utils/capitalize_first_letter.dart';
|
||||
|
||||
// ignore: must_be_immutable
|
||||
class SearchPage extends HookConsumerWidget {
|
||||
@@ -15,7 +21,10 @@ class SearchPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
var box = Hive.box(userInfoBox);
|
||||
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
|
||||
AsyncValue<List<CuratedLocation>> curatedLocation = ref.watch(getCuratedLocationProvider);
|
||||
AsyncValue<List<CuratedObject>> curatedObjects = ref.watch(getCuratedObjectProvider);
|
||||
|
||||
useEffect(() {
|
||||
searchFocusNode = FocusNode();
|
||||
@@ -29,6 +38,101 @@ class SearchPage extends HookConsumerWidget {
|
||||
AutoRouter.of(context).push(SearchResultRoute(searchTerm: searchTerm));
|
||||
}
|
||||
|
||||
_buildPlaces() {
|
||||
return curatedLocation.when(
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (err, stack) => Text('Error: $err'),
|
||||
data: (curatedLocations) {
|
||||
return curatedLocations.isNotEmpty
|
||||
? SizedBox(
|
||||
height: MediaQuery.of(context).size.width / 3,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: curatedLocation.value?.length,
|
||||
itemBuilder: ((context, index) {
|
||||
CuratedLocation locationInfo = curatedLocations[index];
|
||||
var thumbnailRequestUrl =
|
||||
'${box.get(serverEndpointKey)}/asset/file?aid=${locationInfo.deviceAssetId}&did=${locationInfo.deviceId}&isThumb=true';
|
||||
|
||||
return ThumbnailWithInfo(
|
||||
imageUrl: thumbnailRequestUrl,
|
||||
textInfo: locationInfo.city,
|
||||
onTap: () {
|
||||
AutoRouter.of(context).push(SearchResultRoute(searchTerm: locationInfo.city));
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
height: MediaQuery.of(context).size.width / 3,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: 1,
|
||||
itemBuilder: ((context, index) {
|
||||
return ThumbnailWithInfo(
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1612178537253-bccd437b730e?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8NXx8Ymxhbmt8ZW58MHx8MHx8&auto=format&fit=crop&w=700&q=60',
|
||||
textInfo: 'No Places Info Available',
|
||||
onTap: () {},
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_buildThings() {
|
||||
return curatedObjects.when(
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (err, stack) => Text('Error: $err'),
|
||||
data: (objects) {
|
||||
return objects.isNotEmpty
|
||||
? SizedBox(
|
||||
height: MediaQuery.of(context).size.width / 3,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: curatedObjects.value?.length,
|
||||
itemBuilder: ((context, index) {
|
||||
CuratedObject curatedObjectInfo = objects[index];
|
||||
var thumbnailRequestUrl =
|
||||
'${box.get(serverEndpointKey)}/asset/file?aid=${curatedObjectInfo.deviceAssetId}&did=${curatedObjectInfo.deviceId}&isThumb=true';
|
||||
|
||||
return ThumbnailWithInfo(
|
||||
imageUrl: thumbnailRequestUrl,
|
||||
textInfo: curatedObjectInfo.object,
|
||||
onTap: () {
|
||||
AutoRouter.of(context)
|
||||
.push(SearchResultRoute(searchTerm: curatedObjectInfo.object.capitalizeFirstLetter()));
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
height: MediaQuery.of(context).size.width / 3,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: 1,
|
||||
itemBuilder: ((context, index) {
|
||||
return ThumbnailWithInfo(
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1612178537253-bccd437b730e?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8NXx8Ymxhbmt8ZW58MHx8MHx8&auto=format&fit=crop&w=700&q=60',
|
||||
textInfo: 'No Object Info Available',
|
||||
onTap: () {},
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: SearchBar(
|
||||
searchFocusNode: searchFocusNode,
|
||||
@@ -41,11 +145,25 @@ class SearchPage extends HookConsumerWidget {
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
const Center(
|
||||
child: Text("Start typing to search for your photos"),
|
||||
),
|
||||
ListView(
|
||||
children: const [],
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
"Places",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
),
|
||||
_buildPlaces(),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
"Things",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
),
|
||||
_buildThings()
|
||||
],
|
||||
),
|
||||
isSearchEnabled ? SearchSuggestionList(onSubmitted: _onSearchSubmitted) : Container(),
|
||||
],
|
||||
@@ -54,3 +172,66 @@ class SearchPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ThumbnailWithInfo extends StatelessWidget {
|
||||
const ThumbnailWithInfo({Key? key, required this.textInfo, required this.imageUrl, required this.onTap})
|
||||
: super(key: key);
|
||||
|
||||
final String textInfo;
|
||||
final String imageUrl;
|
||||
final Function onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var box = Hive.box(userInfoBox);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
onTap();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width / 3,
|
||||
height: MediaQuery.of(context).size.width / 3,
|
||||
child: Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
Container(
|
||||
foregroundDecoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: Colors.black26,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: CachedNetworkImage(
|
||||
width: 150,
|
||||
height: 150,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: imageUrl,
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 8,
|
||||
left: 10,
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width / 3,
|
||||
child: Text(
|
||||
textInfo.capitalizeFirstLetter(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ 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/providers/search_result_page.provider.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
|
||||
|
||||
class SearchResultPage extends HookConsumerWidget {
|
||||
@@ -28,7 +28,7 @@ class SearchResultPage extends HookConsumerWidget {
|
||||
useEffect(() {
|
||||
searchFocusNode = FocusNode();
|
||||
|
||||
Future.delayed(Duration.zero, () => ref.read(searchResultPageStateProvider.notifier).search(searchTerm));
|
||||
Future.delayed(Duration.zero, () => ref.read(searchResultPageProvider.notifier).search(searchTerm));
|
||||
return () => searchFocusNode.dispose();
|
||||
}, []);
|
||||
|
||||
@@ -37,7 +37,7 @@ class SearchResultPage extends HookConsumerWidget {
|
||||
searchFocusNode.unfocus();
|
||||
isNewSearch.value = false;
|
||||
currentSearchTerm.value = newSearchTerm;
|
||||
ref.watch(searchResultPageStateProvider.notifier).search(newSearchTerm);
|
||||
ref.watch(searchResultPageProvider.notifier).search(newSearchTerm);
|
||||
}
|
||||
|
||||
_buildTextField() {
|
||||
@@ -99,7 +99,7 @@ class SearchResultPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
_buildSearchResult() {
|
||||
var searchResultPageState = ref.watch(searchResultPageStateProvider);
|
||||
var searchResultPageState = ref.watch(searchResultPageProvider);
|
||||
var assetGroupByDateTime = ref.watch(searchResultGroupByDateTimeProvider);
|
||||
|
||||
if (searchResultPageState.isError) {
|
||||
|
||||
34
mobile/lib/routing/tab_navigation_observer.dart
Normal file
34
mobile/lib/routing/tab_navigation_observer.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
|
||||
class TabNavigationObserver extends AutoRouterObserver {
|
||||
/// Riverpod Instance
|
||||
final WidgetRef ref;
|
||||
|
||||
TabNavigationObserver({
|
||||
required this.ref,
|
||||
});
|
||||
|
||||
@override
|
||||
void didInitTabRoute(TabPageRoute route, TabPageRoute? previousRoute) {
|
||||
// Perform tasks on first navigation to SearchRoute
|
||||
if (route.name == 'SearchRoute') {
|
||||
// ref.refresh(getCuratedLocationProvider);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> didChangeTabRoute(TabPageRoute route, TabPageRoute previousRoute) async {
|
||||
// Perform tasks on re-visit to SearchRoute
|
||||
if (route.name == 'SearchRoute') {
|
||||
// Refresh Location State
|
||||
ref.refresh(getCuratedLocationProvider);
|
||||
ref.refresh(getCuratedObjectProvider);
|
||||
}
|
||||
|
||||
ref.watch(serverInfoProvider.notifier).getServerVersion();
|
||||
}
|
||||
}
|
||||
78
mobile/lib/shared/models/server_info_state.model.dart
Normal file
78
mobile/lib/shared/models/server_info_state.model.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:immich_mobile/shared/models/mapbox_info.model.dart';
|
||||
import 'package:immich_mobile/shared/models/server_version.model.dart';
|
||||
|
||||
class ServerInfoState {
|
||||
final MapboxInfo mapboxInfo;
|
||||
final ServerVersion serverVersion;
|
||||
final bool isVersionMismatch;
|
||||
final String versionMismatchErrorMessage;
|
||||
|
||||
ServerInfoState({
|
||||
required this.mapboxInfo,
|
||||
required this.serverVersion,
|
||||
required this.isVersionMismatch,
|
||||
required this.versionMismatchErrorMessage,
|
||||
});
|
||||
|
||||
ServerInfoState copyWith({
|
||||
MapboxInfo? mapboxInfo,
|
||||
ServerVersion? serverVersion,
|
||||
bool? isVersionMismatch,
|
||||
String? versionMismatchErrorMessage,
|
||||
}) {
|
||||
return ServerInfoState(
|
||||
mapboxInfo: mapboxInfo ?? this.mapboxInfo,
|
||||
serverVersion: serverVersion ?? this.serverVersion,
|
||||
isVersionMismatch: isVersionMismatch ?? this.isVersionMismatch,
|
||||
versionMismatchErrorMessage: versionMismatchErrorMessage ?? this.versionMismatchErrorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'mapboxInfo': mapboxInfo.toMap(),
|
||||
'serverVersion': serverVersion.toMap(),
|
||||
'isVersionMismatch': isVersionMismatch,
|
||||
'versionMismatchErrorMessage': versionMismatchErrorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
factory ServerInfoState.fromMap(Map<String, dynamic> map) {
|
||||
return ServerInfoState(
|
||||
mapboxInfo: MapboxInfo.fromMap(map['mapboxInfo']),
|
||||
serverVersion: ServerVersion.fromMap(map['serverVersion']),
|
||||
isVersionMismatch: map['isVersionMismatch'] ?? false,
|
||||
versionMismatchErrorMessage: map['versionMismatchErrorMessage'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory ServerInfoState.fromJson(String source) => ServerInfoState.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ServerInfoState(mapboxInfo: $mapboxInfo, serverVersion: $serverVersion, isVersionMismatch: $isVersionMismatch, versionMismatchErrorMessage: $versionMismatchErrorMessage)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is ServerInfoState &&
|
||||
other.mapboxInfo == mapboxInfo &&
|
||||
other.serverVersion == serverVersion &&
|
||||
other.isVersionMismatch == isVersionMismatch &&
|
||||
other.versionMismatchErrorMessage == versionMismatchErrorMessage;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return mapboxInfo.hashCode ^
|
||||
serverVersion.hashCode ^
|
||||
isVersionMismatch.hashCode ^
|
||||
versionMismatchErrorMessage.hashCode;
|
||||
}
|
||||
}
|
||||
72
mobile/lib/shared/models/server_version.model.dart
Normal file
72
mobile/lib/shared/models/server_version.model.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class ServerVersion {
|
||||
final int major;
|
||||
final int minor;
|
||||
final int patch;
|
||||
final int build;
|
||||
|
||||
ServerVersion({
|
||||
required this.major,
|
||||
required this.minor,
|
||||
required this.patch,
|
||||
required this.build,
|
||||
});
|
||||
|
||||
ServerVersion copyWith({
|
||||
int? major,
|
||||
int? minor,
|
||||
int? patch,
|
||||
int? build,
|
||||
}) {
|
||||
return ServerVersion(
|
||||
major: major ?? this.major,
|
||||
minor: minor ?? this.minor,
|
||||
patch: patch ?? this.patch,
|
||||
build: build ?? this.build,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'major': major,
|
||||
'minor': minor,
|
||||
'patch': patch,
|
||||
'build': build,
|
||||
};
|
||||
}
|
||||
|
||||
factory ServerVersion.fromMap(Map<String, dynamic> map) {
|
||||
return ServerVersion(
|
||||
major: map['major']?.toInt() ?? 0,
|
||||
minor: map['minor']?.toInt() ?? 0,
|
||||
patch: map['patch']?.toInt() ?? 0,
|
||||
build: map['build']?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory ServerVersion.fromJson(String source) => ServerVersion.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ServerVersion(major: $major, minor: $minor, patch: $patch, build: $build)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is ServerVersion &&
|
||||
other.major == major &&
|
||||
other.minor == minor &&
|
||||
other.patch == patch &&
|
||||
other.build == build;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return major.hashCode ^ minor.hashCode ^ patch.hashCode ^ build.hashCode;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +1,19 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'package:immich_mobile/shared/models/mapbox_info.model.dart';
|
||||
import 'package:immich_mobile/shared/models/server_info_state.model.dart';
|
||||
import 'package:immich_mobile/shared/models/server_version.model.dart';
|
||||
import 'package:immich_mobile/shared/services/server_info.service.dart';
|
||||
|
||||
class ServerInfoState {
|
||||
final MapboxInfo mapboxInfo;
|
||||
ServerInfoState({
|
||||
required this.mapboxInfo,
|
||||
});
|
||||
|
||||
ServerInfoState copyWith({
|
||||
MapboxInfo? mapboxInfo,
|
||||
}) {
|
||||
return ServerInfoState(
|
||||
mapboxInfo: mapboxInfo ?? this.mapboxInfo,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'mapboxInfo': mapboxInfo.toMap(),
|
||||
};
|
||||
}
|
||||
|
||||
factory ServerInfoState.fromMap(Map<String, dynamic> map) {
|
||||
return ServerInfoState(
|
||||
mapboxInfo: MapboxInfo.fromMap(map['mapboxInfo']),
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory ServerInfoState.fromJson(String source) => ServerInfoState.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() => 'ServerInfoState(mapboxInfo: $mapboxInfo)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is ServerInfoState && other.mapboxInfo == mapboxInfo;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => mapboxInfo.hashCode;
|
||||
}
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
class ServerInfoNotifier extends StateNotifier<ServerInfoState> {
|
||||
ServerInfoNotifier()
|
||||
: super(
|
||||
ServerInfoState(
|
||||
mapboxInfo: MapboxInfo(isEnable: false, mapboxSecret: ""),
|
||||
serverVersion: ServerVersion(major: 0, patch: 0, minor: 0, build: 0),
|
||||
isVersionMismatch: false,
|
||||
versionMismatchErrorMessage: "",
|
||||
),
|
||||
);
|
||||
|
||||
@@ -61,9 +21,63 @@ class ServerInfoNotifier extends StateNotifier<ServerInfoState> {
|
||||
|
||||
getMapboxInfo() async {
|
||||
MapboxInfo mapboxInfoRes = await _serverInfoService.getMapboxInfo();
|
||||
print(mapboxInfoRes);
|
||||
state = state.copyWith(mapboxInfo: mapboxInfoRes);
|
||||
}
|
||||
|
||||
getServerVersion() async {
|
||||
ServerVersion? serverVersion = await _serverInfoService.getServerVersion();
|
||||
|
||||
if (serverVersion == null) {
|
||||
state = state.copyWith(
|
||||
isVersionMismatch: true,
|
||||
versionMismatchErrorMessage:
|
||||
"Server is out of date. Some functionalities might not working correctly. Download and rebuild server",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(serverVersion: serverVersion);
|
||||
|
||||
PackageInfo packageInfo = await PackageInfo.fromPlatform();
|
||||
|
||||
Map<String, int> appVersion = _getDetailVersion(packageInfo.version);
|
||||
|
||||
if (appVersion["major"]! > serverVersion.major) {
|
||||
state = state.copyWith(
|
||||
isVersionMismatch: true,
|
||||
versionMismatchErrorMessage:
|
||||
"Server is out of date in major version. Some functionalities might not work correctly. Download and rebuild server",
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (appVersion["minor"]! > serverVersion.minor) {
|
||||
state = state.copyWith(
|
||||
isVersionMismatch: true,
|
||||
versionMismatchErrorMessage:
|
||||
"Server is out of date in minor version. Some functionalities might not work correctly. Consider download and rebuild server",
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(isVersionMismatch: false, versionMismatchErrorMessage: "");
|
||||
}
|
||||
|
||||
Map<String, int> _getDetailVersion(String version) {
|
||||
List<String> detail = version.split(".");
|
||||
|
||||
var major = detail[0];
|
||||
var minor = detail[1];
|
||||
var patch = detail[2];
|
||||
|
||||
return {
|
||||
"major": int.parse(major),
|
||||
"minor": int.parse(minor),
|
||||
"patch": int.parse(patch),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
final serverInfoProvider = StateNotifierProvider<ServerInfoNotifier, ServerInfoState>((ref) {
|
||||
|
||||
@@ -30,10 +30,14 @@ class BackupService {
|
||||
Function(int, int) uploadProgress) async {
|
||||
var dio = Dio();
|
||||
dio.interceptors.add(AuthenticatedRequestInterceptor());
|
||||
|
||||
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
||||
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||
File? file;
|
||||
|
||||
MultipartFile assetRawUploadData;
|
||||
MultipartFile thumbnailUploadData;
|
||||
|
||||
for (var entity in assetList) {
|
||||
try {
|
||||
if (entity.type == AssetType.video) {
|
||||
@@ -43,12 +47,20 @@ class BackupService {
|
||||
}
|
||||
|
||||
if (file != null) {
|
||||
FormData formData;
|
||||
String originalFileName = await entity.titleAsync;
|
||||
String fileNameWithoutPath = originalFileName.toString().split(".")[0];
|
||||
var fileExtension = p.extension(file.path);
|
||||
var mimeType = FileHelper.getMimeType(file.path);
|
||||
|
||||
var formData = FormData.fromMap({
|
||||
assetRawUploadData = await MultipartFile.fromFile(
|
||||
file.path,
|
||||
filename: fileNameWithoutPath,
|
||||
contentType: MediaType(
|
||||
mimeType["type"],
|
||||
mimeType["subType"],
|
||||
),
|
||||
);
|
||||
formData = FormData.fromMap({
|
||||
'deviceAssetId': entity.id,
|
||||
'deviceId': deviceId,
|
||||
'assetType': _getAssetType(entity.type),
|
||||
@@ -57,18 +69,36 @@ class BackupService {
|
||||
'isFavorite': entity.isFavorite,
|
||||
'fileExtension': fileExtension,
|
||||
'duration': entity.videoDuration,
|
||||
'files': [
|
||||
await MultipartFile.fromFile(
|
||||
file.path,
|
||||
filename: fileNameWithoutPath,
|
||||
contentType: MediaType(
|
||||
mimeType["type"],
|
||||
mimeType["subType"],
|
||||
),
|
||||
),
|
||||
]
|
||||
'assetData': [assetRawUploadData]
|
||||
});
|
||||
|
||||
// Build thumbnail multipart data
|
||||
var thumbnailData = await entity.thumbDataWithSize(1280, 720);
|
||||
if (thumbnailData != null) {
|
||||
thumbnailUploadData = MultipartFile.fromBytes(
|
||||
List.from(thumbnailData),
|
||||
filename: fileNameWithoutPath,
|
||||
contentType: MediaType(
|
||||
"image",
|
||||
"jpeg",
|
||||
),
|
||||
);
|
||||
|
||||
// Send thumbnail data if it is exist
|
||||
formData = FormData.fromMap({
|
||||
'deviceAssetId': entity.id,
|
||||
'deviceId': deviceId,
|
||||
'assetType': _getAssetType(entity.type),
|
||||
'createdAt': entity.createDateTime.toIso8601String(),
|
||||
'modifiedAt': entity.modifiedDateTime.toIso8601String(),
|
||||
'isFavorite': entity.isFavorite,
|
||||
'fileExtension': fileExtension,
|
||||
'duration': entity.videoDuration,
|
||||
'thumbnailData': [thumbnailUploadData],
|
||||
'assetData': [assetRawUploadData]
|
||||
});
|
||||
}
|
||||
|
||||
Response res = await dio.post(
|
||||
'$savedEndpoint/asset/upload',
|
||||
data: formData,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:immich_mobile/shared/models/mapbox_info.model.dart';
|
||||
import 'package:immich_mobile/shared/models/server_version.model.dart';
|
||||
import 'package:immich_mobile/shared/services/network.service.dart';
|
||||
import 'package:immich_mobile/shared/models/server_info.model.dart';
|
||||
|
||||
@@ -17,4 +18,10 @@ class ServerInfoService {
|
||||
|
||||
return MapboxInfo.fromJson(response.toString());
|
||||
}
|
||||
|
||||
Future<ServerVersion?> getServerVersion() async {
|
||||
Response response = await _networkService.getRequest(url: 'server-info/version');
|
||||
|
||||
return ServerVersion.fromJson(response.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
),
|
||||
BackupInfoCard(
|
||||
title: "Total",
|
||||
subtitle: "All images and video on the device",
|
||||
subtitle: "All images and videos on the device",
|
||||
info: "${_backupState.totalAssetCount}",
|
||||
),
|
||||
BackupInfoCard(
|
||||
|
||||
5
mobile/lib/utils/capitalize_first_letter.dart
Normal file
5
mobile/lib/utils/capitalize_first_letter.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
extension StringExtension on String {
|
||||
String capitalizeFirstLetter() {
|
||||
return "${this[0].toUpperCase()}${substring(1).toLowerCase()}";
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,9 @@ class FileHelper {
|
||||
case 'heif':
|
||||
return {"type": "image", "subType": "heif"};
|
||||
|
||||
case 'dng':
|
||||
return {"type": "image", "subType": "dng"};
|
||||
|
||||
default:
|
||||
return {"type": "unsupport", "subType": "unsupport"};
|
||||
}
|
||||
|
||||
@@ -555,6 +555,48 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
package_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
package_info_plus_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_linux
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
package_info_plus_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
package_info_plus_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
package_info_plus_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: A new Flutter project.
|
||||
|
||||
publish_to: "none"
|
||||
version: 1.2.0+2
|
||||
version: 1.4.0+0
|
||||
|
||||
environment:
|
||||
sdk: ">=2.15.1 <3.0.0"
|
||||
@@ -36,7 +36,8 @@ dependencies:
|
||||
# mapbox_gl: ^0.15.0
|
||||
flutter_map: ^0.14.0
|
||||
flutter_udid: ^2.0.0
|
||||
|
||||
package_info_plus: ^1.4.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
// This is a basic Flutter widget test.
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility that Flutter provides. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:immich_mobile/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const ImmichApp());
|
||||
|
||||
// Verify that our counter starts at 0.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('1'), findsNothing);
|
||||
|
||||
// Tap the '+' icon and trigger a frame.
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pump();
|
||||
|
||||
// Verify that our counter has incremented.
|
||||
expect(find.text('0'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -9,7 +9,7 @@ WORKDIR /usr/src/app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg
|
||||
RUN apk add --update-cache build-base python3
|
||||
|
||||
RUN npm install
|
||||
|
||||
@@ -30,7 +30,7 @@ WORKDIR /usr/src/app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg
|
||||
RUN apk add --update-cache build-base python3
|
||||
|
||||
RUN npm install --only=production
|
||||
|
||||
|
||||
1225
server/package-lock.json
generated
1225
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "0.0.1",
|
||||
"version": "1.3.2",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -41,9 +41,9 @@
|
||||
"bull": "^4.4.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.13.2",
|
||||
"diskusage": "^1.1.3",
|
||||
"dotenv": "^14.2.0",
|
||||
"exifr": "^7.1.3",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"joi": "^17.5.0",
|
||||
"lodash": "^4.17.21",
|
||||
"passport": "^0.5.2",
|
||||
@@ -53,26 +53,23 @@
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"rxjs": "^7.2.0",
|
||||
"sharp": "0.28",
|
||||
"socket.io-redis": "^6.1.1",
|
||||
"systeminformation": "^5.11.0",
|
||||
"typeorm": "^0.2.41"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^8.0.0",
|
||||
"@nestjs/cli": "^8.2.4",
|
||||
"@nestjs/schematics": "^8.0.0",
|
||||
"@nestjs/testing": "^8.0.0",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/bull": "^3.15.7",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/fluent-ffmpeg": "^2.1.20",
|
||||
"@types/imagemin": "^8.0.0",
|
||||
"@types/jest": "27.0.2",
|
||||
"@types/lodash": "^4.14.178",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^16.0.0",
|
||||
"@types/passport-jwt": "^3.0.6",
|
||||
"@types/sharp": "^0.29.5",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
|
||||
@@ -13,51 +13,65 @@ import {
|
||||
Response,
|
||||
Headers,
|
||||
Delete,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
||||
import { AssetService } from './asset.service';
|
||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
||||
import { multerOption } from '../../config/multer-option.config';
|
||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||
import { ServeFileDto } from './dto/serve-file.dto';
|
||||
import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service';
|
||||
import { AssetEntity, AssetType } from './entities/asset.entity';
|
||||
import { AssetEntity } from './entities/asset.entity';
|
||||
import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto';
|
||||
import { Response as Res } from 'express';
|
||||
import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto';
|
||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
||||
import { SearchAssetDto } from './dto/search-asset.dto';
|
||||
import { CommunicationGateway } from '../communication/communication.gateway';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('asset')
|
||||
export class AssetController {
|
||||
constructor(
|
||||
private wsCommunicateionGateway: CommunicationGateway,
|
||||
private assetService: AssetService,
|
||||
private assetOptimizeService: AssetOptimizeService,
|
||||
private backgroundTaskService: BackgroundTaskService,
|
||||
) {}
|
||||
|
||||
@Post('upload')
|
||||
@UseInterceptors(FilesInterceptor('files', 30, multerOption))
|
||||
@UseInterceptors(
|
||||
FileFieldsInterceptor(
|
||||
[
|
||||
{ name: 'assetData', maxCount: 1 },
|
||||
{ name: 'thumbnailData', maxCount: 1 },
|
||||
],
|
||||
multerOption,
|
||||
),
|
||||
)
|
||||
async uploadFile(
|
||||
@GetAuthUser() authUser,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@UploadedFiles() uploadFiles: { assetData: Express.Multer.File[]; thumbnailData?: Express.Multer.File[] },
|
||||
@Body(ValidationPipe) assetInfo: CreateAssetDto,
|
||||
) {
|
||||
files.forEach(async (file) => {
|
||||
const savedAsset = await this.assetService.createUserAsset(authUser, assetInfo, file.path, file.mimetype);
|
||||
for (const file of uploadFiles.assetData) {
|
||||
try {
|
||||
const savedAsset = await this.assetService.createUserAsset(authUser, assetInfo, file.path, file.mimetype);
|
||||
|
||||
if (uploadFiles.thumbnailData != null && savedAsset) {
|
||||
await this.assetService.updateThumbnailInfo(savedAsset.id, uploadFiles.thumbnailData[0].path);
|
||||
await this.backgroundTaskService.tagImage(uploadFiles.thumbnailData[0].path, savedAsset);
|
||||
await this.backgroundTaskService.detectObject(uploadFiles.thumbnailData[0].path, savedAsset);
|
||||
}
|
||||
|
||||
if (savedAsset && savedAsset.type == AssetType.IMAGE) {
|
||||
await this.assetOptimizeService.resizeImage(savedAsset);
|
||||
await this.backgroundTaskService.extractExif(savedAsset, file.originalname, file.size);
|
||||
}
|
||||
|
||||
if (savedAsset && savedAsset.type == AssetType.VIDEO) {
|
||||
await this.assetOptimizeService.getVideoThumbnail(savedAsset, file.originalname);
|
||||
this.wsCommunicateionGateway.server.to(savedAsset.userId).emit('on_upload_success', JSON.stringify(savedAsset));
|
||||
} catch (e) {
|
||||
Logger.error(`Error receiving upload file ${e}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
@@ -72,6 +86,16 @@ export class AssetController {
|
||||
return this.assetService.serveFile(authUser, query, res, headers);
|
||||
}
|
||||
|
||||
@Get('/allObjects')
|
||||
async getCuratedObject(@GetAuthUser() authUser: AuthUserDto) {
|
||||
return this.assetService.getCuratedObject(authUser);
|
||||
}
|
||||
|
||||
@Get('/allLocation')
|
||||
async getCuratedLocation(@GetAuthUser() authUser: AuthUserDto) {
|
||||
return this.assetService.getCuratedLocation(authUser);
|
||||
}
|
||||
|
||||
@Get('/searchTerm')
|
||||
async getAssetSearchTerm(@GetAuthUser() authUser: AuthUserDto) {
|
||||
return this.assetService.getAssetSearchTerm(authUser);
|
||||
@@ -111,10 +135,10 @@ export class AssetController {
|
||||
async deleteAssetById(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) assetIds: DeleteAssetDto) {
|
||||
const deleteAssetList: AssetEntity[] = [];
|
||||
|
||||
assetIds.ids.forEach(async (id) => {
|
||||
for (const id of assetIds.ids) {
|
||||
const assets = await this.assetService.getAssetById(authUser, id);
|
||||
deleteAssetList.push(assets);
|
||||
});
|
||||
}
|
||||
|
||||
const result = await this.assetService.deleteAssetById(authUser, assetIds);
|
||||
|
||||
|
||||
@@ -8,9 +8,12 @@ import { AssetOptimizeService } from '../../modules/image-optimize/image-optimiz
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { BackgroundTaskModule } from '../../modules/background-task/background-task.module';
|
||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||
import { CommunicationModule } from '../communication/communication.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
CommunicationModule,
|
||||
|
||||
BullModule.registerQueue({
|
||||
name: 'optimize',
|
||||
defaultJobOptions: {
|
||||
|
||||
@@ -3,9 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { MoreThan, Repository } from 'typeorm';
|
||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||
import { UpdateAssetDto } from './dto/update-asset.dto';
|
||||
import { AssetEntity, AssetType } from './entities/asset.entity';
|
||||
import _, { result } from 'lodash';
|
||||
import _ from 'lodash';
|
||||
import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto';
|
||||
import { GetAllAssetReponseDto } from './dto/get-all-asset-response.dto';
|
||||
import { createReadStream, stat } from 'fs';
|
||||
@@ -24,6 +23,12 @@ export class AssetService {
|
||||
private assetRepository: Repository<AssetEntity>,
|
||||
) {}
|
||||
|
||||
public async updateThumbnailInfo(assetId: string, path: string) {
|
||||
return await this.assetRepository.update(assetId, {
|
||||
resizePath: path,
|
||||
});
|
||||
}
|
||||
|
||||
public async createUserAsset(authUser: AuthUserDto, assetInfo: CreateAssetDto, path: string, mimeType: string) {
|
||||
const asset = new AssetEntity();
|
||||
asset.deviceAssetId = assetInfo.deviceAssetId;
|
||||
@@ -38,9 +43,7 @@ export class AssetService {
|
||||
asset.duration = assetInfo.duration;
|
||||
|
||||
try {
|
||||
const res = await this.assetRepository.save(asset);
|
||||
|
||||
return res;
|
||||
return await this.assetRepository.save(asset);
|
||||
} catch (e) {
|
||||
Logger.error(`Error Create New Asset ${e}`, 'createUserAsset');
|
||||
}
|
||||
@@ -62,13 +65,11 @@ export class AssetService {
|
||||
|
||||
public async getAllAssetsNoPagination(authUser: AuthUserDto) {
|
||||
try {
|
||||
const assets = await this.assetRepository
|
||||
return await this.assetRepository
|
||||
.createQueryBuilder('a')
|
||||
.where('a."userId" = :userId', { userId: authUser.id })
|
||||
.orderBy('a."createdAt"::date', 'DESC')
|
||||
.getMany();
|
||||
|
||||
return assets;
|
||||
} catch (e) {
|
||||
Logger.error(e, 'getAllAssets');
|
||||
}
|
||||
@@ -220,10 +221,10 @@ export class AssetService {
|
||||
}
|
||||
|
||||
public async deleteAssetById(authUser: AuthUserDto, assetIds: DeleteAssetDto) {
|
||||
let result = [];
|
||||
const result = [];
|
||||
|
||||
const target = assetIds.ids;
|
||||
for (let assetId of target) {
|
||||
for (const assetId of target) {
|
||||
const res = await this.assetRepository.delete({
|
||||
id: assetId,
|
||||
userId: authUser.id,
|
||||
@@ -245,11 +246,11 @@ export class AssetService {
|
||||
return result;
|
||||
}
|
||||
|
||||
async getAssetSearchTerm(authUser: AuthUserDto): Promise<String[]> {
|
||||
const possibleSearchTerm = new Set<String>();
|
||||
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, e.city, e.state, e.country
|
||||
select distinct si.tags, si.objects, e.orientation, e."lensModel", e.make, e.model , a.type, e.city, e.state, e.country
|
||||
from assets a
|
||||
left join exif e on a.id = e."assetId"
|
||||
left join smart_info si on a.id = si."assetId"
|
||||
@@ -262,6 +263,9 @@ export class AssetService {
|
||||
// tags
|
||||
row['tags']?.map((tag) => possibleSearchTerm.add(tag?.toLowerCase()));
|
||||
|
||||
// objects
|
||||
row['objects']?.map((object) => possibleSearchTerm.add(object?.toLowerCase()));
|
||||
|
||||
// asset's tyoe
|
||||
possibleSearchTerm.add(row['type']?.toLowerCase());
|
||||
|
||||
@@ -294,13 +298,39 @@ export class AssetService {
|
||||
WHERE a."userId" = $1
|
||||
AND
|
||||
(
|
||||
TO_TSVECTOR('english', ARRAY_TO_STRING(si.tags, ',')) @@ PLAINTO_TSQUERY('english', $2) OR
|
||||
TO_TSVECTOR('english', ARRAY_TO_STRING(si.tags, ',')) @@ PLAINTO_TSQUERY('english', $2) OR
|
||||
TO_TSVECTOR('english', ARRAY_TO_STRING(si.objects, ',')) @@ PLAINTO_TSQUERY('english', $2) OR
|
||||
e.exif_text_searchable_column @@ PLAINTO_TSQUERY('english', $2)
|
||||
);
|
||||
`;
|
||||
|
||||
const rows = await this.assetRepository.query(query, [authUser.id, searchAssetDto.searchTerm]);
|
||||
return await this.assetRepository.query(query, [authUser.id, searchAssetDto.searchTerm]);
|
||||
}
|
||||
|
||||
return rows;
|
||||
async getCuratedLocation(authUser: AuthUserDto) {
|
||||
return await this.assetRepository.query(
|
||||
`
|
||||
select distinct on (e.city) a.id, e.city, a."resizePath", a."deviceAssetId", a."deviceId"
|
||||
from assets a
|
||||
left join exif e on a.id = e."assetId"
|
||||
where a."userId" = $1
|
||||
and e.city is not null
|
||||
and a.type = 'IMAGE';
|
||||
`,
|
||||
[authUser.id],
|
||||
);
|
||||
}
|
||||
|
||||
async getCuratedObject(authUser: AuthUserDto) {
|
||||
return await this.assetRepository.query(
|
||||
`
|
||||
select distinct on (unnest(si.objects)) a.id, unnest(si.objects) as "object", a."resizePath", a."deviceAssetId", a."deviceId"
|
||||
from assets a
|
||||
left join smart_info si on a.id = si."assetId"
|
||||
where a."userId" = $1
|
||||
and si.objects is not null
|
||||
`,
|
||||
[authUser.id],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ export class SmartInfoEntity {
|
||||
@Column({ type: 'text', array: true, nullable: true })
|
||||
tags: string[];
|
||||
|
||||
@Column({ type: 'text', array: true, nullable: true })
|
||||
objects: string[];
|
||||
|
||||
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
|
||||
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
|
||||
asset: SmartInfoEntity;
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
||||
import { ServerInfoService } from './server-info.service';
|
||||
import mapboxGeocoding, { GeocodeService } from '@mapbox/mapbox-sdk/services/geocoding';
|
||||
import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response';
|
||||
import { serverVersion } from '../../constants/server_version.constant';
|
||||
|
||||
@Controller('server-info')
|
||||
export class ServerInfoController {
|
||||
@@ -30,4 +28,9 @@ export class ServerInfoController {
|
||||
mapboxSecret: this.configService.get('MAPBOX_KEY'),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('/version')
|
||||
async getServerVersion() {
|
||||
return serverVersion;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,27 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import systemInformation from 'systeminformation';
|
||||
import { ServerInfoDto } from './dto/server-info.dto';
|
||||
import diskusage from 'diskusage';
|
||||
|
||||
@Injectable()
|
||||
export class ServerInfoService {
|
||||
constructor() {}
|
||||
async getServerInfo() {
|
||||
const res = await systemInformation.fsSize();
|
||||
const diskInfo = await diskusage.check('./upload');
|
||||
|
||||
const size = res[0].size;
|
||||
const used = res[0].used;
|
||||
const available = res[0].available;
|
||||
const percentageUsage = res[0].use;
|
||||
const usagePercentage = (((diskInfo.total - diskInfo.free) / diskInfo.total) * 100).toFixed(2);
|
||||
|
||||
const serverInfo = new ServerInfoDto();
|
||||
serverInfo.diskAvailable = this.getHumanReadableString(available);
|
||||
serverInfo.diskSize = this.getHumanReadableString(size);
|
||||
serverInfo.diskUse = this.getHumanReadableString(used);
|
||||
serverInfo.diskAvailableRaw = available;
|
||||
serverInfo.diskSizeRaw = size;
|
||||
serverInfo.diskUseRaw = used;
|
||||
serverInfo.diskUsagePercentage = percentageUsage;
|
||||
serverInfo.diskAvailable = ServerInfoService.getHumanReadableString(diskInfo.available);
|
||||
serverInfo.diskSize = ServerInfoService.getHumanReadableString(diskInfo.total);
|
||||
serverInfo.diskUse = ServerInfoService.getHumanReadableString(diskInfo.total - diskInfo.free);
|
||||
serverInfo.diskAvailableRaw = diskInfo.available;
|
||||
serverInfo.diskSizeRaw = diskInfo.total;
|
||||
serverInfo.diskUseRaw = diskInfo.total - diskInfo.free;
|
||||
serverInfo.diskUsagePercentage = parseFloat(usagePercentage);
|
||||
|
||||
return serverInfo;
|
||||
}
|
||||
|
||||
private getHumanReadableString(sizeInByte: number) {
|
||||
private static getHumanReadableString(sizeInByte: number) {
|
||||
const pepibyte = 1.126 * Math.pow(10, 15);
|
||||
const tebibyte = 1.1 * Math.pow(10, 12);
|
||||
const gibibyte = 1.074 * Math.pow(10, 9);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { UserModule } from './api-v1/user/user.module';
|
||||
import { AssetModule } from './api-v1/asset/asset.module';
|
||||
import { AuthModule } from './api-v1/auth/auth.module';
|
||||
import { ImmichJwtModule } from './modules/immich-jwt/immich-jwt.module';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { DeviceInfoModule } from './api-v1/device-info/device-info.module';
|
||||
import { AppLoggerMiddleware } from './middlewares/app-logger.middleware';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
@@ -26,14 +25,12 @@ import { CommunicationModule } from './api-v1/communication/communication.module
|
||||
ImmichJwtModule,
|
||||
DeviceInfoModule,
|
||||
BullModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
useFactory: async () => ({
|
||||
redis: {
|
||||
host: 'immich_redis',
|
||||
port: 6379,
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
|
||||
ImageOptimizeModule,
|
||||
|
||||
@@ -13,9 +13,9 @@ export const immichAppConfig: ConfigModuleOptions = {
|
||||
JWT_SECRET: Joi.string().required(),
|
||||
ENABLE_MAPBOX: Joi.boolean().required().valid(true, false),
|
||||
MAPBOX_KEY: Joi.any().when('ENABLE_MAPBOX', {
|
||||
is: true,
|
||||
then: Joi.string().required(),
|
||||
otherwise: Joi.string().optional,
|
||||
is: false,
|
||||
then: Joi.string().optional().allow(null, ''),
|
||||
otherwise: Joi.string().required(),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ export const multerConfig = {
|
||||
|
||||
export const multerOption: MulterOptions = {
|
||||
fileFilter: (req: Request, file: any, cb: any) => {
|
||||
if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif)$/)) {
|
||||
if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif|dng)$/)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false);
|
||||
@@ -23,17 +23,33 @@ export const multerOption: MulterOptions = {
|
||||
destination: (req: Request, file: Express.Multer.File, cb: any) => {
|
||||
const uploadPath = multerConfig.dest;
|
||||
|
||||
const userPath = `${uploadPath}/${req.user['id']}/original/${req.body['deviceId']}`;
|
||||
if (file.fieldname == 'assetData') {
|
||||
const originalUploadFolder = `${uploadPath}/${req.user['id']}/original/${req.body['deviceId']}`;
|
||||
|
||||
if (!existsSync(userPath)) {
|
||||
mkdirSync(userPath, { recursive: true });
|
||||
if (!existsSync(originalUploadFolder)) {
|
||||
mkdirSync(originalUploadFolder, { recursive: true });
|
||||
}
|
||||
|
||||
cb(null, originalUploadFolder);
|
||||
} else if (file.fieldname == 'thumbnailData') {
|
||||
const thumbnailUploadFolder = `${uploadPath}/${req.user['id']}/thumb/${req.body['deviceId']}`;
|
||||
|
||||
if (!existsSync(thumbnailUploadFolder)) {
|
||||
mkdirSync(thumbnailUploadFolder, { recursive: true });
|
||||
}
|
||||
|
||||
cb(null, thumbnailUploadFolder);
|
||||
}
|
||||
|
||||
cb(null, userPath);
|
||||
},
|
||||
|
||||
filename: (req: Request, file: Express.Multer.File, cb: any) => {
|
||||
cb(null, `${file.originalname.split('.')[0]}${req.body['fileExtension']}`);
|
||||
// console.log(req, file);
|
||||
|
||||
if (file.fieldname == 'assetData') {
|
||||
cb(null, `${file.originalname.split('.')[0]}${req.body['fileExtension']}`);
|
||||
} else if (file.fieldname == 'thumbnailData') {
|
||||
cb(null, `${file.originalname.split('.')[0]}.jpeg`);
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
9
server/src/constants/server_version.constant.ts
Normal file
9
server/src/constants/server_version.constant.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// major.minor.patch+build
|
||||
// check mobile/pubspec.yml for current release version
|
||||
|
||||
export const serverVersion = {
|
||||
major: 1,
|
||||
minor: 4,
|
||||
patch: 0,
|
||||
build: 0,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddObjectColumnToSmartInfo1648317474768
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE smart_info
|
||||
ADD COLUMN objects text[];
|
||||
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE smart_info
|
||||
DROP COLUMN objects;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import exifr from 'exifr';
|
||||
import { readFile } from 'fs/promises';
|
||||
import fs, { rmSync } from 'fs';
|
||||
import fs from 'fs';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ExifEntity } from '../../api-v1/asset/entities/exif.entity';
|
||||
import axios from 'axios';
|
||||
@@ -41,7 +41,6 @@ export class BackgroundTaskProcessor {
|
||||
async extractExif(job: Job) {
|
||||
const { savedAsset, fileName, fileSize }: { savedAsset: AssetEntity; fileName: string; fileSize: number } =
|
||||
job.data;
|
||||
|
||||
const fileBuffer = await readFile(savedAsset.originalPath);
|
||||
|
||||
const exifData = await exifr.parse(fileBuffer);
|
||||
@@ -97,7 +96,7 @@ export class BackgroundTaskProcessor {
|
||||
async deleteFileOnDisk(job) {
|
||||
const { assets }: { assets: AssetEntity[] } = job.data;
|
||||
|
||||
assets.forEach(async (asset) => {
|
||||
for (const asset of assets) {
|
||||
fs.unlink(asset.originalPath, (err) => {
|
||||
if (err) {
|
||||
console.log('error deleting ', asset.originalPath);
|
||||
@@ -109,20 +108,43 @@ export class BackgroundTaskProcessor {
|
||||
console.log('error deleting ', asset.originalPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@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 res = await axios.post('http://immich_microservices:3001/image-classifier/tagImage', {
|
||||
thumbnailPath: thumbnailPath,
|
||||
});
|
||||
|
||||
if (res.status == 201 && res.data.length > 0) {
|
||||
const smartInfo = new SmartInfoEntity();
|
||||
smartInfo.assetId = asset.id;
|
||||
smartInfo.tags = [...res.data];
|
||||
|
||||
this.smartInfoRepository.save(smartInfo);
|
||||
await this.smartInfoRepository.upsert(smartInfo, {
|
||||
conflictPaths: ['assetId'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Process('detect-object')
|
||||
async detectObject(job) {
|
||||
const { thumbnailPath, asset }: { thumbnailPath: string; asset: AssetEntity } = job.data;
|
||||
|
||||
const res = await axios.post('http://immich_microservices:3001/object-detection/detectObject', {
|
||||
thumbnailPath: thumbnailPath,
|
||||
});
|
||||
|
||||
if (res.status == 201 && res.data.length > 0) {
|
||||
const smartInfo = new SmartInfoEntity();
|
||||
smartInfo.assetId = asset.id;
|
||||
smartInfo.objects = [...res.data];
|
||||
await this.smartInfoRepository.upsert(smartInfo, {
|
||||
conflictPaths: ['assetId'],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,4 +43,15 @@ export class BackgroundTaskService {
|
||||
{ jobId: randomUUID() },
|
||||
);
|
||||
}
|
||||
|
||||
async detectObject(thumbnailPath: string, asset: AssetEntity) {
|
||||
await this.backgroundTaskQueue.add(
|
||||
'detect-object',
|
||||
{
|
||||
thumbnailPath,
|
||||
asset,
|
||||
},
|
||||
{ jobId: randomUUID() },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { InjectQueue, Process, Processor } from '@nestjs/bull';
|
||||
import { Processor } from '@nestjs/bull';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Job, Queue } from 'bull';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
|
||||
import sharp from 'sharp';
|
||||
import { existsSync, mkdirSync, readFile } from 'fs';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import { APP_UPLOAD_LOCATION } from '../../constants/upload_location.constant';
|
||||
import { WebSocketServer } from '@nestjs/websockets';
|
||||
import { Socket, Server as SocketIoServer } from 'socket.io';
|
||||
import { CommunicationGateway } from '../../api-v1/communication/communication.gateway';
|
||||
import { BackgroundTaskService } from '../background-task/background-task.service';
|
||||
|
||||
@@ -22,115 +14,4 @@ export class ImageOptimizeProcessor {
|
||||
|
||||
private backgroundTaskService: BackgroundTaskService,
|
||||
) {}
|
||||
|
||||
@Process('resize-image')
|
||||
async resizeUploadedImage(job: Job) {
|
||||
const { savedAsset }: { savedAsset: AssetEntity } = job.data;
|
||||
|
||||
const basePath = APP_UPLOAD_LOCATION;
|
||||
const resizePath = savedAsset.originalPath.replace('/original/', '/thumb/');
|
||||
|
||||
// Create folder for thumb image if not exist
|
||||
|
||||
const resizeDir = `${basePath}/${savedAsset.userId}/thumb/${savedAsset.deviceId}`;
|
||||
|
||||
if (!existsSync(resizeDir)) {
|
||||
mkdirSync(resizeDir, { recursive: true });
|
||||
}
|
||||
|
||||
readFile(savedAsset.originalPath, async (err, data) => {
|
||||
if (err) {
|
||||
console.error('Error Reading File');
|
||||
}
|
||||
|
||||
if (savedAsset.mimeType == 'image/heic' || savedAsset.mimeType == 'image/heif') {
|
||||
let desitnation = '';
|
||||
if (savedAsset.mimeType == 'image/heic') {
|
||||
desitnation = resizePath.replace('.HEIC', '.jpeg');
|
||||
} else {
|
||||
desitnation = resizePath.replace('.HEIF', '.jpeg');
|
||||
}
|
||||
|
||||
sharp(data)
|
||||
.toFormat('jpeg')
|
||||
.resize(512, 512, { fit: 'outside' })
|
||||
.toFile(desitnation, async (err, info) => {
|
||||
if (err) {
|
||||
console.error('Error resizing file ', err);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await this.assetRepository.update(savedAsset, { resizePath: desitnation });
|
||||
|
||||
if (res.affected) {
|
||||
this.wsCommunicateionGateway.server
|
||||
.to(savedAsset.userId)
|
||||
.emit('on_upload_success', JSON.stringify(savedAsset));
|
||||
}
|
||||
|
||||
// Tag Image
|
||||
this.backgroundTaskService.tagImage(desitnation, savedAsset);
|
||||
});
|
||||
} else {
|
||||
sharp(data)
|
||||
.resize(512, 512, { fit: 'outside' })
|
||||
.toFile(resizePath, async (err, info) => {
|
||||
if (err) {
|
||||
console.error('Error resizing file ', err);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await this.assetRepository.update(savedAsset, { resizePath: resizePath });
|
||||
if (res.affected) {
|
||||
this.wsCommunicateionGateway.server
|
||||
.to(savedAsset.userId)
|
||||
.emit('on_upload_success', JSON.stringify(savedAsset));
|
||||
}
|
||||
|
||||
// Tag Image
|
||||
this.backgroundTaskService.tagImage(resizePath, savedAsset);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
@Process('get-video-thumbnail')
|
||||
async resizeUploadedVideo(job: Job) {
|
||||
const { savedAsset, filename }: { savedAsset: AssetEntity; filename: String } = job.data;
|
||||
|
||||
const basePath = APP_UPLOAD_LOCATION;
|
||||
// const resizePath = savedAsset.originalPath.replace('/original/', '/thumb/');
|
||||
// Create folder for thumb image if not exist
|
||||
const resizeDir = `${basePath}/${savedAsset.userId}/thumb/${savedAsset.deviceId}`;
|
||||
|
||||
if (!existsSync(resizeDir)) {
|
||||
mkdirSync(resizeDir, { recursive: true });
|
||||
}
|
||||
|
||||
ffmpeg(savedAsset.originalPath)
|
||||
.thumbnail({
|
||||
count: 1,
|
||||
timestamps: [1],
|
||||
folder: resizeDir,
|
||||
filename: `${filename}.png`,
|
||||
})
|
||||
.on('end', async (a) => {
|
||||
const thumbnailPath = `${resizeDir}/${filename}.png`;
|
||||
|
||||
const res = await this.assetRepository.update(savedAsset, { resizePath: `${resizeDir}/${filename}.png` });
|
||||
|
||||
if (res.affected) {
|
||||
this.wsCommunicateionGateway.server
|
||||
.to(savedAsset.userId)
|
||||
.emit('on_upload_success', JSON.stringify(savedAsset));
|
||||
}
|
||||
|
||||
// Tag Image
|
||||
this.backgroundTaskService.tagImage(thumbnailPath, savedAsset);
|
||||
});
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,33 +7,4 @@ import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
|
||||
@Injectable()
|
||||
export class AssetOptimizeService {
|
||||
constructor(@InjectQueue('optimize') private optimizeQueue: Queue) {}
|
||||
|
||||
public async resizeImage(savedAsset: AssetEntity) {
|
||||
const job = await this.optimizeQueue.add(
|
||||
'resize-image',
|
||||
{
|
||||
savedAsset,
|
||||
},
|
||||
{ jobId: randomUUID() },
|
||||
);
|
||||
|
||||
return {
|
||||
jobId: job.id,
|
||||
};
|
||||
}
|
||||
|
||||
public async getVideoThumbnail(savedAsset: AssetEntity, filename: string) {
|
||||
const job = await this.optimizeQueue.add(
|
||||
'get-video-thumbnail',
|
||||
{
|
||||
savedAsset,
|
||||
filename,
|
||||
},
|
||||
{ jobId: randomUUID() },
|
||||
);
|
||||
|
||||
return {
|
||||
jobId: job.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user