mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
refactor(server)*: tsconfigs (#2689)
* refactor(server): tsconfigs * chore: dummy commit * fix: start.sh * chore: restore original entry scripts
This commit is contained in:
parent
a2130aa6c5
commit
8ebac41318
465 changed files with 209 additions and 332 deletions
89
server/src/microservices/app.service.ts
Normal file
89
server/src/microservices/app.service.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import {
|
||||
FacialRecognitionService,
|
||||
IDeleteFilesJob,
|
||||
JobName,
|
||||
JobService,
|
||||
MediaService,
|
||||
MetadataService,
|
||||
PersonService,
|
||||
SearchService,
|
||||
SmartInfoService,
|
||||
StorageService,
|
||||
StorageTemplateService,
|
||||
SystemConfigService,
|
||||
UserService,
|
||||
} from '@app/domain';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
private logger = new Logger(AppService.name);
|
||||
|
||||
constructor(
|
||||
// TODO refactor to domain
|
||||
private metadataProcessor: MetadataExtractionProcessor,
|
||||
|
||||
private facialRecognitionService: FacialRecognitionService,
|
||||
private jobService: JobService,
|
||||
private mediaService: MediaService,
|
||||
private metadataService: MetadataService,
|
||||
private personService: PersonService,
|
||||
private searchService: SearchService,
|
||||
private smartInfoService: SmartInfoService,
|
||||
private storageTemplateService: StorageTemplateService,
|
||||
private storageService: StorageService,
|
||||
private systemConfigService: SystemConfigService,
|
||||
private userService: UserService,
|
||||
) {}
|
||||
|
||||
async init() {
|
||||
await this.jobService.registerHandlers({
|
||||
[JobName.DELETE_FILES]: (data: IDeleteFilesJob) => this.storageService.handleDeleteFiles(data),
|
||||
[JobName.USER_DELETE_CHECK]: () => this.userService.handleUserDeleteCheck(),
|
||||
[JobName.USER_DELETION]: (data) => this.userService.handleUserDelete(data),
|
||||
[JobName.QUEUE_OBJECT_TAGGING]: (data) => this.smartInfoService.handleQueueObjectTagging(data),
|
||||
[JobName.CLASSIFY_IMAGE]: (data) => this.smartInfoService.handleClassifyImage(data),
|
||||
[JobName.QUEUE_ENCODE_CLIP]: (data) => this.smartInfoService.handleQueueEncodeClip(data),
|
||||
[JobName.ENCODE_CLIP]: (data) => this.smartInfoService.handleEncodeClip(data),
|
||||
[JobName.SEARCH_INDEX_ALBUMS]: () => this.searchService.handleIndexAlbums(),
|
||||
[JobName.SEARCH_INDEX_ASSETS]: () => this.searchService.handleIndexAssets(),
|
||||
[JobName.SEARCH_INDEX_FACES]: () => this.searchService.handleIndexFaces(),
|
||||
[JobName.SEARCH_INDEX_ALBUM]: (data) => this.searchService.handleIndexAlbum(data),
|
||||
[JobName.SEARCH_INDEX_ASSET]: (data) => this.searchService.handleIndexAsset(data),
|
||||
[JobName.SEARCH_INDEX_FACE]: (data) => this.searchService.handleIndexFace(data),
|
||||
[JobName.SEARCH_REMOVE_ALBUM]: (data) => this.searchService.handleRemoveAlbum(data),
|
||||
[JobName.SEARCH_REMOVE_ASSET]: (data) => this.searchService.handleRemoveAsset(data),
|
||||
[JobName.SEARCH_REMOVE_FACE]: (data) => this.searchService.handleRemoveFace(data),
|
||||
[JobName.STORAGE_TEMPLATE_MIGRATION]: () => this.storageTemplateService.handleMigration(),
|
||||
[JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE]: (data) => this.storageTemplateService.handleMigrationSingle(data),
|
||||
[JobName.SYSTEM_CONFIG_CHANGE]: () => this.systemConfigService.refreshConfig(),
|
||||
[JobName.QUEUE_GENERATE_THUMBNAILS]: (data) => this.mediaService.handleQueueGenerateThumbnails(data),
|
||||
[JobName.GENERATE_JPEG_THUMBNAIL]: (data) => this.mediaService.handleGenerateJpegThumbnail(data),
|
||||
[JobName.GENERATE_WEBP_THUMBNAIL]: (data) => this.mediaService.handleGenerateWepbThumbnail(data),
|
||||
[JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data),
|
||||
[JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data),
|
||||
[JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleQueueMetadataExtraction(data),
|
||||
[JobName.METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleMetadataExtraction(data),
|
||||
[JobName.QUEUE_RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleQueueRecognizeFaces(data),
|
||||
[JobName.RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleRecognizeFaces(data),
|
||||
[JobName.GENERATE_FACE_THUMBNAIL]: (data) => this.facialRecognitionService.handleGenerateFaceThumbnail(data),
|
||||
[JobName.PERSON_CLEANUP]: () => this.personService.handlePersonCleanup(),
|
||||
[JobName.QUEUE_SIDECAR]: (data) => this.metadataService.handleQueueSidecar(data),
|
||||
[JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data),
|
||||
[JobName.SIDECAR_SYNC]: () => this.metadataService.handleSidecarSync(),
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error: Error | any) => {
|
||||
const isCsvError = error.code === 'CSV_RECORD_INCONSISTENT_FIELDS_LENGTH';
|
||||
if (!isCsvError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.warn('Geocoding csv parse error, trying again without cache...');
|
||||
this.metadataProcessor.init(true);
|
||||
});
|
||||
|
||||
await this.metadataProcessor.init();
|
||||
}
|
||||
}
|
||||
21
server/src/microservices/main.ts
Normal file
21
server/src/microservices/main.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { getLogLevels, SERVER_VERSION } from '@app/domain';
|
||||
import { RedisIoAdapter } from '@app/infra';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppService } from './app.service';
|
||||
import { MicroservicesModule } from './microservices.module';
|
||||
|
||||
const logger = new Logger('ImmichMicroservice');
|
||||
const port = Number(process.env.MICROSERVICES_PORT) || 3002;
|
||||
const envName = (process.env.NODE_ENV || 'development').toUpperCase();
|
||||
|
||||
export async function bootstrap() {
|
||||
const app = await NestFactory.create(MicroservicesModule, { logger: getLogLevels() });
|
||||
|
||||
app.useWebSocketAdapter(new RedisIoAdapter(app));
|
||||
|
||||
await app.get(AppService).init();
|
||||
await app.listen(port);
|
||||
|
||||
logger.log(`Immich Microservices is listening on ${port} [v${SERVER_VERSION}] [${envName}] `);
|
||||
}
|
||||
17
server/src/microservices/microservices.module.ts
Normal file
17
server/src/microservices/microservices.module.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { DomainModule } from '@app/domain';
|
||||
import { InfraModule } from '@app/infra';
|
||||
import { ExifEntity } from '@app/infra/entities';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AppService } from './app.service';
|
||||
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
//
|
||||
DomainModule.register({ imports: [InfraModule] }),
|
||||
TypeOrmModule.forFeature([ExifEntity]),
|
||||
],
|
||||
providers: [MetadataExtractionProcessor, AppService],
|
||||
})
|
||||
export class MicroservicesModule {}
|
||||
|
|
@ -0,0 +1,342 @@
|
|||
import {
|
||||
IAssetRepository,
|
||||
IBaseJob,
|
||||
IEntityJob,
|
||||
IGeocodingRepository,
|
||||
IJobRepository,
|
||||
JobName,
|
||||
JOBS_ASSET_PAGINATION_SIZE,
|
||||
QueueName,
|
||||
usePagination,
|
||||
WithoutProperty,
|
||||
} from '@app/domain';
|
||||
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import tz_lookup from '@photostructure/tz-lookup';
|
||||
import { exiftool, Tags } from 'exiftool-vendored';
|
||||
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
|
||||
import { Duration } from 'luxon';
|
||||
import fs from 'node:fs';
|
||||
import sharp from 'sharp';
|
||||
import { Repository } from 'typeorm/repository/Repository';
|
||||
import { promisify } from 'util';
|
||||
import { parseLatitude, parseLongitude } from '../utils/exif/coordinates';
|
||||
import { exifTimeZone, exifToDate } from '../utils/exif/date-time';
|
||||
import { parseISO } from '../utils/exif/iso';
|
||||
import { toNumberOrNull } from '../utils/numbers';
|
||||
|
||||
const ffprobe = promisify<string, FfprobeData>(ffmpeg.ffprobe);
|
||||
|
||||
interface ImmichTags extends Tags {
|
||||
ContentIdentifier?: string;
|
||||
}
|
||||
|
||||
export class MetadataExtractionProcessor {
|
||||
private logger = new Logger(MetadataExtractionProcessor.name);
|
||||
private reverseGeocodingEnabled: boolean;
|
||||
|
||||
constructor(
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(IGeocodingRepository) private geocodingRepository: IGeocodingRepository,
|
||||
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
|
||||
|
||||
configService: ConfigService,
|
||||
) {
|
||||
this.reverseGeocodingEnabled = !configService.get('DISABLE_REVERSE_GEOCODING');
|
||||
}
|
||||
|
||||
async init(deleteCache = false) {
|
||||
this.logger.warn(`Reverse geocoding is ${this.reverseGeocodingEnabled ? 'enabled' : 'disabled'}`);
|
||||
if (!this.reverseGeocodingEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (deleteCache) {
|
||||
await this.geocodingRepository.deleteCache();
|
||||
}
|
||||
this.logger.log('Initializing Reverse Geocoding');
|
||||
|
||||
await this.jobRepository.pause(QueueName.METADATA_EXTRACTION);
|
||||
await this.geocodingRepository.init();
|
||||
await this.jobRepository.resume(QueueName.METADATA_EXTRACTION);
|
||||
|
||||
this.logger.log('Reverse Geocoding Initialized');
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Unable to initialize reverse geocoding: ${error}`, error?.stack);
|
||||
}
|
||||
}
|
||||
|
||||
async handleQueueMetadataExtraction(job: IBaseJob) {
|
||||
const { force } = job;
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getAll(pagination)
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.EXIF);
|
||||
});
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id } });
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleMetadataExtraction({ id }: IEntityJob) {
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
if (!asset || !asset.isVisible) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (asset.type === AssetType.VIDEO) {
|
||||
return this.handleVideoMetadataExtraction(asset);
|
||||
} else {
|
||||
return this.handlePhotoMetadataExtraction(asset);
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePhotoMetadataExtraction(asset: AssetEntity) {
|
||||
const mediaExifData = await exiftool.read<ImmichTags>(asset.originalPath).catch((error: any) => {
|
||||
this.logger.warn(
|
||||
`The exifData parsing failed due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
|
||||
error?.stack,
|
||||
);
|
||||
return null;
|
||||
});
|
||||
|
||||
const sidecarExifData = asset.sidecarPath
|
||||
? await exiftool.read<ImmichTags>(asset.sidecarPath).catch((error: any) => {
|
||||
this.logger.warn(
|
||||
`The exifData parsing failed due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
|
||||
error?.stack,
|
||||
);
|
||||
return null;
|
||||
})
|
||||
: {};
|
||||
|
||||
const getExifProperty = <T extends keyof ImmichTags>(
|
||||
...properties: T[]
|
||||
): NonNullable<ImmichTags[T]> | string | null => {
|
||||
for (const property of properties) {
|
||||
const value = sidecarExifData?.[property] ?? mediaExifData?.[property];
|
||||
if (value !== null && value !== undefined) {
|
||||
// Can also be string when the value cannot be parsed
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const timeZone = exifTimeZone(getExifProperty('DateTimeOriginal', 'CreateDate') ?? asset.fileCreatedAt);
|
||||
const fileCreatedAt = exifToDate(getExifProperty('DateTimeOriginal', 'CreateDate') ?? asset.fileCreatedAt);
|
||||
const fileModifiedAt = exifToDate(getExifProperty('ModifyDate') ?? asset.fileModifiedAt);
|
||||
const fileStats = fs.statSync(asset.originalPath);
|
||||
const fileSizeInBytes = fileStats.size;
|
||||
|
||||
const newExif = new ExifEntity();
|
||||
newExif.assetId = asset.id;
|
||||
newExif.fileSizeInByte = fileSizeInBytes;
|
||||
newExif.make = getExifProperty('Make');
|
||||
newExif.model = getExifProperty('Model');
|
||||
newExif.exifImageHeight = toNumberOrNull(getExifProperty('ExifImageHeight', 'ImageHeight'));
|
||||
newExif.exifImageWidth = toNumberOrNull(getExifProperty('ExifImageWidth', 'ImageWidth'));
|
||||
newExif.exposureTime = getExifProperty('ExposureTime');
|
||||
newExif.orientation = getExifProperty('Orientation')?.toString() ?? null;
|
||||
newExif.dateTimeOriginal = fileCreatedAt;
|
||||
newExif.modifyDate = fileModifiedAt;
|
||||
newExif.timeZone = timeZone;
|
||||
newExif.lensModel = getExifProperty('LensModel');
|
||||
newExif.fNumber = toNumberOrNull(getExifProperty('FNumber'));
|
||||
newExif.focalLength = toNumberOrNull(getExifProperty('FocalLength'));
|
||||
|
||||
// Handle array values by converting to string
|
||||
const iso = getExifProperty('ISO')?.toString();
|
||||
newExif.iso = iso ? parseISO(iso) : null;
|
||||
|
||||
const latitude = getExifProperty('GPSLatitude');
|
||||
const longitude = getExifProperty('GPSLongitude');
|
||||
newExif.latitude = latitude !== null ? parseLatitude(latitude) : null;
|
||||
newExif.longitude = longitude !== null ? parseLongitude(longitude) : null;
|
||||
|
||||
newExif.livePhotoCID = getExifProperty('MediaGroupUUID');
|
||||
if (newExif.livePhotoCID && !asset.livePhotoVideoId) {
|
||||
const motionAsset = await this.assetRepository.findLivePhotoMatch({
|
||||
livePhotoCID: newExif.livePhotoCID,
|
||||
otherAssetId: asset.id,
|
||||
ownerId: asset.ownerId,
|
||||
type: AssetType.VIDEO,
|
||||
});
|
||||
if (motionAsset) {
|
||||
await this.assetRepository.save({ id: asset.id, livePhotoVideoId: motionAsset.id });
|
||||
await this.assetRepository.save({ id: motionAsset.id, isVisible: false });
|
||||
}
|
||||
}
|
||||
|
||||
await this.applyReverseGeocoding(asset, newExif);
|
||||
|
||||
/**
|
||||
* IF the EXIF doesn't contain the width and height of the image,
|
||||
* We will use Sharpjs to get the information.
|
||||
*/
|
||||
if (!newExif.exifImageHeight || !newExif.exifImageWidth || !newExif.orientation) {
|
||||
const metadata = await sharp(asset.originalPath).metadata();
|
||||
|
||||
if (newExif.exifImageHeight === null) {
|
||||
newExif.exifImageHeight = metadata.height || null;
|
||||
}
|
||||
|
||||
if (newExif.exifImageWidth === null) {
|
||||
newExif.exifImageWidth = metadata.width || null;
|
||||
}
|
||||
|
||||
if (newExif.orientation === null) {
|
||||
newExif.orientation = metadata.orientation !== undefined ? `${metadata.orientation}` : null;
|
||||
}
|
||||
}
|
||||
|
||||
await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] });
|
||||
await this.assetRepository.save({ id: asset.id, fileCreatedAt: fileCreatedAt || undefined });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async handleVideoMetadataExtraction(asset: AssetEntity) {
|
||||
const data = await ffprobe(asset.originalPath);
|
||||
const durationString = this.extractDuration(data.format.duration || asset.duration);
|
||||
let fileCreatedAt = asset.fileCreatedAt;
|
||||
|
||||
const videoTags = data.format.tags;
|
||||
if (videoTags) {
|
||||
if (videoTags['com.apple.quicktime.creationdate']) {
|
||||
fileCreatedAt = new Date(videoTags['com.apple.quicktime.creationdate']);
|
||||
} else if (videoTags['creation_time']) {
|
||||
fileCreatedAt = new Date(videoTags['creation_time']);
|
||||
}
|
||||
}
|
||||
|
||||
const exifData = await exiftool.read<ImmichTags>(asset.sidecarPath || asset.originalPath).catch((error: any) => {
|
||||
this.logger.warn(
|
||||
`The exifData parsing failed due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
|
||||
error?.stack,
|
||||
);
|
||||
return null;
|
||||
});
|
||||
|
||||
const newExif = new ExifEntity();
|
||||
newExif.assetId = asset.id;
|
||||
newExif.fileSizeInByte = data.format.size || null;
|
||||
newExif.dateTimeOriginal = fileCreatedAt ? new Date(fileCreatedAt) : null;
|
||||
newExif.modifyDate = null;
|
||||
newExif.timeZone = null;
|
||||
newExif.latitude = null;
|
||||
newExif.longitude = null;
|
||||
newExif.city = null;
|
||||
newExif.state = null;
|
||||
newExif.country = null;
|
||||
newExif.fps = null;
|
||||
newExif.livePhotoCID = exifData?.ContentIdentifier || null;
|
||||
|
||||
if (newExif.livePhotoCID) {
|
||||
const photoAsset = await this.assetRepository.findLivePhotoMatch({
|
||||
livePhotoCID: newExif.livePhotoCID,
|
||||
ownerId: asset.ownerId,
|
||||
otherAssetId: asset.id,
|
||||
type: AssetType.IMAGE,
|
||||
});
|
||||
if (photoAsset) {
|
||||
await this.assetRepository.save({ id: photoAsset.id, livePhotoVideoId: asset.id });
|
||||
await this.assetRepository.save({ id: asset.id, isVisible: false });
|
||||
}
|
||||
}
|
||||
|
||||
if (videoTags && videoTags['location']) {
|
||||
const location = videoTags['location'] as string;
|
||||
const locationRegex = /([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)\/$/;
|
||||
const match = location.match(locationRegex);
|
||||
|
||||
if (match?.length === 3) {
|
||||
newExif.latitude = parseLatitude(match[1]);
|
||||
newExif.longitude = parseLongitude(match[2]);
|
||||
}
|
||||
} else if (videoTags && videoTags['com.apple.quicktime.location.ISO6709']) {
|
||||
const location = videoTags['com.apple.quicktime.location.ISO6709'] as string;
|
||||
const locationRegex = /([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)\/$/;
|
||||
const match = location.match(locationRegex);
|
||||
|
||||
if (match?.length === 4) {
|
||||
newExif.latitude = parseLatitude(match[1]);
|
||||
newExif.longitude = parseLongitude(match[2]);
|
||||
}
|
||||
}
|
||||
|
||||
if (newExif.longitude && newExif.latitude) {
|
||||
try {
|
||||
newExif.timeZone = tz_lookup(newExif.latitude, newExif.longitude);
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Error while calculating timezone from gps coordinates: ${error}`, error?.stack);
|
||||
}
|
||||
}
|
||||
|
||||
await this.applyReverseGeocoding(asset, newExif);
|
||||
|
||||
for (const stream of data.streams) {
|
||||
if (stream.codec_type === 'video') {
|
||||
newExif.exifImageWidth = stream.width || null;
|
||||
newExif.exifImageHeight = stream.height || null;
|
||||
|
||||
if (typeof stream.rotation === 'string') {
|
||||
newExif.orientation = stream.rotation;
|
||||
} else if (typeof stream.rotation === 'number') {
|
||||
newExif.orientation = `${stream.rotation}`;
|
||||
} else {
|
||||
newExif.orientation = null;
|
||||
}
|
||||
|
||||
if (stream.r_frame_rate) {
|
||||
const fpsParts = stream.r_frame_rate.split('/');
|
||||
|
||||
if (fpsParts.length === 2) {
|
||||
newExif.fps = Math.round(parseInt(fpsParts[0]) / parseInt(fpsParts[1]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] });
|
||||
await this.assetRepository.save({ id: asset.id, duration: durationString, fileCreatedAt });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async applyReverseGeocoding(asset: AssetEntity, newExif: ExifEntity) {
|
||||
const { latitude, longitude } = newExif;
|
||||
if (this.reverseGeocodingEnabled && longitude && latitude) {
|
||||
try {
|
||||
const { country, state, city } = await this.geocodingRepository.reverseGeocode({ latitude, longitude });
|
||||
newExif.country = country;
|
||||
newExif.state = state;
|
||||
newExif.city = city;
|
||||
} catch (error: any) {
|
||||
this.logger.warn(
|
||||
`Unable to run reverse geocoding due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
|
||||
error?.stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractDuration(duration: number | string | null) {
|
||||
const videoDurationInSecond = Number(duration);
|
||||
if (!videoDurationInSecond) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Duration.fromObject({ seconds: videoDurationInSecond }).toFormat('hh:mm:ss.SSS');
|
||||
}
|
||||
}
|
||||
46
server/src/microservices/utils/exif/coordinates.spec.ts
Normal file
46
server/src/microservices/utils/exif/coordinates.spec.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, it, expect } from '@jest/globals';
|
||||
import { parseLatitude, parseLongitude } from './coordinates';
|
||||
|
||||
describe('parsing latitude from string input', () => {
|
||||
it('returns null for invalid inputs', () => {
|
||||
expect(parseLatitude('')).toBeNull();
|
||||
expect(parseLatitude('NaN')).toBeNull();
|
||||
expect(parseLatitude('Infinity')).toBeNull();
|
||||
expect(parseLatitude('-Infinity')).toBeNull();
|
||||
expect(parseLatitude('90.001')).toBeNull();
|
||||
expect(parseLatitude(-90.000001)).toBeNull();
|
||||
expect(parseLatitude('1000')).toBeNull();
|
||||
expect(parseLatitude(-1000)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the numeric coordinate for valid inputs', () => {
|
||||
expect(parseLatitude('90')).toBeCloseTo(90);
|
||||
expect(parseLatitude('-90')).toBeCloseTo(-90);
|
||||
expect(parseLatitude(89.999999)).toBeCloseTo(89.999999);
|
||||
expect(parseLatitude('-89.9')).toBeCloseTo(-89.9);
|
||||
expect(parseLatitude(0)).toBeCloseTo(0);
|
||||
expect(parseLatitude('-0.0')).toBeCloseTo(-0.0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsing longitude from string input', () => {
|
||||
it('returns null for invalid inputs', () => {
|
||||
expect(parseLongitude('')).toBeNull();
|
||||
expect(parseLongitude('NaN')).toBeNull();
|
||||
expect(parseLongitude(Infinity)).toBeNull();
|
||||
expect(parseLongitude('-Infinity')).toBeNull();
|
||||
expect(parseLongitude('180.001')).toBeNull();
|
||||
expect(parseLongitude('-180.000001')).toBeNull();
|
||||
expect(parseLongitude(1000)).toBeNull();
|
||||
expect(parseLongitude('-1000')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the numeric coordinate for valid inputs', () => {
|
||||
expect(parseLongitude(180)).toBeCloseTo(180);
|
||||
expect(parseLongitude('-180')).toBeCloseTo(-180);
|
||||
expect(parseLongitude('179.999999')).toBeCloseTo(179.999999);
|
||||
expect(parseLongitude(-179.9)).toBeCloseTo(-179.9);
|
||||
expect(parseLongitude('0')).toBeCloseTo(0);
|
||||
expect(parseLongitude('-0.0')).toBeCloseTo(-0.0);
|
||||
});
|
||||
});
|
||||
19
server/src/microservices/utils/exif/coordinates.ts
Normal file
19
server/src/microservices/utils/exif/coordinates.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { isNumberInRange } from '../numbers';
|
||||
|
||||
export function parseLatitude(input: string | number): number | null {
|
||||
const latitude = typeof input === 'string' ? Number.parseFloat(input) : input;
|
||||
|
||||
if (isNumberInRange(latitude, -90, 90)) {
|
||||
return latitude;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseLongitude(input: string | number): number | null {
|
||||
const longitude = typeof input === 'string' ? Number.parseFloat(input) : input;
|
||||
|
||||
if (isNumberInRange(longitude, -180, 180)) {
|
||||
return longitude;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
36
server/src/microservices/utils/exif/date-time.spec.ts
Normal file
36
server/src/microservices/utils/exif/date-time.spec.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, expect, it } from '@jest/globals';
|
||||
import { ExifDateTime } from 'exiftool-vendored';
|
||||
import { exifTimeZone, exifToDate } from './date-time';
|
||||
|
||||
describe('converts exif date to JS date', () => {
|
||||
it('returns null for invalid inputs', () => {
|
||||
expect(exifToDate(undefined)).toBeNull();
|
||||
expect(exifToDate('invalid')).toBeNull();
|
||||
expect(exifToDate(new Date('invalid'))).toBeNull();
|
||||
expect(exifToDate(ExifDateTime.fromEXIF('invalid'))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a valid date object for valid inputs', () => {
|
||||
const date = new Date('2023');
|
||||
expect(exifToDate(date)).toBeInstanceOf(Date);
|
||||
expect(exifToDate(date)?.toISOString()).toBe('2023-01-01T00:00:00.000Z');
|
||||
expect(exifToDate('2023')).toBeInstanceOf(Date);
|
||||
|
||||
const exifDateTime = ExifDateTime.fromISO('2023-01-01T00:00:00.000Z');
|
||||
expect(exifToDate(exifDateTime)).toBeInstanceOf(Date);
|
||||
expect(exifToDate(exifDateTime)?.toISOString()).toBe('2023-01-01T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extracts the timezone from a date', () => {
|
||||
it('returns null for invalid inputs', () => {
|
||||
expect(exifTimeZone(undefined)).toBeNull();
|
||||
expect(exifTimeZone('')).toBeNull();
|
||||
expect(exifTimeZone(new Date('2023'))).toBeNull();
|
||||
expect(exifTimeZone(ExifDateTime.fromEXIF('invalid'))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the timezone for valid inputs', () => {
|
||||
expect(exifTimeZone(ExifDateTime.fromEXIF('2020:12:29 14:24:45.700-05:00'))).toBe('UTC-5');
|
||||
});
|
||||
});
|
||||
24
server/src/microservices/utils/exif/date-time.ts
Normal file
24
server/src/microservices/utils/exif/date-time.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { ExifDateTime } from 'exiftool-vendored';
|
||||
import { isDecimalNumber } from '../numbers';
|
||||
|
||||
export function exifToDate(exifDate: string | Date | ExifDateTime | undefined): Date | null {
|
||||
if (!exifDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = exifDate instanceof ExifDateTime ? exifDate.toDate() : new Date(exifDate);
|
||||
if (!isDecimalNumber(date.valueOf())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
export function exifTimeZone(exifDate: string | Date | ExifDateTime | undefined): string | null {
|
||||
const isExifDate = exifDate instanceof ExifDateTime;
|
||||
if (!isExifDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return exifDate.zone ?? null;
|
||||
}
|
||||
24
server/src/microservices/utils/exif/iso.spec.ts
Normal file
24
server/src/microservices/utils/exif/iso.spec.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, it, expect } from '@jest/globals';
|
||||
import { parseISO } from './iso';
|
||||
|
||||
describe('parsing ISO values', () => {
|
||||
it('returns null for invalid values', () => {
|
||||
expect(parseISO('')).toBeNull();
|
||||
expect(parseISO(',,,')).toBeNull();
|
||||
expect(parseISO('invalid')).toBeNull();
|
||||
expect(parseISO('-5')).toBeNull();
|
||||
expect(parseISO('99999999999999')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the ISO number for valid inputs', () => {
|
||||
expect(parseISO('0.0')).toBe(0);
|
||||
expect(parseISO('32000.9')).toBe(32000);
|
||||
});
|
||||
|
||||
it('returns the first valid ISO number in a comma separated list', () => {
|
||||
expect(parseISO('400, 200, 100')).toBe(400);
|
||||
expect(parseISO('-1600,800')).toBe(800);
|
||||
expect(parseISO('-1, a., 1200')).toBe(1200);
|
||||
expect(parseISO('NaN,50,100')).toBe(50);
|
||||
});
|
||||
});
|
||||
14
server/src/microservices/utils/exif/iso.ts
Normal file
14
server/src/microservices/utils/exif/iso.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { isNumberInRange } from '../numbers';
|
||||
|
||||
export function parseISO(input: string): number | null {
|
||||
const values = input.split(',');
|
||||
|
||||
for (const value of values) {
|
||||
const iso = Number.parseInt(value, 10);
|
||||
if (isNumberInRange(iso, 0, 2 ** 32)) {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
47
server/src/microservices/utils/numbers.spec.ts
Normal file
47
server/src/microservices/utils/numbers.spec.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { describe, it, expect } from '@jest/globals';
|
||||
import { isDecimalNumber, isNumberInRange, toNumberOrNull } from './numbers';
|
||||
|
||||
describe('checks if a number is a decimal number', () => {
|
||||
it('returns false for non-decimal numbers', () => {
|
||||
expect(isDecimalNumber(NaN)).toBe(false);
|
||||
expect(isDecimalNumber(Infinity)).toBe(false);
|
||||
expect(isDecimalNumber(-Infinity)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for decimal numbers', () => {
|
||||
expect(isDecimalNumber(0)).toBe(true);
|
||||
expect(isDecimalNumber(-0)).toBe(true);
|
||||
expect(isDecimalNumber(10.12345)).toBe(true);
|
||||
expect(isDecimalNumber(Number.MAX_VALUE)).toBe(true);
|
||||
expect(isDecimalNumber(Number.MIN_VALUE)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checks if a number is within a range', () => {
|
||||
it('returns false for numbers outside the range', () => {
|
||||
expect(isNumberInRange(0, 10, 10)).toBe(false);
|
||||
expect(isNumberInRange(0.01, 10, 10)).toBe(false);
|
||||
expect(isNumberInRange(50.1, 0, 50)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for numbers inside the range', () => {
|
||||
expect(isNumberInRange(0, 0, 50)).toBe(true);
|
||||
expect(isNumberInRange(50, 0, 50)).toBe(true);
|
||||
expect(isNumberInRange(-50.12345, -50.12345, 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('converts input to a number or null', () => {
|
||||
it('returns null for invalid inputs', () => {
|
||||
expect(toNumberOrNull(null)).toBeNull();
|
||||
expect(toNumberOrNull(undefined)).toBeNull();
|
||||
expect(toNumberOrNull('')).toBeNull();
|
||||
expect(toNumberOrNull(NaN)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a number for valid inputs', () => {
|
||||
expect(toNumberOrNull(0)).toBeCloseTo(0);
|
||||
expect(toNumberOrNull('0')).toBeCloseTo(0);
|
||||
expect(toNumberOrNull('-123.45')).toBeCloseTo(-123.45);
|
||||
});
|
||||
});
|
||||
19
server/src/microservices/utils/numbers.ts
Normal file
19
server/src/microservices/utils/numbers.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export function isDecimalNumber(num: number): boolean {
|
||||
return !Number.isNaN(num) && Number.isFinite(num);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if `num` is a valid number and is between `start` and `end` (inclusive)
|
||||
*/
|
||||
export function isNumberInRange(num: number, start: number, end: number): boolean {
|
||||
return isDecimalNumber(num) && num >= start && num <= end;
|
||||
}
|
||||
|
||||
export function toNumberOrNull(input: number | string | null | undefined): number | null {
|
||||
if (input === null || input === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const num = typeof input === 'string' ? Number.parseFloat(input) : input;
|
||||
return isDecimalNumber(num) ? num : null;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue