Compare commits

...

27 Commits

Author SHA1 Message Date
Alex
9cbd5d1b0c Up Minor 1.4.0 (#79) 2022-03-27 15:55:29 -05:00
Alex
80fd664cc8 Better error message for duplicate file (#78)
* Added try/catch block for saving new asset to database
* Fixed typo for email field
* Added check before generating thumbnail or tag images
2022-03-27 15:47:49 -05:00
Alex
041c711cb9 Add production and development docker-compose (#77) 2022-03-27 15:17:58 -05:00
Alex
dd9c5244fd Added machine learning microservice and object detection (#76) 2022-03-27 14:58:54 -05:00
Alex Tran
fe693db84f Added nestjs microservice 2022-03-25 15:26:55 -05:00
Alex Tran
5c9d3cd08b Added development branch 2022-03-25 15:20:28 -05:00
Alex Tran
725ab5622f Up Version to 1.3.2 2022-03-23 15:36:38 -05:00
Alex
e9acd21733 Implemented getting correct disk info for the mounted directory (#72) 2022-03-23 14:53:45 -05:00
Alex Tran
ce1ab1ed50 Add python dependency to server docker build 2022-03-22 02:13:16 -05:00
Alex Tran
3e918ffd18 Fixed typo in Fastfile iOS 2022-03-22 02:01:09 -05:00
Alex Tran
678ce23c16 Fixed Docker and Fastlane build issue 2022-03-22 01:57:44 -05:00
Alex Tran
31c18ff34c Fixed Docker and Fastlane build issue 2022-03-22 01:57:40 -05:00
Alex
e407a4fa13 Get thumbnail from app (#68)
* Renamed multipart filed name 'files' to 'assetData'. 
* Added an additional field name of 'thumbnailData' to multipart form.
* Implemented upload mechanism for thumbnail directly from the mobile client.
* Removed dead code
* Implemented a version checking mechanism.
2022-03-22 01:22:04 -05:00
Alex
be72df70fe Update issue templates 2022-03-19 12:18:21 -05:00
Alex Tran
dbd79f4797 Fixed MAPBOX_KEY validation error 2022-03-19 10:42:32 -05:00
Alex
f790315d3f Fixed mapbox key required incorrect (#62) 2022-03-19 10:26:58 -05:00
Alex
f1ab700334 Create FUNDING.yml 2022-03-18 20:51:22 -05:00
Alex Tran
afc29a67d2 Change version of fastlane to override the current upload error to TestFlight 2022-03-18 20:39:31 -05:00
Alex Tran
ba816babee Change version of fastlane to override the current upload error to TestFlight 2022-03-18 20:37:04 -05:00
Alex
6e0ac79eae Add support for Apple Pro Raw format (.DNG) (#60)
* Added '.dng' (AppleProRaw) to support file's type
* Added OpenCV python framework for uniform image resizer
* Added version number information
2022-03-18 20:23:05 -05:00
dependabot[bot]
94514cfeea Bump docker/build-push-action from 2.9.0 to 2.10.0 (#53)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 2.9.0 to 2.10.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v2.9.0...v2.10.0)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-03-16 10:22:06 -05:00
Alex
8c7080eaef Show curated asset's location in search page (#55)
* Added Tab Navigation Observer to trigger event handling for tab page navigation
* Added query to get access with distinct location
* Showed places in search page as a horizontal list
* Showed location search result on tapped
2022-03-16 10:19:31 -05:00
Alex Tran
348d395b21 Modified xcode project to disable default questionaire about encryption usage and push notification 2022-03-14 15:28:28 -05:00
Alex Tran
2e7e97ea13 Added pod install steps for TeamCity CI/CD 2022-03-14 14:47:07 -05:00
Alex Tran
d71e7ebff1 Added auto increase build number for TestFlight 2022-03-14 14:35:18 -05:00
Alex Tran
9755936950 update readme 2022-03-14 13:07:06 -05:00
Alex
347052f82f Integrated TeamCity CI/CD and added build status to readme (#52) 2022-03-14 13:03:53 -05:00
88 changed files with 19714 additions and 1430 deletions

BIN
.DS_Store vendored

Binary file not shown.

3
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
# These are supported funding model platforms
github: alextran1502

35
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View 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.

View File

@@ -34,7 +34,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
# https://github.com/docker/build-push-action#multi-platform-image # https://github.com/docker/build-push-action#multi-platform-image
- name: Build and push Immich - name: Build and push Immich
uses: docker/build-push-action@v2.9.0 uses: docker/build-push-action@v2.10.0
with: with:
context: ./server context: ./server
file: ./server/Dockerfile file: ./server/Dockerfile

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.DS_Store
.vscode
.idea

View File

@@ -1,8 +1,8 @@
dev: dev:
docker-compose -f ./docker/docker-compose.yml up docker-compose -f ./docker/docker-compose.dev.yml up --remove-orphans
dev-update: 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: 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
View 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.

View File

@@ -1,5 +1,24 @@
<p align="center"> <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> </p>
# Immich # Immich
@@ -28,18 +47,21 @@ This project is under heavy development, there will be continous functions, feat
# Features # Features
- Upload assets(videos/images). - Upload and view assets(videos/images).
- View assets. - Multi-user supported.
- Quick navigation with drag scroll bar. - Quick navigation with drag scroll bar.
- Auto Backup. - Auto Backup.
- Support HEIC/HEIF Backup. - Support HEIC/HEIF Backup.
- Extract and display EXIF info. - Extract and display EXIF info.
- Real-time render from multi-device upload event. - Real-time render from multi-device upload event.
- Image Tagging/Classification based on ImageNet dataset - 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) - 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) - 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) - [Optional] Reserve geocoding using Mapbox (Generous free-tier of 100,000 search/month)
- Show asset's location information on map (OpenStreetMap). - Show asset's location information on map (OpenStreetMap).
- Show curated places on the search page
- Show curated objects on the search page
# Development # Development
@@ -49,7 +71,7 @@ You can use docker compose for development, there are several services that comp
2. PostgreSQL 2. PostgreSQL
3. Redis 3. Redis
4. Nginx 4. Nginx
5. TensorFlow and Keras 5. TensorFlow
## Populate .env file ## Populate .env file

View 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:

View File

@@ -2,7 +2,7 @@ version: "3.8"
services: services:
immich_server: immich_server:
image: immich-server-dev:1.0.0 image: immich-server-dev:1.4.0
build: build:
context: ../server context: ../server
target: development target: development
@@ -22,6 +22,34 @@ services:
networks: networks:
- immich_network - 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: redis:
container_name: immich_redis container_name: immich_redis
image: redis:6.2 image: redis:6.2
@@ -60,35 +88,6 @@ services:
depends_on: depends_on:
- immich_server - immich_server
immich_tf_fastapi:
container_name: immich_tf_fastapi
image: tensor_flow_fastapi:1.0.0
restart: always
command: uvicorn app.main:app --proxy-headers --host 0.0.0.0 --port 8000 --reload
build:
context: ../machine_learning
target: 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: networks:
immich_network: immich_network:
volumes: volumes:

View File

@@ -2,13 +2,12 @@ version: "3.8"
services: services:
immich_server: immich_server:
image: immich-server-dev:1.0.0 image: immich-server:1.4.0
build: build:
context: ../server context: ../server
target: development target: production
dockerfile: ../server/Dockerfile dockerfile: ../server/Dockerfile
entrypoint: ["/bin/sh", "./entrypoint.sh"] command: npm run start:prod
# command: npm run start:dev
expose: expose:
- "3000" - "3000"
volumes: volumes:
@@ -23,6 +22,27 @@ services:
networks: networks:
- immich_network - 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: redis:
container_name: immich_redis container_name: immich_redis
image: redis:6.2 image: redis:6.2
@@ -61,26 +81,26 @@ services:
depends_on: depends_on:
- immich_server - immich_server
immich_tf_fastapi: # immich_tf_fastapi:
container_name: immich_tf_fastapi # container_name: immich_tf_fastapi
image: tensor_flow_fastapi:1.0.0 # image: tensor_flow_fastapi:1.0.0
restart: always # restart: always
command: uvicorn app.main:app --proxy-headers --host 0.0.0.0 --port 8000 --reload # command: uvicorn app.main:app --proxy-headers --host 0.0.0.0 --port 8000 --reload
build: # build:
context: ../machine_learning # context: ../machine_learning
target: cpu # target: cpu
dockerfile: ../machine_learning/Dockerfile # dockerfile: ../machine_learning/Dockerfile
volumes: # volumes:
- ../machine_learning/app:/code/app # - ../machine_learning/app:/code/app
- ${UPLOAD_LOCATION}:/code/app/upload # - ${UPLOAD_LOCATION}:/code/app/upload
ports: # ports:
- 2285:8000 # - 2285:8000
expose: # expose:
- "8000" # - "8000"
depends_on: # depends_on:
- database # - database
networks: # networks:
- immich_network # - immich_network
networks: networks:
immich_network: immich_network:

View File

@@ -13,7 +13,7 @@ server {
client_max_body_size 50000M; client_max_body_size 50000M;
listen 80; listen 80;
access_log off;
location / { location / {
proxy_buffering off; proxy_buffering off;
proxy_buffer_size 16k; proxy_buffer_size 16k;

View File

@@ -13,6 +13,9 @@
## CPU BUILD ## CPU BUILD
FROM python:3.8 as cpu FROM python:3.8 as cpu
RUN apt-get update
RUN apt-get install ffmpeg libsm6 libxext6 -y
WORKDIR /code WORKDIR /code
COPY ./requirements.txt /code/requirements.txt COPY ./requirements.txt /code/requirements.txt

View File

@@ -2,15 +2,20 @@ from tensorflow.keras.applications import InceptionV3
from tensorflow.keras.applications.inception_v3 import preprocess_input, decode_predictions from tensorflow.keras.applications.inception_v3 import preprocess_input, decode_predictions
from tensorflow.keras.preprocessing import image from tensorflow.keras.preprocessing import image
import numpy as np import numpy as np
from PIL import Image
import cv2
IMG_SIZE = 299 IMG_SIZE = 299
PREDICTION_MODEL = InceptionV3(weights='imagenet') PREDICTION_MODEL = InceptionV3(weights='imagenet')
def classify_image(image_path: str): def classify_image(image_path: str):
img_path = f'./app/{image_path}' img_path = f'./app/{image_path}'
img = image.load_img(img_path, target_size=(IMG_SIZE, IMG_SIZE)) # img = image.load_img(img_path, target_size=(IMG_SIZE, IMG_SIZE))
x = image.img_to_array(img)
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 = np.expand_dims(x, axis=0)
x = preprocess_input(x) x = preprocess_input(x)

View File

@@ -1,3 +1,4 @@
opencv-python==4.5.5.64
fastapi>=0.68.0,<0.69.0 fastapi>=0.68.0,<0.69.0
pydantic>=1.8.0,<2.0.0 pydantic>=1.8.0,<2.0.0
uvicorn>=0.15.0,<0.16.0 uvicorn>=0.15.0,<0.16.0

View File

@@ -0,0 +1,4 @@
node_modules/
upload/
dist/

View 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
View 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

View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

43
microservices/Dockerfile Normal file
View 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
View File

@@ -0,0 +1,4 @@
# Microservices for Immich
## Image Classifier

View File

@@ -0,0 +1,4 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}

17323
microservices/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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 {}

View 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,
};

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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
View 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();

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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);
}
}
}

View 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!');
});
});

