mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +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:
parent
d1498506a8
commit
de1dbcea9c
35 changed files with 1092 additions and 847 deletions
|
|
@ -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',
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue