mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00: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:
@@ -1,158 +0,0 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
UseInterceptors,
|
||||
UploadedFiles,
|
||||
Body,
|
||||
UseGuards,
|
||||
Get,
|
||||
Param,
|
||||
ValidationPipe,
|
||||
StreamableFile,
|
||||
Query,
|
||||
Response,
|
||||
Headers,
|
||||
Delete,
|
||||
Logger,
|
||||
Patch,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
||||
import { AssetService } from './asset.service';
|
||||
import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
||||
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 { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto';
|
||||
import { Response as Res } from 'express';
|
||||
import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto';
|
||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
||||
import { SearchAssetDto } from './dto/search-asset.dto';
|
||||
import { CommunicationGateway } from '../communication/communication.gateway';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('asset')
|
||||
export class AssetController {
|
||||
constructor(
|
||||
private wsCommunicateionGateway: CommunicationGateway,
|
||||
private assetService: AssetService,
|
||||
private backgroundTaskService: BackgroundTaskService,
|
||||
) { }
|
||||
|
||||
@Post('upload')
|
||||
@UseInterceptors(
|
||||
FileFieldsInterceptor(
|
||||
[
|
||||
{ name: 'assetData', maxCount: 1 },
|
||||
{ name: 'thumbnailData', maxCount: 1 },
|
||||
],
|
||||
assetUploadOption,
|
||||
),
|
||||
)
|
||||
async uploadFile(
|
||||
@GetAuthUser() authUser,
|
||||
@UploadedFiles() uploadFiles: { assetData: Express.Multer.File[]; thumbnailData?: Express.Multer.File[] },
|
||||
@Body(ValidationPipe) assetInfo: CreateAssetDto,
|
||||
) {
|
||||
for (const file of uploadFiles.assetData) {
|
||||
try {
|
||||
const savedAsset = await this.assetService.createUserAsset(authUser, assetInfo, file.path, file.mimetype);
|
||||
|
||||
if (uploadFiles.thumbnailData != null && savedAsset) {
|
||||
await this.assetService.updateThumbnailInfo(savedAsset.id, uploadFiles.thumbnailData[0].path);
|
||||
await this.backgroundTaskService.tagImage(uploadFiles.thumbnailData[0].path, savedAsset);
|
||||
await this.backgroundTaskService.detectObject(uploadFiles.thumbnailData[0].path, savedAsset);
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
@Get('/download')
|
||||
async downloadFile(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@Response({ passthrough: true }) res: Res,
|
||||
@Query(ValidationPipe) query: ServeFileDto,
|
||||
) {
|
||||
return this.assetService.downloadFile(query, res);
|
||||
}
|
||||
|
||||
@Get('/file')
|
||||
async serveFile(
|
||||
@Headers() headers,
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@Response({ passthrough: true }) res: Res,
|
||||
@Query(ValidationPipe) query: ServeFileDto,
|
||||
): Promise<StreamableFile> {
|
||||
return this.assetService.serveFile(authUser, query, res, headers);
|
||||
}
|
||||
|
||||
@Get('/thumbnail/:assetId')
|
||||
async getAssetThumbnail(@Param('assetId') assetId: string): Promise<StreamableFile> {
|
||||
return await this.assetService.getAssetThumbnail(assetId);
|
||||
}
|
||||
|
||||
@Get('/allObjects')
|
||||
async getCuratedObject(@GetAuthUser() authUser: AuthUserDto) {
|
||||
return this.assetService.getCuratedObject(authUser);
|
||||
}
|
||||
|
||||
@Get('/allLocation')
|
||||
async getCuratedLocation(@GetAuthUser() authUser: AuthUserDto) {
|
||||
return this.assetService.getCuratedLocation(authUser);
|
||||
}
|
||||
|
||||
@Get('/searchTerm')
|
||||
async getAssetSearchTerm(@GetAuthUser() authUser: AuthUserDto) {
|
||||
return this.assetService.getAssetSearchTerm(authUser);
|
||||
}
|
||||
|
||||
@Post('/search')
|
||||
async searchAsset(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) searchAssetDto: SearchAssetDto) {
|
||||
return this.assetService.searchAsset(authUser, searchAssetDto);
|
||||
}
|
||||
|
||||
@Get('/')
|
||||
async getAllAssets(@GetAuthUser() authUser: AuthUserDto) {
|
||||
return await this.assetService.getAllAssets(authUser);
|
||||
}
|
||||
|
||||
@Get('/:deviceId')
|
||||
async getUserAssetsByDeviceId(@GetAuthUser() authUser: AuthUserDto, @Param('deviceId') deviceId: string) {
|
||||
return await this.assetService.getUserAssetsByDeviceId(authUser, deviceId);
|
||||
}
|
||||
|
||||
@Get('/assetById/:assetId')
|
||||
async getAssetById(@GetAuthUser() authUser: AuthUserDto, @Param('assetId') assetId) {
|
||||
return await this.assetService.getAssetById(authUser, assetId);
|
||||
}
|
||||
|
||||
@Delete('/')
|
||||
async deleteAssetById(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) assetIds: DeleteAssetDto) {
|
||||
const deleteAssetList: AssetEntity[] = [];
|
||||
|
||||
for (const id of assetIds.ids) {
|
||||
const assets = await this.assetService.getAssetById(authUser, id);
|
||||
deleteAssetList.push(assets);
|
||||
}
|
||||
|
||||
const result = await this.assetService.deleteAssetById(authUser, assetIds);
|
||||
|
||||
result.forEach((res) => {
|
||||
deleteAssetList.filter((a) => a.id == res.id && res.status == 'success');
|
||||
});
|
||||
|
||||
await this.backgroundTaskService.deleteFileOnDisk(deleteAssetList);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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 { BullModule } from '@nestjs/bull';
|
||||
import { BackgroundTaskModule } from '../../modules/background-task/background-task.module';
|
||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||
import { CommunicationModule } from '../communication/communication.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
CommunicationModule,
|
||||
|
||||
BullModule.registerQueue({
|
||||
name: 'optimize',
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
},
|
||||
}),
|
||||
BullModule.registerQueue({
|
||||
name: 'background-task',
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
},
|
||||
}),
|
||||
TypeOrmModule.forFeature([AssetEntity]),
|
||||
ImageOptimizeModule,
|
||||
BackgroundTaskModule,
|
||||
],
|
||||
controllers: [AssetController],
|
||||
providers: [AssetService, AssetOptimizeService, BackgroundTaskService],
|
||||
exports: [],
|
||||
})
|
||||
export class AssetModule { }
|
||||
@@ -1,365 +0,0 @@
|
||||
import { BadRequestException, Injectable, 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 _ from 'lodash';
|
||||
import { createReadStream, stat } from 'fs';
|
||||
import { ServeFileDto } from './dto/serve-file.dto';
|
||||
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);
|
||||
|
||||
@Injectable()
|
||||
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 createUserAsset(authUser: AuthUserDto, assetInfo: CreateAssetDto, path: string, mimeType: string) {
|
||||
const asset = new AssetEntity();
|
||||
asset.deviceAssetId = assetInfo.deviceAssetId;
|
||||
asset.userId = authUser.id;
|
||||
asset.deviceId = assetInfo.deviceId;
|
||||
asset.type = assetInfo.assetType || AssetType.OTHER;
|
||||
asset.originalPath = path;
|
||||
asset.createdAt = assetInfo.createdAt;
|
||||
asset.modifiedAt = assetInfo.modifiedAt;
|
||||
asset.isFavorite = assetInfo.isFavorite;
|
||||
asset.mimeType = mimeType;
|
||||
asset.duration = assetInfo.duration;
|
||||
|
||||
try {
|
||||
return await this.assetRepository.save(asset);
|
||||
} catch (e) {
|
||||
Logger.error(`Error Create New Asset ${e}`, 'createUserAsset');
|
||||
}
|
||||
}
|
||||
|
||||
public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) {
|
||||
const rows = await this.assetRepository.find({
|
||||
where: {
|
||||
userId: authUser.id,
|
||||
deviceId: deviceId,
|
||||
},
|
||||
select: ['deviceAssetId'],
|
||||
});
|
||||
|
||||
const res = [];
|
||||
rows.forEach((v) => res.push(v.deviceAssetId));
|
||||
return res;
|
||||
}
|
||||
|
||||
public async getAllAssets(authUser: AuthUserDto) {
|
||||
try {
|
||||
return await this.assetRepository.find({
|
||||
where: {
|
||||
userId: authUser.id
|
||||
},
|
||||
relations: ['exifInfo'],
|
||||
order: {
|
||||
createdAt: 'DESC'
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
Logger.error(e, 'getAllAssets');
|
||||
}
|
||||
}
|
||||
|
||||
public async findOne(deviceId: string, assetId: string): Promise<AssetEntity> {
|
||||
const rows = await this.assetRepository.query(
|
||||
'SELECT * FROM assets a WHERE a."deviceAssetId" = $1 AND a."deviceId" = $2',
|
||||
[assetId, deviceId],
|
||||
);
|
||||
|
||||
if (rows.lengh == 0) {
|
||||
throw new BadRequestException('Not Found');
|
||||
}
|
||||
|
||||
return rows[0] as AssetEntity;
|
||||
}
|
||||
|
||||
public async getAssetById(authUser: AuthUserDto, assetId: string) {
|
||||
return await this.assetRepository.findOne({
|
||||
where: {
|
||||
id: assetId,
|
||||
},
|
||||
relations: ['exifInfo'],
|
||||
});
|
||||
}
|
||||
|
||||
public async downloadFile(query: ServeFileDto, res: Res) {
|
||||
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);
|
||||
}
|
||||
|
||||
return new StreamableFile(file);
|
||||
}
|
||||
|
||||
public async getAssetThumbnail(assetId: string) {
|
||||
const asset = await this.assetRepository.findOne({ id: assetId });
|
||||
|
||||
if (asset.webpPath != '') {
|
||||
return new StreamableFile(createReadStream(asset.webpPath));
|
||||
} else {
|
||||
return new StreamableFile(createReadStream(asset.resizePath));
|
||||
}
|
||||
}
|
||||
|
||||
public async serveFile(authUser: AuthUserDto, query: ServeFileDto, res: Res, headers: any) {
|
||||
let file = null;
|
||||
const asset = await this.findOne(query.did, query.aid);
|
||||
|
||||
if (!asset) {
|
||||
throw new BadRequestException('Asset does not exist');
|
||||
}
|
||||
|
||||
|
||||
// Handle Sending Images
|
||||
if (asset.type == AssetType.IMAGE || query.isThumb == 'true') {
|
||||
/**
|
||||
* Serve file viewer on the web
|
||||
*/
|
||||
if (query.isWeb) {
|
||||
res.set({
|
||||
'Content-Type': 'image/jpeg',
|
||||
});
|
||||
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 != '') {
|
||||
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);
|
||||
|
||||
} 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}`,
|
||||
});
|
||||
|
||||
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': mimeType,
|
||||
});
|
||||
|
||||
return new StreamableFile(createReadStream(videoPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteAssetById(authUser: AuthUserDto, assetIds: DeleteAssetDto) {
|
||||
const result = [];
|
||||
|
||||
const target = assetIds.ids;
|
||||
for (const assetId of target) {
|
||||
const res = await this.assetRepository.delete({
|
||||
id: assetId,
|
||||
userId: authUser.id,
|
||||
});
|
||||
|
||||
if (res.affected) {
|
||||
result.push({
|
||||
id: assetId,
|
||||
status: 'success',
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
id: assetId,
|
||||
status: 'failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getAssetSearchTerm(authUser: AuthUserDto): Promise<string[]> {
|
||||
const possibleSearchTerm = new Set<string>();
|
||||
const rows = await this.assetRepository.query(
|
||||
`
|
||||
select distinct si.tags, si.objects, e.orientation, e."lensModel", e.make, e.model , a.type, e.city, e.state, e.country
|
||||
from assets a
|
||||
left join exif e on a.id = e."assetId"
|
||||
left join smart_info si on a.id = si."assetId"
|
||||
where a."userId" = $1;
|
||||
`,
|
||||
[authUser.id],
|
||||
);
|
||||
|
||||
rows.forEach((row) => {
|
||||
// tags
|
||||
row['tags']?.map((tag) => possibleSearchTerm.add(tag?.toLowerCase()));
|
||||
|
||||
// objects
|
||||
row['objects']?.map((object) => possibleSearchTerm.add(object?.toLowerCase()));
|
||||
|
||||
// asset's tyoe
|
||||
possibleSearchTerm.add(row['type']?.toLowerCase());
|
||||
|
||||
// image orientation
|
||||
possibleSearchTerm.add(row['orientation']?.toLowerCase());
|
||||
|
||||
// Lens model
|
||||
possibleSearchTerm.add(row['lensModel']?.toLowerCase());
|
||||
|
||||
// Make and model
|
||||
possibleSearchTerm.add(row['make']?.toLowerCase());
|
||||
possibleSearchTerm.add(row['model']?.toLowerCase());
|
||||
|
||||
// Location
|
||||
possibleSearchTerm.add(row['city']?.toLowerCase());
|
||||
possibleSearchTerm.add(row['state']?.toLowerCase());
|
||||
possibleSearchTerm.add(row['country']?.toLowerCase());
|
||||
});
|
||||
|
||||
return Array.from(possibleSearchTerm).filter((x) => x != null);
|
||||
}
|
||||
|
||||
async searchAsset(authUser: AuthUserDto, searchAssetDto: SearchAssetDto) {
|
||||
const query = `
|
||||
SELECT a.*
|
||||
FROM assets a
|
||||
LEFT JOIN smart_info si ON a.id = si."assetId"
|
||||
LEFT JOIN exif e ON a.id = e."assetId"
|
||||
|
||||
WHERE a."userId" = $1
|
||||
AND
|
||||
(
|
||||
TO_TSVECTOR('english', ARRAY_TO_STRING(si.tags, ',')) @@ PLAINTO_TSQUERY('english', $2) OR
|
||||
TO_TSVECTOR('english', ARRAY_TO_STRING(si.objects, ',')) @@ PLAINTO_TSQUERY('english', $2) OR
|
||||
e.exif_text_searchable_column @@ PLAINTO_TSQUERY('english', $2)
|
||||
);
|
||||
`;
|
||||
|
||||
return await this.assetRepository.query(query, [authUser.id, searchAssetDto.searchTerm]);
|
||||
}
|
||||
|
||||
async getCuratedLocation(authUser: AuthUserDto) {
|
||||
return await this.assetRepository.query(
|
||||
`
|
||||
select distinct on (e.city) a.id, e.city, a."resizePath", a."deviceAssetId", a."deviceId"
|
||||
from assets a
|
||||
left join exif e on a.id = e."assetId"
|
||||
where a."userId" = $1
|
||||
and e.city is not null
|
||||
and a.type = 'IMAGE';
|
||||
`,
|
||||
[authUser.id],
|
||||
);
|
||||
}
|
||||
|
||||
async getCuratedObject(authUser: AuthUserDto) {
|
||||
return await this.assetRepository.query(
|
||||
`
|
||||
select distinct on (unnest(si.objects)) a.id, unnest(si.objects) as "object", a."resizePath", a."deviceAssetId", a."deviceId"
|
||||
from assets a
|
||||
left join smart_info si on a.id = si."assetId"
|
||||
where a."userId" = $1
|
||||
and si.objects is not null
|
||||
`,
|
||||
[authUser.id],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { IsNotEmpty, IsOptional } from 'class-validator';
|
||||
import { AssetType } from '../entities/asset.entity';
|
||||
|
||||
export class CreateAssetDto {
|
||||
@IsNotEmpty()
|
||||
deviceAssetId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
deviceId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
assetType: AssetType;
|
||||
|
||||
@IsNotEmpty()
|
||||
createdAt: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
modifiedAt: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
isFavorite: boolean;
|
||||
|
||||
@IsNotEmpty()
|
||||
fileExtension: string;
|
||||
|
||||
@IsOptional()
|
||||
duration: string;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { IsNotEmpty, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateExifDto {
|
||||
@IsNotEmpty()
|
||||
assetId: string;
|
||||
|
||||
@IsOptional()
|
||||
make: string;
|
||||
|
||||
@IsOptional()
|
||||
model: string;
|
||||
|
||||
@IsOptional()
|
||||
imageName: string;
|
||||
|
||||
@IsOptional()
|
||||
exifImageWidth: number;
|
||||
|
||||
@IsOptional()
|
||||
exifImageHeight: number;
|
||||
|
||||
@IsOptional()
|
||||
fileSizeInByte: number;
|
||||
|
||||
@IsOptional()
|
||||
orientation: string;
|
||||
|
||||
@IsOptional()
|
||||
dateTimeOriginal: Date;
|
||||
|
||||
@IsOptional()
|
||||
modifiedDate: Date;
|
||||
|
||||
@IsOptional()
|
||||
lensModel: string;
|
||||
|
||||
@IsOptional()
|
||||
fNumber: number;
|
||||
|
||||
@IsOptional()
|
||||
focalLenght: number;
|
||||
|
||||
@IsOptional()
|
||||
iso: number;
|
||||
|
||||
@IsOptional()
|
||||
exposureTime: number;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class DeleteAssetDto {
|
||||
@IsNotEmpty()
|
||||
ids: string[];
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { IsNotEmpty, IsOptional } from 'class-validator';
|
||||
|
||||
export class GetAllAssetQueryDto {
|
||||
@IsOptional()
|
||||
nextPageKey: string;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { AssetEntity } from '../entities/asset.entity';
|
||||
|
||||
export class GetAllAssetReponseDto {
|
||||
data: Array<{ date: string; assets: Array<AssetEntity> }>;
|
||||
count: number;
|
||||
nextPageKey: string;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class GetAssetDto {
|
||||
@IsNotEmpty()
|
||||
deviceId: string;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class GetNewAssetQueryDto {
|
||||
@IsNotEmpty()
|
||||
latestDate: string;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class SearchAssetDto {
|
||||
@IsNotEmpty()
|
||||
searchTerm: string;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsBooleanString, IsNotEmpty, IsOptional } from 'class-validator';
|
||||
|
||||
export class ServeFileDto {
|
||||
//assetId
|
||||
@IsNotEmpty()
|
||||
aid: string;
|
||||
|
||||
//deviceId
|
||||
@IsNotEmpty()
|
||||
did: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBooleanString()
|
||||
isThumb: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBooleanString()
|
||||
isWeb: string;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateAssetDto } from './create-asset.dto';
|
||||
|
||||
export class UpdateAssetDto extends PartialType(CreateAssetDto) {}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateExifDto } from './create-exif.dto';
|
||||
|
||||
export class UpdateExifDto extends PartialType(CreateExifDto) {}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn, PrimaryGeneratedColumn, Unique } from 'typeorm';
|
||||
import { ExifEntity } from './exif.entity';
|
||||
import { SmartInfoEntity } from './smart-info.entity';
|
||||
|
||||
@Entity('assets')
|
||||
@Unique(['deviceAssetId', 'userId', 'deviceId'])
|
||||
export class AssetEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
deviceAssetId: string;
|
||||
|
||||
@Column()
|
||||
userId: string;
|
||||
|
||||
@Column()
|
||||
deviceId: string;
|
||||
|
||||
@Column()
|
||||
type: AssetType;
|
||||
|
||||
@Column()
|
||||
originalPath: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
resizePath: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
webpPath: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
encodedVideoPath: string;
|
||||
|
||||
@Column()
|
||||
createdAt: string;
|
||||
|
||||
@Column()
|
||||
modifiedAt: string;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isFavorite: boolean;
|
||||
|
||||
@Column({ nullable: true })
|
||||
mimeType: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
duration: string;
|
||||
|
||||
@OneToOne(() => ExifEntity, (exifEntity) => exifEntity.asset)
|
||||
exifInfo: ExifEntity;
|
||||
|
||||
@OneToOne(() => SmartInfoEntity, (smartInfoEntity) => smartInfoEntity.asset)
|
||||
smartInfo: SmartInfoEntity;
|
||||
}
|
||||
|
||||
export enum AssetType {
|
||||
IMAGE = 'IMAGE',
|
||||
VIDEO = 'VIDEO',
|
||||
AUDIO = 'AUDIO',
|
||||
OTHER = 'OTHER',
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Index, JoinColumn, OneToOne } from 'typeorm';
|
||||
import { Column } from 'typeorm/decorator/columns/Column';
|
||||
import { PrimaryGeneratedColumn } from 'typeorm/decorator/columns/PrimaryGeneratedColumn';
|
||||
import { Entity } from 'typeorm/decorator/entity/Entity';
|
||||
import { AssetEntity } from './asset.entity';
|
||||
|
||||
@Entity('exif')
|
||||
export class ExifEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: string;
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column({ type: 'uuid' })
|
||||
assetId: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
make: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
model: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
imageName: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
exifImageWidth: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
exifImageHeight: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
fileSizeInByte: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
orientation: string;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
dateTimeOriginal: Date;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
modifyDate: Date;
|
||||
|
||||
@Column({ nullable: true })
|
||||
lensModel: string;
|
||||
|
||||
@Column({ type: 'float8', nullable: true })
|
||||
fNumber: number;
|
||||
|
||||
@Column({ type: 'float8', nullable: true })
|
||||
focalLength: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
iso: number;
|
||||
|
||||
@Column({ type: 'float', nullable: true })
|
||||
exposureTime: number;
|
||||
|
||||
@Column({ type: 'float', nullable: true })
|
||||
latitude: number;
|
||||
|
||||
@Column({ type: 'float', nullable: true })
|
||||
longitude: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
city: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
state: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
country: string;
|
||||
|
||||
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
|
||||
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
|
||||
asset: ExifEntity;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Column, Entity, Index, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { AssetEntity } from './asset.entity';
|
||||
|
||||
@Entity('smart_info')
|
||||
export class SmartInfoEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: string;
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column({ type: 'uuid' })
|
||||
assetId: string;
|
||||
|
||||
@Column({ type: 'text', array: true, nullable: true })
|
||||
tags: string[];
|
||||
|
||||
@Column({ type: 'text', array: true, nullable: true })
|
||||
objects: string[];
|
||||
|
||||
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
|
||||
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
|
||||
asset: SmartInfoEntity;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Body, Controller, Post, UseGuards, ValidationPipe } from '@nestjs/common';
|
||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginCredentialDto } from './dto/login-credential.dto';
|
||||
import { SignUpDto } from './dto/sign-up.dto';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) { }
|
||||
|
||||
@Post('/login')
|
||||
async login(@Body(ValidationPipe) loginCredential: LoginCredentialDto) {
|
||||
return await this.authService.login(loginCredential);
|
||||
}
|
||||
|
||||
@Post('/admin-sign-up')
|
||||
async adminSignUp(@Body(ValidationPipe) signUpCrendential: SignUpDto) {
|
||||
return await this.authService.adminSignUp(signUpCrendential);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('/validateToken')
|
||||
async validateToken(@GetAuthUser() authUser: AuthUserDto) {
|
||||
return {
|
||||
authStatus: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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 { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
||||
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { jwtConfig } from '../../config/jwt.config';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, ImmichJwtService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -1,101 +0,0 @@
|
||||
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 { LoginCredentialDto } from './dto/login-credential.dto';
|
||||
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
||||
import { JwtPayloadDto } from './dto/jwt-payload.dto';
|
||||
import { SignUpDto } from './dto/sign-up.dto';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { mapUser, User } from '../user/response-dto/user';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@InjectRepository(UserEntity)
|
||||
private userRepository: Repository<UserEntity>,
|
||||
private immichJwtService: ImmichJwtService,
|
||||
) {}
|
||||
|
||||
private async validateUser(loginCredential: LoginCredentialDto): Promise<UserEntity> {
|
||||
const user = await this.userRepository.findOne(
|
||||
{ email: loginCredential.email },
|
||||
{
|
||||
select: [
|
||||
'id',
|
||||
'email',
|
||||
'password',
|
||||
'salt',
|
||||
'firstName',
|
||||
'lastName',
|
||||
'isAdmin',
|
||||
'profileImagePath',
|
||||
'isFirstLoggedIn',
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const isAuthenticated = await this.validatePassword(user.password, loginCredential.password, user.salt);
|
||||
|
||||
if (user && isAuthenticated) {
|
||||
return user;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async login(loginCredential: LoginCredentialDto) {
|
||||
const validatedUser = await this.validateUser(loginCredential);
|
||||
|
||||
if (!validatedUser) {
|
||||
throw new BadRequestException('Incorrect email or password');
|
||||
}
|
||||
|
||||
const payload = new JwtPayloadDto(validatedUser.id, validatedUser.email);
|
||||
|
||||
return {
|
||||
accessToken: await this.immichJwtService.generateToken(payload),
|
||||
userId: validatedUser.id,
|
||||
userEmail: validatedUser.email,
|
||||
firstName: validatedUser.firstName,
|
||||
lastName: validatedUser.lastName,
|
||||
isAdmin: validatedUser.isAdmin,
|
||||
profileImagePath: validatedUser.profileImagePath,
|
||||
isFirstLogin: validatedUser.isFirstLoggedIn,
|
||||
};
|
||||
}
|
||||
|
||||
public async adminSignUp(signUpCredential: SignUpDto): Promise<User> {
|
||||
const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } });
|
||||
|
||||
if (adminUser) {
|
||||
throw new BadRequestException('The server already has an admin');
|
||||
}
|
||||
|
||||
const newAdminUser = new UserEntity();
|
||||
newAdminUser.email = signUpCredential.email;
|
||||
newAdminUser.salt = await bcrypt.genSalt();
|
||||
newAdminUser.password = await this.hashPassword(signUpCredential.password, newAdminUser.salt);
|
||||
newAdminUser.firstName = signUpCredential.firstName;
|
||||
newAdminUser.lastName = signUpCredential.lastName;
|
||||
newAdminUser.isAdmin = true;
|
||||
|
||||
try {
|
||||
const savedNewAdminUserUser = await this.userRepository.save(newAdminUser);
|
||||
|
||||
return mapUser(savedNewAdminUserUser);
|
||||
} catch (e) {
|
||||
Logger.error('e', 'signUp');
|
||||
throw new InternalServerErrorException('Failed to register new admin user');
|
||||
}
|
||||
}
|
||||
|
||||
private async hashPassword(password: string, salt: string): Promise<string> {
|
||||
return bcrypt.hash(password, salt);
|
||||
}
|
||||
|
||||
private async validatePassword(hasedPassword: string, inputPassword: string, salt: string): Promise<boolean> {
|
||||
const hash = await bcrypt.hash(inputPassword, salt);
|
||||
return hash === hasedPassword;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export class JwtPayloadDto {
|
||||
constructor(userId: string, email: string) {
|
||||
this.userId = userId;
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
userId: string;
|
||||
email: string;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class LoginCredentialDto {
|
||||
@IsNotEmpty()
|
||||
email: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class SignUpDto {
|
||||
@IsNotEmpty()
|
||||
email: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
firstName: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
lastName: string;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
||||
import { CommunicationService } from './communication.service';
|
||||
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 { Repository } from 'typeorm';
|
||||
|
||||
@WebSocketGateway()
|
||||
export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
constructor(
|
||||
private immichJwtService: ImmichJwtService,
|
||||
|
||||
@InjectRepository(UserEntity)
|
||||
private userRepository: Repository<UserEntity>,
|
||||
) {}
|
||||
|
||||
@WebSocketServer() server: Server;
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
client.leave(client.nsp.name);
|
||||
|
||||
Logger.log(`Client ${client.id} disconnected`);
|
||||
}
|
||||
|
||||
async handleConnection(client: Socket, ...args: any[]) {
|
||||
Logger.log(`New websocket connection: ${client.id}`, 'NewWebSocketConnection');
|
||||
const accessToken = client.handshake.headers.authorization.split(' ')[1];
|
||||
const res = await this.immichJwtService.validateToken(accessToken);
|
||||
|
||||
if (!res.status) {
|
||||
client.emit('error', 'unauthorized');
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findOne({ where: { id: res.userId } });
|
||||
if (!user) {
|
||||
client.emit('error', 'unauthorized');
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
client.join(user.id);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CommunicationService } from './communication.service';
|
||||
import { CommunicationGateway } from './communication.gateway';
|
||||
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
|
||||
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';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)],
|
||||
providers: [CommunicationGateway, CommunicationService, ImmichJwtService],
|
||||
exports: [CommunicationGateway],
|
||||
})
|
||||
export class CommunicationModule {}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class CommunicationService {}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, ValidationPipe } from '@nestjs/common';
|
||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
||||
import { DeviceInfoService } from './device-info.service';
|
||||
import { CreateDeviceInfoDto } from './dto/create-device-info.dto';
|
||||
import { UpdateDeviceInfoDto } from './dto/update-device-info.dto';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('device-info')
|
||||
export class DeviceInfoController {
|
||||
constructor(private readonly deviceInfoService: DeviceInfoService) {}
|
||||
|
||||
@Post()
|
||||
async create(@Body(ValidationPipe) createDeviceInfoDto: CreateDeviceInfoDto, @GetAuthUser() authUser: AuthUserDto) {
|
||||
return await this.deviceInfoService.create(createDeviceInfoDto, authUser);
|
||||
}
|
||||
|
||||
@Patch()
|
||||
async update(@Body(ValidationPipe) updateDeviceInfoDto: UpdateDeviceInfoDto, @GetAuthUser() authUser: AuthUserDto) {
|
||||
return this.deviceInfoService.update(authUser.id, updateDeviceInfoDto);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([DeviceInfoEntity])],
|
||||
controllers: [DeviceInfoController],
|
||||
providers: [DeviceInfoService],
|
||||
})
|
||||
export class DeviceInfoModule {}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { BadRequestException, HttpCode, Injectable, Logger, Res } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class DeviceInfoService {
|
||||
constructor(
|
||||
@InjectRepository(DeviceInfoEntity)
|
||||
private deviceRepository: Repository<DeviceInfoEntity>,
|
||||
) {}
|
||||
|
||||
async create(createDeviceInfoDto: CreateDeviceInfoDto, authUser: AuthUserDto) {
|
||||
const res = await this.deviceRepository.findOne({
|
||||
deviceId: createDeviceInfoDto.deviceId,
|
||||
userId: authUser.id,
|
||||
});
|
||||
|
||||
if (res) {
|
||||
Logger.log('Device Info Exist', 'createDeviceInfo');
|
||||
return res;
|
||||
}
|
||||
|
||||
const deviceInfo = new DeviceInfoEntity();
|
||||
deviceInfo.deviceId = createDeviceInfoDto.deviceId;
|
||||
deviceInfo.deviceType = createDeviceInfoDto.deviceType;
|
||||
deviceInfo.userId = authUser.id;
|
||||
|
||||
try {
|
||||
return await this.deviceRepository.save(deviceInfo);
|
||||
} catch (e) {
|
||||
Logger.error('Error creating new device info', 'createDeviceInfo');
|
||||
}
|
||||
}
|
||||
|
||||
async update(userId: string, updateDeviceInfoDto: UpdateDeviceInfoDto) {
|
||||
const deviceInfo = await this.deviceRepository.findOne({
|
||||
where: { deviceId: updateDeviceInfoDto.deviceId, userId: userId },
|
||||
});
|
||||
|
||||
if (!deviceInfo) {
|
||||
throw new BadRequestException('Device Not Found');
|
||||
}
|
||||
|
||||
const res = await this.deviceRepository.update(
|
||||
{
|
||||
id: deviceInfo.id,
|
||||
},
|
||||
updateDeviceInfoDto,
|
||||
);
|
||||
|
||||
if (res.affected == 1) {
|
||||
return await this.deviceRepository.findOne({
|
||||
where: { deviceId: updateDeviceInfoDto.deviceId, userId: userId },
|
||||
});
|
||||
} else {
|
||||
throw new BadRequestException('Bad Request');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { IsNotEmpty, IsOptional } from 'class-validator';
|
||||
import { DeviceType } from '../entities/device-info.entity';
|
||||
|
||||
export class CreateDeviceInfoDto {
|
||||
@IsNotEmpty()
|
||||
deviceId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
deviceType: DeviceType;
|
||||
|
||||
@IsOptional()
|
||||
isAutoBackup: boolean;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { IsOptional } from 'class-validator';
|
||||
import { DeviceType } from '../entities/device-info.entity';
|
||||
import { CreateDeviceInfoDto } from './create-device-info.dto';
|
||||
|
||||
export class UpdateDeviceInfoDto extends PartialType(CreateDeviceInfoDto) {}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, Unique } from 'typeorm';
|
||||
|
||||
@Entity('device_info')
|
||||
@Unique(['userId', 'deviceId'])
|
||||
export class DeviceInfoEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column()
|
||||
userId: string;
|
||||
|
||||
@Column()
|
||||
deviceId: string;
|
||||
|
||||
@Column()
|
||||
deviceType: DeviceType;
|
||||
|
||||
@Column({ nullable: true })
|
||||
notificationToken: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: string;
|
||||
|
||||
@Column({ type: 'bool', default: false })
|
||||
isAutoBackup: boolean;
|
||||
}
|
||||
|
||||
export enum DeviceType {
|
||||
IOS = 'IOS',
|
||||
ANDROID = 'ANDROID',
|
||||
WEB = 'WEB',
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export class ServerInfoDto {
|
||||
diskSize: String;
|
||||
diskUse: String;
|
||||
diskAvailable: String;
|
||||
diskSizeRaw: number;
|
||||
diskUseRaw: number;
|
||||
diskAvailableRaw: number;
|
||||
diskUsagePercentage: number;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
||||
import { ServerInfoService } from './server-info.service';
|
||||
import { serverVersion } from '../../constants/server_version.constant';
|
||||
|
||||
@Controller('server-info')
|
||||
export class ServerInfoController {
|
||||
constructor(private readonly serverInfoService: ServerInfoService, private readonly configService: ConfigService) {}
|
||||
|
||||
@Get()
|
||||
async getServerInfo() {
|
||||
return await this.serverInfoService.getServerInfo();
|
||||
}
|
||||
|
||||
@Get('/ping')
|
||||
async getServerPulse() {
|
||||
return {
|
||||
res: 'pong',
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('/mapbox')
|
||||
async getMapboxInfo() {
|
||||
return {
|
||||
isEnable: this.configService.get('ENABLE_MAPBOX'),
|
||||
mapboxSecret: this.configService.get('MAPBOX_KEY'),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('/version')
|
||||
async getServerVersion() {
|
||||
return serverVersion;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ServerInfoService } from './server-info.service';
|
||||
import { ServerInfoController } from './server-info.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [ServerInfoController],
|
||||
providers: [ServerInfoService]
|
||||
})
|
||||
export class ServerInfoModule {}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ServerInfoDto } from './dto/server-info.dto';
|
||||
import diskusage from 'diskusage';
|
||||
|
||||
@Injectable()
|
||||
export class ServerInfoService {
|
||||
async getServerInfo() {
|
||||
const diskInfo = await diskusage.check('./upload');
|
||||
|
||||
const usagePercentage = (((diskInfo.total - diskInfo.free) / diskInfo.total) * 100).toFixed(2);
|
||||
|
||||
const serverInfo = new ServerInfoDto();
|
||||
serverInfo.diskAvailable = ServerInfoService.getHumanReadableString(diskInfo.available);
|
||||
serverInfo.diskSize = ServerInfoService.getHumanReadableString(diskInfo.total);
|
||||
serverInfo.diskUse = ServerInfoService.getHumanReadableString(diskInfo.total - diskInfo.free);
|
||||
serverInfo.diskAvailableRaw = diskInfo.available;
|
||||
serverInfo.diskSizeRaw = diskInfo.total;
|
||||
serverInfo.diskUseRaw = diskInfo.total - diskInfo.free;
|
||||
serverInfo.diskUsagePercentage = parseFloat(usagePercentage);
|
||||
|
||||
return serverInfo;
|
||||
}
|
||||
|
||||
private static getHumanReadableString(sizeInByte: number) {
|
||||
const pepibyte = 1.126 * Math.pow(10, 15);
|
||||
const tebibyte = 1.1 * Math.pow(10, 12);
|
||||
const gibibyte = 1.074 * Math.pow(10, 9);
|
||||
const mebibyte = 1.049 * Math.pow(10, 6);
|
||||
const kibibyte = 1024;
|
||||
// Pebibyte
|
||||
if (sizeInByte >= pepibyte) {
|
||||
// Pe
|
||||
return `${(sizeInByte / pepibyte).toFixed(1)}PB`;
|
||||
} else if (tebibyte <= sizeInByte && sizeInByte < pepibyte) {
|
||||
// Te
|
||||
return `${(sizeInByte / tebibyte).toFixed(1)}TB`;
|
||||
} else if (gibibyte <= sizeInByte && sizeInByte < tebibyte) {
|
||||
// Gi
|
||||
return `${(sizeInByte / gibibyte).toFixed(1)}GB`;
|
||||
} else if (mebibyte <= sizeInByte && sizeInByte < gibibyte) {
|
||||
// Mega
|
||||
return `${(sizeInByte / mebibyte).toFixed(1)}MB`;
|
||||
} else if (kibibyte <= sizeInByte && sizeInByte < mebibyte) {
|
||||
// Kibi
|
||||
return `${(sizeInByte / kibibyte).toFixed(1)}KB`;
|
||||
} else {
|
||||
return `${sizeInByte}B`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
import { AssetEntity } from '../../asset/entities/asset.entity';
|
||||
|
||||
export class AddAssetsDto {
|
||||
@IsNotEmpty()
|
||||
albumId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
assetIds: string[];
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class AddUsersDto {
|
||||
@IsNotEmpty()
|
||||
albumId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
sharedUserIds: string[];
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { IsNotEmpty, IsOptional } from 'class-validator';
|
||||
import { AssetEntity } from '../../asset/entities/asset.entity';
|
||||
|
||||
export class CreateSharedAlbumDto {
|
||||
@IsNotEmpty()
|
||||
albumName: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
sharedWithUserIds: string[];
|
||||
|
||||
@IsOptional()
|
||||
assetIds: string[];
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class RemoveAssetsDto {
|
||||
@IsNotEmpty()
|
||||
albumId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
assetIds: string[];
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class UpdateShareAlbumDto {
|
||||
@IsNotEmpty()
|
||||
albumId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
albumName: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
ownerId: string;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
|
||||
import { AssetEntity } from '../../asset/entities/asset.entity';
|
||||
import { SharedAlbumEntity } from './shared-album.entity';
|
||||
|
||||
@Entity('asset_shared_album')
|
||||
@Unique('PK_unique_asset_in_album', ['albumId', 'assetId'])
|
||||
export class AssetSharedAlbumEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
albumId: string;
|
||||
|
||||
@Column()
|
||||
assetId: string;
|
||||
|
||||
@ManyToOne(() => SharedAlbumEntity, (sharedAlbum) => sharedAlbum.sharedAssets, {
|
||||
onDelete: 'CASCADE',
|
||||
nullable: true,
|
||||
})
|
||||
@JoinColumn({ name: 'albumId' })
|
||||
albumInfo: SharedAlbumEntity;
|
||||
|
||||
@ManyToOne(() => AssetEntity, {
|
||||
onDelete: 'CASCADE',
|
||||
nullable: true,
|
||||
})
|
||||
@JoinColumn({ name: 'assetId' })
|
||||
assetInfo: AssetEntity;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { AssetSharedAlbumEntity } from './asset-shared-album.entity';
|
||||
import { UserSharedAlbumEntity } from './user-shared-album.entity';
|
||||
|
||||
@Entity('shared_albums')
|
||||
export class SharedAlbumEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
ownerId: string;
|
||||
|
||||
@Column({ default: 'Untitled Album' })
|
||||
albumName: string;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: string;
|
||||
|
||||
@Column({ comment: 'Asset ID to be used as thumbnail', nullable: true })
|
||||
albumThumbnailAssetId: string;
|
||||
|
||||
@OneToMany(() => UserSharedAlbumEntity, (userSharedAlbums) => userSharedAlbums.albumInfo)
|
||||
sharedUsers: UserSharedAlbumEntity[];
|
||||
|
||||
@OneToMany(() => AssetSharedAlbumEntity, (assetSharedAlbumEntity) => assetSharedAlbumEntity.albumInfo)
|
||||
sharedAssets: AssetSharedAlbumEntity[];
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
|
||||
import { UserEntity } from '../../user/entities/user.entity';
|
||||
import { SharedAlbumEntity } from './shared-album.entity';
|
||||
|
||||
@Entity('user_shared_album')
|
||||
@Unique('PK_unique_user_in_album', ['albumId', 'sharedUserId'])
|
||||
export class UserSharedAlbumEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
albumId: string;
|
||||
|
||||
@Column()
|
||||
sharedUserId: string;
|
||||
|
||||
@ManyToOne(() => SharedAlbumEntity, (sharedAlbum) => sharedAlbum.sharedUsers, {
|
||||
onDelete: 'CASCADE',
|
||||
nullable: true,
|
||||
})
|
||||
@JoinColumn({ name: 'albumId' })
|
||||
albumInfo: SharedAlbumEntity;
|
||||
|
||||
@ManyToOne(() => UserEntity)
|
||||
@JoinColumn({ name: 'sharedUserId' })
|
||||
userInfo: UserEntity;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, ValidationPipe, Query } from '@nestjs/common';
|
||||
import { SharingService } from './sharing.service';
|
||||
import { CreateSharedAlbumDto } from './dto/create-shared-album.dto';
|
||||
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||
import { AddAssetsDto } from './dto/add-assets.dto';
|
||||
import { AddUsersDto } from './dto/add-users.dto';
|
||||
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
||||
import { UpdateShareAlbumDto } from './dto/update-shared-album.dto';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('shared')
|
||||
export class SharingController {
|
||||
constructor(private readonly sharingService: SharingService) {}
|
||||
|
||||
@Post('/createAlbum')
|
||||
async create(@GetAuthUser() authUser, @Body(ValidationPipe) createSharedAlbumDto: CreateSharedAlbumDto) {
|
||||
return await this.sharingService.create(authUser, createSharedAlbumDto);
|
||||
}
|
||||
|
||||
@Post('/addUsers')
|
||||
async addUsers(@Body(ValidationPipe) addUsersDto: AddUsersDto) {
|
||||
return await this.sharingService.addUsersToAlbum(addUsersDto);
|
||||
}
|
||||
|
||||
@Post('/addAssets')
|
||||
async addAssets(@Body(ValidationPipe) addAssetsDto: AddAssetsDto) {
|
||||
return await this.sharingService.addAssetsToAlbum(addAssetsDto);
|
||||
}
|
||||
|
||||
@Get('/allSharedAlbums')
|
||||
async getAllSharedAlbums(@GetAuthUser() authUser) {
|
||||
return await this.sharingService.getAllSharedAlbums(authUser);
|
||||
}
|
||||
|
||||
@Get('/:albumId')
|
||||
async getAlbumInfo(@GetAuthUser() authUser, @Param('albumId') albumId: string) {
|
||||
return await this.sharingService.getAlbumInfo(authUser, albumId);
|
||||
}
|
||||
|
||||
@Delete('/removeAssets')
|
||||
async removeAssetFromAlbum(@GetAuthUser() authUser, @Body(ValidationPipe) removeAssetsDto: RemoveAssetsDto) {
|
||||
console.log('removeAssets');
|
||||
return await this.sharingService.removeAssetsFromAlbum(authUser, removeAssetsDto);
|
||||
}
|
||||
|
||||
@Delete('/:albumId')
|
||||
async deleteAlbum(@GetAuthUser() authUser, @Param('albumId') albumId: string) {
|
||||
return await this.sharingService.deleteAlbum(authUser, albumId);
|
||||
}
|
||||
|
||||
@Delete('/leaveAlbum/:albumId')
|
||||
async leaveAlbum(@GetAuthUser() authUser, @Param('albumId') albumId: string) {
|
||||
return await this.sharingService.leaveAlbum(authUser, albumId);
|
||||
}
|
||||
|
||||
@Patch('/updateInfo')
|
||||
async updateAlbumInfo(@GetAuthUser() authUser, @Body(ValidationPipe) updateAlbumInfoDto: UpdateShareAlbumDto) {
|
||||
return await this.sharingService.updateAlbumTitle(authUser, updateAlbumInfoDto);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
AssetEntity,
|
||||
UserEntity,
|
||||
SharedAlbumEntity,
|
||||
AssetSharedAlbumEntity,
|
||||
UserSharedAlbumEntity,
|
||||
]),
|
||||
],
|
||||
controllers: [SharingController],
|
||||
providers: [SharingService],
|
||||
})
|
||||
export class SharingModule {}
|
||||
@@ -1,199 +0,0 @@
|
||||
import { BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
||||
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 { 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 _ from 'lodash';
|
||||
import { AddUsersDto } from './dto/add-users.dto';
|
||||
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
||||
import { UpdateShareAlbumDto } from './dto/update-shared-album.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SharingService {
|
||||
constructor(
|
||||
@InjectRepository(AssetEntity)
|
||||
private assetRepository: Repository<AssetEntity>,
|
||||
|
||||
@InjectRepository(UserEntity)
|
||||
private userRepository: Repository<UserEntity>,
|
||||
|
||||
@InjectRepository(SharedAlbumEntity)
|
||||
private sharedAlbumRepository: Repository<SharedAlbumEntity>,
|
||||
|
||||
@InjectRepository(AssetSharedAlbumEntity)
|
||||
private assetSharedAlbumRepository: Repository<AssetSharedAlbumEntity>,
|
||||
|
||||
@InjectRepository(UserSharedAlbumEntity)
|
||||
private userSharedAlbumRepository: Repository<UserSharedAlbumEntity>,
|
||||
) {}
|
||||
|
||||
async create(authUser: AuthUserDto, createSharedAlbumDto: CreateSharedAlbumDto) {
|
||||
return await getConnection().transaction(async (transactionalEntityManager) => {
|
||||
// Create album entity
|
||||
const newSharedAlbum = new SharedAlbumEntity();
|
||||
newSharedAlbum.ownerId = authUser.id;
|
||||
newSharedAlbum.albumName = createSharedAlbumDto.albumName;
|
||||
|
||||
const sharedAlbum = await transactionalEntityManager.save(newSharedAlbum);
|
||||
|
||||
// Add shared users
|
||||
for (const sharedUserId of createSharedAlbumDto.sharedWithUserIds) {
|
||||
const newSharedUser = new UserSharedAlbumEntity();
|
||||
newSharedUser.albumId = sharedAlbum.id;
|
||||
newSharedUser.sharedUserId = sharedUserId;
|
||||
|
||||
await transactionalEntityManager.save(newSharedUser);
|
||||
}
|
||||
|
||||
// Add shared assets
|
||||
const newRecords: AssetSharedAlbumEntity[] = [];
|
||||
|
||||
for (const assetId of createSharedAlbumDto.assetIds) {
|
||||
const newAssetSharedAlbum = new AssetSharedAlbumEntity();
|
||||
newAssetSharedAlbum.assetId = assetId;
|
||||
newAssetSharedAlbum.albumId = sharedAlbum.id;
|
||||
|
||||
newRecords.push(newAssetSharedAlbum);
|
||||
}
|
||||
|
||||
if (!sharedAlbum.albumThumbnailAssetId && newRecords.length > 0) {
|
||||
sharedAlbum.albumThumbnailAssetId = newRecords[0].assetId;
|
||||
await transactionalEntityManager.save(sharedAlbum);
|
||||
}
|
||||
|
||||
await transactionalEntityManager.save([...newRecords]);
|
||||
|
||||
return sharedAlbum;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all shared album, including owned and shared one.
|
||||
* @param authUser AuthUserDto
|
||||
* @returns All Shared Album And Its Members
|
||||
*/
|
||||
async getAllSharedAlbums(authUser: AuthUserDto) {
|
||||
const ownedAlbums = await this.sharedAlbumRepository.find({
|
||||
where: { ownerId: authUser.id },
|
||||
relations: ['sharedUsers', 'sharedUsers.userInfo'],
|
||||
});
|
||||
|
||||
const isSharedWithAlbums = await this.userSharedAlbumRepository.find({
|
||||
where: {
|
||||
sharedUserId: authUser.id,
|
||||
},
|
||||
relations: ['albumInfo', 'albumInfo.sharedUsers', 'albumInfo.sharedUsers.userInfo'],
|
||||
select: ['albumInfo'],
|
||||
});
|
||||
|
||||
return [...ownedAlbums, ...isSharedWithAlbums.map((o) => o.albumInfo)].sort(
|
||||
(a, b) => new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf(),
|
||||
);
|
||||
}
|
||||
|
||||
async getAlbumInfo(authUser: AuthUserDto, albumId: string) {
|
||||
const albumOwner = await this.sharedAlbumRepository.findOne({ where: { ownerId: authUser.id } });
|
||||
const personShared = await this.userSharedAlbumRepository.findOne({
|
||||
where: { albumId: albumId, sharedUserId: authUser.id },
|
||||
});
|
||||
|
||||
if (!(albumOwner || personShared)) {
|
||||
throw new UnauthorizedException('Unauthorized Album Access');
|
||||
}
|
||||
|
||||
const albumInfo = await this.sharedAlbumRepository.findOne({
|
||||
where: { id: albumId },
|
||||
relations: ['sharedUsers', 'sharedUsers.userInfo', 'sharedAssets', 'sharedAssets.assetInfo'],
|
||||
});
|
||||
|
||||
if (!albumInfo) {
|
||||
throw new NotFoundException('Album Not Found');
|
||||
}
|
||||
const sortedSharedAsset = albumInfo.sharedAssets.sort(
|
||||
(a, b) => new Date(a.assetInfo.createdAt).valueOf() - new Date(b.assetInfo.createdAt).valueOf(),
|
||||
);
|
||||
|
||||
albumInfo.sharedAssets = sortedSharedAsset;
|
||||
|
||||
return albumInfo;
|
||||
}
|
||||
|
||||
async addUsersToAlbum(addUsersDto: AddUsersDto) {
|
||||
const newRecords: UserSharedAlbumEntity[] = [];
|
||||
|
||||
for (const sharedUserId of addUsersDto.sharedUserIds) {
|
||||
const newEntity = new UserSharedAlbumEntity();
|
||||
newEntity.albumId = addUsersDto.albumId;
|
||||
newEntity.sharedUserId = sharedUserId;
|
||||
|
||||
newRecords.push(newEntity);
|
||||
}
|
||||
|
||||
return await this.userSharedAlbumRepository.save([...newRecords]);
|
||||
}
|
||||
|
||||
async deleteAlbum(authUser: AuthUserDto, albumId: string) {
|
||||
return await this.sharedAlbumRepository.delete({ id: albumId, ownerId: authUser.id });
|
||||
}
|
||||
|
||||
async leaveAlbum(authUser: AuthUserDto, albumId: string) {
|
||||
return await this.userSharedAlbumRepository.delete({ albumId: albumId, sharedUserId: authUser.id });
|
||||
}
|
||||
|
||||
async removeUsersFromAlbum() {}
|
||||
|
||||
async removeAssetsFromAlbum(authUser: AuthUserDto, removeAssetsDto: RemoveAssetsDto) {
|
||||
let deleteAssetCount = 0;
|
||||
const album = await this.sharedAlbumRepository.findOne({ id: removeAssetsDto.albumId });
|
||||
|
||||
if (album.ownerId != authUser.id) {
|
||||
throw new BadRequestException("You don't have permission to remove assets in this album");
|
||||
}
|
||||
|
||||
for (const assetId of removeAssetsDto.assetIds) {
|
||||
const res = await this.assetSharedAlbumRepository.delete({ albumId: removeAssetsDto.albumId, assetId: assetId });
|
||||
if (res.affected == 1) deleteAssetCount++;
|
||||
}
|
||||
|
||||
return deleteAssetCount == removeAssetsDto.assetIds.length;
|
||||
}
|
||||
|
||||
async addAssetsToAlbum(addAssetsDto: AddAssetsDto) {
|
||||
const newRecords: AssetSharedAlbumEntity[] = [];
|
||||
|
||||
for (const assetId of addAssetsDto.assetIds) {
|
||||
const newAssetSharedAlbum = new AssetSharedAlbumEntity();
|
||||
newAssetSharedAlbum.assetId = assetId;
|
||||
newAssetSharedAlbum.albumId = addAssetsDto.albumId;
|
||||
|
||||
newRecords.push(newAssetSharedAlbum);
|
||||
}
|
||||
|
||||
// Add album thumbnail if not exist.
|
||||
const album = await this.sharedAlbumRepository.findOne({ id: addAssetsDto.albumId });
|
||||
|
||||
if (!album.albumThumbnailAssetId && newRecords.length > 0) {
|
||||
album.albumThumbnailAssetId = newRecords[0].assetId;
|
||||
await this.sharedAlbumRepository.save(album);
|
||||
}
|
||||
|
||||
return await this.assetSharedAlbumRepository.save([...newRecords]);
|
||||
}
|
||||
|
||||
async updateAlbumTitle(authUser: AuthUserDto, updateShareAlbumDto: UpdateShareAlbumDto) {
|
||||
if (authUser.id != updateShareAlbumDto.ownerId) {
|
||||
throw new BadRequestException('Unauthorized to change album info');
|
||||
}
|
||||
|
||||
const sharedAlbum = await this.sharedAlbumRepository.findOne({ where: { id: updateShareAlbumDto.albumId } });
|
||||
sharedAlbum.albumName = updateShareAlbumDto.albumName;
|
||||
|
||||
return await this.sharedAlbumRepository.save(sharedAlbum);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { IsNotEmpty, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsNotEmpty()
|
||||
email: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
firstName: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
lastName: string;
|
||||
|
||||
@IsOptional()
|
||||
profileImagePath?: string;
|
||||
|
||||
@IsOptional()
|
||||
isAdmin?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
isFirstLoggedIn?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
id?: string;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateUserDto } from './create-user.dto';
|
||||
|
||||
export class UpdateUserDto extends PartialType(CreateUserDto) {}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
@Entity('users')
|
||||
export class UserEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
firstName: string;
|
||||
|
||||
@Column()
|
||||
lastName: string;
|
||||
|
||||
@Column()
|
||||
isAdmin: boolean;
|
||||
|
||||
@Column()
|
||||
email: string;
|
||||
|
||||
@Column({ select: false })
|
||||
password: string;
|
||||
|
||||
@Column({ select: false })
|
||||
salt: string;
|
||||
|
||||
@Column()
|
||||
profileImagePath: string;
|
||||
|
||||
@Column()
|
||||
isFirstLoggedIn: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { UserEntity } from '../entities/user.entity';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function mapUser(entity: UserEntity): User {
|
||||
return {
|
||||
id: entity.id,
|
||||
email: entity.email,
|
||||
firstName: entity.firstName,
|
||||
lastName: entity.lastName,
|
||||
createdAt: entity.createdAt,
|
||||
};
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { AdminRolesGuard } from '../../middlewares/admin-role-guard.middleware';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { profileImageUploadOption } from '../../config/profile-image-upload.config';
|
||||
import { Response as Res } from 'express';
|
||||
|
||||
@Controller('user')
|
||||
export class UserController {
|
||||
constructor(private readonly userService: UserService) { }
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get()
|
||||
async getAllUsers(@GetAuthUser() authUser: AuthUserDto, @Query('isAll') isAll: boolean) {
|
||||
return await this.userService.getAllUsers(authUser, isAll);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(AdminRolesGuard)
|
||||
@Post()
|
||||
async createNewUser(@Body(ValidationPipe) createUserDto: CreateUserDto) {
|
||||
return await this.userService.createUser(createUserDto);
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseInterceptors(FileInterceptor('file', profileImageUploadOption))
|
||||
@Post('/profile-image')
|
||||
async createProfileImage(@GetAuthUser() authUser: AuthUserDto, @UploadedFile() fileInfo: Express.Multer.File) {
|
||||
return await this.userService.createProfileImage(authUser, fileInfo);
|
||||
}
|
||||
|
||||
@Get('/profile-image/:userId')
|
||||
async getProfileImage(@Param('userId') userId: string,
|
||||
@Response({ passthrough: true }) res: Res,
|
||||
) {
|
||||
return await this.userService.getUserProfileImage(userId, res);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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 { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
|
||||
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { jwtConfig } from '../../config/jwt.config';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)],
|
||||
controllers: [UserController],
|
||||
providers: [UserService, ImmichJwtService],
|
||||
})
|
||||
export class UserModule { }
|
||||
@@ -1,152 +0,0 @@
|
||||
import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
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 * as bcrypt from 'bcrypt';
|
||||
import { createReadStream } from 'fs';
|
||||
import { Response as Res } from 'express';
|
||||
import { mapUser, User } from './response-dto/user';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(
|
||||
@InjectRepository(UserEntity)
|
||||
private userRepository: Repository<UserEntity>,
|
||||
) {}
|
||||
|
||||
async getAllUsers(authUser: AuthUserDto, isAll: boolean) {
|
||||
if (isAll) {
|
||||
return await this.userRepository.find();
|
||||
}
|
||||
|
||||
return await this.userRepository.find({
|
||||
where: { id: Not(authUser.id) },
|
||||
order: {
|
||||
createdAt: 'DESC',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getUserCount(isAdmin: boolean) {
|
||||
let users;
|
||||
|
||||
if (isAdmin) {
|
||||
users = await this.userRepository.find({ where: { isAdmin: true } });
|
||||
} else {
|
||||
users = await this.userRepository.find();
|
||||
}
|
||||
|
||||
return {
|
||||
userCount: users.length,
|
||||
};
|
||||
}
|
||||
|
||||
async createUser(createUserDto: CreateUserDto): Promise<User> {
|
||||
const user = await this.userRepository.findOne({ where: { email: createUserDto.email } });
|
||||
|
||||
if (user) {
|
||||
throw new BadRequestException('User exists');
|
||||
}
|
||||
|
||||
const newUser = new UserEntity();
|
||||
newUser.email = createUserDto.email;
|
||||
newUser.salt = await bcrypt.genSalt();
|
||||
newUser.password = await this.hashPassword(createUserDto.password, newUser.salt);
|
||||
newUser.firstName = createUserDto.firstName;
|
||||
newUser.lastName = createUserDto.lastName;
|
||||
newUser.isAdmin = false;
|
||||
|
||||
try {
|
||||
const savedUser = await this.userRepository.save(newUser);
|
||||
|
||||
return mapUser(savedUser);
|
||||
} catch (e) {
|
||||
Logger.error(e, 'Create new user');
|
||||
throw new InternalServerErrorException('Failed to register new user');
|
||||
}
|
||||
}
|
||||
|
||||
private async hashPassword(password: string, salt: string): Promise<string> {
|
||||
return bcrypt.hash(password, salt);
|
||||
}
|
||||
|
||||
async updateUser(updateUserDto: UpdateUserDto) {
|
||||
const user = await this.userRepository.findOne(updateUserDto.id);
|
||||
|
||||
user.lastName = updateUserDto.lastName || user.lastName;
|
||||
user.firstName = updateUserDto.firstName || user.firstName;
|
||||
user.profileImagePath = updateUserDto.profileImagePath || user.profileImagePath;
|
||||
user.isFirstLoggedIn = updateUserDto.isFirstLoggedIn || user.isFirstLoggedIn;
|
||||
|
||||
// If payload includes password - Create new password for user
|
||||
if (updateUserDto.password) {
|
||||
user.salt = await bcrypt.genSalt();
|
||||
user.password = await this.hashPassword(updateUserDto.password, user.salt);
|
||||
}
|
||||
|
||||
if (updateUserDto.isAdmin) {
|
||||
const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } });
|
||||
|
||||
if (adminUser) {
|
||||
throw new BadRequestException('Admin user exists');
|
||||
}
|
||||
|
||||
user.isAdmin = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedUser = await this.userRepository.save(user);
|
||||
|
||||
return {
|
||||
id: updatedUser.id,
|
||||
email: updatedUser.email,
|
||||
firstName: updatedUser.firstName,
|
||||
lastName: updatedUser.lastName,
|
||||
isAdmin: updatedUser.isAdmin,
|
||||
profileImagePath: updatedUser.profileImagePath,
|
||||
};
|
||||
} catch (e) {
|
||||
Logger.error(e, 'Create new user');
|
||||
throw new InternalServerErrorException('Failed to register new user');
|
||||
}
|
||||
}
|
||||
|
||||
async createProfileImage(authUser: AuthUserDto, fileInfo: Express.Multer.File) {
|
||||
try {
|
||||
await this.userRepository.update(authUser.id, {
|
||||
profileImagePath: fileInfo.path,
|
||||
});
|
||||
|
||||
return {
|
||||
userId: authUser.id,
|
||||
profileImagePath: fileInfo.path,
|
||||
};
|
||||
} catch (e) {
|
||||
Logger.error(e, 'Create User Profile Image');
|
||||
throw new InternalServerErrorException('Failed to create new user profile image');
|
||||
}
|
||||
}
|
||||
|
||||
async getUserProfileImage(userId: string, res: Res) {
|
||||
try {
|
||||
const user = await this.userRepository.findOne({ id: userId });
|
||||
|
||||
if (!user.profileImagePath) {
|
||||
// throw new BadRequestException('User does not have a profile image');
|
||||
res.status(404).send('User does not have a profile image');
|
||||
return;
|
||||
}
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'image/jpeg',
|
||||
});
|
||||
const fileStream = createReadStream(user.profileImagePath);
|
||||
return new StreamableFile(fileStream);
|
||||
} catch (e) {
|
||||
console.log('error getting user profile');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Controller, Get, Res, Headers } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Controller()
|
||||
|
||||
export class AppController {
|
||||
constructor() { }
|
||||
|
||||
@Get()
|
||||
async redirectToWebpage(@Res({ passthrough: true }) res: Response, @Headers() headers) {
|
||||
const host = headers.host;
|
||||
|
||||
return res.redirect(`http://${host}:2285`)
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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';
|
||||
import { ImmichJwtModule } from './modules/immich-jwt/immich-jwt.module';
|
||||
import { DeviceInfoModule } from './api-v1/device-info/device-info.module';
|
||||
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';
|
||||
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';
|
||||
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot(immichAppConfig),
|
||||
|
||||
TypeOrmModule.forRoot(databaseConfig),
|
||||
|
||||
UserModule,
|
||||
|
||||
AssetModule,
|
||||
|
||||
AuthModule,
|
||||
|
||||
ImmichJwtModule,
|
||||
|
||||
DeviceInfoModule,
|
||||
|
||||
BullModule.forRootAsync({
|
||||
useFactory: async () => ({
|
||||
redis: {
|
||||
host: process.env.REDIS_HOSTNAME || 'immich_redis',
|
||||
port: 6379,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
|
||||
ImageOptimizeModule,
|
||||
|
||||
ServerInfoModule,
|
||||
|
||||
BackgroundTaskModule,
|
||||
|
||||
CommunicationModule,
|
||||
|
||||
SharingModule,
|
||||
|
||||
ScheduleModule.forRoot(),
|
||||
|
||||
ScheduleTasksModule
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer): void {
|
||||
if (process.env.NODE_ENV == 'development') {
|
||||
// consumer.apply(AppLoggerMiddleware).forRoutes('*');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { ConfigModuleOptions } from '@nestjs/config';
|
||||
import Joi from 'joi';
|
||||
|
||||
export const immichAppConfig: ConfigModuleOptions = {
|
||||
envFilePath: '.env',
|
||||
isGlobal: true,
|
||||
validationSchema: Joi.object({
|
||||
NODE_ENV: Joi.string().required().valid('development', 'production', 'staging').default('development'),
|
||||
DB_USERNAME: Joi.string().required(),
|
||||
DB_PASSWORD: Joi.string().required(),
|
||||
DB_DATABASE_NAME: Joi.string().required(),
|
||||
UPLOAD_LOCATION: Joi.string().required(),
|
||||
JWT_SECRET: Joi.string().required(),
|
||||
ENABLE_MAPBOX: Joi.boolean().required().valid(true, false),
|
||||
MAPBOX_KEY: Joi.any().when('ENABLE_MAPBOX', {
|
||||
is: false,
|
||||
then: Joi.string().optional().allow(null, ''),
|
||||
otherwise: Joi.string().required(),
|
||||
}),
|
||||
VITE_SERVER_ENDPOINT: Joi.string().required(),
|
||||
}),
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { diskStorage } from 'multer';
|
||||
import { extname } from 'path';
|
||||
import { Request } from 'express';
|
||||
import { APP_UPLOAD_LOCATION } from '../constants/upload_location.constant';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { CreateAssetDto } from '../api-v1/asset/dto/create-asset.dto';
|
||||
|
||||
export const assetUploadOption: MulterOptions = {
|
||||
fileFilter: (req: Request, file: any, cb: any) => {
|
||||
if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif|dng|webp)$/)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false);
|
||||
}
|
||||
},
|
||||
|
||||
storage: diskStorage({
|
||||
destination: (req: Request, file: Express.Multer.File, cb: any) => {
|
||||
const basePath = APP_UPLOAD_LOCATION;
|
||||
const fileInfo = req.body as CreateAssetDto;
|
||||
|
||||
const yearInfo = new Date(fileInfo.createdAt).getFullYear();
|
||||
const monthInfo = new Date(fileInfo.createdAt).getMonth();
|
||||
|
||||
if (file.fieldname == 'assetData') {
|
||||
const originalUploadFolder = `${basePath}/${req.user['id']}/original/${req.body['deviceId']}`;
|
||||
|
||||
if (!existsSync(originalUploadFolder)) {
|
||||
mkdirSync(originalUploadFolder, { recursive: true });
|
||||
}
|
||||
|
||||
// Save original to disk
|
||||
cb(null, originalUploadFolder);
|
||||
} else if (file.fieldname == 'thumbnailData') {
|
||||
const thumbnailUploadFolder = `${basePath}/${req.user['id']}/thumb/${req.body['deviceId']}`;
|
||||
|
||||
if (!existsSync(thumbnailUploadFolder)) {
|
||||
mkdirSync(thumbnailUploadFolder, { recursive: true });
|
||||
}
|
||||
|
||||
// Save thumbnail to disk
|
||||
cb(null, thumbnailUploadFolder);
|
||||
}
|
||||
},
|
||||
|
||||
filename: (req: Request, file: Express.Multer.File, cb: any) => {
|
||||
const fileNameUUID = randomUUID();
|
||||
if (file.fieldname == 'assetData') {
|
||||
cb(null, `${fileNameUUID}${req.body['fileExtension'].toLowerCase()}`);
|
||||
} else if (file.fieldname == 'thumbnailData') {
|
||||
cb(null, `${fileNameUUID}.jpeg`);
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
|
||||
export const databaseConfig: TypeOrmModuleOptions = {
|
||||
type: 'postgres',
|
||||
host: process.env.DB_HOSTNAME || 'immich_postgres',
|
||||
port: 5432,
|
||||
username: process.env.DB_USERNAME,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_DATABASE_NAME,
|
||||
entities: [__dirname + '/../**/*.entity.{js,ts}'],
|
||||
synchronize: false,
|
||||
migrations: [__dirname + '/../migration/*.{js,ts}'],
|
||||
cli: {
|
||||
migrationsDir: __dirname + '/../migration',
|
||||
},
|
||||
migrationsRun: true,
|
||||
};
|
||||
|
||||
export default databaseConfig;
|
||||
@@ -1,7 +0,0 @@
|
||||
import { JwtModuleOptions } from '@nestjs/jwt';
|
||||
import { jwtSecret } from '../constants/jwt.constant';
|
||||
|
||||
export const jwtConfig: JwtModuleOptions = {
|
||||
secret: jwtSecret,
|
||||
signOptions: { expiresIn: '36500d' },
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { diskStorage } from 'multer';
|
||||
import { extname } from 'path';
|
||||
import { Request } from 'express';
|
||||
import { APP_UPLOAD_LOCATION } from '../constants/upload_location.constant';
|
||||
|
||||
export const profileImageUploadOption: MulterOptions = {
|
||||
fileFilter: (req: Request, file: any, cb: any) => {
|
||||
if (file.mimetype.match(/\/(jpg|jpeg|png|heic|heif|dng|webp)$/)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false);
|
||||
}
|
||||
},
|
||||
|
||||
storage: diskStorage({
|
||||
destination: (req: Request, file: Express.Multer.File, cb: any) => {
|
||||
const basePath = APP_UPLOAD_LOCATION;
|
||||
const profileImageLocation = `${basePath}/${req.user['id']}/profile`;
|
||||
|
||||
if (!existsSync(profileImageLocation)) {
|
||||
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)}`);
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const jwtSecret = process.env.JWT_SECRET;
|
||||
@@ -1,9 +0,0 @@
|
||||
// major.minor.patch+build
|
||||
// check mobile/pubspec.yml for current release version
|
||||
|
||||
export const serverVersion = {
|
||||
major: 1,
|
||||
minor: 10,
|
||||
patch: 0,
|
||||
build: 14,
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export const APP_UPLOAD_LOCATION = './upload';
|
||||
@@ -1,21 +0,0 @@
|
||||
import { createParamDecorator, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { UserEntity } from '../api-v1/user/entities/user.entity';
|
||||
// import { AuthUserDto } from './dto/auth-user.dto';
|
||||
|
||||
export class AuthUserDto {
|
||||
id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export const GetAuthUser = createParamDecorator((data, ctx: ExecutionContext): AuthUserDto => {
|
||||
const req = ctx.switchToHttp().getRequest();
|
||||
|
||||
const { id, email } = req.user as UserEntity;
|
||||
|
||||
const authUser: any = {
|
||||
id: id.toString(),
|
||||
email,
|
||||
};
|
||||
|
||||
return authUser;
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { AppModule } from './app.module';
|
||||
import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
|
||||
app.enableCors();
|
||||
|
||||
app.set('trust proxy');
|
||||
|
||||
app.useWebSocketAdapter(new RedisIoAdapter(app));
|
||||
|
||||
await app.listen(3000, () => {
|
||||
if (process.env.NODE_ENV == 'development') {
|
||||
Logger.log('Running Immich Server in DEVELOPMENT environment', 'IMMICH SERVER');
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV == 'production') {
|
||||
Logger.log('Running Immich Server in PRODUCTION environment', 'IMMICH SERVER');
|
||||
}
|
||||
});
|
||||
}
|
||||
bootstrap();
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
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 { ImmichJwtService } from '../modules/immich-jwt/immich-jwt.service';
|
||||
|
||||
@Injectable()
|
||||
export class AdminRolesGuard implements CanActivate {
|
||||
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 { userId } = await this.jwtService.validateToken(bearerToken);
|
||||
|
||||
const user = await this.userRepository.findOne(userId);
|
||||
|
||||
return user.isAdmin;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Injectable, NestMiddleware, Logger } from '@nestjs/common';
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class AppLoggerMiddleware implements NestMiddleware {
|
||||
private logger = new Logger('HTTP');
|
||||
|
||||
use(request: Request, response: Response, next: NextFunction): void {
|
||||
const { ip, method, path: url, baseUrl } = request;
|
||||
const userAgent = request.get('user-agent') || '';
|
||||
|
||||
response.on('close', () => {
|
||||
const { statusCode } = response;
|
||||
const contentLength = response.get('content-length');
|
||||
|
||||
this.logger.log(`${method} ${baseUrl} ${statusCode} ${contentLength} - ${userAgent} ${ip}`);
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { IoAdapter } from '@nestjs/platform-socket.io';
|
||||
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 pubClient = createClient({ url: `redis://${redis_host}:6379` });
|
||||
// const subClient = pubClient.duplicate();
|
||||
|
||||
const pubClient = new RedisClient({
|
||||
host: redis_host,
|
||||
port: 6379,
|
||||
});
|
||||
|
||||
const subClient = pubClient.duplicate();
|
||||
const redisAdapter = createAdapter({ pubClient, subClient });
|
||||
|
||||
export class RedisIoAdapter extends IoAdapter {
|
||||
createIOServer(port: number, options?: ServerOptions): any {
|
||||
const server = super.createIOServer(port, options);
|
||||
server.adapter(redisAdapter);
|
||||
return server;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateUserTable1645130759468 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
create table if not exists users
|
||||
(
|
||||
id uuid default uuid_generate_v4() not null
|
||||
constraint "PK_a3ffb1c0c8416b9fc6f907b7433"
|
||||
primary key,
|
||||
email varchar not null,
|
||||
password varchar not null,
|
||||
salt varchar not null,
|
||||
"createdAt" timestamp default now() not null
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`drop table users`);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateDeviceInfoTable1645130777674 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
create table if not exists device_info
|
||||
(
|
||||
id serial
|
||||
constraint "PK_b1c15a80b0a4e5f4eebadbdd92c"
|
||||
primary key,
|
||||
"userId" varchar not null,
|
||||
"deviceId" varchar not null,
|
||||
"deviceType" varchar not null,
|
||||
"notificationToken" varchar,
|
||||
"createdAt" timestamp default now() not null,
|
||||
"isAutoBackup" boolean default false not null,
|
||||
constraint "UQ_ebad78f36b10d15fbea8560e107"
|
||||
unique ("userId", "deviceId")
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`drop table device_info`);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateAssetsTable1645130805273 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
create table if not exists assets
|
||||
(
|
||||
id uuid default uuid_generate_v4() not null
|
||||
constraint "PK_da96729a8b113377cfb6a62439c"
|
||||
primary key,
|
||||
"deviceAssetId" varchar not null,
|
||||
"userId" varchar not null,
|
||||
"deviceId" varchar not null,
|
||||
type varchar not null,
|
||||
"originalPath" varchar not null,
|
||||
"resizePath" varchar,
|
||||
"createdAt" varchar not null,
|
||||
"modifiedAt" varchar not null,
|
||||
"isFavorite" boolean default false not null,
|
||||
"mimeType" varchar,
|
||||
duration varchar,
|
||||
constraint "UQ_b599ab0bd9574958acb0b30a90e"
|
||||
unique ("deviceAssetId", "userId", "deviceId")
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`drop table assets`);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateExifTable1645130817965 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
create table if not exists exif
|
||||
(
|
||||
id serial
|
||||
constraint "PK_28663352d85078ad0046dafafaa"
|
||||
primary key,
|
||||
"assetId" uuid not null
|
||||
constraint "REL_c0117fdbc50b917ef9067740c4"
|
||||
unique
|
||||
constraint "FK_c0117fdbc50b917ef9067740c44"
|
||||
references assets
|
||||
on delete cascade,
|
||||
make varchar,
|
||||
model varchar,
|
||||
"imageName" varchar,
|
||||
"exifImageWidth" integer,
|
||||
"exifImageHeight" integer,
|
||||
"fileSizeInByte" integer,
|
||||
orientation varchar,
|
||||
"dateTimeOriginal" timestamp with time zone,
|
||||
"modifyDate" timestamp with time zone,
|
||||
"lensModel" varchar,
|
||||
"fNumber" double precision,
|
||||
"focalLength" double precision,
|
||||
iso integer,
|
||||
"exposureTime" double precision,
|
||||
latitude double precision,
|
||||
longitude double precision
|
||||
);
|
||||
|
||||
create unique index if not exists "IDX_c0117fdbc50b917ef9067740c4" on exif ("assetId");
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`drop table exif`);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateSmartInfoTable1645130870184 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
create table if not exists smart_info
|
||||
(
|
||||
id serial
|
||||
constraint "PK_0beace66440e9713f5c40470e46"
|
||||
primary key,
|
||||
"assetId" uuid not null
|
||||
constraint "UQ_5e3753aadd956110bf3ec0244ac"
|
||||
unique
|
||||
constraint "FK_5e3753aadd956110bf3ec0244ac"
|
||||
references assets
|
||||
on delete cascade,
|
||||
tags text[]
|
||||
);
|
||||
|
||||
create unique index if not exists "IDX_5e3753aadd956110bf3ec0244a"
|
||||
on smart_info ("assetId");
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
drop table smart_info;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddExifTextSearchColumn1646249209023 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE exif
|
||||
ADD COLUMN IF NOT EXISTS exif_text_searchable_column tsvector
|
||||
GENERATED ALWAYS AS (
|
||||
TO_TSVECTOR('english',
|
||||
COALESCE(make, '') || ' ' ||
|
||||
COALESCE(model, '') || ' ' ||
|
||||
COALESCE(orientation, '') || ' ' ||
|
||||
COALESCE("lensModel", '')
|
||||
)
|
||||
) STORED;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE exif
|
||||
DROP COLUMN IF EXISTS exif_text_searchable_column;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateExifTextSearchIndex1646249734844 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX exif_text_searchable_idx
|
||||
ON exif
|
||||
USING GIN (exif_text_searchable_column);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS exif_text_searchable_idx ON exif;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddRegionCityToExIf1646709533213 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE exif
|
||||
ADD COLUMN if not exists city varchar;
|
||||
|
||||
ALTER TABLE exif
|
||||
ADD COLUMN if not exists state varchar;
|
||||
|
||||
ALTER TABLE exif
|
||||
ADD COLUMN if not exists country varchar;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE exif
|
||||
DROP COLUMN city;
|
||||
|
||||
ALTER TABLE exif
|
||||
DROP COLUMN state;
|
||||
|
||||
ALTER TABLE exif
|
||||
DROP COLUMN country;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddLocationToExifTextSearch1646710459852 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE exif
|
||||
DROP COLUMN IF EXISTS exif_text_searchable_column;
|
||||
|
||||
ALTER TABLE exif
|
||||
ADD COLUMN IF NOT EXISTS exif_text_searchable_column tsvector
|
||||
GENERATED ALWAYS AS (
|
||||
TO_TSVECTOR('english',
|
||||
COALESCE(make, '') || ' ' ||
|
||||
COALESCE(model, '') || ' ' ||
|
||||
COALESCE(orientation, '') || ' ' ||
|
||||
COALESCE("lensModel", '') || ' ' ||
|
||||
COALESCE("city", '') || ' ' ||
|
||||
COALESCE("state", '') || ' ' ||
|
||||
COALESCE("country", '')
|
||||
)
|
||||
) STORED;
|
||||
|
||||
CREATE INDEX exif_text_searchable_idx
|
||||
ON exif
|
||||
USING GIN (exif_text_searchable_column);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE exif
|
||||
DROP COLUMN IF EXISTS exif_text_searchable_column;
|
||||
|
||||
DROP INDEX IF EXISTS exif_text_searchable_idx ON exif;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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 if not exists objects text[];
|
||||
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE smart_info
|
||||
DROP COLUMN objects;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateSharedAlbumAndRelatedTables1649643216111 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Create shared_albums
|
||||
await queryRunner.query(`
|
||||
create table if not exists shared_albums
|
||||
(
|
||||
id uuid default uuid_generate_v4() not null
|
||||
constraint "PK_7f71c7b5bc7c87b8f94c9a93a00"
|
||||
primary key,
|
||||
"ownerId" varchar not null,
|
||||
"albumName" varchar default 'Untitled Album'::character varying not null,
|
||||
"createdAt" timestamp with time zone default now() not null,
|
||||
"albumThumbnailAssetId" varchar
|
||||
);
|
||||
|
||||
comment on column shared_albums."albumThumbnailAssetId" is 'Asset ID to be used as thumbnail';
|
||||
`);
|
||||
|
||||
// Create user_shared_album
|
||||
await queryRunner.query(`
|
||||
create table if not exists user_shared_album
|
||||
(
|
||||
id serial
|
||||
constraint "PK_b6562316a98845a7b3e9a25cdd0"
|
||||
primary key,
|
||||
"albumId" uuid not null
|
||||
constraint "FK_7b3bf0f5f8da59af30519c25f18"
|
||||
references shared_albums
|
||||
on delete cascade,
|
||||
"sharedUserId" uuid not null
|
||||
constraint "FK_543c31211653e63e080ba882eb5"
|
||||
references users,
|
||||
constraint "PK_unique_user_in_album"
|
||||
unique ("albumId", "sharedUserId")
|
||||
);
|
||||
`);
|
||||
|
||||
// Create asset_shared_album
|
||||
await queryRunner.query(
|
||||
`
|
||||
create table if not exists asset_shared_album
|
||||
(
|
||||
id serial
|
||||
constraint "PK_a34e076afbc601d81938e2c2277"
|
||||
primary key,
|
||||
"albumId" uuid not null
|
||||
constraint "FK_a8b79a84996cef6ba6a3662825d"
|
||||
references shared_albums
|
||||
on delete cascade,
|
||||
"assetId" uuid not null
|
||||
constraint "FK_64f2e7d68d1d1d8417acc844a4a"
|
||||
references assets
|
||||
on delete cascade,
|
||||
constraint "UQ_a1e2734a1ce361e7a26f6b28288"
|
||||
unique ("albumId", "assetId")
|
||||
);
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
drop table asset_shared_album;
|
||||
drop table user_shared_album;
|
||||
drop table shared_albums;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class UpdateUserTableWithAdminAndName1652633525943 implements MigrationInterface {
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
alter table users
|
||||
add column if not exists "firstName" varchar default '';
|
||||
|
||||
alter table users
|
||||
add column if not exists "lastName" varchar default '';
|
||||
|
||||
alter table users
|
||||
add column if not exists "profileImagePath" varchar default '';
|
||||
|
||||
alter table users
|
||||
add column if not exists "isAdmin" bool default false;
|
||||
|
||||
alter table users
|
||||
add column if not exists "isFirstLoggedIn" bool default true;
|
||||
`)
|
||||
|
||||
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
alter table users
|
||||
drop column "firstName";
|
||||
|
||||
alter table users
|
||||
drop column "lastName";
|
||||
|
||||
alter table users
|
||||
drop column "isAdmin";
|
||||
|
||||
`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class UpdateAssetTableWithWebpPath1653214255670 implements MigrationInterface {
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
alter table assets
|
||||
add column if not exists "webpPath" varchar default '';
|
||||
`)
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
alter table assets
|
||||
drop column if exists "webpPath";
|
||||
`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class UpdateAssetTableWithEncodeVideoPath1654299904583 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
alter table assets
|
||||
add column if not exists "encodedVideoPath" varchar default '';
|
||||
`)
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
alter table assets
|
||||
drop column if exists "encodedVideoPath";
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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 { BackgroundTaskProcessor } from './background-task.processor';
|
||||
import { BackgroundTaskService } from './background-task.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
BullModule.registerQueue({
|
||||
name: 'background-task',
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
},
|
||||
}),
|
||||
|
||||
TypeOrmModule.forFeature([AssetEntity, ExifEntity, SmartInfoEntity]),
|
||||
],
|
||||
providers: [BackgroundTaskService, BackgroundTaskProcessor],
|
||||
exports: [BackgroundTaskService],
|
||||
})
|
||||
export class BackgroundTaskModule { }
|
||||
@@ -1,155 +0,0 @@
|
||||
import { InjectQueue, Process, Processor } from '@nestjs/bull';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Job, Queue } from 'bull';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import exifr from 'exifr';
|
||||
import { readFile } from 'fs/promises';
|
||||
import fs from 'fs';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ExifEntity } from '../../api-v1/asset/entities/exif.entity';
|
||||
import axios from 'axios';
|
||||
import { SmartInfoEntity } from '../../api-v1/asset/entities/smart-info.entity';
|
||||
import mapboxGeocoding, { GeocodeService } from '@mapbox/mapbox-sdk/services/geocoding';
|
||||
import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response';
|
||||
|
||||
@Processor('background-task')
|
||||
export class BackgroundTaskProcessor {
|
||||
private geocodingClient: GeocodeService;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AssetEntity)
|
||||
private assetRepository: Repository<AssetEntity>,
|
||||
|
||||
@InjectRepository(SmartInfoEntity)
|
||||
private smartInfoRepository: Repository<SmartInfoEntity>,
|
||||
|
||||
@InjectRepository(ExifEntity)
|
||||
private exifRepository: Repository<ExifEntity>,
|
||||
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
if (this.configService.get('ENABLE_MAPBOX')) {
|
||||
this.geocodingClient = mapboxGeocoding({
|
||||
accessToken: this.configService.get('MAPBOX_KEY'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Process('extract-exif')
|
||||
async extractExif(job: Job) {
|
||||
const { savedAsset, fileName, fileSize }: { savedAsset: AssetEntity; fileName: string; fileSize: number } =
|
||||
job.data;
|
||||
const fileBuffer = await readFile(savedAsset.originalPath);
|
||||
|
||||
const exifData = await exifr.parse(fileBuffer);
|
||||
|
||||
const newExif = new ExifEntity();
|
||||
newExif.assetId = savedAsset.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 (this.configService.get('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);
|
||||
|
||||
try {
|
||||
} catch (e) {
|
||||
Logger.error(`Error extracting EXIF ${e.toString()}`, 'extractExif');
|
||||
}
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Process('tag-image')
|
||||
async tagImage(job) {
|
||||
const { thumbnailPath, asset }: { thumbnailPath: string; asset: AssetEntity } = job.data;
|
||||
|
||||
const res = await axios.post('http://immich-microservices:3001/image-classifier/tagImage', {
|
||||
thumbnailPath: thumbnailPath,
|
||||
});
|
||||
|
||||
if (res.status == 201 && res.data.length > 0) {
|
||||
const smartInfo = new SmartInfoEntity();
|
||||
smartInfo.assetId = asset.id;
|
||||
smartInfo.tags = [...res.data];
|
||||
|
||||
await this.smartInfoRepository.upsert(smartInfo, {
|
||||
conflictPaths: ['assetId'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Process('detect-object')
|
||||
async detectObject(job) {
|
||||
try {
|
||||
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'],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`Failed to trigger object detection pipe line ${error.toString()}`)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { InjectQueue } from '@nestjs/bull/dist/decorators';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Queue } from 'bull';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
|
||||
|
||||
@Injectable()
|
||||
export class BackgroundTaskService {
|
||||
constructor(
|
||||
@InjectQueue('background-task')
|
||||
private backgroundTaskQueue: Queue,
|
||||
) { }
|
||||
|
||||
async extractExif(savedAsset: AssetEntity, fileName: string, fileSize: number) {
|
||||
await this.backgroundTaskQueue.add(
|
||||
'extract-exif',
|
||||
{
|
||||
savedAsset,
|
||||
fileName,
|
||||
fileSize,
|
||||
},
|
||||
{ jobId: randomUUID() },
|
||||
);
|
||||
}
|
||||
|
||||
async deleteFileOnDisk(assets: AssetEntity[]) {
|
||||
await this.backgroundTaskQueue.add(
|
||||
'delete-file-on-disk',
|
||||
{
|
||||
assets,
|
||||
},
|
||||
{ jobId: randomUUID() },
|
||||
);
|
||||
}
|
||||
|
||||
async tagImage(thumbnailPath: string, asset: AssetEntity) {
|
||||
await this.backgroundTaskQueue.add(
|
||||
'tag-image',
|
||||
{
|
||||
thumbnailPath,
|
||||
asset,
|
||||
},
|
||||
{ jobId: randomUUID() },
|
||||
);
|
||||
}
|
||||
|
||||
async detectObject(thumbnailPath: string, asset: AssetEntity) {
|
||||
await this.backgroundTaskQueue.add(
|
||||
'detect-object',
|
||||
{
|
||||
thumbnailPath,
|
||||
asset,
|
||||
},
|
||||
{ jobId: randomUUID() },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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 { CommunicationModule } from '../../api-v1/communication/communication.module';
|
||||
import { BackgroundTaskModule } from '../background-task/background-task.module';
|
||||
import { BackgroundTaskService } from '../background-task/background-task.service';
|
||||
import { ImageOptimizeProcessor } from './image-optimize.processor';
|
||||
import { AssetOptimizeService } from './image-optimize.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
CommunicationModule,
|
||||
BackgroundTaskModule,
|
||||
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]),
|
||||
],
|
||||
providers: [AssetOptimizeService, ImageOptimizeProcessor, BackgroundTaskService],
|
||||
exports: [AssetOptimizeService],
|
||||
})
|
||||
export class ImageOptimizeModule { }
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Processor } from '@nestjs/bull';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
|
||||
import { CommunicationGateway } from '../../api-v1/communication/communication.gateway';
|
||||
import { BackgroundTaskService } from '../background-task/background-task.service';
|
||||
|
||||
@Processor('optimize')
|
||||
export class ImageOptimizeProcessor {
|
||||
constructor(
|
||||
private wsCommunicateionGateway: CommunicationGateway,
|
||||
@InjectRepository(AssetEntity)
|
||||
private assetRepository: Repository<AssetEntity>,
|
||||
|
||||
private backgroundTaskService: BackgroundTaskService,
|
||||
) {}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { InjectQueue } from '@nestjs/bull';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Queue } from 'bull';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AssetOptimizeService {
|
||||
constructor(@InjectQueue('optimize') private optimizeQueue: Queue) {}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ImmichJwtService } from './immich-jwt.service';
|
||||
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';
|
||||
|
||||
@Module({
|
||||
imports: [JwtModule.register(jwtConfig), TypeOrmModule.forFeature([UserEntity])],
|
||||
providers: [ImmichJwtService, JwtStrategy],
|
||||
exports: [ImmichJwtService],
|
||||
})
|
||||
export class ImmichJwtModule {}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { JwtPayloadDto } from '../../api-v1/auth/dto/jwt-payload.dto';
|
||||
import { jwtSecret } from '../../constants/jwt.constant';
|
||||
|
||||
@Injectable()
|
||||
export class ImmichJwtService {
|
||||
constructor(private jwtService: JwtService) {}
|
||||
|
||||
public async generateToken(payload: JwtPayloadDto) {
|
||||
return this.jwtService.sign({
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
|
||||
public async validateToken(accessToken: string) {
|
||||
try {
|
||||
const payload = await this.jwtService.verify(accessToken, { secret: jwtSecret });
|
||||
return {
|
||||
userId: payload['userId'],
|
||||
status: true,
|
||||
};
|
||||
} catch (e) {
|
||||
Logger.error('Error validating token from websocket request', 'ValidateWebsocketToken');
|
||||
return {
|
||||
userId: null,
|
||||
status: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
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 { jwtSecret } from '../../../constants/jwt.constant';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(
|
||||
@InjectRepository(UserEntity)
|
||||
private usersRepository: Repository<UserEntity>,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: jwtSecret,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayloadDto) {
|
||||
const { userId } = payload;
|
||||
const user = await this.usersRepository.findOne({ id: userId });
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Failure to validate JWT payload');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
|
||||
import sharp from 'sharp';
|
||||
|
||||
@Injectable()
|
||||
export class ImageConversionService {
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AssetEntity)
|
||||
private assetRepository: Repository<AssetEntity>
|
||||
) { }
|
||||
|
||||
@Cron(CronExpression.EVERY_5_MINUTES
|
||||
, {
|
||||
name: 'webp-conversion'
|
||||
})
|
||||
async webpConversion() {
|
||||
Logger.log('Starting Webp Conversion Tasks', 'ImageConversionService')
|
||||
|
||||
const assets = await this.assetRepository.find({
|
||||
where: {
|
||||
webpPath: ''
|
||||
},
|
||||
take: 500
|
||||
});
|
||||
|
||||
|
||||
if (assets.length == 0) {
|
||||
Logger.log('All assets has webp file - aborting task', 'ImageConversionService')
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
for (const asset of assets) {
|
||||
const resizePath = asset.resizePath;
|
||||
if (resizePath != '') {
|
||||
const webpPath = resizePath.replace('jpeg', 'webp')
|
||||
|
||||
sharp(resizePath).resize(250).webp().toFile(webpPath, (err, info) => {
|
||||
|
||||
if (!err) {
|
||||
this.assetRepository.update({ id: asset.id }, { webpPath: webpPath })
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AssetModule } from '../../api-v1/asset/asset.module';
|
||||
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
|
||||
import { ImageConversionService } from './image-conversion.service';
|
||||
import { VideoConversionProcessor } from './video-conversion.processor';
|
||||
import { VideoConversionService } from './video-conversion.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AssetEntity]),
|
||||
|
||||
BullModule.registerQueue({
|
||||
settings: {},
|
||||
name: 'video-conversion',
|
||||
limiter: {
|
||||
max: 1,
|
||||
duration: 60000
|
||||
},
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
providers: [ImageConversionService, VideoConversionService, VideoConversionProcessor,],
|
||||
})
|
||||
export class ScheduleTasksModule { }
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Process, Processor } from '@nestjs/bull';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Job } from 'bull';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { APP_UPLOAD_LOCATION } from '../../constants/upload_location.constant';
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
@Processor('video-conversion')
|
||||
export class VideoConversionProcessor {
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AssetEntity)
|
||||
private assetRepository: Repository<AssetEntity>,
|
||||
) { }
|
||||
|
||||
@Process('to-mp4')
|
||||
async convertToMp4(job: Job) {
|
||||
const { asset }: { asset: AssetEntity } = job.data;
|
||||
|
||||
const basePath = APP_UPLOAD_LOCATION;
|
||||
const encodedVideoPath = `${basePath}/${asset.userId}/encoded-video`;
|
||||
|
||||
if (!existsSync(encodedVideoPath)) {
|
||||
mkdirSync(encodedVideoPath, { recursive: true });
|
||||
}
|
||||
|
||||
const latestAssetInfo = await this.assetRepository.findOne({ id: asset.id });
|
||||
const savedEncodedPath = encodedVideoPath + "/" + latestAssetInfo.id + '.mp4'
|
||||
|
||||
if (latestAssetInfo.encodedVideoPath == '') {
|
||||
ffmpeg(latestAssetInfo.originalPath)
|
||||
.outputOptions([
|
||||
'-crf 23',
|
||||
'-preset ultrafast',
|
||||
'-vcodec libx264',
|
||||
'-acodec mp3',
|
||||
'-vf scale=1280:-2'
|
||||
])
|
||||
.output(savedEncodedPath)
|
||||
.on('start', () => Logger.log("Start Converting", 'VideoConversionMOV2MP4'))
|
||||
.on('error', (a, b, c) => {
|
||||
Logger.error('Cannot Convert Video', 'VideoConversionMOV2MP4')
|
||||
console.log(a, b, c)
|
||||
})
|
||||
.on('end', async () => {
|
||||
Logger.log(`Converting Success ${latestAssetInfo.id}`, 'VideoConversionMOV2MP4')
|
||||
await this.assetRepository.update({ id: latestAssetInfo.id }, { encodedVideoPath: savedEncodedPath });
|
||||
}).run();
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
|
||||
import sharp from 'sharp';
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import { APP_UPLOAD_LOCATION } from '../../constants/upload_location.constant';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { InjectQueue } from '@nestjs/bull/dist/decorators';
|
||||
import { Queue } from 'bull';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class VideoConversionService {
|
||||
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AssetEntity)
|
||||
private assetRepository: Repository<AssetEntity>,
|
||||
|
||||
@InjectQueue('video-conversion')
|
||||
private videoEncodingQueue: Queue
|
||||
) { }
|
||||
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE
|
||||
, {
|
||||
name: 'video-encoding'
|
||||
})
|
||||
async mp4Conversion() {
|
||||
const assets = await this.assetRepository.find({
|
||||
where: {
|
||||
type: 'VIDEO',
|
||||
mimeType: 'video/quicktime',
|
||||
encodedVideoPath: ''
|
||||
},
|
||||
order: {
|
||||
createdAt: 'DESC'
|
||||
},
|
||||
take: 1
|
||||
});
|
||||
|
||||
if (assets.length > 0) {
|
||||
const asset = assets[0];
|
||||
await this.videoEncodingQueue.add('to-mp4', { asset }, { jobId: asset.id },)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user