View File

@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View 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
}
}

View File

@@ -1,6 +1,4 @@
PODS: PODS:
- device_info_plus (0.0.1):
- Flutter
- Flutter (1.0.0) - Flutter (1.0.0)
- flutter_udid (0.0.1): - flutter_udid (0.0.1):
- Flutter - Flutter
@@ -11,6 +9,8 @@ PODS:
- FMDB (2.7.5): - FMDB (2.7.5):
- FMDB/standard (= 2.7.5) - FMDB/standard (= 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): - path_provider_ios (0.0.1):
- Flutter - Flutter
- photo_manager (1.0.0): - photo_manager (1.0.0):
@@ -27,10 +27,10 @@ PODS:
- Flutter - Flutter
DEPENDENCIES: DEPENDENCIES:
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`) - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/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`) - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
- photo_manager (from `.symlinks/plugins/photo_manager/ios`) - photo_manager (from `.symlinks/plugins/photo_manager/ios`)
- sqflite (from `.symlinks/plugins/sqflite/ios`) - sqflite (from `.symlinks/plugins/sqflite/ios`)
@@ -44,14 +44,14 @@ SPEC REPOS:
- Toast - Toast
EXTERNAL SOURCES: EXTERNAL SOURCES:
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
flutter_udid: flutter_udid:
:path: ".symlinks/plugins/flutter_udid/ios" :path: ".symlinks/plugins/flutter_udid/ios"
fluttertoast: fluttertoast:
:path: ".symlinks/plugins/fluttertoast/ios" :path: ".symlinks/plugins/fluttertoast/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_ios: path_provider_ios:
:path: ".symlinks/plugins/path_provider_ios/ios" :path: ".symlinks/plugins/path_provider_ios/ios"
photo_manager: photo_manager:
@@ -64,11 +64,11 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/wakelock/ios" :path: ".symlinks/plugins/wakelock/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed
Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a
flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c
fluttertoast: 6122fa75143e992b1d3470f61000f591a798cc58 fluttertoast: 6122fa75143e992b1d3470f61000f591a798cc58
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5 path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5
photo_manager: 84fa94fbeb82e607333ea9a13c43b58e0903a463 photo_manager: 84fa94fbeb82e607333ea9a13c43b58e0903a463
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
@@ -79,4 +79,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 05c3056158482c567a3e0cdab1351ceeee238a07 PODFILE CHECKSUM: 05c3056158482c567a3e0cdab1351ceeee238a07
COCOAPODS: 1.10.1 COCOAPODS: 1.11.3

View File

