Compare commits

...

8 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
53 changed files with 18358 additions and 158 deletions

4
.gitignore vendored
View File

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

View File

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

View File

@@ -55,11 +55,13 @@ This project is under heavy development, there will be continous functions, feat
- Extract and display EXIF info. - Extract and display EXIF info.
- Real-time render from multi-device upload event. - Real-time render from multi-device upload event.
- Image Tagging/Classification based on ImageNet dataset - Image Tagging/Classification based on ImageNet dataset
- Object detection based on COCO SSD.
- Search assets based on tags and exif data (lens, make, model, orientation) - Search assets based on tags and exif data (lens, make, model, orientation)
- Upload assets from your local computer/server using [immich cli tools](https://www.npmjs.com/package/immich) - Upload assets from your local computer/server using [immich cli tools](https://www.npmjs.com/package/immich)
- [Optional] Reserve geocoding using Mapbox (Generous free-tier of 100,000 search/month) - [Optional] Reserve geocoding using Mapbox (Generous free-tier of 100,000 search/month)
- Show asset's location information on map (OpenStreetMap). - Show asset's location information on map (OpenStreetMap).
- Show curated places on the search page - Show curated places on the search page
- Show curated objects on the search page
# Development # Development
@@ -69,7 +71,7 @@ You can use docker compose for development, there are several services that comp
2. PostgreSQL 2. PostgreSQL
3. Redis 3. Redis
4. Nginx 4. Nginx
5. TensorFlow and Keras 5. TensorFlow
## Populate .env file ## Populate .env file

View File

@@ -0,0 +1,87 @@
version: "3.8"
services:
immich_server:
image: immich-server-dev:1.3.2
build:
context: ../server
target: development
dockerfile: ../server/Dockerfile
command: npm run start:dev
expose:
- "3000"
volumes:
- ../server:/usr/src/app
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /usr/src/app/node_modules
env_file:
- .env
depends_on:
- redis
- database
networks:
- immich_network
immich_microservices:
image: immich-microservices-dev:1.3.2
build:
context: ../microservices
target: development
dockerfile: ../microservices/Dockerfile
command: npm run start:dev
expose:
- "3001"
volumes:
- ../microservices:/usr/src/app
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /usr/src/app/node_modules
env_file:
- .env
depends_on:
- database
networks:
- immich_network
redis:
container_name: immich_redis
image: redis:6.2
networks:
- immich_network
database:
container_name: immich_postgres
image: postgres:14
env_file:
- .env
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
PG_DATA: /var/lib/postgresql/data
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- 5432:5432
networks:
- immich_network
nginx:
container_name: proxy_nginx
image: nginx:latest
volumes:
- ./settings/nginx-conf:/etc/nginx/conf.d
ports:
- 2283:80
- 2284:443
logging:
driver: none
networks:
- immich_network
depends_on:
- immich_server
networks:
immich_network:
volumes:
pgdata:

View File

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

View File

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

View File

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

View File

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

@@ -79,4 +79,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 05c3056158482c567a3e0cdab1351ceeee238a07 PODFILE CHECKSUM: 05c3056158482c567a3e0cdab1351ceeee238a07
COCOAPODS: 1.10.1 COCOAPODS: 1.11.3

View File

@@ -341,7 +341,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 9.0; IPHONEOS_DEPLOYMENT_TARGET = 11.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
NEW_SETTING = ""; NEW_SETTING = "";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -425,7 +425,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 9.0; IPHONEOS_DEPLOYMENT_TARGET = 11.0;
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
NEW_SETTING = ""; NEW_SETTING = "";
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
@@ -475,7 +475,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 9.0; IPHONEOS_DEPLOYMENT_TARGET = 11.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
NEW_SETTING = ""; NEW_SETTING = "";
SDKROOT = iphoneos; SDKROOT = iphoneos;

View File

@@ -19,10 +19,10 @@ platform :ios do
desc "iOS Beta" desc "iOS Beta"
lane :beta do lane :beta do
increment_version_number( increment_version_number(
version_number: "1.3.1" version_number: "1.4.0"
) )
increment_build_number({ increment_build_number({
build_number: latest_testflight_build_number + 1 build_number: 0
}) })
build_app(scheme: "Runner", build_app(scheme: "Runner",
workspace: "Runner.xcworkspace", workspace: "Runner.xcworkspace",
@@ -32,19 +32,4 @@ platform :ios do
) )
end end
desc "iOS Release"
lane :release do
increment_version_number(
version_number: "1.3.1"
)
increment_build_number({
build_number: latest_testflight_build_number + 1
})
build_app(scheme: "Runner",
workspace: "Runner.xcworkspace",
xcargs: "-allowProvisioningUpdates")
upload_to_testflight(
skip_waiting_for_build_processing: true
)
end
end end

View File

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

View File

@@ -0,0 +1,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

@@ -1,5 +1,6 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/search/models/curated_location.model.dart'; import 'package:immich_mobile/modules/search/models/curated_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/models/search_page_state.model.dart';
import 'package:immich_mobile/modules/search/services/search.service.dart'; import 'package:immich_mobile/modules/search/services/search.service.dart';
@@ -64,3 +65,14 @@ final getCuratedLocationProvider = FutureProvider.autoDispose<List<CuratedLocati
return []; 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

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/search/models/curated_location.model.dart'; import 'package:immich_mobile/modules/search/models/curated_location.model.dart';
import 'package:immich_mobile/modules/search/models/curated_object.model.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/services/network.service.dart'; import 'package:immich_mobile/shared/services/network.service.dart';
@@ -52,4 +53,19 @@ class SearchService {
throw Error(); 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

@@ -6,10 +6,12 @@ import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/search/models/curated_location.model.dart'; import 'package:immich_mobile/modules/search/models/curated_location.model.dart';
import 'package:immich_mobile/modules/search/models/curated_object.model.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart'; import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
import 'package:immich_mobile/modules/search/ui/search_bar.dart'; import 'package:immich_mobile/modules/search/ui/search_bar.dart';
import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart'; import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/capitalize_first_letter.dart';
// ignore: must_be_immutable // ignore: must_be_immutable
class SearchPage extends HookConsumerWidget { class SearchPage extends HookConsumerWidget {
@@ -22,6 +24,7 @@ class SearchPage extends HookConsumerWidget {
var box = Hive.box(userInfoBox); var box = Hive.box(userInfoBox);
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled; final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
AsyncValue<List<CuratedLocation>> curatedLocation = ref.watch(getCuratedLocationProvider); AsyncValue<List<CuratedLocation>> curatedLocation = ref.watch(getCuratedLocationProvider);
AsyncValue<List<CuratedObject>> curatedObjects = ref.watch(getCuratedObjectProvider);
useEffect(() { useEffect(() {
searchFocusNode = FocusNode(); searchFocusNode = FocusNode();
@@ -82,6 +85,54 @@ class SearchPage extends HookConsumerWidget {
); );
} }
_buildThings() {
return curatedObjects.when(
loading: () => const CircularProgressIndicator(),
error: (err, stack) => Text('Error: $err'),
data: (objects) {
return objects.isNotEmpty
? SizedBox(
height: MediaQuery.of(context).size.width / 3,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal,
itemCount: curatedObjects.value?.length,
itemBuilder: ((context, index) {
CuratedObject curatedObjectInfo = objects[index];
var thumbnailRequestUrl =
'${box.get(serverEndpointKey)}/asset/file?aid=${curatedObjectInfo.deviceAssetId}&did=${curatedObjectInfo.deviceId}&isThumb=true';
return ThumbnailWithInfo(
imageUrl: thumbnailRequestUrl,
textInfo: curatedObjectInfo.object,
onTap: () {
AutoRouter.of(context)
.push(SearchResultRoute(searchTerm: curatedObjectInfo.object.capitalizeFirstLetter()));
},
);
}),
),
)
: SizedBox(
height: MediaQuery.of(context).size.width / 3,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal,
itemCount: 1,
itemBuilder: ((context, index) {
return ThumbnailWithInfo(
imageUrl:
'https://images.unsplash.com/photo-1612178537253-bccd437b730e?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxzZWFyY2h8NXx8Ymxhbmt8ZW58MHx8MHx8&auto=format&fit=crop&w=700&q=60',
textInfo: 'No Object Info Available',
onTap: () {},
);
}),
),
);
},
);
}
return Scaffold( return Scaffold(
appBar: SearchBar( appBar: SearchBar(
searchFocusNode: searchFocusNode, searchFocusNode: searchFocusNode,
@@ -104,6 +155,14 @@ class SearchPage extends HookConsumerWidget {
), ),
), ),
_buildPlaces(), _buildPlaces(),
const Padding(
padding: EdgeInsets.all(16.0),
child: Text(
"Things",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
),
_buildThings()
], ],
), ),
isSearchEnabled ? SearchSuggestionList(onSubmitted: _onSearchSubmitted) : Container(), isSearchEnabled ? SearchSuggestionList(onSubmitted: _onSearchSubmitted) : Container(),
@@ -160,7 +219,7 @@ class ThumbnailWithInfo extends StatelessWidget {
child: SizedBox( child: SizedBox(
width: MediaQuery.of(context).size.width / 3, width: MediaQuery.of(context).size.width / 3,
child: Text( child: Text(
textInfo, textInfo.capitalizeFirstLetter(),
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,

View File

@@ -26,6 +26,7 @@ class TabNavigationObserver extends AutoRouterObserver {
if (route.name == 'SearchRoute') { if (route.name == 'SearchRoute') {
// Refresh Location State // Refresh Location State
ref.refresh(getCuratedLocationProvider); ref.refresh(getCuratedLocationProvider);
ref.refresh(getCuratedObjectProvider);
} }
ref.watch(serverInfoProvider.notifier).getServerVersion(); ref.watch(serverInfoProvider.notifier).getServerVersion();

View File

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

View File

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

View File

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

126
server/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "immich", "name": "immich",
"version": "0.0.1", "version": "1.3.2",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich", "name": "immich",
"version": "0.0.1", "version": "1.3.2",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@mapbox/mapbox-sdk": "^0.13.3", "@mapbox/mapbox-sdk": "^0.13.3",
@@ -28,6 +28,7 @@
"bull": "^4.4.0", "bull": "^4.4.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.13.2", "class-validator": "^0.13.2",
"diskusage": "^1.1.3",
"dotenv": "^14.2.0", "dotenv": "^14.2.0",
"exifr": "^7.1.3", "exifr": "^7.1.3",
"joi": "^17.5.0", "joi": "^17.5.0",
@@ -1546,6 +1547,66 @@
} }
} }
}, },
"node_modules/@nestjs/microservices": {
"version": "8.4.3",
"resolved": "https://registry.npmjs.org/@nestjs/microservices/-/microservices-8.4.3.tgz",
"integrity": "sha512-/ZT5wo1s65J9Cqp2g5eNrYO34VH7/qUkDu4jJyZCT61I9UqpO49J3+1YIAHfmJJzHcrenjgt1sBtlFhwPR3Lgg==",
"optional": true,
"peer": true,
"dependencies": {
"iterare": "1.2.1",
"json-socket": "0.3.0",
"tslib": "2.3.1"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/nest"
},
"peerDependencies": {
"@grpc/grpc-js": "*",
"@nestjs/common": "^8.0.0",
"@nestjs/core": "^8.0.0",
"@nestjs/websockets": "^8.0.0",
"amqp-connection-manager": "*",
"amqplib": "*",
"cache-manager": "*",
"kafkajs": "*",
"mqtt": "*",
"nats": "*",
"redis": "*",
"reflect-metadata": "^0.1.12",
"rxjs": "^7.1.0"
},
"peerDependenciesMeta": {
"@grpc/grpc-js": {
"optional": true
},
"@nestjs/websockets": {
"optional": true
},
"amqp-connection-manager": {
"optional": true
},
"amqplib": {
"optional": true
},
"cache-manager": {
"optional": true
},
"kafkajs": {
"optional": true
},
"mqtt": {
"optional": true
},
"nats": {
"optional": true
},
"redis": {
"optional": true
}
}
},
"node_modules/@nestjs/passport": { "node_modules/@nestjs/passport": {
"version": "8.1.0", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-8.1.0.tgz", "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-8.1.0.tgz",
@@ -4258,6 +4319,16 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/diskusage": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/diskusage/-/diskusage-1.1.3.tgz",
"integrity": "sha512-EAyaxl8hy4Ph07kzlzGTfpbZMNAAAHXSZtNEMwdlnSd1noHzvA6HsgKt4fEMSvaEXQYLSphe5rPMxN4WOj0hcQ==",
"hasInstallScript": true,
"dependencies": {
"es6-promise": "^4.2.5",
"nan": "^2.14.0"
}
},
"node_modules/doctrine": { "node_modules/doctrine": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
@@ -4444,6 +4515,11 @@
"integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==", "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==",
"dev": true "dev": true
}, },
"node_modules/es6-promise": {
"version": "4.2.8",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
"integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
@@ -7037,6 +7113,13 @@
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
}, },
"node_modules/json-socket": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/json-socket/-/json-socket-0.3.0.tgz",
"integrity": "sha512-jc8ZbUnYIWdxERFWQKVgwSLkGSe+kyzvmYxwNaRgx/c8NNyuHes4UHnPM3LUrAFXUx1BhNJ94n1h/KCRlbvV0g==",
"optional": true,
"peer": true
},
"node_modules/json-stable-stringify-without-jsonify": { "node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@@ -7712,8 +7795,7 @@
"node_modules/nan": { "node_modules/nan": {
"version": "2.15.0", "version": "2.15.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz", "resolved": "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz",
"integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==", "integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ=="
"optional": true
}, },
"node_modules/natural-compare": { "node_modules/natural-compare": {
"version": "1.4.0", "version": "1.4.0",
@@ -11928,6 +12010,18 @@
"integrity": "sha512-NFvofzSinp00j5rzUd4tf+xi9od6383iY0JP7o0Bnu1fuItAUkWBgc4EKuIQ3D+c2QI3i9pG1kDWAeY27EMGtg==", "integrity": "sha512-NFvofzSinp00j5rzUd4tf+xi9od6383iY0JP7o0Bnu1fuItAUkWBgc4EKuIQ3D+c2QI3i9pG1kDWAeY27EMGtg==",
"requires": {} "requires": {}
}, },
"@nestjs/microservices": {
"version": "8.4.3",
"resolved": "https://registry.npmjs.org/@nestjs/microservices/-/microservices-8.4.3.tgz",
"integrity": "sha512-/ZT5wo1s65J9Cqp2g5eNrYO34VH7/qUkDu4jJyZCT61I9UqpO49J3+1YIAHfmJJzHcrenjgt1sBtlFhwPR3Lgg==",
"optional": true,
"peer": true,
"requires": {
"iterare": "1.2.1",
"json-socket": "0.3.0",
"tslib": "2.3.1"
}
},
"@nestjs/passport": { "@nestjs/passport": {
"version": "8.1.0", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-8.1.0.tgz", "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-8.1.0.tgz",
@@ -14098,6 +14192,15 @@
"path-type": "^4.0.0" "path-type": "^4.0.0"
} }
}, },
"diskusage": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/diskusage/-/diskusage-1.1.3.tgz",
"integrity": "sha512-EAyaxl8hy4Ph07kzlzGTfpbZMNAAAHXSZtNEMwdlnSd1noHzvA6HsgKt4fEMSvaEXQYLSphe5rPMxN4WOj0hcQ==",
"requires": {
"es6-promise": "^4.2.5",
"nan": "^2.14.0"
}
},
"doctrine": { "doctrine": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
@@ -14246,6 +14349,11 @@
"integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==", "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==",
"dev": true "dev": true
}, },
"es6-promise": {
"version": "4.2.8",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
"integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="
},
"escalade": { "escalade": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
@@ -16214,6 +16322,13 @@
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
}, },
"json-socket": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/json-socket/-/json-socket-0.3.0.tgz",
"integrity": "sha512-jc8ZbUnYIWdxERFWQKVgwSLkGSe+kyzvmYxwNaRgx/c8NNyuHes4UHnPM3LUrAFXUx1BhNJ94n1h/KCRlbvV0g==",
"optional": true,
"peer": true
},
"json-stable-stringify-without-jsonify": { "json-stable-stringify-without-jsonify": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@@ -16753,8 +16868,7 @@
"nan": { "nan": {
"version": "2.15.0", "version": "2.15.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz", "resolved": "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz",
"integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==", "integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ=="
"optional": true
}, },
"natural-compare": { "natural-compare": {
"version": "1.4.0", "version": "1.4.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "0.0.1", "version": "1.3.2",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -41,6 +41,7 @@
"bull": "^4.4.0", "bull": "^4.4.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.13.2", "class-validator": "^0.13.2",
"diskusage": "^1.1.3",
"dotenv": "^14.2.0", "dotenv": "^14.2.0",
"exifr": "^7.1.3", "exifr": "^7.1.3",
"joi": "^17.5.0", "joi": "^17.5.0",

View File

@@ -13,16 +13,16 @@ import {
Response, Response,
Headers, Headers,
Delete, Delete,
Logger,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AssetService } from './asset.service'; import { AssetService } from './asset.service';
import { FileFieldsInterceptor, FilesInterceptor } from '@nestjs/platform-express'; import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { multerOption } from '../../config/multer-option.config'; import { multerOption } from '../../config/multer-option.config';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { CreateAssetDto } from './dto/create-asset.dto'; import { CreateAssetDto } from './dto/create-asset.dto';
import { ServeFileDto } from './dto/serve-file.dto'; import { ServeFileDto } from './dto/serve-file.dto';
import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service'; import { AssetEntity } from './entities/asset.entity';
import { AssetEntity, AssetType } from './entities/asset.entity';
import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto'; import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto';
import { Response as Res } from 'express'; import { Response as Res } from 'express';
import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto'; import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto';
@@ -55,18 +55,23 @@ export class AssetController {
@UploadedFiles() uploadFiles: { assetData: Express.Multer.File[]; thumbnailData?: Express.Multer.File[] }, @UploadedFiles() uploadFiles: { assetData: Express.Multer.File[]; thumbnailData?: Express.Multer.File[] },
@Body(ValidationPipe) assetInfo: CreateAssetDto, @Body(ValidationPipe) assetInfo: CreateAssetDto,
) { ) {
uploadFiles.assetData.forEach(async (file) => { for (const file of uploadFiles.assetData) {
const savedAsset = await this.assetService.createUserAsset(authUser, assetInfo, file.path, file.mimetype); try {
const savedAsset = await this.assetService.createUserAsset(authUser, assetInfo, file.path, file.mimetype);
if (uploadFiles.thumbnailData != null) { if (uploadFiles.thumbnailData != null && savedAsset) {
await this.assetService.updateThumbnailInfo(savedAsset.id, uploadFiles.thumbnailData[0].path); await this.assetService.updateThumbnailInfo(savedAsset.id, uploadFiles.thumbnailData[0].path);
await this.backgroundTaskService.tagImage(uploadFiles.thumbnailData[0].path, savedAsset); await this.backgroundTaskService.tagImage(uploadFiles.thumbnailData[0].path, savedAsset);
await this.backgroundTaskService.detectObject(uploadFiles.thumbnailData[0].path, savedAsset);
}
await this.backgroundTaskService.extractExif(savedAsset, file.originalname, file.size);
this.wsCommunicateionGateway.server.to(savedAsset.userId).emit('on_upload_success', JSON.stringify(savedAsset));
} catch (e) {
Logger.error(`Error receiving upload file ${e}`);
} }
}
await this.backgroundTaskService.extractExif(savedAsset, file.originalname, file.size);
this.wsCommunicateionGateway.server.to(savedAsset.userId).emit('on_upload_success', JSON.stringify(savedAsset));
});
return 'ok'; return 'ok';
} }
@@ -81,6 +86,11 @@ export class AssetController {
return this.assetService.serveFile(authUser, query, res, headers); return this.assetService.serveFile(authUser, query, res, headers);
} }
@Get('/allObjects')
async getCuratedObject(@GetAuthUser() authUser: AuthUserDto) {
return this.assetService.getCuratedObject(authUser);
}
@Get('/allLocation') @Get('/allLocation')
async getCuratedLocation(@GetAuthUser() authUser: AuthUserDto) { async getCuratedLocation(@GetAuthUser() authUser: AuthUserDto) {
return this.assetService.getCuratedLocation(authUser); return this.assetService.getCuratedLocation(authUser);
@@ -125,10 +135,10 @@ export class AssetController {
async deleteAssetById(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) assetIds: DeleteAssetDto) { async deleteAssetById(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) assetIds: DeleteAssetDto) {
const deleteAssetList: AssetEntity[] = []; const deleteAssetList: AssetEntity[] = [];
assetIds.ids.forEach(async (id) => { for (const id of assetIds.ids) {
const assets = await this.assetService.getAssetById(authUser, id); const assets = await this.assetService.getAssetById(authUser, id);
deleteAssetList.push(assets); deleteAssetList.push(assets);
}); }
const result = await this.assetService.deleteAssetById(authUser, assetIds); const result = await this.assetService.deleteAssetById(authUser, assetIds);

View File

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

View File

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

View File

@@ -1,10 +1,7 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common'; import { Controller, Get, UseGuards } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { ServerInfoService } from './server-info.service'; import { ServerInfoService } from './server-info.service';
import mapboxGeocoding, { GeocodeService } from '@mapbox/mapbox-sdk/services/geocoding';
import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response';
import { serverVersion } from '../../constants/server_version.constant'; import { serverVersion } from '../../constants/server_version.constant';
@Controller('server-info') @Controller('server-info')

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
export const serverVersion = { export const serverVersion = {
major: 1, major: 1,
minor: 3, minor: 4,
patch: 0, patch: 0,
build: 0, build: 0,
}; };

View File

@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddObjectColumnToSmartInfo1648317474768
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE smart_info
ADD COLUMN objects text[];
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE smart_info
DROP COLUMN objects;
`);
}
}

View File

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

View File

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