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 }}
# https://github.com/docker/build-push-action#multi-platform-image
- name: Build and push Immich
uses: docker/build-push-action@v2.9.0
uses: docker/build-push-action@v2.10.0
with:
context: ./server
file: ./server/Dockerfile

3
.gitignore vendored Normal file
View File

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

View File

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

13
PR_CHECKLIST.md Normal file
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">
<img src="design/immich-logo.svg" width="150" title="hover text">
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: MIT"></a>
<a href="https://github.com/alextran1502/immich"><img src="https://img.shields.io/github/stars/alextran1502/immich.svg?style=for-the-badge&logo=github&color=3F51B5&label=Stars&logoColor=000000&labelColor=ececec" alt="Star on Github"></a>
<a href="https://immichci.little-home.net/viewType.html?buildTypeId=Immich_BuildAndroidAndGetArtifact&guest=1">
<img src="https://img.shields.io/teamcity/http/immichci.little-home.net/s/Immich_BuildAndroidAndGetArtifact.svg?style=for-the-badge&label=Android&logo=teamcity&logoColor=000000&labelColor=ececec" alt="Android Build"/>
</a>
<a href="https://immichci.little-home.net/viewType.html?buildTypeId=Immich_BuildAndPublishIOSToTestFlight&guest=1">
<img src="https://img.shields.io/teamcity/http/immichci.little-home.net/s/Immich_BuildAndPublishIOSToTestFlight.svg?style=for-the-badge&label=iOS&logo=teamcity&logoColor=000000&labelColor=ececec" alt="iOS Build"/>
</a>
<a href="https://actions-badge.atrox.dev/alextran1502/immich/goto?ref=main">
<img alt="Build Status" src="https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Falextran1502%2Fimmich%2Fbadge%3Fref%3Dmain&style=for-the-badge&label=Server Docker&logo=docker&labelColor=ececec" />
</a>
<br/>
<br/>
<br/>
<br/>
<p align="center">
<img src="design/immich-logo.svg" width="200" title="Immich Logo">
</p>
</p>
# Immich
@@ -28,18 +47,21 @@ This project is under heavy development, there will be continous functions, feat
# Features
- Upload assets(videos/images).
- View assets.
- Upload and view assets(videos/images).
- Multi-user supported.
- Quick navigation with drag scroll bar.
- Auto Backup.
- Support HEIC/HEIF Backup.
- Extract and display EXIF info.
- Real-time render from multi-device upload event.
- Image Tagging/Classification based on ImageNet dataset
- Object detection based on COCO SSD.
- Search assets based on tags and exif data (lens, make, model, orientation)
- Upload assets from your local computer/server using [immich cli tools](https://www.npmjs.com/package/immich)
- [Optional] Reserve geocoding using Mapbox (Generous free-tier of 100,000 search/month)
- Show asset's location information on map (OpenStreetMap).
- Show curated places on the search page
- Show curated objects on the search page
# Development
@@ -49,7 +71,7 @@ You can use docker compose for development, there are several services that comp
2. PostgreSQL
3. Redis
4. Nginx
5. TensorFlow and Keras
5. TensorFlow
## Populate .env file

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:
immich_server:
image: immich-server-dev:1.0.0
image: immich-server-dev:1.4.0
build:
context: ../server
target: development
@@ -22,6 +22,34 @@ services:
networks:
- immich_network
immich_microservices:
image: immich-microservices-dev:1.4.0
build:
context: ../microservices
target: development
dockerfile: ../microservices/Dockerfile
command: npm run start:dev
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [ gpu ]
expose:
- "3001"
volumes:
- ../microservices:/usr/src/app
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /usr/src/app/node_modules
env_file:
- .env
depends_on:
- database
- immich_server
networks:
- immich_network
redis:
container_name: immich_redis
image: redis:6.2
@@ -60,35 +88,6 @@ services:
depends_on:
- immich_server
immich_tf_fastapi:
container_name: immich_tf_fastapi
image: tensor_flow_fastapi:1.0.0
restart: always
command: uvicorn app.main:app --proxy-headers --host 0.0.0.0 --port 8000 --reload
build:
context: ../machine_learning
target: gpu
dockerfile: ../machine_learning/Dockerfile
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
volumes:
- ../machine_learning/app:/code/app
- ${UPLOAD_LOCATION}:/code/app/upload
ports:
- 2285:8000
expose:
- "8000"
depends_on:
- database
networks:
- immich_network
networks:
immich_network:
volumes:

View File

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

View File

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

View File

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

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.preprocessing import image
import numpy as np
from PIL import Image
import cv2
IMG_SIZE = 299
PREDICTION_MODEL = InceptionV3(weights='imagenet')
def classify_image(image_path: str):
img_path = f'./app/{image_path}'
img = image.load_img(img_path, target_size=(IMG_SIZE, IMG_SIZE))
x = image.img_to_array(img)
# img = image.load_img(img_path, target_size=(IMG_SIZE, IMG_SIZE))
target_image = cv2.imread(img_path, cv2.IMREAD_UNCHANGED)
resized_target_image = cv2.resize(target_image, (IMG_SIZE, IMG_SIZE))
x = image.img_to_array(resized_target_image)
x = np.expand_dims(x, axis=0)
x = preprocess_input(x)

View File

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

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

View File

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

View File

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

View File

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

View File

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

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 classname="fastlane.lanes" name="1: build_app" time="71.433647">
<testcase classname="fastlane.lanes" name="1: latest_testflight_build_number" time="4.608292">
</testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_testflight" time="104.299383">
<testcase classname="fastlane.lanes" name="2: increment_build_number" time="0.747162">
</testcase>
<testcase classname="fastlane.lanes" name="3: build_app" time="88.727281">
</testcase>
<testcase classname="fastlane.lanes" name="4: upload_to_testflight" time="7.79397">
</testcase>

View File

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

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/shared/models/backup_state.model.dart';
import 'package:immich_mobile/shared/models/server_info_state.model.dart';
import 'package:immich_mobile/shared/providers/backup.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
class ImmichSliverAppBar extends ConsumerWidget {
const ImmichSliverAppBar({
@@ -21,6 +23,8 @@ class ImmichSliverAppBar extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final BackUpState _backupState = ref.watch(backupProvider);
bool _isEnableAutoBackup = ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
final ServerInfoState _serverInfoState = ref.watch(serverInfoProvider);
return SliverAppBar(
centerTitle: true,
floating: true,
@@ -30,12 +34,46 @@ class ImmichSliverAppBar extends ConsumerWidget {
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
leading: Builder(
builder: (BuildContext context) {
return IconButton(
icon: const Icon(Icons.account_circle_rounded),
onPressed: () {
Scaffold.of(context).openDrawer();
},
tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip,
return Stack(
children: [
Positioned(
top: 5,
child: IconButton(
splashRadius: 25,
icon: const Icon(
Icons.account_circle_rounded,
size: 30,
),
onPressed: () {
Scaffold.of(context).openDrawer();
},
),
),
_serverInfoState.isVersionMismatch
? Positioned(
bottom: 12,
right: 12,
child: GestureDetector(
onTap: () => Scaffold.of(context).openDrawer(),
child: Material(
color: Colors.grey[200],
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50.0),
),
child: const Padding(
padding: EdgeInsets.all(2.0),
child: Icon(
Icons.info,
color: Color.fromARGB(255, 243, 188, 106),
size: 15,
),
),
),
),
)
: Container(),
],
);
},
),

View File

@@ -1,18 +1,40 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/models/server_info_state.model.dart';
import 'package:immich_mobile/shared/providers/backup.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:package_info_plus/package_info_plus.dart';
class ProfileDrawer extends ConsumerWidget {
class ProfileDrawer extends HookConsumerWidget {
const ProfileDrawer({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
AuthenticationState _authState = ref.watch(authenticationProvider);
ServerInfoState _serverInfoState = ref.watch(serverInfoProvider);
final appInfo = useState({});
_getPackageInfo() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
appInfo.value = {
"version": packageInfo.version,
"buildNumber": packageInfo.buildNumber,
};
}
useEffect(() {
_getPackageInfo();
return null;
}, []);
return Drawer(
shape: const RoundedRectangleBorder(
@@ -21,50 +43,125 @@ class ProfileDrawer extends ConsumerWidget {
bottomRight: Radius.circular(5),
),
),
child: ListView(
padding: EdgeInsets.zero,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
DrawerHeader(
decoration: BoxDecoration(
color: Colors.grey[200],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Image(
image: AssetImage('assets/immich-logo-no-outline.png'),
width: 50,
filterQuality: FilterQuality.high,
ListView(
shrinkWrap: true,
padding: EdgeInsets.zero,
children: [
DrawerHeader(
decoration: BoxDecoration(
color: Colors.grey[200],
),
const Padding(padding: EdgeInsets.all(8)),
Text(
_authState.userEmail,
style: TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold),
)
],
),
),
ListTile(
tileColor: Colors.grey[100],
leading: const Icon(
Icons.logout_rounded,
color: Colors.black54,
),
title: const Text(
"Sign Out",
style: TextStyle(color: Colors.black54, fontSize: 14),
),
onTap: () async {
bool res = await ref.read(authenticationProvider.notifier).logout();
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Image(
image: AssetImage('assets/immich-logo-no-outline.png'),
width: 50,
filterQuality: FilterQuality.high,
),
const Padding(padding: EdgeInsets.all(8)),
Text(
_authState.userEmail,
style: TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold),
)
],
),
),
ListTile(
tileColor: Colors.grey[100],
leading: const Icon(
Icons.logout_rounded,
color: Colors.black54,
),
title: const Text(
"Sign Out",
style: TextStyle(color: Colors.black54, fontSize: 14),
),
onTap: () async {
bool res = await ref.read(authenticationProvider.notifier).logout();
if (res) {
ref.watch(backupProvider.notifier).cancelBackup();
ref.watch(assetProvider.notifier).clearAllAsset();
ref.watch(websocketProvider.notifier).disconnect();
AutoRouter.of(context).popUntilRoot();
}
},
if (res) {
ref.watch(backupProvider.notifier).cancelBackup();
ref.watch(assetProvider.notifier).clearAllAsset();
ref.watch(websocketProvider.notifier).disconnect();
AutoRouter.of(context).popUntilRoot();
}
},
)
],
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
color: Colors.grey[100],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
_serverInfoState.isVersionMismatch
? _serverInfoState.versionMismatchErrorMessage
: "Client and Server are up-to-date",
textAlign: TextAlign.center,
style:
TextStyle(fontSize: 11, color: Theme.of(context).primaryColor, fontWeight: FontWeight.w600),
),
),
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"App Version",
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
fontWeight: FontWeight.bold,
),
),
Text(
"${appInfo.value["version"]} build.${appInfo.value["buildNumber"]}",
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Server Version",
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
fontWeight: FontWeight.bold,
),
),
Text(
"${_serverInfoState.serverVersion.major}.${_serverInfoState.serverVersion.minor}.${_serverInfoState.serverVersion.patch}",
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
fontWeight: FontWeight.bold,
),
),
],
),
],
),
),
),
)
],
),

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