@@ -341,7 +341,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 9.0; IPHONEOS_DEPLOYMENT_TARGET = 11.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
NEW_SETTING = ""; NEW_SETTING = "";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -360,7 +360,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -425,7 +425,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 9.0; IPHONEOS_DEPLOYMENT_TARGET = 11.0;
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
NEW_SETTING = ""; NEW_SETTING = "";
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
@@ -475,7 +475,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 9.0; IPHONEOS_DEPLOYMENT_TARGET = 11.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
NEW_SETTING = ""; NEW_SETTING = "";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -495,7 +495,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -522,7 +522,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -21,7 +21,7 @@
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>2</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>MGLMapboxMetricsEnabledSettingShownInApp</key> <key>MGLMapboxMetricsEnabledSettingShownInApp</key>
@@ -60,5 +60,7 @@
<true/> <true/>
<key>io.flutter.embedded_views_preview</key> <key>io.flutter.embedded_views_preview</key>
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
</dict> </dict>
</plist> </plist>

View File

@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="UTF-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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict/> <dict>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist> </plist>

View File

@@ -16,8 +16,14 @@
default_platform(:ios) default_platform(:ios)
platform :ios do platform :ios do
desc "iOS deployment" desc "iOS Beta"
lane :beta do lane :beta do
increment_version_number(
version_number: "1.4.0"
)
increment_build_number({
build_number: 0
})
build_app(scheme: "Runner", build_app(scheme: "Runner",
workspace: "Runner.xcworkspace", workspace: "Runner.xcworkspace",
xcargs: "-allowProvisioningUpdates") xcargs: "-allowProvisioningUpdates")
@@ -25,4 +31,5 @@ platform :ios do
skip_waiting_for_build_processing: true skip_waiting_for_build_processing: true
) )
end end
end end

View File

@@ -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>
<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>
<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> </testcase>

View File

@@ -4,8 +4,10 @@ import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
import 'package:immich_mobile/routing/router.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/app_state.provider.dart';
import 'package:immich_mobile/shared/providers/backup.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 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'constants/hive_box.dart'; import 'constants/hive_box.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
@@ -42,7 +44,10 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
ref.watch(backupProvider.notifier).resumeBackup(); ref.watch(backupProvider.notifier).resumeBackup();
ref.watch(websocketProvider.notifier).connect(); ref.watch(websocketProvider.notifier).connect();
ref.watch(assetProvider.notifier).getAllAsset(); ref.watch(assetProvider.notifier).getAllAsset();
ref.watch(serverInfoProvider.notifier).getServerVersion();
break; break;
case AppLifecycleState.inactive: case AppLifecycleState.inactive:
debugPrint("[APP STATE] inactive"); debugPrint("[APP STATE] inactive");
ref.watch(appStateProvider.notifier).state = AppStateEnum.inactive; ref.watch(appStateProvider.notifier).state = AppStateEnum.inactive;
@@ -50,10 +55,12 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
ref.watch(backupProvider.notifier).cancelBackup(); ref.watch(backupProvider.notifier).cancelBackup();
break; break;
case AppLifecycleState.paused: case AppLifecycleState.paused:
debugPrint("[APP STATE] paused"); debugPrint("[APP STATE] paused");
ref.watch(appStateProvider.notifier).state = AppStateEnum.paused; ref.watch(appStateProvider.notifier).state = AppStateEnum.paused;
break; break;
case AppLifecycleState.detached: case AppLifecycleState.detached:
debugPrint("[APP STATE] detached"); debugPrint("[APP STATE] detached");
ref.watch(appStateProvider.notifier).state = AppStateEnum.detached; ref.watch(appStateProvider.notifier).state = AppStateEnum.detached;
@@ -100,7 +107,7 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
), ),
), ),
routeInformationParser: _immichRouter.defaultRouteParser(), routeInformationParser: _immichRouter.defaultRouteParser(),
routerDelegate: _immichRouter.delegate(), routerDelegate: _immichRouter.delegate(navigatorObservers: () => [TabNavigationObserver(ref: ref)]),
); );
} }
} }

View File

@@ -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/routing/router.dart';
import 'package:immich_mobile/shared/models/backup_state.model.dart'; import 'package:immich_mobile/shared/models/backup_state.model.dart';
import 'package:immich_mobile/shared/models/server_info_state.model.dart';
import 'package:immich_mobile/shared/providers/backup.provider.dart'; import 'package:immich_mobile/shared/providers/backup.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
class ImmichSliverAppBar extends ConsumerWidget { class ImmichSliverAppBar extends ConsumerWidget {
const ImmichSliverAppBar({ const ImmichSliverAppBar({
@@ -21,6 +23,8 @@ class ImmichSliverAppBar extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final BackUpState _backupState = ref.watch(backupProvider); final BackUpState _backupState = ref.watch(backupProvider);
bool _isEnableAutoBackup = ref.watch(authenticationProvider).deviceInfo.isAutoBackup; bool _isEnableAutoBackup = ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
final ServerInfoState _serverInfoState = ref.watch(serverInfoProvider);
return SliverAppBar( return SliverAppBar(
centerTitle: true, centerTitle: true,
floating: true, floating: true,
@@ -30,12 +34,46 @@ class ImmichSliverAppBar extends ConsumerWidget {
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))), shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
leading: Builder( leading: Builder(
builder: (BuildContext context) { builder: (BuildContext context) {
return IconButton( return Stack(
icon: const Icon(Icons.account_circle_rounded), children: [
onPressed: () { Positioned(
Scaffold.of(context).openDrawer(); top: 5,
}, child: IconButton(
tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip, 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(),
],
); );
}, },
), ),

View File

@@ -1,18 +1,40 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/models/server_info_state.model.dart';
import 'package:immich_mobile/shared/providers/backup.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 '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); const ProfileDrawer({Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
AuthenticationState _authState = ref.watch(authenticationProvider); 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( return Drawer(
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
@@ -21,50 +43,125 @@ class ProfileDrawer extends ConsumerWidget {
bottomRight: Radius.circular(5), bottomRight: Radius.circular(5),
), ),
), ),
child: ListView( child: Column(
padding: EdgeInsets.zero, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
DrawerHeader( ListView(
decoration: BoxDecoration( shrinkWrap: true,
color: Colors.grey[200], padding: EdgeInsets.zero,
), children: [
child: Column( DrawerHeader(
mainAxisAlignment: MainAxisAlignment.center, decoration: BoxDecoration(
crossAxisAlignment: CrossAxisAlignment.center, color: Colors.grey[200],
children: [
const Image(
image: AssetImage('assets/immich-logo-no-outline.png'),
width: 50,
filterQuality: FilterQuality.high,
), ),
const Padding(padding: EdgeInsets.all(8)), child: Column(
Text( mainAxisAlignment: MainAxisAlignment.center,
_authState.userEmail, crossAxisAlignment: CrossAxisAlignment.center,
style: TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold), children: [
) const Image(
], image: AssetImage('assets/immich-logo-no-outline.png'),
), width: 50,
), filterQuality: FilterQuality.high,
ListTile( ),
tileColor: Colors.grey[100], const Padding(padding: EdgeInsets.all(8)),
leading: const Icon( Text(
Icons.logout_rounded, _authState.userEmail,
color: Colors.black54, style: TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold),
), )
title: const Text( ],
"Sign Out", ),
style: TextStyle(color: Colors.black54, fontSize: 14), ),
), ListTile(
onTap: () async { tileColor: Colors.grey[100],
bool res = await ref.read(authenticationProvider.notifier).logout(); 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) { if (res) {
ref.watch(backupProvider.notifier).cancelBackup(); ref.watch(backupProvider.notifier).cancelBackup();
ref.watch(assetProvider.notifier).clearAllAsset(); ref.watch(assetProvider.notifier).clearAllAsset();
ref.watch(websocketProvider.notifier).disconnect(); ref.watch(websocketProvider.notifier).disconnect();
AutoRouter.of(context).popUntilRoot(); 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,
),
),
],
),
],
),
),
),
) )
], ],
), ),

View File

@@ -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/monthly_title_text.dart';
import 'package:immich_mobile/modules/home/ui/profile_drawer.dart'; import 'package:immich_mobile/modules/home/ui/profile_drawer.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.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:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:sliver_tools/sliver_tools.dart'; import 'package:sliver_tools/sliver_tools.dart';
@@ -28,6 +29,7 @@ class HomePage extends HookConsumerWidget {
useEffect(() { useEffect(() {
ref.read(websocketProvider.notifier).connect(); ref.read(websocketProvider.notifier).connect();
ref.read(assetProvider.notifier).getAllAsset(); ref.read(assetProvider.notifier).getAllAsset();
ref.watch(serverInfoProvider.notifier).getServerVersion();
return null; return null;
}, []); }, []);

View File

@@ -15,7 +15,7 @@ class LoginForm extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final usernameController = useTextEditingController(text: 'testuser@email.com'); final usernameController = useTextEditingController(text: 'testuser@email.com');
final passwordController = useTextEditingController(text: 'password'); final passwordController = useTextEditingController(text: 'password');
final serverEndpointController = useTextEditingController(text: 'http://192.168.1.103:2283'); final serverEndpointController = useTextEditingController(text: 'http://192.168.1.216:2283');
return Center( return Center(
child: ConstrainedBox( child: ConstrainedBox(
@@ -78,7 +78,7 @@ class EmailInput extends StatelessWidget {
return TextFormField( return TextFormField(
controller: controller, controller: controller,
decoration: decoration:
const InputDecoration(labelText: 'email', border: OutlineInputBorder(), hintText: 'youremail@email.com'), const InputDecoration(labelText: 'Email', border: OutlineInputBorder(), hintText: 'youremail@email.com'),
); );
} }
} }

View 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;
}
}

View 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;
}
}

View File

@@ -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;
}
}

View File

