mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00:00
Implemented EXIF store and display (#19)
* Added EXIF extracting in the backend * Added EXIF displaying on `image_viewer_page.dart` * Added Icon for backup option not enable
This commit is contained in:
@@ -17,35 +17,25 @@ COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
##################################
|
||||
#################################
|
||||
# PRODUCTION
|
||||
##################################
|
||||
# FROM node:16-bullseye-slim as production
|
||||
# ARG DEBIAN_FRONTEND=noninteractive
|
||||
# ARG NODE_ENV=production
|
||||
# ENV NODE_ENV=${NODE_ENV}
|
||||
#################################
|
||||
FROM node:16-alpine3.14 AS production
|
||||
|
||||
# WORKDIR /usr/src/app
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG NODE_ENV=production
|
||||
ENV NODE_ENV=${NODE_ENV}
|
||||
|
||||
# COPY package.json yarn.lock ./
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# RUN apt-get update
|
||||
# RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
# RUN npm i -g yarn --force
|
||||
RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg
|
||||
|
||||
# RUN yarn install --only=production
|
||||
RUN npm install --only=production
|
||||
|
||||
# COPY . .
|
||||
COPY . .
|
||||
|
||||
# COPY --from=development /usr/src/app/dist ./dist
|
||||
COPY --from=development /usr/src/app/dist ./dist
|
||||
|
||||
# # Clean up commands
|
||||
# RUN apt-get autoremove -y && apt-get clean && \
|
||||
# rm -rf /usr/local/src/*
|
||||
|
||||
# RUN apt-get clean && \
|
||||
# rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
# CMD ["node", "dist/main"]
|
||||
CMD ["node", "dist/main"]
|
||||
692
server/package-lock.json
generated
692
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,7 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.13.2",
|
||||
"dotenv": "^14.2.0",
|
||||
"exifr": "^7.1.3",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"joi": "^17.5.0",
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
@@ -30,6 +30,7 @@ import { promisify } from 'util';
|
||||
import { stat } from 'fs';
|
||||
import { pipeline } from 'stream';
|
||||
import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto';
|
||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||
|
||||
const fileInfo = promisify(stat);
|
||||
|
||||
@@ -37,8 +38,9 @@ const fileInfo = promisify(stat);
|
||||
@Controller('asset')
|
||||
export class AssetController {
|
||||
constructor(
|
||||
private readonly assetService: AssetService,
|
||||
private readonly assetOptimizeService: AssetOptimizeService,
|
||||
private assetService: AssetService,
|
||||
private assetOptimizeService: AssetOptimizeService,
|
||||
private backgroundTaskService: BackgroundTaskService,
|
||||
) {}
|
||||
|
||||
@Post('upload')
|
||||
@@ -53,6 +55,7 @@ export class AssetController {
|
||||
|
||||
if (savedAsset && savedAsset.type == AssetType.IMAGE) {
|
||||
await this.assetOptimizeService.resizeImage(savedAsset);
|
||||
await this.backgroundTaskService.extractExif(savedAsset, file.originalname, file.size);
|
||||
}
|
||||
|
||||
if (savedAsset && savedAsset.type == AssetType.VIDEO) {
|
||||
@@ -155,4 +158,9 @@ export class AssetController {
|
||||
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 this.assetService.getAssetById(authUser, assetId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ 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';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -17,11 +19,20 @@ import { BullModule } from '@nestjs/bull';
|
||||
removeOnFail: false,
|
||||
},
|
||||
}),
|
||||
BullModule.registerQueue({
|
||||
name: 'background-task',
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
},
|
||||
}),
|
||||
TypeOrmModule.forFeature([AssetEntity]),
|
||||
ImageOptimizeModule,
|
||||
BackgroundTaskModule,
|
||||
],
|
||||
controllers: [AssetController],
|
||||
providers: [AssetService, AssetOptimizeService],
|
||||
providers: [AssetService, AssetOptimizeService, BackgroundTaskService],
|
||||
exports: [],
|
||||
})
|
||||
export class AssetModule {}
|
||||
|
||||
@@ -112,4 +112,14 @@ export class AssetService {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async getAssetById(authUser: AuthUserDto, assetId: string) {
|
||||
return await this.assetRepository.findOne({
|
||||
where: {
|
||||
userId: authUser.id,
|
||||
id: assetId,
|
||||
},
|
||||
relations: ['exifInfo'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
48
server/src/api-v1/asset/dto/create-exif.dto.ts
Normal file
48
server/src/api-v1/asset/dto/create-exif.dto.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
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;
|
||||
}
|
||||
4
server/src/api-v1/asset/dto/update-exif.dto.ts
Normal file
4
server/src/api-v1/asset/dto/update-exif.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateExifDto } from './create-exif.dto';
|
||||
|
||||
export class UpdateExifDto extends PartialType(CreateExifDto) {}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Column, Entity, PrimaryColumn, PrimaryGeneratedColumn, Unique } from 'typeorm';
|
||||
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn, PrimaryGeneratedColumn, Unique } from 'typeorm';
|
||||
import { ExifEntity } from './exif.entity';
|
||||
|
||||
@Entity('assets')
|
||||
@Unique(['deviceAssetId', 'userId', 'deviceId'])
|
||||
@@ -38,6 +39,9 @@ export class AssetEntity {
|
||||
|
||||
@Column({ nullable: true })
|
||||
duration: string;
|
||||
|
||||
@OneToOne(() => ExifEntity, (exifEntity) => exifEntity.asset)
|
||||
exifInfo: ExifEntity;
|
||||
}
|
||||
|
||||
export enum AssetType {
|
||||
|
||||
67
server/src/api-v1/asset/entities/exif.entity.ts
Normal file
67
server/src/api-v1/asset/entities/exif.entity.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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;
|
||||
|
||||
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
|
||||
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
|
||||
asset: ExifEntity;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ 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';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -29,7 +30,6 @@ import { ServerInfoModule } from './api-v1/server-info/server-info.module';
|
||||
redis: {
|
||||
host: 'immich_redis',
|
||||
port: 6379,
|
||||
// password: configService.get('REDIS_PASSWORD'),
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
@@ -38,6 +38,8 @@ import { ServerInfoModule } from './api-v1/server-info/server-info.module';
|
||||
ImageOptimizeModule,
|
||||
|
||||
ServerInfoModule,
|
||||
|
||||
BackgroundTaskModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [],
|
||||
|
||||
24
server/src/modules/background-task/background-task.module.ts
Normal file
24
server/src/modules/background-task/background-task.module.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
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 { 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]),
|
||||
],
|
||||
providers: [BackgroundTaskService, BackgroundTaskProcessor],
|
||||
exports: [BackgroundTaskService],
|
||||
})
|
||||
export class BackgroundTaskModule {}
|
||||
@@ -0,0 +1,59 @@
|
||||
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 { Logger } from '@nestjs/common';
|
||||
import { ExifEntity } from '../../api-v1/asset/entities/exif.entity';
|
||||
|
||||
@Processor('background-task')
|
||||
export class BackgroundTaskProcessor {
|
||||
constructor(
|
||||
@InjectRepository(AssetEntity)
|
||||
private assetRepository: Repository<AssetEntity>,
|
||||
|
||||
@InjectRepository(ExifEntity)
|
||||
private exifRepository: Repository<ExifEntity>,
|
||||
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
@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;
|
||||
|
||||
await this.exifRepository.save(newExif);
|
||||
|
||||
try {
|
||||
} catch (e) {
|
||||
Logger.error(`Error extracting EXIF ${e.toString()}`, 'extractExif');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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) {
|
||||
const job = await this.backgroundTaskQueue.add(
|
||||
'extract-exif',
|
||||
{
|
||||
savedAsset,
|
||||
fileName,
|
||||
fileSize,
|
||||
},
|
||||
{ jobId: randomUUID() },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export class AssetOptimizeService {
|
||||
};
|
||||
}
|
||||
|
||||
public async getVideoThumbnail(savedAsset: AssetEntity, filename: String) {
|
||||
public async getVideoThumbnail(savedAsset: AssetEntity, filename: string) {
|
||||
const job = await this.optimizeQueue.add(
|
||||
'get-video-thumbnail',
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user