View File

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

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

View File

@@ -1,85 +1,10 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/search/models/curated_location.model.dart';
import 'package:immich_mobile/modules/search/models/curated_object.model.dart';
import 'package:immich_mobile/modules/search/models/search_page_state.model.dart';
import 'package:immich_mobile/modules/search/services/search.service.dart';
class SearchPageState {
final String searchTerm;
final bool isSearchEnabled;
final List<String> searchSuggestion;
final List<String> userSuggestedSearchTerms;
SearchPageState({
required this.searchTerm,
required this.isSearchEnabled,
required this.searchSuggestion,
required this.userSuggestedSearchTerms,
});
SearchPageState copyWith({
String? searchTerm,
bool? isSearchEnabled,
List<String>? searchSuggestion,
List<String>? userSuggestedSearchTerms,
}) {
return SearchPageState(
searchTerm: searchTerm ?? this.searchTerm,
isSearchEnabled: isSearchEnabled ?? this.isSearchEnabled,
searchSuggestion: searchSuggestion ?? this.searchSuggestion,
userSuggestedSearchTerms: userSuggestedSearchTerms ?? this.userSuggestedSearchTerms,
);
}
Map<String, dynamic> toMap() {
return {
'searchTerm': searchTerm,
'isSearchEnabled': isSearchEnabled,
'searchSuggestion': searchSuggestion,
'userSuggestedSearchTerms': userSuggestedSearchTerms,
};
}
factory SearchPageState.fromMap(Map<String, dynamic> map) {
return SearchPageState(
searchTerm: map['searchTerm'] ?? '',
isSearchEnabled: map['isSearchEnabled'] ?? false,
searchSuggestion: List<String>.from(map['searchSuggestion']),
userSuggestedSearchTerms: List<String>.from(map['userSuggestedSearchTerms']),
);
}
String toJson() => json.encode(toMap());
factory SearchPageState.fromJson(String source) => SearchPageState.fromMap(json.decode(source));
@override
String toString() {
return 'SearchPageState(searchTerm: $searchTerm, isSearchEnabled: $isSearchEnabled, searchSuggestion: $searchSuggestion, userSuggestedSearchTerms: $userSuggestedSearchTerms)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return other is SearchPageState &&
other.searchTerm == searchTerm &&
other.isSearchEnabled == isSearchEnabled &&
listEquals(other.searchSuggestion, searchSuggestion) &&
listEquals(other.userSuggestedSearchTerms, userSuggestedSearchTerms);
}
@override
int get hashCode {
return searchTerm.hashCode ^
isSearchEnabled.hashCode ^
searchSuggestion.hashCode ^
userSuggestedSearchTerms.hashCode;
}
}
class SearchPageStateNotifier extends StateNotifier<SearchPageState> {
SearchPageStateNotifier()
: super(
@@ -129,3 +54,25 @@ class SearchPageStateNotifier extends StateNotifier<SearchPageState> {
final searchPageStateProvider = StateNotifierProvider<SearchPageStateNotifier, SearchPageState>((ref) {
return SearchPageStateNotifier();
});
final getCuratedLocationProvider = FutureProvider.autoDispose<List<CuratedLocation>>((ref) async {
final SearchService _searchService = SearchService();
var curatedLocation = await _searchService.getCuratedLocation();
if (curatedLocation != null) {
return curatedLocation;
} else {
return [];
}
});
final getCuratedObjectProvider = FutureProvider.autoDispose<List<CuratedObject>>((ref) async {
final SearchService _searchService = SearchService();
var curatedObject = await _searchService.getCuratedObjects();
if (curatedObject != null) {
return curatedObject;
} else {
return [];
}
});

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 'package:flutter/material.dart';
import 'package:immich_mobile/modules/search/models/curated_location.model.dart';
import 'package:immich_mobile/modules/search/models/curated_object.model.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/services/network.service.dart';
@@ -36,4 +38,34 @@ class SearchService {
return null;
}
}
Future<List<CuratedLocation>?> getCuratedLocation() async {
try {
var res = await _networkService.getRequest(url: "asset/allLocation");
List<dynamic> decodedData = jsonDecode(res.toString());
List<CuratedLocation> result = List.from(decodedData.map((a) => CuratedLocation.fromMap(a)));
return result;
} catch (e) {
debugPrint("[ERROR] [getCuratedLocation] ${e.toString()}");
throw Error();
}
}
Future<List<CuratedObject>?> getCuratedObjects() async {
try {
var res = await _networkService.getRequest(url: "asset/allObjects");
List<dynamic> decodedData = jsonDecode(res.toString());
List<CuratedObject> result = List.from(decodedData.map((a) => CuratedObject.fromMap(a)));
return result;
} catch (e) {
debugPrint("[ERROR] [CuratedObject] ${e.toString()}");
throw Error();
}
}
}

View File

@@ -1,11 +1,17 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/search/models/curated_location.model.dart';
import 'package:immich_mobile/modules/search/models/curated_object.model.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
import 'package:immich_mobile/modules/search/ui/search_bar.dart';
import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/capitalize_first_letter.dart';
// ignore: must_be_immutable
class SearchPage extends HookConsumerWidget {
@@ -15,7 +21,10 @@ class SearchPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox);
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
AsyncValue<List<CuratedLocation>> curatedLocation = ref.watch(getCuratedLocationProvider);
AsyncValue<List<CuratedObject>> curatedObjects = ref.watch(getCuratedObjectProvider);
useEffect(() {
searchFocusNode = FocusNode();
@@ -29,6 +38,101 @@ class SearchPage extends HookConsumerWidget {
AutoRouter.of(context).push(SearchResultRoute(searchTerm: searchTerm));
}
_buildPlaces() {
return curatedLocation.when(
loading: () => const CircularProgressIndicator(),
error: (err, stack) => Text('Error: $err'),
data: (curatedLocations) {
return curatedLocations.isNotEmpty
? SizedBox(
height: MediaQuery.of(context).size.width / 3,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal,
itemCount: curatedLocation.value?.length,
itemBuilder: ((context, index) {
CuratedLocation locationInfo = curatedLocations[index];
var thumbnailRequestUrl =
'${box.get(serverEndpointKey)}/asset/file?aid=${locationInfo.deviceAssetId}&did=${locationInfo.deviceId}&isThumb=true';
return ThumbnailWithInfo(
imageUrl: thumbnailRequestUrl,
textInfo: locationInfo.city,
onTap: () {
AutoRouter.of(context).push(SearchResultRoute(searchTerm: locationInfo.city));
},
);
}),
),
)
: SizedBox(
height: MediaQuery.of(context).size.width / 3,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal,
itemCount: 1,
itemBuilder: ((context, index) {
return ThumbnailWithInfo(
imageUrl:
'https://images.unsplash.com/photo-1612178537253-bccd437b730e?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8NXx8Ymxhbmt8ZW58MHx8MHx8&auto=format&fit=crop&w=700&q=60',
textInfo: 'No Places Info Available',
onTap: () {},
);
}),
),
);
},
);
}
_buildThings() {
return curatedObjects.when(
loading: () => const CircularProgressIndicator(),
error: (err, stack) => Text('Error: $err'),
data: (objects) {
return objects.isNotEmpty
? SizedBox(
height: MediaQuery.of(context).size.width / 3,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal,
itemCount: curatedObjects.value?.length,
itemBuilder: ((context, index) {
CuratedObject curatedObjectInfo = objects[index];
var thumbnailRequestUrl =
'${box.get(serverEndpointKey)}/asset/file?aid=${curatedObjectInfo.deviceAssetId}&did=${curatedObjectInfo.deviceId}&isThumb=true';
return ThumbnailWithInfo(
imageUrl: thumbnailRequestUrl,
textInfo: curatedObjectInfo.object,
onTap: () {
AutoRouter.of(context)
.push(SearchResultRoute(searchTerm: curatedObjectInfo.object.capitalizeFirstLetter()));
},
);
}),
),
)
: SizedBox(
height: MediaQuery.of(context).size.width / 3,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal,
itemCount: 1,
itemBuilder: ((context, index) {
return ThumbnailWithInfo(
imageUrl:
'https://images.unsplash.com/photo-1612178537253-bccd437b730e?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8NXx8Ymxhbmt8ZW58MHx8MHx8&auto=format&fit=crop&w=700&q=60',
textInfo: 'No Object Info Available',
onTap: () {},
);
}),
),
);
},
);
}
return Scaffold(
appBar: SearchBar(
searchFocusNode: searchFocusNode,
@@ -41,11 +145,25 @@ class SearchPage extends HookConsumerWidget {
},
child: Stack(
children: [
const Center(
child: Text("Start typing to search for your photos"),
),
ListView(
children: const [],
children: [
const Padding(
padding: EdgeInsets.all(16.0),
child: Text(
"Places",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
),
_buildPlaces(),
const Padding(
padding: EdgeInsets.all(16.0),
child: Text(
"Things",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
),
_buildThings()
],
),
isSearchEnabled ? SearchSuggestionList(onSubmitted: _onSearchSubmitted) : Container(),
],
@@ -54,3 +172,66 @@ class SearchPage extends HookConsumerWidget {
);
}
}
class ThumbnailWithInfo extends StatelessWidget {
const ThumbnailWithInfo({Key? key, required this.textInfo, required this.imageUrl, required this.onTap})
: super(key: key);
final String textInfo;
final String imageUrl;
final Function onTap;
@override
Widget build(BuildContext context) {
var box = Hive.box(userInfoBox);
return GestureDetector(
onTap: () {
onTap();
},
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: SizedBox(
width: MediaQuery.of(context).size.width / 3,
height: MediaQuery.of(context).size.width / 3,
child: Stack(
alignment: Alignment.bottomCenter,
children: [
Container(
foregroundDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: Colors.black26,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: CachedNetworkImage(
width: 150,
height: 150,
fit: BoxFit.cover,
imageUrl: imageUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
),
),
),
Positioned(
bottom: 8,
left: 10,
child: SizedBox(
width: MediaQuery.of(context).size.width / 3,
child: Text(
textInfo.capitalizeFirstLetter(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
),
],
),
),
),
);
}
}

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

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:immich_mobile/shared/models/mapbox_info.model.dart';
import 'package:immich_mobile/shared/models/server_info_state.model.dart';
import 'package:immich_mobile/shared/models/server_version.model.dart';
import 'package:immich_mobile/shared/services/server_info.service.dart';
class ServerInfoState {
final MapboxInfo mapboxInfo;
ServerInfoState({
required this.mapboxInfo,
});
ServerInfoState copyWith({
MapboxInfo? mapboxInfo,
}) {
return ServerInfoState(
mapboxInfo: mapboxInfo ?? this.mapboxInfo,
);
}
Map<String, dynamic> toMap() {
return {
'mapboxInfo': mapboxInfo.toMap(),
};
}
factory ServerInfoState.fromMap(Map<String, dynamic> map) {
return ServerInfoState(
mapboxInfo: MapboxInfo.fromMap(map['mapboxInfo']),
);
}
String toJson() => json.encode(toMap());
factory ServerInfoState.fromJson(String source) => ServerInfoState.fromMap(json.decode(source));
@override
String toString() => 'ServerInfoState(mapboxInfo: $mapboxInfo)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ServerInfoState && other.mapboxInfo == mapboxInfo;
}
@override
int get hashCode => mapboxInfo.hashCode;
}
import 'package:package_info_plus/package_info_plus.dart';
class ServerInfoNotifier extends StateNotifier<ServerInfoState> {
ServerInfoNotifier()
: super(
ServerInfoState(
mapboxInfo: MapboxInfo(isEnable: false, mapboxSecret: ""),
serverVersion: ServerVersion(major: 0, patch: 0, minor: 0, build: 0),
isVersionMismatch: false,
versionMismatchErrorMessage: "",
),
);
@@ -61,9 +21,63 @@ class ServerInfoNotifier extends StateNotifier<ServerInfoState> {
getMapboxInfo() async {
MapboxInfo mapboxInfoRes = await _serverInfoService.getMapboxInfo();
print(mapboxInfoRes);
state = state.copyWith(mapboxInfo: mapboxInfoRes);
}
getServerVersion() async {
ServerVersion? serverVersion = await _serverInfoService.getServerVersion();
if (serverVersion == null) {
state = state.copyWith(
isVersionMismatch: true,
versionMismatchErrorMessage:
"Server is out of date. Some functionalities might not working correctly. Download and rebuild server",
);
return;
}
state = state.copyWith(serverVersion: serverVersion);
PackageInfo packageInfo = await PackageInfo.fromPlatform();
Map<String, int> appVersion = _getDetailVersion(packageInfo.version);
if (appVersion["major"]! > serverVersion.major) {
state = state.copyWith(
isVersionMismatch: true,
versionMismatchErrorMessage:
"Server is out of date in major version. Some functionalities might not work correctly. Download and rebuild server",
);
return;
}
if (appVersion["minor"]! > serverVersion.minor) {
state = state.copyWith(
isVersionMismatch: true,
versionMismatchErrorMessage:
"Server is out of date in minor version. Some functionalities might not work correctly. Consider download and rebuild server",
);
return;
}
state = state.copyWith(isVersionMismatch: false, versionMismatchErrorMessage: "");
}
Map<String, int> _getDetailVersion(String version) {
List<String> detail = version.split(".");
var major = detail[0];
var minor = detail[1];
var patch = detail[2];
return {
"major": int.parse(major),
"minor": int.parse(minor),
"patch": int.parse(patch),
};
}
}
final serverInfoProvider = StateNotifierProvider<ServerInfoNotifier, ServerInfoState>((ref) {

View File

@@ -30,10 +30,14 @@ class BackupService {
Function(int, int) uploadProgress) async {
var dio = Dio();
dio.interceptors.add(AuthenticatedRequestInterceptor());
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
File? file;
MultipartFile assetRawUploadData;
MultipartFile thumbnailUploadData;
for (var entity in assetList) {
try {
if (entity.type == AssetType.video) {
@@ -43,12 +47,20 @@ class BackupService {
}
if (file != null) {
FormData formData;
String originalFileName = await entity.titleAsync;
String fileNameWithoutPath = originalFileName.toString().split(".")[0];
var fileExtension = p.extension(file.path);
var mimeType = FileHelper.getMimeType(file.path);
var formData = FormData.fromMap({
assetRawUploadData = await MultipartFile.fromFile(
file.path,
filename: fileNameWithoutPath,
contentType: MediaType(
mimeType["type"],
mimeType["subType"],
),
);
formData = FormData.fromMap({
'deviceAssetId': entity.id,
'deviceId': deviceId,
'assetType': _getAssetType(entity.type),
@@ -57,18 +69,36 @@ class BackupService {
'isFavorite': entity.isFavorite,
'fileExtension': fileExtension,
'duration': entity.videoDuration,
'files': [
await MultipartFile.fromFile(
file.path,
filename: fileNameWithoutPath,
contentType: MediaType(
mimeType["type"],
mimeType["subType"],
),
),
]
'assetData': [assetRawUploadData]
});
// Build thumbnail multipart data
var thumbnailData = await entity.thumbDataWithSize(1280, 720);
if (thumbnailData != null) {
thumbnailUploadData = MultipartFile.fromBytes(
List.from(thumbnailData),
filename: fileNameWithoutPath,
contentType: MediaType(
"image",
"jpeg",
),
);
// Send thumbnail data if it is exist
formData = FormData.fromMap({
'deviceAssetId': entity.id,
'deviceId': deviceId,
'assetType': _getAssetType(entity.type),
'createdAt': entity.createDateTime.toIso8601String(),
'modifiedAt': entity.modifiedDateTime.toIso8601String(),
'isFavorite': entity.isFavorite,
'fileExtension': fileExtension,
'duration': entity.videoDuration,
'thumbnailData': [thumbnailUploadData],
'assetData': [assetRawUploadData]
});
}
Response res = await dio.post(
'$savedEndpoint/asset/upload',
data: formData,

View File

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

View File

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

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':
return {"type": "image", "subType": "heif"};
case 'dng':
return {"type": "image", "subType": "dng"};
default:
return {"type": "unsupport", "subType": "unsupport"};
}

View File

@@ -555,6 +555,48 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
url: "https://pub.dartlang.org"
source: hosted
version: "1.4.0"
package_info_plus_linux:
dependency: transitive
description:
name: package_info_plus_linux
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.3"
package_info_plus_macos:
dependency: transitive
description:
name: package_info_plus_macos
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.0"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
package_info_plus_web:
dependency: transitive
description:
name: package_info_plus_web
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.4"
package_info_plus_windows:
dependency: transitive
description:
name: package_info_plus_windows
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.4"
path:
dependency: transitive
description:

View File

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

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 ./
RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg
RUN apk add --update-cache build-base python3
RUN npm install
@@ -30,7 +30,7 @@ WORKDIR /usr/src/app
COPY package.json package-lock.json ./
RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg
RUN apk add --update-cache build-base python3
RUN npm install --only=production

1225
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ export const multerConfig = {
export const multerOption: MulterOptions = {
fileFilter: (req: Request, file: any, cb: any) => {
if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif)$/)) {
if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif|dng)$/)) {
cb(null, true);
} else {
cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false);
@@ -23,17 +23,33 @@ export const multerOption: MulterOptions = {
destination: (req: Request, file: Express.Multer.File, cb: any) => {
const uploadPath = multerConfig.dest;
const userPath = `${uploadPath}/${req.user['id']}/original/${req.body['deviceId']}`;
if (file.fieldname == 'assetData') {
const originalUploadFolder = `${uploadPath}/${req.user['id']}/original/${req.body['deviceId']}`;
if (!existsSync(userPath)) {
mkdirSync(userPath, { recursive: true });
if (!existsSync(originalUploadFolder)) {
mkdirSync(originalUploadFolder, { recursive: true });
}
cb(null, originalUploadFolder);
} else if (file.fieldname == 'thumbnailData') {
const thumbnailUploadFolder = `${uploadPath}/${req.user['id']}/thumb/${req.body['deviceId']}`;
if (!existsSync(thumbnailUploadFolder)) {
mkdirSync(thumbnailUploadFolder, { recursive: true });
}
cb(null, thumbnailUploadFolder);
}
cb(null, userPath);
},
filename: (req: Request, file: Express.Multer.File, cb: any) => {
cb(null, `${file.originalname.split('.')[0]}${req.body['fileExtension']}`);
// console.log(req, file);
if (file.fieldname == 'assetData') {
cb(null, `${file.originalname.split('.')[0]}${req.body['fileExtension']}`);
} else if (file.fieldname == 'thumbnailData') {
cb(null, `${file.originalname.split('.')[0]}.jpeg`);
}
},
}),
};

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 exifr from 'exifr';
import { readFile } from 'fs/promises';
import fs, { rmSync } from 'fs';
import fs from 'fs';
import { Logger } from '@nestjs/common';
import { ExifEntity } from '../../api-v1/asset/entities/exif.entity';
import axios from 'axios';
@@ -41,7 +41,6 @@ export class BackgroundTaskProcessor {
async extractExif(job: Job) {
const { savedAsset, fileName, fileSize }: { savedAsset: AssetEntity; fileName: string; fileSize: number } =
job.data;
const fileBuffer = await readFile(savedAsset.originalPath);
const exifData = await exifr.parse(fileBuffer);
@@ -97,7 +96,7 @@ export class BackgroundTaskProcessor {
async deleteFileOnDisk(job) {
const { assets }: { assets: AssetEntity[] } = job.data;
assets.forEach(async (asset) => {
for (const asset of assets) {
fs.unlink(asset.originalPath, (err) => {
if (err) {
console.log('error deleting ', asset.originalPath);
@@ -109,20 +108,43 @@ export class BackgroundTaskProcessor {
console.log('error deleting ', asset.originalPath);
}
});
});
}
}
@Process('tag-image')
async tagImage(job) {
const { thumbnailPath, asset }: { thumbnailPath: string; asset: AssetEntity } = job.data;
const res = await axios.post('http://immich_tf_fastapi:8000/tagImage', { thumbnail_path: thumbnailPath });
if (res.status == 200) {
const res = await axios.post('http://immich_microservices:3001/image-classifier/tagImage', {
thumbnailPath: thumbnailPath,
});
if (res.status == 201 && res.data.length > 0) {
const smartInfo = new SmartInfoEntity();
smartInfo.assetId = asset.id;
smartInfo.tags = [...res.data];
this.smartInfoRepository.save(smartInfo);
await this.smartInfoRepository.upsert(smartInfo, {
conflictPaths: ['assetId'],
});
}
}
@Process('detect-object')
async detectObject(job) {
const { thumbnailPath, asset }: { thumbnailPath: string; asset: AssetEntity } = job.data;
const res = await axios.post('http://immich_microservices:3001/object-detection/detectObject', {
thumbnailPath: thumbnailPath,
});
if (res.status == 201 && res.data.length > 0) {
const smartInfo = new SmartInfoEntity();
smartInfo.assetId = asset.id;
smartInfo.objects = [...res.data];
await this.smartInfoRepository.upsert(smartInfo, {
conflictPaths: ['assetId'],
});
}
}
}

View File

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

View File

@@ -1,15 +1,7 @@
import { InjectQueue, Process, Processor } from '@nestjs/bull';
import { Processor } from '@nestjs/bull';
import { InjectRepository } from '@nestjs/typeorm';
import { Job, Queue } from 'bull';
import { Repository } from 'typeorm';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import sharp from 'sharp';
import { existsSync, mkdirSync, readFile } from 'fs';
import { ConfigService } from '@nestjs/config';
import ffmpeg from 'fluent-ffmpeg';
import { APP_UPLOAD_LOCATION } from '../../constants/upload_location.constant';
import { WebSocketServer } from '@nestjs/websockets';
import { Socket, Server as SocketIoServer } from 'socket.io';
import { CommunicationGateway } from '../../api-v1/communication/communication.gateway';
import { BackgroundTaskService } from '../background-task/background-task.service';
@@ -22,115 +14,4 @@ export class ImageOptimizeProcessor {
private backgroundTaskService: BackgroundTaskService,
) {}
@Process('resize-image')
async resizeUploadedImage(job: Job) {
const { savedAsset }: { savedAsset: AssetEntity } = job.data;
const basePath = APP_UPLOAD_LOCATION;
const resizePath = savedAsset.originalPath.replace('/original/', '/thumb/');
// Create folder for thumb image if not exist
const resizeDir = `${basePath}/${savedAsset.userId}/thumb/${savedAsset.deviceId}`;
if (!existsSync(resizeDir)) {
mkdirSync(resizeDir, { recursive: true });
}
readFile(savedAsset.originalPath, async (err, data) => {
if (err) {
console.error('Error Reading File');
}
if (savedAsset.mimeType == 'image/heic' || savedAsset.mimeType == 'image/heif') {
let desitnation = '';
if (savedAsset.mimeType == 'image/heic') {
desitnation = resizePath.replace('.HEIC', '.jpeg');
} else {
desitnation = resizePath.replace('.HEIF', '.jpeg');
}
sharp(data)
.toFormat('jpeg')
.resize(512, 512, { fit: 'outside' })
.toFile(desitnation, async (err, info) => {
if (err) {
console.error('Error resizing file ', err);
return;
}
const res = await this.assetRepository.update(savedAsset, { resizePath: desitnation });
if (res.affected) {
this.wsCommunicateionGateway.server
.to(savedAsset.userId)
.emit('on_upload_success', JSON.stringify(savedAsset));
}
// Tag Image
this.backgroundTaskService.tagImage(desitnation, savedAsset);
});
} else {
sharp(data)
.resize(512, 512, { fit: 'outside' })
.toFile(resizePath, async (err, info) => {
if (err) {
console.error('Error resizing file ', err);
return;
}
const res = await this.assetRepository.update(savedAsset, { resizePath: resizePath });
if (res.affected) {
this.wsCommunicateionGateway.server
.to(savedAsset.userId)
.emit('on_upload_success', JSON.stringify(savedAsset));
}
// Tag Image
this.backgroundTaskService.tagImage(resizePath, savedAsset);
});
}
});
return 'ok';
}
@Process('get-video-thumbnail')
async resizeUploadedVideo(job: Job) {
const { savedAsset, filename }: { savedAsset: AssetEntity; filename: String } = job.data;
const basePath = APP_UPLOAD_LOCATION;
// const resizePath = savedAsset.originalPath.replace('/original/', '/thumb/');
// Create folder for thumb image if not exist
const resizeDir = `${basePath}/${savedAsset.userId}/thumb/${savedAsset.deviceId}`;
if (!existsSync(resizeDir)) {
mkdirSync(resizeDir, { recursive: true });
}
ffmpeg(savedAsset.originalPath)
.thumbnail({
count: 1,
timestamps: [1],
folder: resizeDir,
filename: `${filename}.png`,
})
.on('end', async (a) => {
const thumbnailPath = `${resizeDir}/${filename}.png`;
const res = await this.assetRepository.update(savedAsset, { resizePath: `${resizeDir}/${filename}.png` });
if (res.affected) {
this.wsCommunicateionGateway.server
.to(savedAsset.userId)
.emit('on_upload_success', JSON.stringify(savedAsset));
}
// Tag Image
this.backgroundTaskService.tagImage(thumbnailPath, savedAsset);
});
return 'ok';
}
}

View File

@@ -7,33 +7,4 @@ import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
@Injectable()
export class AssetOptimizeService {
constructor(@InjectQueue('optimize') private optimizeQueue: Queue) {}
public async resizeImage(savedAsset: AssetEntity) {
const job = await this.optimizeQueue.add(
'resize-image',
{
savedAsset,
},
{ jobId: randomUUID() },
);
return {
jobId: job.id,
};
}
public async getVideoThumbnail(savedAsset: AssetEntity, filename: string) {
const job = await this.optimizeQueue.add(
'get-video-thumbnail',
{
savedAsset,
filename,
},
{ jobId: randomUUID() },
);
return {
jobId: job.id,
};
}
}