@@ -1,32 +1,28 @@
import 'dart:convert'; import 'dart:convert';
import 'package:collection/collection.dart'; 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:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:intl/intl.dart';
class SearchresultPageState { class SearchResultPageState {
final bool isLoading; final bool isLoading;
final bool isSuccess; final bool isSuccess;
final bool isError; final bool isError;
final List<ImmichAsset> searchResult; final List<ImmichAsset> searchResult;
SearchresultPageState({ SearchResultPageState({
required this.isLoading, required this.isLoading,
required this.isSuccess, required this.isSuccess,
required this.isError, required this.isError,
required this.searchResult, required this.searchResult,
}); });
SearchresultPageState copyWith({ SearchResultPageState copyWith({
bool? isLoading, bool? isLoading,
bool? isSuccess, bool? isSuccess,
bool? isError, bool? isError,
List<ImmichAsset>? searchResult, List<ImmichAsset>? searchResult,
}) { }) {
return SearchresultPageState( return SearchResultPageState(
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
isSuccess: isSuccess ?? this.isSuccess, isSuccess: isSuccess ?? this.isSuccess,
isError: isError ?? this.isError, isError: isError ?? this.isError,
@@ -43,8 +39,8 @@ class SearchresultPageState {
}; };
} }
factory SearchresultPageState.fromMap(Map<String, dynamic> map) { factory SearchResultPageState.fromMap(Map<String, dynamic> map) {
return SearchresultPageState( return SearchResultPageState(
isLoading: map['isLoading'] ?? false, isLoading: map['isLoading'] ?? false,
isSuccess: map['isSuccess'] ?? false, isSuccess: map['isSuccess'] ?? false,
isError: map['isError'] ?? false, isError: map['isError'] ?? false,
@@ -54,7 +50,7 @@ class SearchresultPageState {
String toJson() => json.encode(toMap()); 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 @override
String toString() { String toString() {
@@ -66,7 +62,7 @@ class SearchresultPageState {
if (identical(this, other)) return true; if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals; final listEquals = const DeepCollectionEquality().equals;
return other is SearchresultPageState && return other is SearchResultPageState &&
other.isLoading == isLoading && other.isLoading == isLoading &&
other.isSuccess == isSuccess && other.isSuccess == isSuccess &&
other.isError == isError && other.isError == isError &&
@@ -78,34 +74,3 @@ class SearchresultPageState {
return isLoading.hashCode ^ isSuccess.hashCode ^ isError.hashCode ^ searchResult.hashCode; return isLoading.hashCode ^ isSuccess.hashCode ^ isError.hashCode ^ searchResult.hashCode;
} }
} }
class SearchResultPageStateNotifier extends StateNotifier<SearchresultPageState> {
SearchResultPageStateNotifier()
: super(SearchresultPageState(searchResult: [], isError: false, isLoading: true, isSuccess: false));
final SearchService _searchService = SearchService();
search(String searchTerm) async {
state = state.copyWith(searchResult: [], isError: false, isLoading: true, isSuccess: false);
List<ImmichAsset>? assets = await _searchService.searchAsset(searchTerm);
if (assets != null) {
state = state.copyWith(searchResult: assets, isError: false, isLoading: false, isSuccess: true);
} else {
state = state.copyWith(searchResult: [], isError: true, isLoading: false, isSuccess: false);
}
}
}
final searchResultPageStateProvider =
StateNotifierProvider<SearchResultPageStateNotifier, SearchresultPageState>((ref) {
return SearchResultPageStateNotifier();
});
final searchResultGroupByDateTimeProvider = StateProvider((ref) {
var assets = ref.watch(searchResultPageStateProvider).searchResult;
assets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
return assets.groupListsBy((element) => DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)));
});

View File

@@ -1,85 +1,10 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.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'; 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> { class SearchPageStateNotifier extends StateNotifier<SearchPageState> {
SearchPageStateNotifier() SearchPageStateNotifier()
: super( : super(
@@ -129,3 +54,25 @@ class SearchPageStateNotifier extends StateNotifier<SearchPageState> {
final searchPageStateProvider = StateNotifierProvider<SearchPageStateNotifier, SearchPageState>((ref) { final searchPageStateProvider = StateNotifierProvider<SearchPageStateNotifier, SearchPageState>((ref) {
return SearchPageStateNotifier(); 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 [];
}
});

View File

@@ -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)));
});

View File

@@ -1,6 +1,8 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; 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/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/services/network.service.dart'; import 'package:immich_mobile/shared/services/network.service.dart';
@@ -36,4 +38,34 @@ class SearchService {
return null; 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();
}
}
} }

View File

@@ -1,11 +1,17 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/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/providers/search_page_state.provider.dart';
import 'package:immich_mobile/modules/search/ui/search_bar.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/modules/search/ui/search_suggestion_list.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/capitalize_first_letter.dart';
// ignore: must_be_immutable // ignore: must_be_immutable
class SearchPage extends HookConsumerWidget { class SearchPage extends HookConsumerWidget {
@@ -15,7 +21,10 @@ class SearchPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox);
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled; final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
AsyncValue<List<CuratedLocation>> curatedLocation = ref.watch(getCuratedLocationProvider);
AsyncValue<List<CuratedObject>> curatedObjects = ref.watch(getCuratedObjectProvider);
useEffect(() { useEffect(() {
searchFocusNode = FocusNode(); searchFocusNode = FocusNode();
@@ -29,6 +38,101 @@ class SearchPage extends HookConsumerWidget {
AutoRouter.of(context).push(SearchResultRoute(searchTerm: searchTerm)); 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( return Scaffold(
appBar: SearchBar( appBar: SearchBar(
searchFocusNode: searchFocusNode, searchFocusNode: searchFocusNode,
@@ -41,11 +145,25 @@ class SearchPage extends HookConsumerWidget {
}, },
child: Stack( child: Stack(
children: [ children: [
const Center(
child: Text("Start typing to search for your photos"),
),
ListView( 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(), 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,
),
),
),
),
],
),
),
),
);
}
}

View File

@@ -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/image_grid.dart';
import 'package:immich_mobile/modules/home/ui/monthly_title_text.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_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'; import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
class SearchResultPage extends HookConsumerWidget { class SearchResultPage extends HookConsumerWidget {
@@ -28,7 +28,7 @@ class SearchResultPage extends HookConsumerWidget {
useEffect(() { useEffect(() {
searchFocusNode = FocusNode(); 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(); return () => searchFocusNode.dispose();
}, []); }, []);
@@ -37,7 +37,7 @@ class SearchResultPage extends HookConsumerWidget {
searchFocusNode.unfocus(); searchFocusNode.unfocus();
isNewSearch.value = false; isNewSearch.value = false;
currentSearchTerm.value = newSearchTerm; currentSearchTerm.value = newSearchTerm;
ref.watch(searchResultPageStateProvider.notifier).search(newSearchTerm); ref.watch(searchResultPageProvider.notifier).search(newSearchTerm);
} }
_buildTextField() { _buildTextField() {
@@ -99,7 +99,7 @@ class SearchResultPage extends HookConsumerWidget {
} }
_buildSearchResult() { _buildSearchResult() {
var searchResultPageState = ref.watch(searchResultPageStateProvider); var searchResultPageState = ref.watch(searchResultPageProvider);
var assetGroupByDateTime = ref.watch(searchResultGroupByDateTimeProvider); var assetGroupByDateTime = ref.watch(searchResultGroupByDateTimeProvider);
if (searchResultPageState.isError) { if (searchResultPageState.isError) {

View 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();
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -1,59 +1,19 @@
import 'dart:convert';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/mapbox_info.model.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'; import 'package:immich_mobile/shared/services/server_info.service.dart';
import 'package:package_info_plus/package_info_plus.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;
}
class ServerInfoNotifier extends StateNotifier<ServerInfoState> { class ServerInfoNotifier extends StateNotifier<ServerInfoState> {
ServerInfoNotifier() ServerInfoNotifier()
: super( : super(
ServerInfoState( ServerInfoState(
mapboxInfo: MapboxInfo(isEnable: false, mapboxSecret: ""), 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 { getMapboxInfo() async {
MapboxInfo mapboxInfoRes = await _serverInfoService.getMapboxInfo(); MapboxInfo mapboxInfoRes = await _serverInfoService.getMapboxInfo();
print(mapboxInfoRes);
state = state.copyWith(mapboxInfo: 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) { final serverInfoProvider = StateNotifierProvider<ServerInfoNotifier, ServerInfoState>((ref) {

View File

@@ -30,10 +30,14 @@ class BackupService {
Function(int, int) uploadProgress) async { Function(int, int) uploadProgress) async {
var dio = Dio(); var dio = Dio();
dio.interceptors.add(AuthenticatedRequestInterceptor()); dio.interceptors.add(AuthenticatedRequestInterceptor());
String deviceId = Hive.box(userInfoBox).get(deviceIdKey); String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey); String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
File? file; File? file;
MultipartFile assetRawUploadData;
MultipartFile thumbnailUploadData;
for (var entity in assetList) { for (var entity in assetList) {
try { try {
if (entity.type == AssetType.video) { if (entity.type == AssetType.video) {
@@ -43,12 +47,20 @@ class BackupService {
} }
if (file != null) { if (file != null) {
FormData formData;
String originalFileName = await entity.titleAsync; String originalFileName = await entity.titleAsync;
String fileNameWithoutPath = originalFileName.toString().split(".")[0]; String fileNameWithoutPath = originalFileName.toString().split(".")[0];
var fileExtension = p.extension(file.path); var fileExtension = p.extension(file.path);
var mimeType = FileHelper.getMimeType(file.path); var mimeType = FileHelper.getMimeType(file.path);
assetRawUploadData = await MultipartFile.fromFile(
var formData = FormData.fromMap({ file.path,
filename: fileNameWithoutPath,
contentType: MediaType(
mimeType["type"],
mimeType["subType"],
),
);
formData = FormData.fromMap({
'deviceAssetId': entity.id, 'deviceAssetId': entity.id,
'deviceId': deviceId, 'deviceId': deviceId,
'assetType': _getAssetType(entity.type), 'assetType': _getAssetType(entity.type),
@@ -57,18 +69,36 @@ class BackupService {
'isFavorite': entity.isFavorite, 'isFavorite': entity.isFavorite,
'fileExtension': fileExtension, 'fileExtension': fileExtension,
'duration': entity.videoDuration, 'duration': entity.videoDuration,
'files': [ 'assetData': [assetRawUploadData]
await MultipartFile.fromFile(
file.path,
filename: fileNameWithoutPath,
contentType: MediaType(
mimeType["type"],
mimeType["subType"],
),
),
]
}); });
// 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( Response res = await dio.post(
'$savedEndpoint/asset/upload', '$savedEndpoint/asset/upload',
data: formData, data: formData,

View File

@@ -1,5 +1,6 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:immich_mobile/shared/models/mapbox_info.model.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/services/network.service.dart';
import 'package:immich_mobile/shared/models/server_info.model.dart'; import 'package:immich_mobile/shared/models/server_info.model.dart';
@@ -17,4 +18,10 @@ class ServerInfoService {
return MapboxInfo.fromJson(response.toString()); return MapboxInfo.fromJson(response.toString());
} }
Future<ServerVersion?> getServerVersion() async {
Response response = await _networkService.getRequest(url: 'server-info/version');
return ServerVersion.fromJson(response.toString());
}
} }

View File

@@ -125,7 +125,7 @@ class BackupControllerPage extends HookConsumerWidget {
), ),
BackupInfoCard( BackupInfoCard(
title: "Total", title: "Total",
subtitle: "All images and video on the device", subtitle: "All images and videos on the device",
info: "${_backupState.totalAssetCount}", info: "${_backupState.totalAssetCount}",
), ),
BackupInfoCard( BackupInfoCard(

View File

@@ -0,0 +1,5 @@
extension StringExtension on String {
String capitalizeFirstLetter() {
return "${this[0].toUpperCase()}${substring(1).toLowerCase()}";
}
}

View File

@@ -32,6 +32,9 @@ class FileHelper {
case 'heif': case 'heif':
return {"type": "image", "subType": "heif"}; return {"type": "image", "subType": "heif"};
case 'dng':
return {"type": "image", "subType": "dng"};
default: default:
return {"type": "unsupport", "subType": "unsupport"}; return {"type": "unsupport", "subType": "unsupport"};
} }

View File

@@ -555,6 +555,48 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.2" 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: path:
dependency: transitive dependency: transitive
description: description:

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: A new Flutter project. description: A new Flutter project.
publish_to: "none" publish_to: "none"
version: 1.2.0+2 version: 1.4.0+0
environment: environment:
sdk: ">=2.15.1 <3.0.0" sdk: ">=2.15.1 <3.0.0"
@@ -36,7 +36,8 @@ dependencies:
# mapbox_gl: ^0.15.0 # mapbox_gl: ^0.15.0
flutter_map: ^0.14.0 flutter_map: ^0.14.0
flutter_udid: ^2.0.0 flutter_udid: ^2.0.0
package_info_plus: ^1.4.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter

View File

@@ -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);
});
}

View File

@@ -9,7 +9,7 @@ WORKDIR /usr/src/app
COPY package.json package-lock.json ./ 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 RUN npm install
@@ -30,7 +30,7 @@ WORKDIR /usr/src/app
COPY package.json package-lock.json ./ 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 RUN npm install --only=production

1225
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "0.0.1", "version": "1.3.2",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -41,9 +41,9 @@
"bull": "^4.4.0", "bull": "^4.4.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.13.2", "class-validator": "^0.13.2",
"diskusage": "^1.1.3",
"dotenv": "^14.2.0", "dotenv": "^14.2.0",
"exifr": "^7.1.3", "exifr": "^7.1.3",
"fluent-ffmpeg": "^2.1.2",
"joi": "^17.5.0", "joi": "^17.5.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"passport": "^0.5.2", "passport": "^0.5.2",
@@ -53,26 +53,23 @@
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"rxjs": "^7.2.0", "rxjs": "^7.2.0",
"sharp": "0.28",
"socket.io-redis": "^6.1.1", "socket.io-redis": "^6.1.1",
"systeminformation": "^5.11.0", "systeminformation": "^5.11.0",
"typeorm": "^0.2.41" "typeorm": "^0.2.41"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^8.0.0", "@nestjs/cli": "^8.2.4",
"@nestjs/schematics": "^8.0.0", "@nestjs/schematics": "^8.0.0",
"@nestjs/testing": "^8.0.0", "@nestjs/testing": "^8.0.0",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/bull": "^3.15.7", "@types/bull": "^3.15.7",
"@types/express": "^4.17.13", "@types/express": "^4.17.13",
"@types/fluent-ffmpeg": "^2.1.20",
"@types/imagemin": "^8.0.0", "@types/imagemin": "^8.0.0",
"@types/jest": "27.0.2", "@types/jest": "27.0.2",
"@types/lodash": "^4.14.178", "@types/lodash": "^4.14.178",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/node": "^16.0.0", "@types/node": "^16.0.0",
"@types/passport-jwt": "^3.0.6", "@types/passport-jwt": "^3.0.6",
"@types/sharp": "^0.29.5",
"@types/supertest": "^2.0.11", "@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0", "@typescript-eslint/parser": "^5.0.0",

View File

@@ -13,51 +13,65 @@ import {
Response, Response,
Headers, Headers,
Delete, Delete,
Logger,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AssetService } from './asset.service'; import { AssetService } from './asset.service';
import { FilesInterceptor } from '@nestjs/platform-express'; import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { multerOption } from '../../config/multer-option.config'; import { multerOption } from '../../config/multer-option.config';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { CreateAssetDto } from './dto/create-asset.dto'; import { CreateAssetDto } from './dto/create-asset.dto';
import { ServeFileDto } from './dto/serve-file.dto'; import { ServeFileDto } from './dto/serve-file.dto';
import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service'; import { AssetEntity } from './entities/asset.entity';
import { AssetEntity, AssetType } from './entities/asset.entity';
import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto'; import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto';
import { Response as Res } from 'express'; import { Response as Res } from 'express';
import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto'; import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service'; import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
import { DeleteAssetDto } from './dto/delete-asset.dto'; import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-asset.dto'; import { SearchAssetDto } from './dto/search-asset.dto';
import { CommunicationGateway } from '../communication/communication.gateway';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('asset') @Controller('asset')
export class AssetController { export class AssetController {
constructor( constructor(
private wsCommunicateionGateway: CommunicationGateway,
private assetService: AssetService, private assetService: AssetService,
private assetOptimizeService: AssetOptimizeService,
private backgroundTaskService: BackgroundTaskService, private backgroundTaskService: BackgroundTaskService,
) {} ) {}
@Post('upload') @Post('upload')
@UseInterceptors(FilesInterceptor('files', 30, multerOption)) @UseInterceptors(
FileFieldsInterceptor(
[
{ name: 'assetData', maxCount: 1 },
{ name: 'thumbnailData', maxCount: 1 },
],
multerOption,
),
)
async uploadFile( async uploadFile(
@GetAuthUser() authUser, @GetAuthUser() authUser,
@UploadedFiles() files: Express.Multer.File[], @UploadedFiles() uploadFiles: { assetData: Express.Multer.File[]; thumbnailData?: Express.Multer.File[] },
@Body(ValidationPipe) assetInfo: CreateAssetDto, @Body(ValidationPipe) assetInfo: CreateAssetDto,
) { ) {
files.forEach(async (file) => { for (const file of uploadFiles.assetData) {
const savedAsset = await this.assetService.createUserAsset(authUser, assetInfo, file.path, file.mimetype); 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); await this.backgroundTaskService.extractExif(savedAsset, file.originalname, file.size);
}
if (savedAsset && savedAsset.type == AssetType.VIDEO) { this.wsCommunicateionGateway.server.to(savedAsset.userId).emit('on_upload_success', JSON.stringify(savedAsset));
await this.assetOptimizeService.getVideoThumbnail(savedAsset, file.originalname); } catch (e) {
Logger.error(`Error receiving upload file ${e}`);
} }
}); }
return 'ok'; return 'ok';
} }
@@ -72,6 +86,16 @@ export class AssetController {
return this.assetService.serveFile(authUser, query, res, headers); 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') @Get('/searchTerm')
async getAssetSearchTerm(@GetAuthUser() authUser: AuthUserDto) { async getAssetSearchTerm(@GetAuthUser() authUser: AuthUserDto) {
return this.assetService.getAssetSearchTerm(authUser); return this.assetService.getAssetSearchTerm(authUser);
@@ -111,10 +135,10 @@ export class AssetController {
async deleteAssetById(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) assetIds: DeleteAssetDto) { async deleteAssetById(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) assetIds: DeleteAssetDto) {
const deleteAssetList: AssetEntity[] = []; const deleteAssetList: AssetEntity[] = [];
assetIds.ids.forEach(async (id) => { for (const id of assetIds.ids) {
const assets = await this.assetService.getAssetById(authUser, id); const assets = await this.assetService.getAssetById(authUser, id);
deleteAssetList.push(assets); deleteAssetList.push(assets);
}); }
const result = await this.assetService.deleteAssetById(authUser, assetIds); const result = await this.assetService.deleteAssetById(authUser, assetIds);

View File

@@ -8,9 +8,12 @@ import { AssetOptimizeService } from '../../modules/image-optimize/image-optimiz
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { BackgroundTaskModule } from '../../modules/background-task/background-task.module'; import { BackgroundTaskModule } from '../../modules/background-task/background-task.module';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service'; import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
import { CommunicationModule } from '../communication/communication.module';
@Module({ @Module({
imports: [ imports: [
CommunicationModule,
BullModule.registerQueue({ BullModule.registerQueue({
name: 'optimize', name: 'optimize',
defaultJobOptions: { defaultJobOptions: {

View File

@@ -3,9 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm';
import { MoreThan, Repository } from 'typeorm'; import { MoreThan, Repository } from 'typeorm';
import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateAssetDto } from './dto/create-asset.dto'; import { CreateAssetDto } from './dto/create-asset.dto';
import { UpdateAssetDto } from './dto/update-asset.dto';
import { AssetEntity, AssetType } from './entities/asset.entity'; import { AssetEntity, AssetType } from './entities/asset.entity';
import _, { result } from 'lodash'; import _ from 'lodash';
import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto'; import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto';
import { GetAllAssetReponseDto } from './dto/get-all-asset-response.dto'; import { GetAllAssetReponseDto } from './dto/get-all-asset-response.dto';
import { createReadStream, stat } from 'fs'; import { createReadStream, stat } from 'fs';
@@ -24,6 +23,12 @@ export class AssetService {
private assetRepository: Repository<AssetEntity>, 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) { public async createUserAsset(authUser: AuthUserDto, assetInfo: CreateAssetDto, path: string, mimeType: string) {
const asset = new AssetEntity(); const asset = new AssetEntity();
asset.deviceAssetId = assetInfo.deviceAssetId; asset.deviceAssetId = assetInfo.deviceAssetId;
@@ -38,9 +43,7 @@ export class AssetService {
asset.duration = assetInfo.duration; asset.duration = assetInfo.duration;
try { try {
const res = await this.assetRepository.save(asset); return await this.assetRepository.save(asset);
return res;
} catch (e) { } catch (e) {
Logger.error(`Error Create New Asset ${e}`, 'createUserAsset'); Logger.error(`Error Create New Asset ${e}`, 'createUserAsset');
} }
@@ -62,13 +65,11 @@ export class AssetService {
public async getAllAssetsNoPagination(authUser: AuthUserDto) { public async getAllAssetsNoPagination(authUser: AuthUserDto) {
try { try {
const assets = await this.assetRepository return await this.assetRepository
.createQueryBuilder('a') .createQueryBuilder('a')
.where('a."userId" = :userId', { userId: authUser.id }) .where('a."userId" = :userId', { userId: authUser.id })
.orderBy('a."createdAt"::date', 'DESC') .orderBy('a."createdAt"::date', 'DESC')
.getMany(); .getMany();
return assets;
} catch (e) { } catch (e) {
Logger.error(e, 'getAllAssets'); Logger.error(e, 'getAllAssets');
} }
@@ -220,10 +221,10 @@ export class AssetService {
} }
public async deleteAssetById(authUser: AuthUserDto, assetIds: DeleteAssetDto) { public async deleteAssetById(authUser: AuthUserDto, assetIds: DeleteAssetDto) {
let result = []; const result = [];
const target = assetIds.ids; const target = assetIds.ids;
for (let assetId of target) { for (const assetId of target) {
const res = await this.assetRepository.delete({ const res = await this.assetRepository.delete({
id: assetId, id: assetId,
userId: authUser.id, userId: authUser.id,
@@ -245,11 +246,11 @@ export class AssetService {
return result; return result;
} }
async getAssetSearchTerm(authUser: AuthUserDto): Promise<String[]> { async getAssetSearchTerm(authUser: AuthUserDto): Promise<string[]> {
const possibleSearchTerm = new Set<String>(); const possibleSearchTerm = new Set<string>();
const rows = await this.assetRepository.query( 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 from assets a
left join exif e on a.id = e."assetId" left join exif e on a.id = e."assetId"
left join smart_info si on a.id = si."assetId" left join smart_info si on a.id = si."assetId"
@@ -262,6 +263,9 @@ export class AssetService {
// tags // tags
row['tags']?.map((tag) => possibleSearchTerm.add(tag?.toLowerCase())); row['tags']?.map((tag) => possibleSearchTerm.add(tag?.toLowerCase()));
// objects
row['objects']?.map((object) => possibleSearchTerm.add(object?.toLowerCase()));
// asset's tyoe // asset's tyoe
possibleSearchTerm.add(row['type']?.toLowerCase()); possibleSearchTerm.add(row['type']?.toLowerCase());
@@ -294,13 +298,39 @@ export class AssetService {
WHERE a."userId" = $1 WHERE a."userId" = $1
AND 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) 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],
);
} }
} }

View File

@@ -13,6 +13,9 @@ export class SmartInfoEntity {
@Column({ type: 'text', array: true, nullable: true }) @Column({ type: 'text', array: true, nullable: true })
tags: string[]; tags: string[];
@Column({ type: 'text', array: true, nullable: true })
objects: string[];
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true }) @OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' }) @JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
asset: SmartInfoEntity; asset: SmartInfoEntity;

View File

@@ -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 { ConfigService } from '@nestjs/config';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { ServerInfoService } from './server-info.service'; import { ServerInfoService } from './server-info.service';
import mapboxGeocoding, { GeocodeService } from '@mapbox/mapbox-sdk/services/geocoding'; import { serverVersion } from '../../constants/server_version.constant';
import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response';
@Controller('server-info') @Controller('server-info')
export class ServerInfoController { export class ServerInfoController {
@@ -30,4 +28,9 @@ export class ServerInfoController {
mapboxSecret: this.configService.get('MAPBOX_KEY'), mapboxSecret: this.configService.get('MAPBOX_KEY'),
}; };
} }
@Get('/version')
async getServerVersion() {
return serverVersion;
}
} }

View File

@@ -1,31 +1,27 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import systemInformation from 'systeminformation';
import { ServerInfoDto } from './dto/server-info.dto'; import { ServerInfoDto } from './dto/server-info.dto';
import diskusage from 'diskusage';
@Injectable() @Injectable()
export class ServerInfoService { export class ServerInfoService {
constructor() {}
async getServerInfo() { async getServerInfo() {
const res = await systemInformation.fsSize(); const diskInfo = await diskusage.check('./upload');
const size = res[0].size; const usagePercentage = (((diskInfo.total - diskInfo.free) / diskInfo.total) * 100).toFixed(2);
const used = res[0].used;
const available = res[0].available;
const percentageUsage = res[0].use;
const serverInfo = new ServerInfoDto(); const serverInfo = new ServerInfoDto();
serverInfo.diskAvailable = this.getHumanReadableString(available); serverInfo.diskAvailable = ServerInfoService.getHumanReadableString(diskInfo.available);
serverInfo.diskSize = this.getHumanReadableString(size); serverInfo.diskSize = ServerInfoService.getHumanReadableString(diskInfo.total);
serverInfo.diskUse = this.getHumanReadableString(used); serverInfo.diskUse = ServerInfoService.getHumanReadableString(diskInfo.total - diskInfo.free);
serverInfo.diskAvailableRaw = available; serverInfo.diskAvailableRaw = diskInfo.available;
serverInfo.diskSizeRaw = size; serverInfo.diskSizeRaw = diskInfo.total;
serverInfo.diskUseRaw = used; serverInfo.diskUseRaw = diskInfo.total - diskInfo.free;
serverInfo.diskUsagePercentage = percentageUsage; serverInfo.diskUsagePercentage = parseFloat(usagePercentage);
return serverInfo; return serverInfo;
} }
private getHumanReadableString(sizeInByte: number) { private static getHumanReadableString(sizeInByte: number) {
const pepibyte = 1.126 * Math.pow(10, 15); const pepibyte = 1.126 * Math.pow(10, 15);
const tebibyte = 1.1 * Math.pow(10, 12); const tebibyte = 1.1 * Math.pow(10, 12);
const gibibyte = 1.074 * Math.pow(10, 9); const gibibyte = 1.074 * Math.pow(10, 9);

View File

@@ -5,7 +5,6 @@ import { UserModule } from './api-v1/user/user.module';
import { AssetModule } from './api-v1/asset/asset.module'; import { AssetModule } from './api-v1/asset/asset.module';
import { AuthModule } from './api-v1/auth/auth.module'; import { AuthModule } from './api-v1/auth/auth.module';
import { ImmichJwtModule } from './modules/immich-jwt/immich-jwt.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 { DeviceInfoModule } from './api-v1/device-info/device-info.module';
import { AppLoggerMiddleware } from './middlewares/app-logger.middleware'; import { AppLoggerMiddleware } from './middlewares/app-logger.middleware';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
@@ -26,14 +25,12 @@ import { CommunicationModule } from './api-v1/communication/communication.module
ImmichJwtModule, ImmichJwtModule,
DeviceInfoModule, DeviceInfoModule,
BullModule.forRootAsync({ BullModule.forRootAsync({
imports: [ConfigModule], useFactory: async () => ({
useFactory: async (configService: ConfigService) => ({
redis: { redis: {
host: 'immich_redis', host: 'immich_redis',
port: 6379, port: 6379,
}, },
}), }),
inject: [ConfigService],
}), }),
ImageOptimizeModule, ImageOptimizeModule,

View File

@@ -13,9 +13,9 @@ export const immichAppConfig: ConfigModuleOptions = {
JWT_SECRET: Joi.string().required(), JWT_SECRET: Joi.string().required(),
ENABLE_MAPBOX: Joi.boolean().required().valid(true, false), ENABLE_MAPBOX: Joi.boolean().required().valid(true, false),
MAPBOX_KEY: Joi.any().when('ENABLE_MAPBOX', { MAPBOX_KEY: Joi.any().when('ENABLE_MAPBOX', {
is: true, is: false,
then: Joi.string().required(), then: Joi.string().optional().allow(null, ''),
otherwise: Joi.string().optional, otherwise: Joi.string().required(),
}), }),
}), }),
}; };

View File

@@ -12,7 +12,7 @@ export const multerConfig = {
export const multerOption: MulterOptions = { export const multerOption: MulterOptions = {
fileFilter: (req: Request, file: any, cb: any) => { fileFilter: (req: Request, file: any, cb: any) => {
if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif)$/)) { if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif|dng)$/)) {
cb(null, true); cb(null, true);
} else { } else {
cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false); cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false);
@@ -23,17 +23,33 @@ export const multerOption: MulterOptions = {
destination: (req: Request, file: Express.Multer.File, cb: any) => { destination: (req: Request, file: Express.Multer.File, cb: any) => {
const uploadPath = multerConfig.dest; 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)) { if (!existsSync(originalUploadFolder)) {
mkdirSync(userPath, { recursive: true }); 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) => { 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`);
}
}, },
}), }),
}; };

View 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,
};

View File

@@ -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;
`);
}
}

View File

@@ -6,7 +6,7 @@ import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import exifr from 'exifr'; import exifr from 'exifr';
import { readFile } from 'fs/promises'; import { readFile } from 'fs/promises';
import fs, { rmSync } from 'fs'; import fs from 'fs';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { ExifEntity } from '../../api-v1/asset/entities/exif.entity'; import { ExifEntity } from '../../api-v1/asset/entities/exif.entity';
import axios from 'axios'; import axios from 'axios';
@@ -41,7 +41,6 @@ export class BackgroundTaskProcessor {
async extractExif(job: Job) { async extractExif(job: Job) {
const { savedAsset, fileName, fileSize }: { savedAsset: AssetEntity; fileName: string; fileSize: number } = const { savedAsset, fileName, fileSize }: { savedAsset: AssetEntity; fileName: string; fileSize: number } =
job.data; job.data;
const fileBuffer = await readFile(savedAsset.originalPath); const fileBuffer = await readFile(savedAsset.originalPath);
const exifData = await exifr.parse(fileBuffer); const exifData = await exifr.parse(fileBuffer);
@@ -97,7 +96,7 @@ export class BackgroundTaskProcessor {
async deleteFileOnDisk(job) { async deleteFileOnDisk(job) {
const { assets }: { assets: AssetEntity[] } = job.data; const { assets }: { assets: AssetEntity[] } = job.data;
assets.forEach(async (asset) => { for (const asset of assets) {
fs.unlink(asset.originalPath, (err) => { fs.unlink(asset.originalPath, (err) => {
if (err) { if (err) {
console.log('error deleting ', asset.originalPath); console.log('error deleting ', asset.originalPath);
@@ -109,20 +108,43 @@ export class BackgroundTaskProcessor {
console.log('error deleting ', asset.originalPath); console.log('error deleting ', asset.originalPath);
} }
}); });
}); }
} }
@Process('tag-image') @Process('tag-image')
async tagImage(job) { async tagImage(job) {
const { thumbnailPath, asset }: { thumbnailPath: string; asset: AssetEntity } = job.data; 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(); const smartInfo = new SmartInfoEntity();
smartInfo.assetId = asset.id; smartInfo.assetId = asset.id;
smartInfo.tags = [...res.data]; 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'],
});
} }
} }
} }

View File

@@ -43,4 +43,15 @@ export class BackgroundTaskService {
{ jobId: randomUUID() }, { jobId: randomUUID() },
); );
} }
async detectObject(thumbnailPath: string, asset: AssetEntity) {
await this.backgroundTaskQueue.add(
'detect-object',
{
thumbnailPath,
asset,
},
{ jobId: randomUUID() },
);
}
} }

View File

@@ -1,15 +1,7 @@
import { InjectQueue, Process, Processor } from '@nestjs/bull'; import { Processor } from '@nestjs/bull';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Job, Queue } from 'bull';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity'; import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import sharp from 'sharp';
import { 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 { CommunicationGateway } from '../../api-v1/communication/communication.gateway';
import { BackgroundTaskService } from '../background-task/background-task.service'; import { BackgroundTaskService } from '../background-task/background-task.service';
@@ -22,115 +14,4 @@ export class ImageOptimizeProcessor {
private backgroundTaskService: BackgroundTaskService, 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';
}
} }

View File

@@ -7,33 +7,4 @@ import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
@Injectable() @Injectable()
export class AssetOptimizeService { export class AssetOptimizeService {
constructor(@InjectQueue('optimize') private optimizeQueue: Queue) {} 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,
};
}
} }