1
0
mirror of https://github.com/immich-app/immich.git synced 2025-08-09 23:17:29 +02:00

WIP refactor container and queuing system (#206)

* refactor microservices to machine-learning

* Update tGithub issue template with correct task syntax

* Added microservices container

* Communicate between service based on queue system

* added dependency

* Fixed problem with having to import BullQueue into the individual service

* Added todo

* refactor server into monorepo with microservices

* refactor database and entity to library

* added simple migration

* Move migrations and database config to library

* Migration works in library

* Cosmetic change in logging message

* added user dto

* Fixed issue with testing not able to find the shared library

* Clean up library mapping path

* Added webp generator to microservices

* Update Github Action build latest

* Fixed issue NPM cannot install due to conflict witl Bull Queue

* format project with prettier

* Modified docker-compose file

* Add GH Action for Staging build:

* Fixed GH action job name

* Modified GH Action to only build & push latest when pushing to main

* Added Test 2e2 Github Action

* Added Test 2e2 Github Action

* Implemented microservice to extract exif

* Added cronjob to scan and generate webp thumbnail  at midnight

* Refactor to ireduce hit time to database when running microservices

* Added error handling to asset services that handle read file from disk

* Added video transcoding queue to process one video at a time

* Fixed loading spinner on web while loading covering the info panel

* Add mechanism to show new release announcement to web and mobile app (#209)

* Added changelog page

* Fixed issues based on PR comments

* Fixed issue with video transcoding run on the server

* Change entry point content for backward combatibility when starting up server

* Added announcement box

* Added error handling to failed silently when the app version checking is not able to make the request to GITHUB

* Added new version announcement overlay

* Update message

* Added messages

* Added logic to check and show announcement

* Add method to handle saving new version

* Added button to dimiss the acknowledge message

* Up version for deployment to the app store
This commit is contained in:
Alex
2022-06-11 16:12:06 -05:00
committed by GitHub
parent 397f8c70b4
commit a8220172f8
192 changed files with 1823 additions and 2117 deletions

View File

@@ -1,4 +1,4 @@
FROM node:16-alpine3.14
FROM node:16-alpine3.14 as core
ARG DEBIAN_FRONTEND=noninteractive

View File

@@ -23,7 +23,7 @@ import { assetUploadOption } from '../../config/asset-upload.config';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { CreateAssetDto } from './dto/create-asset.dto';
import { ServeFileDto } from './dto/serve-file.dto';
import { AssetEntity } from './entities/asset.entity';
import { AssetEntity } from '@app/database/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';
@@ -31,6 +31,8 @@ import { BackgroundTaskService } from '../../modules/background-task/background-
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-asset.dto';
import { CommunicationGateway } from '../communication/communication.gateway';
import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull';
@UseGuards(JwtAuthGuard)
@Controller('asset')
@@ -39,7 +41,10 @@ export class AssetController {
private wsCommunicateionGateway: CommunicationGateway,
private assetService: AssetService,
private backgroundTaskService: BackgroundTaskService,
) { }
@InjectQueue('asset-uploaded-queue')
private assetUploadedQueue: Queue,
) {}
@Post('upload')
@UseInterceptors(
@@ -61,12 +66,23 @@ export class AssetController {
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);
}
const assetWithThumbnail = await this.assetService.updateThumbnailInfo(
savedAsset,
uploadFiles.thumbnailData[0].path,
);
await this.backgroundTaskService.extractExif(savedAsset, file.originalname, file.size);
await this.assetUploadedQueue.add(
'asset-uploaded',
{ asset: assetWithThumbnail, fileName: file.originalname, fileSize: file.size, hasThumbnail: true },
{ jobId: savedAsset.id },
);
} else {
await this.assetUploadedQueue.add(
'asset-uploaded',
{ asset: savedAsset, fileName: file.originalname, fileSize: file.size, hasThumbnail: false },
{ jobId: savedAsset.id },
);
}
this.wsCommunicateionGateway.server.to(savedAsset.userId).emit('on_upload_success', JSON.stringify(savedAsset));
} catch (e) {

View File

@@ -2,9 +2,7 @@ import { Module } from '@nestjs/common';
import { AssetService } from './asset.service';
import { AssetController } from './asset.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetEntity } from './entities/asset.entity';
import { ImageOptimizeModule } from '../../modules/image-optimize/image-optimize.module';
import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { BullModule } from '@nestjs/bull';
import { BackgroundTaskModule } from '../../modules/background-task/background-task.module';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
@@ -13,29 +11,19 @@ import { CommunicationModule } from '../communication/communication.module';
@Module({
imports: [
CommunicationModule,
BullModule.registerQueue({
name: 'optimize',
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
BullModule.registerQueue({
name: 'background-task',
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
TypeOrmModule.forFeature([AssetEntity]),
ImageOptimizeModule,
BackgroundTaskModule,
TypeOrmModule.forFeature([AssetEntity]),
BullModule.registerQueue({
name: 'asset-uploaded-queue',
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
],
controllers: [AssetController],
providers: [AssetService, AssetOptimizeService, BackgroundTaskService],
providers: [AssetService, BackgroundTaskService],
exports: [],
})
export class AssetModule { }
export class AssetModule {}

View File

@@ -1,9 +1,9 @@
import { BadRequestException, Injectable, Logger, StreamableFile } from '@nestjs/common';
import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateAssetDto } from './dto/create-asset.dto';
import { AssetEntity, AssetType } from './entities/asset.entity';
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
import _ from 'lodash';
import { createReadStream, stat } from 'fs';
import { ServeFileDto } from './dto/serve-file.dto';
@@ -11,7 +11,6 @@ import { Response as Res } from 'express';
import { promisify } from 'util';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-asset.dto';
import ffmpeg from 'fluent-ffmpeg';
const fileInfo = promisify(stat);
@@ -20,12 +19,18 @@ export class AssetService {
constructor(
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
) { }
) {}
public async updateThumbnailInfo(assetId: string, path: string) {
return await this.assetRepository.update(assetId, {
resizePath: path,
});
public async updateThumbnailInfo(asset: AssetEntity, thumbnailPath: string): Promise<AssetEntity> {
const updatedAsset = await this.assetRepository
.createQueryBuilder('assets')
.update<AssetEntity>(AssetEntity, { ...asset, resizePath: thumbnailPath })
.where('assets.id = :id', { id: asset.id })
.returning('*')
.updateEntity(true)
.execute();
return updatedAsset.raw[0];
}
public async createUserAsset(authUser: AuthUserDto, assetInfo: CreateAssetDto, path: string, mimeType: string) {
@@ -66,13 +71,13 @@ export class AssetService {
try {
return await this.assetRepository.find({
where: {
userId: authUser.id
userId: authUser.id,
},
relations: ['exifInfo'],
order: {
createdAt: 'DESC'
}
})
createdAt: 'DESC',
},
});
} catch (e) {
Logger.error(e, 'getAllAssets');
}
@@ -101,35 +106,45 @@ export class AssetService {
}
public async downloadFile(query: ServeFileDto, res: Res) {
let file = null;
const asset = await this.findOne(query.did, query.aid);
try {
let file = null;
const asset = await this.findOne(query.did, query.aid);
if (query.isThumb === 'false' || !query.isThumb) {
const { size } = await fileInfo(asset.originalPath);
res.set({
'Content-Type': asset.mimeType,
'Content-Length': size,
});
file = createReadStream(asset.originalPath);
} else {
const { size } = await fileInfo(asset.resizePath);
res.set({
'Content-Type': 'image/jpeg',
'Content-Length': size,
});
file = createReadStream(asset.resizePath);
if (query.isThumb === 'false' || !query.isThumb) {
const { size } = await fileInfo(asset.originalPath);
res.set({
'Content-Type': asset.mimeType,
'Content-Length': size,
});
file = createReadStream(asset.originalPath);
} else {
const { size } = await fileInfo(asset.resizePath);
res.set({
'Content-Type': 'image/jpeg',
'Content-Length': size,
});
file = createReadStream(asset.resizePath);
}
return new StreamableFile(file);
} catch (e) {
Logger.error('Error download asset ', e);
throw new InternalServerErrorException(`Failed to download asset ${e}`, 'DownloadFile');
}
return new StreamableFile(file);
}
public async getAssetThumbnail(assetId: string) {
const asset = await this.assetRepository.findOne({ id: assetId });
try {
const asset = await this.assetRepository.findOne({ id: assetId });
if (asset.webpPath != '') {
return new StreamableFile(createReadStream(asset.webpPath));
} else {
return new StreamableFile(createReadStream(asset.resizePath));
if (asset.webpPath && asset.webpPath.length > 0) {
return new StreamableFile(createReadStream(asset.webpPath));
} else {
return new StreamableFile(createReadStream(asset.resizePath));
}
} catch (e) {
Logger.error('Error serving asset thumbnail ', e);
throw new InternalServerErrorException('Failed to serve asset thumbnail', 'GetAssetThumbnail');
}
}
@@ -141,7 +156,6 @@ export class AssetService {
throw new BadRequestException('Asset does not exist');
}
// Handle Sending Images
if (asset.type == AssetType.IMAGE || query.isThumb == 'true') {
/**
@@ -154,97 +168,102 @@ export class AssetService {
return new StreamableFile(createReadStream(asset.resizePath));
}
/**
* Serve thumbnail image for both web and mobile app
*/
if (query.isThumb === 'false' || !query.isThumb) {
res.set({
'Content-Type': asset.mimeType,
});
file = createReadStream(asset.originalPath);
} else {
if (asset.webpPath != '') {
try {
/**
* Serve thumbnail image for both web and mobile app
*/
if (query.isThumb === 'false' || !query.isThumb) {
res.set({
'Content-Type': 'image/webp',
'Content-Type': asset.mimeType,
});
file = createReadStream(asset.webpPath);
file = createReadStream(asset.originalPath);
} else {
if (asset.webpPath && asset.webpPath.length > 0) {
res.set({
'Content-Type': 'image/webp',
});
file = createReadStream(asset.webpPath);
} else {
res.set({
'Content-Type': 'image/jpeg',
});
file = createReadStream(asset.resizePath);
}
}
file.on('error', (error) => {
Logger.log(`Cannot create read stream ${error}`);
return new BadRequestException('Cannot Create Read Stream');
});
return new StreamableFile(file);
} catch (e) {
Logger.error('Error serving IMAGE asset ', e);
throw new InternalServerErrorException(`Failed to serve image asset ${e}`, 'ServeFile');
}
} else if (asset.type == AssetType.VIDEO) {
try {
// Handle Video
let videoPath = asset.originalPath;
let mimeType = asset.mimeType;
if (query.isWeb && asset.mimeType == 'video/quicktime') {
videoPath = asset.encodedVideoPath == '' ? asset.originalPath : asset.encodedVideoPath;
mimeType = asset.encodedVideoPath == '' ? asset.mimeType : 'video/mp4';
}
const { size } = await fileInfo(videoPath);
const range = headers.range;
if (range) {
/** Extracting Start and End value from Range Header */
let [start, end] = range.replace(/bytes=/, '').split('-');
start = parseInt(start, 10);
end = end ? parseInt(end, 10) : size - 1;
if (!isNaN(start) && isNaN(end)) {
start = start;
end = size - 1;
}
if (isNaN(start) && !isNaN(end)) {
start = size - end;
end = size - 1;
}
// Handle unavailable range request
if (start >= size || end >= size) {
console.error('Bad Request');
// Return the 416 Range Not Satisfiable.
res.status(416).set({
'Content-Range': `bytes */${size}`,
});
throw new BadRequestException('Bad Request Range');
}
/** Sending Partial Content With HTTP Code 206 */
res.status(206).set({
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Type': mimeType,
});
const videoStream = createReadStream(videoPath, { start: start, end: end });
return new StreamableFile(videoStream);
} else {
res.set({
'Content-Type': 'image/jpeg',
});
file = createReadStream(asset.resizePath);
}
}
file.on('error', (error) => {
Logger.log(`Cannot create read stream ${error}`);
return new BadRequestException('Cannot Create Read Stream');
});
return new StreamableFile(file);
} else if (asset.type == AssetType.VIDEO) {
// Handle Video
let videoPath = asset.originalPath;
let mimeType = asset.mimeType;
if (query.isWeb && asset.mimeType == 'video/quicktime') {
videoPath = asset.encodedVideoPath == '' ? asset.originalPath : asset.encodedVideoPath;
mimeType = asset.encodedVideoPath == '' ? asset.mimeType : 'video/mp4';
}
const { size } = await fileInfo(videoPath);
const range = headers.range;
if (range) {
/** Extracting Start and End value from Range Header */
let [start, end] = range.replace(/bytes=/, '').split('-');
start = parseInt(start, 10);
end = end ? parseInt(end, 10) : size - 1;
if (!isNaN(start) && isNaN(end)) {
start = start;
end = size - 1;
}
if (isNaN(start) && !isNaN(end)) {
start = size - end;
end = size - 1;
}
// Handle unavailable range request
if (start >= size || end >= size) {
console.error('Bad Request');
// Return the 416 Range Not Satisfiable.
res.status(416).set({
'Content-Range': `bytes */${size}`,
'Content-Type': mimeType,
});
throw new BadRequestException('Bad Request Range');
return new StreamableFile(createReadStream(videoPath));
}
/** Sending Partial Content With HTTP Code 206 */
res.status(206).set({
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Type': mimeType,
});
const videoStream = createReadStream(videoPath, { start: start, end: end });
return new StreamableFile(videoStream);
} else {
res.set({
'Content-Type': mimeType,
});
return new StreamableFile(createReadStream(videoPath));
} catch (e) {
Logger.error('Error serving VIDEO asset ', e);
throw new InternalServerErrorException(`Failed to serve video asset ${e}`, 'ServeFile');
}
}
}

View File

@@ -1,5 +1,5 @@
import { IsNotEmpty, IsOptional } from 'class-validator';
import { AssetType } from '../entities/asset.entity';
import { AssetType } from '@app/database/entities/asset.entity';
export class CreateAssetDto {
@IsNotEmpty()

View File

@@ -1,4 +1,4 @@
import { AssetEntity } from '../entities/asset.entity';
import { AssetEntity } from '@app/database/entities/asset.entity';
export class GetAllAssetReponseDto {
data: Array<{ date: string; assets: Array<AssetEntity> }>;

View File

@@ -7,7 +7,7 @@ import { SignUpDto } from './dto/sign-up.dto';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) { }
constructor(private readonly authService: AuthService) {}
@Post('/login')
async login(@Body(ValidationPipe) loginCredential: LoginCredentialDto) {

View File

@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '../user/entities/user.entity';
import { UserEntity } from '@app/database/entities/user.entity';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
import { JwtModule } from '@nestjs/jwt';

View File

@@ -1,7 +1,7 @@
import { BadRequestException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserEntity } from '../user/entities/user.entity';
import { UserEntity } from '@app/database/entities/user.entity';
import { LoginCredentialDto } from './dto/login-credential.dto';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { JwtPayloadDto } from './dto/jwt-payload.dto';

View File

@@ -4,7 +4,7 @@ import { Socket, Server } from 'socket.io';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { UserEntity } from '../user/entities/user.entity';
import { UserEntity } from '@app/database/entities/user.entity';
import { Repository } from 'typeorm';
@WebSocketGateway()

View File

@@ -6,7 +6,7 @@ import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { JwtModule } from '@nestjs/jwt';
import { jwtConfig } from '../../config/jwt.config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '../user/entities/user.entity';
import { UserEntity } from '@app/database/entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)],

View File

@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { DeviceInfoService } from './device-info.service';
import { DeviceInfoController } from './device-info.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DeviceInfoEntity } from './entities/device-info.entity';
import { DeviceInfoEntity } from '@app/database/entities/device-info.entity';
@Module({
imports: [TypeOrmModule.forFeature([DeviceInfoEntity])],

View File

@@ -4,7 +4,7 @@ import { Repository } from 'typeorm';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateDeviceInfoDto } from './dto/create-device-info.dto';
import { UpdateDeviceInfoDto } from './dto/update-device-info.dto';
import { DeviceInfoEntity } from './entities/device-info.entity';
import { DeviceInfoEntity } from '@app/database/entities/device-info.entity';
@Injectable()
export class DeviceInfoService {

View File

@@ -1,5 +1,5 @@
import { IsNotEmpty, IsOptional } from 'class-validator';
import { DeviceType } from '../entities/device-info.entity';
import { DeviceType } from '@app/database/entities/device-info.entity';
export class CreateDeviceInfoDto {
@IsNotEmpty()

View File

@@ -1,6 +1,6 @@
import { PartialType } from '@nestjs/mapped-types';
import { IsOptional } from 'class-validator';
import { DeviceType } from '../entities/device-info.entity';
import { DeviceType } from '@app/database/entities/device-info.entity';
import { CreateDeviceInfoDto } from './create-device-info.dto';
export class UpdateDeviceInfoDto extends PartialType(CreateDeviceInfoDto) {}

View File

@@ -4,6 +4,6 @@ import { ServerInfoController } from './server-info.controller';
@Module({
controllers: [ServerInfoController],
providers: [ServerInfoService]
providers: [ServerInfoService],
})
export class ServerInfoModule {}

View File

@@ -1,5 +1,5 @@
import { IsNotEmpty } from 'class-validator';
import { AssetEntity } from '../../asset/entities/asset.entity';
import { AssetEntity } from '@app/database/entities/asset.entity';
export class AddAssetsDto {
@IsNotEmpty()

View File

@@ -1,5 +1,5 @@
import { IsNotEmpty, IsOptional } from 'class-validator';
import { AssetEntity } from '../../asset/entities/asset.entity';
import { AssetEntity } from '@app/database/entities/asset.entity';
export class CreateSharedAlbumDto {
@IsNotEmpty()

View File

@@ -2,11 +2,11 @@ import { Module } from '@nestjs/common';
import { SharingService } from './sharing.service';
import { SharingController } from './sharing.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetEntity } from '../asset/entities/asset.entity';
import { UserEntity } from '../user/entities/user.entity';
import { SharedAlbumEntity } from './entities/shared-album.entity';
import { AssetSharedAlbumEntity } from './entities/asset-shared-album.entity';
import { UserSharedAlbumEntity } from './entities/user-shared-album.entity';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { UserEntity } from '@app/database/entities/user.entity';
import { AssetSharedAlbumEntity } from '@app/database/entities/asset-shared-album.entity';
import { SharedAlbumEntity } from '@app/database/entities/shared-album.entity';
import { UserSharedAlbumEntity } from '@app/database/entities/user-shared-album.entity';
@Module({
imports: [

View File

@@ -2,13 +2,13 @@ import { BadRequestException, Injectable, NotFoundException, UnauthorizedExcepti
import { InjectRepository } from '@nestjs/typeorm';
import { getConnection, Repository } from 'typeorm';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { AssetEntity } from '../asset/entities/asset.entity';
import { UserEntity } from '../user/entities/user.entity';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { UserEntity } from '@app/database/entities/user.entity';
import { AddAssetsDto } from './dto/add-assets.dto';
import { CreateSharedAlbumDto } from './dto/create-shared-album.dto';
import { AssetSharedAlbumEntity } from './entities/asset-shared-album.entity';
import { SharedAlbumEntity } from './entities/shared-album.entity';
import { UserSharedAlbumEntity } from './entities/user-shared-album.entity';
import { AssetSharedAlbumEntity } from '@app/database/entities/asset-shared-album.entity';
import { SharedAlbumEntity } from '@app/database/entities/shared-album.entity';
import { UserSharedAlbumEntity } from '@app/database/entities/user-shared-album.entity';
import _ from 'lodash';
import { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';

View File

@@ -1,4 +1,4 @@
import { UserEntity } from '../entities/user.entity';
import { UserEntity } from '../../../../../../libs/database/src/entities/user.entity';
export interface User {
id: string;

View File

@@ -1,4 +1,19 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, ValidationPipe, Put, Query, UseInterceptors, UploadedFile, Response } from '@nestjs/common';
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseGuards,
ValidationPipe,
Put,
Query,
UseInterceptors,
UploadedFile,
Response,
} from '@nestjs/common';
import { UserService } from './user.service';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
@@ -11,7 +26,7 @@ import { Response as Res } from 'express';
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) { }
constructor(private readonly userService: UserService) {}
@UseGuards(JwtAuthGuard)
@Get()
@@ -28,14 +43,13 @@ export class UserController {
@Get('/count')
async getUserCount(@Query('isAdmin') isAdmin: boolean) {
return await this.userService.getUserCount(isAdmin);
}
@UseGuards(JwtAuthGuard)
@Put()
async updateUser(@Body(ValidationPipe) updateUserDto: UpdateUserDto) {
return await this.userService.updateUser(updateUserDto)
return await this.userService.updateUser(updateUserDto);
}
@UseGuards(JwtAuthGuard)
@@ -46,9 +60,7 @@ export class UserController {
}
@Get('/profile-image/:userId')
async getProfileImage(@Param('userId') userId: string,
@Response({ passthrough: true }) res: Res,
) {
async getProfileImage(@Param('userId') userId: string, @Response({ passthrough: true }) res: Res) {
return await this.userService.getUserProfileImage(userId, res);
}
}

View File

@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from './entities/user.entity';
import { UserEntity } from '@app/database/entities/user.entity';
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { JwtModule } from '@nestjs/jwt';
@@ -13,4 +13,4 @@ import { jwtConfig } from '../../config/jwt.config';
controllers: [UserController],
providers: [UserService, ImmichJwtService],
})
export class UserModule { }
export class UserModule {}

View File

@@ -4,7 +4,7 @@ import { Not, Repository } from 'typeorm';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserEntity } from './entities/user.entity';
import { UserEntity } from '@app/database/entities/user.entity';
import * as bcrypt from 'bcrypt';
import { createReadStream } from 'fs';
import { Response as Res } from 'express';

View File

@@ -1,15 +1,13 @@
import { Controller, Get, Res, Headers } from '@nestjs/common';
import { Response } from 'express';
@Controller()
export class AppController {
constructor() { }
constructor() {}
@Get()
async redirectToWebpage(@Res({ passthrough: true }) res: Response, @Headers() headers) {
const host = headers.host;
return res.redirect(`http://${host}:2285`)
return res.redirect(`http://${host}:2285`);
}
}

View File

@@ -1,6 +1,4 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { databaseConfig } from './config/database.config';
import { UserModule } from './api-v1/user/user.module';
import { AssetModule } from './api-v1/asset/asset.module';
import { AuthModule } from './api-v1/auth/auth.module';
@@ -10,7 +8,6 @@ import { AppLoggerMiddleware } from './middlewares/app-logger.middleware';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { immichAppConfig } from './config/app.config';
import { BullModule } from '@nestjs/bull';
import { ImageOptimizeModule } from './modules/image-optimize/image-optimize.module';
import { ServerInfoModule } from './api-v1/server-info/server-info.module';
import { BackgroundTaskModule } from './modules/background-task/background-task.module';
import { CommunicationModule } from './api-v1/communication/communication.module';
@@ -18,14 +15,13 @@ import { SharingModule } from './api-v1/sharing/sharing.module';
import { AppController } from './app.controller';
import { ScheduleModule } from '@nestjs/schedule';
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
import { DatabaseModule } from '@app/database';
@Module({
imports: [
ConfigModule.forRoot(immichAppConfig),
TypeOrmModule.forRoot(databaseConfig),
DatabaseModule,
UserModule,
AssetModule,
@@ -45,8 +41,6 @@ import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.mod
}),
}),
ImageOptimizeModule,
ServerInfoModule,
BackgroundTaskModule,
@@ -57,7 +51,7 @@ import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.mod
ScheduleModule.forRoot(),
ScheduleTasksModule
ScheduleTasksModule,
],
controllers: [AppController],
providers: [],

View File

@@ -56,5 +56,3 @@ export const assetUploadOption: MulterOptions = {
},
}),
};

View File

@@ -24,17 +24,13 @@ export const profileImageUploadOption: MulterOptions = {
mkdirSync(profileImageLocation, { recursive: true });
}
cb(null, profileImageLocation);
},
filename: (req: Request, file: Express.Multer.File, cb: any) => {
const userId = req.user['id'];
cb(null, `${userId}${extname(file.originalname)}`);
},
}),
};

View File

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

View File

@@ -1,5 +1,5 @@
import { createParamDecorator, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { UserEntity } from '../api-v1/user/entities/user.entity';
import { UserEntity } from '@app/database/entities/user.entity';
// import { AuthUserDto } from './dto/auth-user.dto';
export class AuthUserDto {
@@ -18,4 +18,4 @@ export const GetAuthUser = createParamDecorator((data, ctx: ExecutionContext): A
};
return authUser;
});
});

View File

@@ -15,11 +15,11 @@ async function bootstrap() {
await app.listen(3000, () => {
if (process.env.NODE_ENV == 'development') {
Logger.log('Running Immich Server in DEVELOPMENT environment', 'IMMICH SERVER');
Logger.log('Running Immich Server in DEVELOPMENT environment', 'ImmichServer');
}
if (process.env.NODE_ENV == 'production') {
Logger.log('Running Immich Server in PRODUCTION environment', 'IMMICH SERVER');
Logger.log('Running Immich Server in PRODUCTION environment', 'ImmichServer');
}
});
}

View File

@@ -3,21 +3,23 @@ import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserEntity } from '../api-v1/user/entities/user.entity';
import { UserEntity } from '@app/database/entities/user.entity';
import { ImmichJwtService } from '../modules/immich-jwt/immich-jwt.service';
@Injectable()
export class AdminRolesGuard implements CanActivate {
constructor(private reflector: Reflector, private jwtService: ImmichJwtService,
constructor(
private reflector: Reflector,
private jwtService: ImmichJwtService,
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
) { }
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
if (request.headers['authorization']) {
const bearerToken = request.headers['authorization'].split(" ")[1]
const bearerToken = request.headers['authorization'].split(' ')[1];
const { userId } = await this.jwtService.validateToken(bearerToken);
const user = await this.userRepository.findOne(userId);
@@ -27,4 +29,4 @@ export class AdminRolesGuard implements CanActivate {
return false;
}
}
}

View File

@@ -3,7 +3,7 @@ import { RedisClient } from 'redis';
import { ServerOptions } from 'socket.io';
import { createAdapter } from 'socket.io-redis';
const redis_host = process.env.REDIS_HOSTNAME || 'immich_redis'
const redis_host = process.env.REDIS_HOSTNAME || 'immich_redis';
// const pubClient = createClient({ url: `redis://${redis_host}:6379` });
// const subClient = pubClient.duplicate();

View File

@@ -1,9 +1,9 @@
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import { ExifEntity } from '../../api-v1/asset/entities/exif.entity';
import { SmartInfoEntity } from '../../api-v1/asset/entities/smart-info.entity';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { ExifEntity } from '@app/database/entities/exif.entity';
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
import { BackgroundTaskProcessor } from './background-task.processor';
import { BackgroundTaskService } from './background-task.service';
@@ -17,10 +17,9 @@ import { BackgroundTaskService } from './background-task.service';
removeOnFail: false,
},
}),
TypeOrmModule.forFeature([AssetEntity, ExifEntity, SmartInfoEntity]),
],
providers: [BackgroundTaskService, BackgroundTaskProcessor],
exports: [BackgroundTaskService],
exports: [BackgroundTaskService, BullModule],
})
export class BackgroundTaskModule { }
export class BackgroundTaskModule {}

View File

@@ -0,0 +1,39 @@
import { InjectQueue, Process, Processor } from '@nestjs/bull';
import { InjectRepository } from '@nestjs/typeorm';
import { Job, Queue } from 'bull';
import { Repository } from 'typeorm';
import { AssetEntity } from '@app/database/entities/asset.entity';
import fs from 'fs';
import { Logger } from '@nestjs/common';
import axios from 'axios';
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
@Processor('background-task')
export class BackgroundTaskProcessor {
constructor(
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
@InjectRepository(SmartInfoEntity)
private smartInfoRepository: Repository<SmartInfoEntity>,
) {}
@Process('delete-file-on-disk')
async deleteFileOnDisk(job) {
const { assets }: { assets: AssetEntity[] } = job.data;
for (const asset of assets) {
fs.unlink(asset.originalPath, (err) => {
if (err) {
console.log('error deleting ', asset.originalPath);
}
});
fs.unlink(asset.resizePath, (err) => {
if (err) {
console.log('error deleting ', asset.originalPath);
}
});
}
}
}

View File

@@ -0,0 +1,23 @@
import { InjectQueue } from '@nestjs/bull/dist/decorators';
import { Injectable } from '@nestjs/common';
import { Queue } from 'bull';
import { randomUUID } from 'node:crypto';
import { AssetEntity } from '@app/database/entities/asset.entity';
@Injectable()
export class BackgroundTaskService {
constructor(
@InjectQueue('background-task')
private backgroundTaskQueue: Queue,
) {}
async deleteFileOnDisk(assets: AssetEntity[]) {
await this.backgroundTaskQueue.add(
'delete-file-on-disk',
{
assets,
},
{ jobId: randomUUID() },
);
}
}

View File

@@ -4,7 +4,7 @@ import { JwtModule } from '@nestjs/jwt';
import { jwtConfig } from '../../config/jwt.config';
import { JwtStrategy } from './strategies/jwt.strategy';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '../../api-v1/user/entities/user.entity';
import { UserEntity } from '@app/database/entities/user.entity';
@Module({
imports: [JwtModule.register(jwtConfig), TypeOrmModule.forFeature([UserEntity])],

View File

@@ -4,7 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Repository } from 'typeorm';
import { JwtPayloadDto } from '../../../api-v1/auth/dto/jwt-payload.dto';
import { UserEntity } from '../../../api-v1/user/entities/user.entity';
import { UserEntity } from '@app/database/entities/user.entity';
import { jwtSecret } from '../../../constants/jwt.constant';
@Injectable()

View File

@@ -0,0 +1,30 @@
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { ScheduleTasksService } from './schedule-tasks.service';
import { MicroservicesModule } from '../../../../microservices/src/microservices.module';
@Module({
imports: [
TypeOrmModule.forFeature([AssetEntity]),
BullModule.registerQueue({
name: 'video-conversion-queue',
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
BullModule.registerQueue({
name: 'thumbnail-generator-queue',
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
],
providers: [ScheduleTasksService],
})
export class ScheduleTasksModule {}

View File

@@ -0,0 +1,60 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull';
import { randomUUID } from 'crypto';
@Injectable()
export class ScheduleTasksService {
constructor(
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
@InjectQueue('thumbnail-generator-queue')
private thumbnailGeneratorQueue: Queue,
@InjectQueue('video-conversion-queue')
private videoConversionQueue: Queue,
) {}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async webpConversion() {
Logger.log('Starting Schedule Webp Conversion Tasks', 'CronjobWebpGenerator');
const assets = await this.assetRepository.find({
where: {
webpPath: '',
},
});
if (assets.length == 0) {
Logger.log('All assets has webp file - aborting task', 'CronjobWebpGenerator');
return;
}
for (const asset of assets) {
await this.thumbnailGeneratorQueue.add('generate-webp-thumbnail', { asset: asset }, { jobId: randomUUID() });
}
}
@Cron(CronExpression.EVERY_DAY_AT_1AM)
async videoConversion() {
const assets = await this.assetRepository.find({
where: {
type: 'VIDEO',
mimeType: 'video/quicktime',
encodedVideoPath: '',
},
order: {
createdAt: 'DESC',
},
});
for (const asset of assets) {
await this.videoConversionQueue.add('mp4-conversion', { asset }, { jobId: randomUUID() });
}
}
}

View File

@@ -0,0 +1,13 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"@app/database/config/(.*)": "<rootDir>../../../libs/database/src/config/$1",
"@app/database/entities/(.*)": "<rootDir>../../../libs/database/src/entities/$1"
}
}

View File

@@ -3,7 +3,7 @@ import { INestApplication } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import request from 'supertest';
import { clearDb, authCustom } from './test-utils';
import { databaseConfig } from '../src/config/database.config';
import { databaseConfig } from '@app/database/config/database.config';
import { UserModule } from '../src/api-v1/user/user.module';
import { ImmichJwtModule } from '../src/modules/immich-jwt/immich-jwt.module';
import { UserService } from '../src/api-v1/user/user.service';

View File

@@ -0,0 +1,17 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"declaration": false,
"outDir": "../../dist/apps/immich"
},
"include": [
"src/**/*",
"../../libs/**/*"
],
"exclude": [
"node_modules",
"dist",
"test",
"**/*spec.ts"
]
}

View File

@@ -0,0 +1,18 @@
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { MicroservicesModule } from './microservices.module';
async function bootstrap() {
const app = await NestFactory.create(MicroservicesModule);
await app.listen(3000, () => {
if (process.env.NODE_ENV == 'development') {
Logger.log('Running Immich Microservices in DEVELOPMENT environment', 'ImmichMicroservice');
}
if (process.env.NODE_ENV == 'production') {
Logger.log('Running Immich Microservices in PRODUCTION environment', 'ImmichMicroservice');
}
});
}
bootstrap();

View File

@@ -0,0 +1,70 @@
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DatabaseModule } from '@app/database';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { ExifEntity } from '@app/database/entities/exif.entity';
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
import { UserEntity } from '@app/database/entities/user.entity';
import { MicroservicesService } from './microservices.service';
import { AssetUploadedProcessor } from './processors/asset-uploaded.processor';
import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor';
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
import { VideoTranscodeProcessor } from './processors/video-transcode.processor';
@Module({
imports: [
DatabaseModule,
TypeOrmModule.forFeature([UserEntity, ExifEntity, AssetEntity, SmartInfoEntity]),
BullModule.forRootAsync({
useFactory: async () => ({
redis: {
host: process.env.REDIS_HOSTNAME || 'immich_redis',
port: 6379,
},
}),
}),
BullModule.registerQueue({
name: 'thumbnail-generator-queue',
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
BullModule.registerQueue({
name: 'asset-uploaded-queue',
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
BullModule.registerQueue({
name: 'metadata-extraction-queue',
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
BullModule.registerQueue({
name: 'video-conversion-queue',
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
],
controllers: [],
providers: [
MicroservicesService,
AssetUploadedProcessor,
ThumbnailGeneratorProcessor,
MetadataExtractionProcessor,
VideoTranscodeProcessor,
],
exports: [],
})
export class MicroservicesModule {}

View File

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class MicroservicesService {
getHello(): string {
return 'Hello World 123!';
}
}

View File

@@ -0,0 +1,67 @@
import { InjectQueue, OnQueueActive, OnQueueCompleted, OnQueueWaiting, Process, Processor } from '@nestjs/bull';
import { Job, Queue } from 'bull';
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { randomUUID } from 'crypto';
@Processor('asset-uploaded-queue')
export class AssetUploadedProcessor {
constructor(
@InjectQueue('thumbnail-generator-queue')
private thumbnailGeneratorQueue: Queue,
@InjectQueue('metadata-extraction-queue')
private metadataExtractionQueue: Queue,
@InjectQueue('video-conversion-queue')
private videoConversionQueue: Queue,
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
) {}
/**
* Post processing uploaded asset to perform the following function if missing
* 1. Generate JPEG Thumbnail
* 2. Generate Webp Thumbnail <-> if JPEG thumbnail exist
* 3. EXIF extractor
* 4. Reverse Geocoding
*
* @param job asset-uploaded
*/
@Process('asset-uploaded')
async processUploadedVideo(job: Job) {
const {
asset,
fileName,
fileSize,
hasThumbnail,
}: { asset: AssetEntity; fileName: string; fileSize: number; hasThumbnail: boolean } = job.data;
if (hasThumbnail) {
// The jobs below depends on the existence of jpeg thumbnail
await this.thumbnailGeneratorQueue.add('generate-webp-thumbnail', { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add('tag-image', { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add('detect-object', { asset }, { jobId: randomUUID() });
} else {
// Generate Thumbnail -> Then generate webp, tag image and detect object
}
// Video Conversion
if (asset.type == AssetType.VIDEO) {
await this.videoConversionQueue.add('mp4-conversion', { asset }, { jobId: randomUUID() });
} else {
// Extract Metadata/Exif for Images - Currently the library cannot extract EXIF for video yet
await this.metadataExtractionQueue.add(
'exif-extraction',
{
asset,
fileName,
fileSize,
},
{ jobId: randomUUID() },
);
}
}
}

View File

@@ -0,0 +1,131 @@
import { Process, Processor } from '@nestjs/bull';
import { Job } from 'bull';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { Repository } from 'typeorm/repository/Repository';
import { InjectRepository } from '@nestjs/typeorm';
import { ExifEntity } from '@app/database/entities/exif.entity';
import exifr from 'exifr';
import mapboxGeocoding, { GeocodeService } from '@mapbox/mapbox-sdk/services/geocoding';
import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response';
import { readFile } from 'fs/promises';
import { Logger } from '@nestjs/common';
import axios from 'axios';
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
@Processor('metadata-extraction-queue')
export class MetadataExtractionProcessor {
private geocodingClient: GeocodeService;
constructor(
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
@InjectRepository(ExifEntity)
private exifRepository: Repository<ExifEntity>,
@InjectRepository(SmartInfoEntity)
private smartInfoRepository: Repository<SmartInfoEntity>,
) {
if (process.env.ENABLE_MAPBOX) {
this.geocodingClient = mapboxGeocoding({
accessToken: process.env.MAPBOX_KEY,
});
}
}
@Process('exif-extraction')
async extractExifInfo(job: Job) {
try {
const { asset, fileName, fileSize }: { asset: AssetEntity; fileName: string; fileSize: number } = job.data;
const fileBuffer = await readFile(asset.originalPath);
const exifData = await exifr.parse(fileBuffer);
const newExif = new ExifEntity();
newExif.assetId = asset.id;
newExif.make = exifData['Make'] || null;
newExif.model = exifData['Model'] || null;
newExif.imageName = fileName || null;
newExif.exifImageHeight = exifData['ExifImageHeight'] || null;
newExif.exifImageWidth = exifData['ExifImageWidth'] || null;
newExif.fileSizeInByte = fileSize || null;
newExif.orientation = exifData['Orientation'] || null;
newExif.dateTimeOriginal = exifData['DateTimeOriginal'] || null;
newExif.modifyDate = exifData['ModifyDate'] || null;
newExif.lensModel = exifData['LensModel'] || null;
newExif.fNumber = exifData['FNumber'] || null;
newExif.focalLength = exifData['FocalLength'] || null;
newExif.iso = exifData['ISO'] || null;
newExif.exposureTime = exifData['ExposureTime'] || null;
newExif.latitude = exifData['latitude'] || null;
newExif.longitude = exifData['longitude'] || null;
// Reverse GeoCoding
if (process.env.ENABLE_MAPBOX && exifData['longitude'] && exifData['latitude']) {
const geoCodeInfo: MapiResponse = await this.geocodingClient
.reverseGeocode({
query: [exifData['longitude'], exifData['latitude']],
types: ['country', 'region', 'place'],
})
.send();
const res: [] = geoCodeInfo.body['features'];
const city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
const state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
const country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
newExif.city = city || null;
newExif.state = state || null;
newExif.country = country || null;
}
await this.exifRepository.save(newExif);
} catch (e) {
Logger.error(`Error extracting EXIF ${e.toString()}`, 'extractExif');
}
}
@Process({ name: 'tag-image', concurrency: 2 })
async tagImage(job: Job) {
const { asset }: { asset: AssetEntity } = job.data;
const res = await axios.post('http://immich-machine-learning:3001/image-classifier/tag-image', {
thumbnailPath: asset.resizePath,
});
if (res.status == 201 && res.data.length > 0) {
const smartInfo = new SmartInfoEntity();
smartInfo.assetId = asset.id;
smartInfo.tags = [...res.data];
await this.smartInfoRepository.upsert(smartInfo, {
conflictPaths: ['assetId'],
});
}
}
@Process({ name: 'detect-object', concurrency: 2 })
async detectObject(job: Job) {
try {
const { asset }: { asset: AssetEntity } = job.data;
const res = await axios.post('http://immich-machine-learning:3001/object-detection/detect-object', {
thumbnailPath: asset.resizePath,
});
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'],
});
}
} catch (error) {
Logger.error(`Failed to trigger object detection pipe line ${error.toString()}`);
}
}
}

View File

@@ -0,0 +1,37 @@
import { Process, Processor } from '@nestjs/bull';
import { Job } from 'bull';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { Repository } from 'typeorm/repository/Repository';
import { InjectRepository } from '@nestjs/typeorm';
import sharp from 'sharp';
@Processor('thumbnail-generator-queue')
export class ThumbnailGeneratorProcessor {
constructor(
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
) {}
@Process('generate-jpeg-thumbnail')
async generateJPEGThumbnail(job: Job) {
const { asset }: { asset: AssetEntity } = job.data;
console.log(asset);
}
@Process({ name: 'generate-webp-thumbnail', concurrency: 2 })
async generateWepbThumbnail(job: Job) {
const { asset }: { asset: AssetEntity } = job.data;
const webpPath = asset.resizePath.replace('jpeg', 'webp');
sharp(asset.resizePath)
.resize(250)
.webp()
.toFile(webpPath, (err, info) => {
if (!err) {
this.assetRepository.update({ id: asset.id }, { webpPath: webpPath });
}
});
}
}

View File

@@ -0,0 +1,59 @@
import { Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Job } from 'bull';
import ffmpeg from 'fluent-ffmpeg';
import { existsSync, mkdirSync } from 'fs';
import { Repository } from 'typeorm';
import { AssetEntity } from '../../../../libs/database/src/entities/asset.entity';
import { APP_UPLOAD_LOCATION } from '../../../immich/src/constants/upload_location.constant';
@Processor('video-conversion-queue')
export class VideoTranscodeProcessor {
constructor(
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
) {}
@Process({ name: 'mp4-conversion', concurrency: 1 })
async mp4Conversion(job: Job) {
const { asset }: { asset: AssetEntity } = job.data;
if (asset.mimeType != 'video/mp4') {
const basePath = APP_UPLOAD_LOCATION;
const encodedVideoPath = `${basePath}/${asset.userId}/encoded-video`;
if (!existsSync(encodedVideoPath)) {
mkdirSync(encodedVideoPath, { recursive: true });
}
const savedEncodedPath = encodedVideoPath + '/' + asset.id + '.mp4';
if (asset.encodedVideoPath == '' || !asset.encodedVideoPath) {
// Put the processing into its own async function to prevent the job exist right away
await this.runFFMPEGPipeLine(asset, savedEncodedPath);
}
}
}
async runFFMPEGPipeLine(asset: AssetEntity, savedEncodedPath: string): Promise<void> {
return new Promise((resolve, reject) => {
ffmpeg(asset.originalPath)
.outputOptions(['-crf 23', '-preset ultrafast', '-vcodec libx264', '-acodec mp3', '-vf scale=1280:-2'])
.output(savedEncodedPath)
.on('start', () => {
Logger.log('Start Converting', 'mp4Conversion');
})
.on('error', (error, b, c) => {
Logger.error(`Cannot Convert Video ${error}`, 'mp4Conversion');
reject();
})
.on('end', async () => {
Logger.log(`Converting Success ${asset.id}`, 'mp4Conversion');
await this.assetRepository.update({ id: asset.id }, { encodedVideoPath: savedEncodedPath });
resolve();
})
.run();
});
}
}

View File

@@ -0,0 +1,21 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { MicroservicesModule } from './../src/microservices.module';
describe('MicroservicesController (e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [MicroservicesModule],
}).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 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"declaration": false,
"outDir": "../../dist/apps/microservices"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}

View File

@@ -1,2 +1 @@
# npm run typeorm migration:run
npm run build && npm run start:prod
npm start immich

View File

@@ -9,11 +9,12 @@ export const databaseConfig: TypeOrmModuleOptions = {
database: process.env.DB_DATABASE_NAME,
entities: [__dirname + '/../**/*.entity.{js,ts}'],
synchronize: false,
migrations: [__dirname + '/../migration/*.{js,ts}'],
migrations: [__dirname + '/../migrations/*.{js,ts}'],
cli: {
migrationsDir: __dirname + '/../migration',
migrationsDir: __dirname + '/../migrations',
},
migrationsRun: true,
autoLoadEntities: true,
};
export default databaseConfig;

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { databaseConfig } from './config/database.config';
@Module({
imports: [TypeOrmModule.forRoot(databaseConfig)],
providers: [],
exports: [TypeOrmModule],
})
export class DatabaseModule {}

View File

@@ -1,5 +1,5 @@
import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
import { AssetEntity } from '../../asset/entities/asset.entity';
import { AssetEntity } from './asset.entity';
import { SharedAlbumEntity } from './shared-album.entity';
@Entity('asset_shared_album')

View File

@@ -1,5 +1,5 @@
import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
import { UserEntity } from '../../user/entities/user.entity';
import { UserEntity } from './user.entity';
import { SharedAlbumEntity } from './shared-album.entity';
@Entity('user_shared_album')

View File

@@ -0,0 +1 @@
export * from './database.module';

Some files were not shown because too many files have changed in this diff Show More