diff --git a/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.module.ts b/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.module.ts index 2f69a741ef..51f70fe8f5 100644 --- a/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.module.ts +++ b/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.module.ts @@ -3,11 +3,16 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AssetEntity } from '@app/database/entities/asset.entity'; import { ScheduleTasksService } from './schedule-tasks.service'; -import { thumbnailGeneratorQueueName, videoConversionQueueName } from '@app/job/constants/queue-name.constant'; +import { + metadataExtractionQueueName, + thumbnailGeneratorQueueName, + videoConversionQueueName, +} from '@app/job/constants/queue-name.constant'; +import { ExifEntity } from '@app/database/entities/exif.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([AssetEntity]), + TypeOrmModule.forFeature([AssetEntity, ExifEntity]), BullModule.registerQueue({ name: videoConversionQueueName, defaultJobOptions: { @@ -24,6 +29,15 @@ import { thumbnailGeneratorQueueName, videoConversionQueueName } from '@app/job/ removeOnFail: false, }, }), + + BullModule.registerQueue({ + name: metadataExtractionQueueName, + defaultJobOptions: { + attempts: 3, + removeOnComplete: true, + removeOnFail: false, + }, + }), ], providers: [ScheduleTasksService], }) diff --git a/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts b/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts index f09a3a72bd..30f1f02d88 100644 --- a/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts +++ b/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts @@ -1,14 +1,23 @@ import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { IsNull, Not, Repository } from 'typeorm'; import { AssetEntity, AssetType } from '@app/database/entities/asset.entity'; import { InjectQueue } from '@nestjs/bull'; import { Queue } from 'bull'; import { randomUUID } from 'crypto'; -import { generateWEBPThumbnailProcessorName, mp4ConversionProcessorName } from '@app/job/constants/job-name.constant'; -import { thumbnailGeneratorQueueName, videoConversionQueueName } from '@app/job/constants/queue-name.constant'; -import { IVideoTranscodeJob } from '@app/job/interfaces/video-transcode.interface'; +import { ExifEntity } from '@app/database/entities/exif.entity'; +import { + IMetadataExtractionJob, + IVideoTranscodeJob, + metadataExtractionQueueName, + thumbnailGeneratorQueueName, + videoConversionQueueName, + generateWEBPThumbnailProcessorName, + mp4ConversionProcessorName, + reverseGeocodingProcessorName, +} from '@app/job'; +import { ConfigService } from '@nestjs/config'; @Injectable() export class ScheduleTasksService { @@ -16,17 +25,23 @@ export class ScheduleTasksService { @InjectRepository(AssetEntity) private assetRepository: Repository, + @InjectRepository(ExifEntity) + private exifRepository: Repository, + @InjectQueue(thumbnailGeneratorQueueName) private thumbnailGeneratorQueue: Queue, @InjectQueue(videoConversionQueueName) private videoConversionQueue: Queue, + + @InjectQueue(metadataExtractionQueueName) + private metadataExtractionQueue: Queue, + + private configService: ConfigService, ) {} @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) async webpConversion() { - Logger.log('Starting Schedule Webp Conversion Tasks', 'CronjobWebpGenerator'); - const assets = await this.assetRepository.find({ where: { webpPath: '', @@ -64,4 +79,23 @@ export class ScheduleTasksService { await this.videoConversionQueue.add(mp4ConversionProcessorName, { asset }, { jobId: randomUUID() }); } } + + @Cron(CronExpression.EVERY_5_SECONDS) + async reverseGeocoding() { + const isMapboxEnable = this.configService.get('ENABLE_MAPBOX'); + + if (isMapboxEnable) { + const exifInfo = await this.exifRepository.find({ + where: { + city: IsNull(), + longitude: Not(IsNull()), + latitude: Not(IsNull()), + }, + }); + + for (const exif of exifInfo) { + await this.metadataExtractionQueue.add(reverseGeocodingProcessorName, { exif }, { jobId: randomUUID() }); + } + } + } } diff --git a/server/apps/microservices/src/processors/metadata-extraction.processor.ts b/server/apps/microservices/src/processors/metadata-extraction.processor.ts index a74f77e9be..86e1ebace0 100644 --- a/server/apps/microservices/src/processors/metadata-extraction.processor.ts +++ b/server/apps/microservices/src/processors/metadata-extraction.processor.ts @@ -21,6 +21,8 @@ import { objectDetectionProcessorName, videoMetadataExtractionProcessorName, metadataExtractionQueueName, + reverseGeocodingProcessorName, + IReverseGeocodingProcessor, } from '@app/job'; @Processor(metadataExtractionQueueName) @@ -98,6 +100,28 @@ export class MetadataExtractionProcessor { } } + @Process({ name: reverseGeocodingProcessorName }) + async reverseGeocoding(job: Job) { + const { exif } = job.data; + + if (this.geocodingClient) { + const geoCodeInfo: MapiResponse = await this.geocodingClient + .reverseGeocode({ + query: [Number(exif.longitude), Number(exif.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']; + + await this.exifRepository.update({ id: exif.id }, { city, state, country }); + } + } + @Process({ name: imageTaggingProcessorName, concurrency: 2 }) async tagImage(job: Job) { const { asset }: { asset: AssetEntity } = job.data; diff --git a/server/libs/job/src/constants/job-name.constant.ts b/server/libs/job/src/constants/job-name.constant.ts index 2cb6796ff2..002dd7f5a7 100644 --- a/server/libs/job/src/constants/job-name.constant.ts +++ b/server/libs/job/src/constants/job-name.constant.ts @@ -19,5 +19,6 @@ export const generateWEBPThumbnailProcessorName = 'generate-webp-thumbnail'; */ export const exifExtractionProcessorName = 'exif-extraction'; export const videoMetadataExtractionProcessorName = 'extract-video-metadata'; +export const reverseGeocodingProcessorName = 'reverse-geocoding'; export const objectDetectionProcessorName = 'detect-object'; export const imageTaggingProcessorName = 'tag-image'; diff --git a/server/libs/job/src/interfaces/metadata-extraction.interface.ts b/server/libs/job/src/interfaces/metadata-extraction.interface.ts index 76209ca375..de3f2be6d9 100644 --- a/server/libs/job/src/interfaces/metadata-extraction.interface.ts +++ b/server/libs/job/src/interfaces/metadata-extraction.interface.ts @@ -1,4 +1,5 @@ import { AssetEntity } from '@app/database/entities/asset.entity'; +import { ExifEntity } from '@app/database/entities/exif.entity'; export interface IExifExtractionProcessor { /** @@ -24,4 +25,14 @@ export interface IVideoLengthExtractionProcessor { asset: AssetEntity; } -export type IMetadataExtractionJob = IExifExtractionProcessor | IVideoLengthExtractionProcessor; +export interface IReverseGeocodingProcessor { + /** + * The Asset entity that was saved in the database + */ + exif: ExifEntity; +} + +export type IMetadataExtractionJob = + | IExifExtractionProcessor + | IVideoLengthExtractionProcessor + | IReverseGeocodingProcessor;