2023-12-14 11:55:40 -05:00
|
|
|
import { Inject, Injectable, UnsupportedMediaTypeException } from '@nestjs/common';
|
2024-04-19 11:50:13 -04:00
|
|
|
import { dirname } from 'node:path';
|
2024-09-27 10:28:42 -04:00
|
|
|
import { GeneratedImageType, StorageCore } from 'src/cores/storage.core';
|
|
|
|
|
import { SystemConfigCore } from 'src/cores/system-config.core';
|
|
|
|
|
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
|
|
|
|
import { AssetEntity } from 'src/entities/asset.entity';
|
2024-03-20 19:32:04 +01:00
|
|
|
import {
|
2024-09-27 10:28:42 -04:00
|
|
|
AssetFileType,
|
|
|
|
|
AssetPathType,
|
|
|
|
|
AssetType,
|
2024-03-20 19:32:04 +01:00
|
|
|
AudioCodec,
|
|
|
|
|
Colorspace,
|
2024-04-02 00:56:56 -04:00
|
|
|
ImageFormat,
|
2024-09-27 18:10:39 -04:00
|
|
|
LogLevel,
|
2024-09-27 10:28:42 -04:00
|
|
|
StorageFolder,
|
2024-03-20 19:32:04 +01:00
|
|
|
TranscodeHWAccel,
|
|
|
|
|
TranscodePolicy,
|
|
|
|
|
TranscodeTarget,
|
|
|
|
|
VideoCodec,
|
2024-07-21 17:14:23 -04:00
|
|
|
VideoContainer,
|
2024-09-27 10:28:42 -04:00
|
|
|
} from 'src/enum';
|
2024-03-21 12:59:49 +01:00
|
|
|
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
|
|
|
|
|
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
2024-03-20 22:15:09 -05:00
|
|
|
import {
|
|
|
|
|
IBaseJob,
|
|
|
|
|
IEntityJob,
|
|
|
|
|
IJobRepository,
|
|
|
|
|
JOBS_ASSET_PAGINATION_SIZE,
|
|
|
|
|
JobItem,
|
|
|
|
|
JobName,
|
|
|
|
|
JobStatus,
|
|
|
|
|
QueueName,
|
2024-03-21 12:59:49 +01:00
|
|
|
} from 'src/interfaces/job.interface';
|
2024-04-17 03:00:31 +05:30
|
|
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
2024-09-27 18:10:39 -04:00
|
|
|
import {
|
|
|
|
|
AudioStreamInfo,
|
|
|
|
|
IMediaRepository,
|
|
|
|
|
TranscodeCommand,
|
|
|
|
|
VideoFormat,
|
|
|
|
|
VideoStreamInfo,
|
|
|
|
|
} from 'src/interfaces/media.interface';
|
2024-03-21 12:59:49 +01:00
|
|
|
import { IMoveRepository } from 'src/interfaces/move.interface';
|
|
|
|
|
import { IPersonRepository } from 'src/interfaces/person.interface';
|
|
|
|
|
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
2024-05-15 18:58:23 -04:00
|
|
|
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
2024-08-19 20:03:33 -04:00
|
|
|
import { getAssetFiles } from 'src/utils/asset.util';
|
2024-05-27 15:20:07 -04:00
|
|
|
import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
|
2024-04-19 11:50:13 -04:00
|
|
|
import { mimeTypes } from 'src/utils/mime-types';
|
2024-03-20 22:15:09 -05:00
|
|
|
import { usePagination } from 'src/utils/pagination';
|
2023-09-08 08:49:43 +02:00
|
|
|
|
2023-02-25 09:12:03 -05:00
|
|
|
@Injectable()
|
|
|
|
|
export class MediaService {
|
2023-04-04 10:48:02 -04:00
|
|
|
private configCore: SystemConfigCore;
|
2023-09-25 17:07:21 +02:00
|
|
|
private storageCore: StorageCore;
|
2024-05-27 15:20:07 -04:00
|
|
|
private maliOpenCL?: boolean;
|
|
|
|
|
private devices?: string[];
|
2023-02-25 09:12:03 -05:00
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
2023-09-08 08:49:43 +02:00
|
|
|
@Inject(IPersonRepository) private personRepository: IPersonRepository,
|
2023-02-25 09:12:03 -05:00
|
|
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
|
|
|
|
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
|
|
|
|
|
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
2024-05-15 18:58:23 -04:00
|
|
|
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
|
2023-10-11 04:14:44 +02:00
|
|
|
@Inject(IMoveRepository) moveRepository: IMoveRepository,
|
2024-03-20 19:32:04 +01:00
|
|
|
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
|
2024-04-17 03:00:31 +05:30
|
|
|
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
2023-04-04 10:48:02 -04:00
|
|
|
) {
|
2024-04-17 03:00:31 +05:30
|
|
|
this.logger.setContext(MediaService.name);
|
2024-05-15 18:58:23 -04:00
|
|
|
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
|
2023-12-29 18:41:33 +00:00
|
|
|
this.storageCore = StorageCore.create(
|
|
|
|
|
assetRepository,
|
2024-04-17 03:00:31 +05:30
|
|
|
cryptoRepository,
|
2023-12-29 18:41:33 +00:00
|
|
|
moveRepository,
|
|
|
|
|
personRepository,
|
|
|
|
|
storageRepository,
|
2024-05-15 18:58:23 -04:00
|
|
|
systemMetadataRepository,
|
2024-04-17 03:00:31 +05:30
|
|
|
this.logger,
|
2023-12-29 18:41:33 +00:00
|
|
|
);
|
2023-04-04 10:48:02 -04:00
|
|
|
}
|
2023-02-25 09:12:03 -05:00
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
async handleQueueGenerateThumbnails({ force }: IBaseJob): Promise<JobStatus> {
|
2023-05-26 15:43:24 -04:00
|
|
|
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
|
|
|
|
return force
|
2024-08-19 20:03:33 -04:00
|
|
|
? this.assetRepository.getAll(pagination, {
|
|
|
|
|
isVisible: true,
|
|
|
|
|
withDeleted: true,
|
|
|
|
|
withArchived: true,
|
|
|
|
|
})
|
2023-05-26 15:43:24 -04:00
|
|
|
: this.assetRepository.getWithout(pagination, WithoutProperty.THUMBNAIL);
|
|
|
|
|
});
|
2023-03-20 11:55:28 -04:00
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
for await (const assets of assetPagination) {
|
2024-01-01 15:45:42 -05:00
|
|
|
const jobs: JobItem[] = [];
|
|
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
for (const asset of assets) {
|
2024-08-19 20:03:33 -04:00
|
|
|
const { previewFile, thumbnailFile } = getAssetFiles(asset.files);
|
|
|
|
|
|
|
|
|
|
if (!previewFile || force) {
|
2024-04-02 00:56:56 -04:00
|
|
|
jobs.push({ name: JobName.GENERATE_PREVIEW, data: { id: asset.id } });
|
2023-06-17 23:22:31 -04:00
|
|
|
continue;
|
|
|
|
|
}
|
2024-08-19 20:03:33 -04:00
|
|
|
|
|
|
|
|
if (!thumbnailFile) {
|
2024-04-02 00:56:56 -04:00
|
|
|
jobs.push({ name: JobName.GENERATE_THUMBNAIL, data: { id: asset.id } });
|
2023-06-17 23:22:31 -04:00
|
|
|
}
|
2024-08-19 20:03:33 -04:00
|
|
|
|
2023-06-17 23:22:31 -04:00
|
|
|
if (!asset.thumbhash) {
|
2024-04-02 00:56:56 -04:00
|
|
|
jobs.push({ name: JobName.GENERATE_THUMBHASH, data: { id: asset.id } });
|
2023-06-17 23:22:31 -04:00
|
|
|
}
|
2023-03-20 11:55:28 -04:00
|
|
|
}
|
2024-01-01 15:45:42 -05:00
|
|
|
|
|
|
|
|
await this.jobRepository.queueAll(jobs);
|
2023-03-20 11:55:28 -04:00
|
|
|
}
|
|
|
|
|
|
2024-01-01 15:45:42 -05:00
|
|
|
const jobs: JobItem[] = [];
|
2024-01-18 00:08:48 -05:00
|
|
|
const personPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
|
|
|
|
this.personRepository.getAll(pagination, { where: force ? undefined : { thumbnailPath: '' } }),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
for await (const people of personPagination) {
|
|
|
|
|
for (const person of people) {
|
|
|
|
|
if (!person.faceAssetId) {
|
|
|
|
|
const face = await this.personRepository.getRandomFace(person.id);
|
|
|
|
|
if (!face) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-10 09:48:29 -04:00
|
|
|
await this.personRepository.update({ id: person.id, faceAssetId: face.id });
|
2023-09-26 03:03:22 -04:00
|
|
|
}
|
|
|
|
|
|
2024-01-18 00:08:48 -05:00
|
|
|
jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: person.id } });
|
2023-09-08 08:49:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-01-01 15:45:42 -05:00
|
|
|
await this.jobRepository.queueAll(jobs);
|
|
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-05-26 15:43:24 -04:00
|
|
|
}
|
2023-04-11 20:28:25 -05:00
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
async handleQueueMigration(): Promise<JobStatus> {
|
2023-09-25 17:07:21 +02:00
|
|
|
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
|
|
|
|
this.assetRepository.getAll(pagination),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const { active, waiting } = await this.jobRepository.getJobCounts(QueueName.MIGRATION);
|
|
|
|
|
if (active === 1 && waiting === 0) {
|
|
|
|
|
await this.storageCore.removeEmptyDirs(StorageFolder.THUMBNAILS);
|
|
|
|
|
await this.storageCore.removeEmptyDirs(StorageFolder.ENCODED_VIDEO);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for await (const assets of assetPagination) {
|
2024-01-01 15:45:42 -05:00
|
|
|
await this.jobRepository.queueAll(
|
|
|
|
|
assets.map((asset) => ({ name: JobName.MIGRATE_ASSET, data: { id: asset.id } })),
|
|
|
|
|
);
|
2023-09-25 17:07:21 +02:00
|
|
|
}
|
|
|
|
|
|
2024-01-18 00:08:48 -05:00
|
|
|
const personPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
|
|
|
|
this.personRepository.getAll(pagination),
|
2024-01-01 15:45:42 -05:00
|
|
|
);
|
2023-09-25 17:07:21 +02:00
|
|
|
|
2024-01-18 00:08:48 -05:00
|
|
|
for await (const people of personPagination) {
|
|
|
|
|
await this.jobRepository.queueAll(
|
|
|
|
|
people.map((person) => ({ name: JobName.MIGRATE_PERSON, data: { id: person.id } })),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-09-25 17:07:21 +02:00
|
|
|
}
|
|
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
async handleAssetMigration({ id }: IEntityJob): Promise<JobStatus> {
|
2024-06-12 07:07:35 -04:00
|
|
|
const { image } = await this.configCore.getConfig({ withCache: true });
|
2024-08-19 20:03:33 -04:00
|
|
|
const [asset] = await this.assetRepository.getByIds([id], { files: true });
|
2023-09-25 17:07:21 +02:00
|
|
|
if (!asset) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2023-09-25 17:07:21 +02:00
|
|
|
}
|
|
|
|
|
|
2024-04-02 00:56:56 -04:00
|
|
|
await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.previewFormat);
|
|
|
|
|
await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat);
|
|
|
|
|
await this.storageCore.moveAssetVideo(asset);
|
2023-09-25 17:07:21 +02:00
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-09-25 17:07:21 +02:00
|
|
|
}
|
|
|
|
|
|
2024-04-02 00:56:56 -04:00
|
|
|
async handleGeneratePreview({ id }: IEntityJob): Promise<JobStatus> {
|
2024-04-07 12:44:34 -04:00
|
|
|
const [{ image }, [asset]] = await Promise.all([
|
2024-06-12 07:07:35 -04:00
|
|
|
this.configCore.getConfig({ withCache: true }),
|
2024-09-03 21:23:34 -04:00
|
|
|
this.assetRepository.getByIds([id], { exifInfo: true, files: true }),
|
2024-04-07 12:44:34 -04:00
|
|
|
]);
|
2023-04-11 20:28:25 -05:00
|
|
|
if (!asset) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2023-04-11 20:28:25 -05:00
|
|
|
}
|
2023-02-25 09:12:03 -05:00
|
|
|
|
2024-04-18 21:37:55 -04:00
|
|
|
if (!asset.isVisible) {
|
|
|
|
|
return JobStatus.SKIPPED;
|
|
|
|
|
}
|
|
|
|
|
|
2024-04-07 12:44:34 -04:00
|
|
|
const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, image.previewFormat);
|
2024-08-19 13:50:00 -04:00
|
|
|
if (!previewPath) {
|
|
|
|
|
return JobStatus.SKIPPED;
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-19 20:03:33 -04:00
|
|
|
const { previewFile } = getAssetFiles(asset.files);
|
|
|
|
|
if (previewFile && previewFile.path !== previewPath) {
|
2024-04-27 18:43:05 -04:00
|
|
|
this.logger.debug(`Deleting old preview for asset ${asset.id}`);
|
2024-08-19 20:03:33 -04:00
|
|
|
await this.storageRepository.unlink(previewFile.path);
|
2024-04-27 18:43:05 -04:00
|
|
|
}
|
2024-08-19 13:50:00 -04:00
|
|
|
|
2024-08-19 20:03:33 -04:00
|
|
|
await this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.PREVIEW, path: previewPath });
|
|
|
|
|
await this.assetRepository.update({ id: asset.id, updatedAt: new Date() });
|
2024-08-19 13:50:00 -04:00
|
|
|
await this.assetRepository.upsertJobStatus({ assetId: asset.id, previewAt: new Date() });
|
|
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-09-03 02:21:51 -04:00
|
|
|
}
|
2023-05-26 15:43:24 -04:00
|
|
|
|
2024-04-02 00:56:56 -04:00
|
|
|
private async generateThumbnail(asset: AssetEntity, type: GeneratedImageType, format: ImageFormat) {
|
2024-06-12 07:07:35 -04:00
|
|
|
const { image, ffmpeg } = await this.configCore.getConfig({ withCache: true });
|
2024-04-02 00:56:56 -04:00
|
|
|
const size = type === AssetPathType.PREVIEW ? image.previewSize : image.thumbnailSize;
|
|
|
|
|
const path = StorageCore.getImagePath(asset, type, format);
|
2023-10-11 04:14:44 +02:00
|
|
|
this.storageCore.ensureFolders(path);
|
|
|
|
|
|
2023-06-15 04:42:35 +01:00
|
|
|
switch (asset.type) {
|
2024-02-02 04:18:00 +01:00
|
|
|
case AssetType.IMAGE: {
|
2024-04-19 11:50:13 -04:00
|
|
|
const shouldExtract = image.extractEmbedded && mimeTypes.isRaw(asset.originalPath);
|
|
|
|
|
const extractedPath = StorageCore.getTempPathInDir(dirname(path));
|
|
|
|
|
const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath));
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.previewSize));
|
|
|
|
|
const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace;
|
2024-07-18 18:07:22 +02:00
|
|
|
const imageOptions = {
|
|
|
|
|
format,
|
|
|
|
|
size,
|
|
|
|
|
colorspace,
|
|
|
|
|
quality: image.quality,
|
|
|
|
|
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
|
|
|
|
|
};
|
2024-04-19 11:50:13 -04:00
|
|
|
|
2024-05-08 09:09:34 -04:00
|
|
|
const outputPath = useExtracted ? extractedPath : asset.originalPath;
|
|
|
|
|
await this.mediaRepository.generateThumbnail(outputPath, path, imageOptions);
|
2024-04-19 11:50:13 -04:00
|
|
|
} finally {
|
|
|
|
|
if (didExtract) {
|
|
|
|
|
await this.storageRepository.unlink(extractedPath);
|
|
|
|
|
}
|
|
|
|
|
}
|
2023-06-15 04:42:35 +01:00
|
|
|
break;
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
2023-10-11 04:14:44 +02:00
|
|
|
|
2024-02-02 04:18:00 +01:00
|
|
|
case AssetType.VIDEO: {
|
2023-10-11 04:14:44 +02:00
|
|
|
const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
|
|
|
|
|
const mainVideoStream = this.getMainStream(videoStreams);
|
|
|
|
|
if (!mainVideoStream) {
|
|
|
|
|
this.logger.warn(`Skipped thumbnail generation for asset ${asset.id}: no video streams found`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const mainAudioStream = this.getMainStream(audioStreams);
|
2024-05-27 15:20:07 -04:00
|
|
|
const config = ThumbnailConfig.create({ ...ffmpeg, targetResolution: size.toString() });
|
|
|
|
|
const options = config.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream);
|
2023-10-11 04:14:44 +02:00
|
|
|
await this.mediaRepository.transcode(asset.originalPath, path, options);
|
2023-06-15 04:42:35 +01:00
|
|
|
break;
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
2023-10-11 04:14:44 +02:00
|
|
|
|
2024-02-02 04:18:00 +01:00
|
|
|
default: {
|
2023-09-03 02:21:51 -04:00
|
|
|
throw new UnsupportedMediaTypeException(`Unsupported asset type for thumbnail generation: ${asset.type}`);
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
2023-05-26 15:43:24 -04:00
|
|
|
}
|
2024-08-30 00:16:12 +02:00
|
|
|
|
|
|
|
|
const assetLabel = asset.isExternal ? asset.originalPath : asset.id;
|
2023-09-03 02:21:51 -04:00
|
|
|
this.logger.log(
|
2024-08-30 00:16:12 +02:00
|
|
|
`Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} ${type} for asset ${assetLabel}`,
|
2023-09-03 02:21:51 -04:00
|
|
|
);
|
2024-08-30 00:16:12 +02:00
|
|
|
|
2023-09-03 02:21:51 -04:00
|
|
|
return path;
|
|
|
|
|
}
|
2023-02-25 09:12:03 -05:00
|
|
|
|
2024-04-02 00:56:56 -04:00
|
|
|
async handleGenerateThumbnail({ id }: IEntityJob): Promise<JobStatus> {
|
2024-04-07 12:44:34 -04:00
|
|
|
const [{ image }, [asset]] = await Promise.all([
|
2024-06-12 07:07:35 -04:00
|
|
|
this.configCore.getConfig({ withCache: true }),
|
2024-08-19 20:03:33 -04:00
|
|
|
this.assetRepository.getByIds([id], { exifInfo: true, files: true }),
|
2024-04-07 12:44:34 -04:00
|
|
|
]);
|
2023-09-04 19:24:55 -04:00
|
|
|
if (!asset) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2023-02-25 09:12:03 -05:00
|
|
|
}
|
|
|
|
|
|
2024-04-18 21:37:55 -04:00
|
|
|
if (!asset.isVisible) {
|
|
|
|
|
return JobStatus.SKIPPED;
|
|
|
|
|
}
|
|
|
|
|
|
2024-04-07 12:44:34 -04:00
|
|
|
const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat);
|
2024-08-19 13:50:00 -04:00
|
|
|
if (!thumbnailPath) {
|
|
|
|
|
return JobStatus.SKIPPED;
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-19 20:03:33 -04:00
|
|
|
const { thumbnailFile } = getAssetFiles(asset.files);
|
|
|
|
|
if (thumbnailFile && thumbnailFile.path !== thumbnailPath) {
|
2024-04-27 18:43:05 -04:00
|
|
|
this.logger.debug(`Deleting old thumbnail for asset ${asset.id}`);
|
2024-08-19 20:03:33 -04:00
|
|
|
await this.storageRepository.unlink(thumbnailFile.path);
|
2024-04-27 18:43:05 -04:00
|
|
|
}
|
2024-08-19 13:50:00 -04:00
|
|
|
|
2024-08-19 20:03:33 -04:00
|
|
|
await this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.THUMBNAIL, path: thumbnailPath });
|
|
|
|
|
await this.assetRepository.update({ id: asset.id, updatedAt: new Date() });
|
2024-08-19 13:50:00 -04:00
|
|
|
await this.assetRepository.upsertJobStatus({ assetId: asset.id, thumbnailAt: new Date() });
|
|
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-02-25 09:12:03 -05:00
|
|
|
}
|
2023-04-04 10:48:02 -04:00
|
|
|
|
2024-04-02 00:56:56 -04:00
|
|
|
async handleGenerateThumbhash({ id }: IEntityJob): Promise<JobStatus> {
|
2024-08-19 20:03:33 -04:00
|
|
|
const [asset] = await this.assetRepository.getByIds([id], { files: true });
|
2024-04-18 21:37:55 -04:00
|
|
|
if (!asset) {
|
|
|
|
|
return JobStatus.FAILED;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!asset.isVisible) {
|
|
|
|
|
return JobStatus.SKIPPED;
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-19 20:03:33 -04:00
|
|
|
const { previewFile } = getAssetFiles(asset.files);
|
|
|
|
|
if (!previewFile) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2023-06-17 23:22:31 -04:00
|
|
|
}
|
|
|
|
|
|
2024-08-19 20:03:33 -04:00
|
|
|
const thumbhash = await this.mediaRepository.generateThumbhash(previewFile.path);
|
2024-03-19 22:42:10 -04:00
|
|
|
await this.assetRepository.update({ id: asset.id, thumbhash });
|
2023-06-17 23:22:31 -04:00
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-06-17 23:22:31 -04:00
|
|
|
}
|
|
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
async handleQueueVideoConversion(job: IBaseJob): Promise<JobStatus> {
|
2023-04-04 10:48:02 -04:00
|
|
|
const { force } = job;
|
|
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
|
|
|
|
return force
|
|
|
|
|
? this.assetRepository.getAll(pagination, { type: AssetType.VIDEO })
|
|
|
|
|
: this.assetRepository.getWithout(pagination, WithoutProperty.ENCODED_VIDEO);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
for await (const assets of assetPagination) {
|
2024-01-01 15:45:42 -05:00
|
|
|
await this.jobRepository.queueAll(
|
|
|
|
|
assets.map((asset) => ({ name: JobName.VIDEO_CONVERSION, data: { id: asset.id } })),
|
|
|
|
|
);
|
2023-04-04 10:48:02 -04:00
|
|
|
}
|
|
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-05-26 15:43:24 -04:00
|
|
|
}
|
2023-04-11 08:56:52 -05:00
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
async handleVideoConversion({ id }: IEntityJob): Promise<JobStatus> {
|
2023-05-26 15:43:24 -04:00
|
|
|
const [asset] = await this.assetRepository.getByIds([id]);
|
2023-07-05 01:36:16 -04:00
|
|
|
if (!asset || asset.type !== AssetType.VIDEO) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2023-04-11 08:56:52 -05:00
|
|
|
}
|
2023-04-04 10:48:02 -04:00
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
const input = asset.originalPath;
|
2023-10-23 17:52:21 +02:00
|
|
|
const output = StorageCore.getEncodedVideoPath(asset);
|
2023-10-11 04:14:44 +02:00
|
|
|
this.storageCore.ensureFolders(output);
|
2023-05-26 15:43:24 -04:00
|
|
|
|
2024-09-27 18:10:39 -04:00
|
|
|
const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input, {
|
|
|
|
|
countFrames: this.logger.isLevelEnabled(LogLevel.DEBUG), // makes frame count more reliable for progress logs
|
|
|
|
|
});
|
2023-08-29 05:01:42 -04:00
|
|
|
const mainVideoStream = this.getMainStream(videoStreams);
|
|
|
|
|
const mainAudioStream = this.getMainStream(audioStreams);
|
2024-07-21 17:14:23 -04:00
|
|
|
if (!mainVideoStream || !format.formatName) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2023-05-26 15:43:24 -04:00
|
|
|
}
|
2023-04-04 10:48:02 -04:00
|
|
|
|
2023-12-28 00:34:00 -05:00
|
|
|
if (!mainVideoStream.height || !mainVideoStream.width) {
|
|
|
|
|
this.logger.warn(`Skipped transcoding for asset ${asset.id}: no video streams found`);
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2023-12-28 00:34:00 -05:00
|
|
|
}
|
|
|
|
|
|
2024-06-12 07:07:35 -04:00
|
|
|
const { ffmpeg } = await this.configCore.getConfig({ withCache: true });
|
2024-05-27 15:20:07 -04:00
|
|
|
const target = this.getTranscodeTarget(ffmpeg, mainVideoStream, mainAudioStream);
|
2024-07-21 17:14:23 -04:00
|
|
|
if (target === TranscodeTarget.NONE && !this.isRemuxRequired(ffmpeg, format)) {
|
2023-12-28 00:34:00 -05:00
|
|
|
if (asset.encodedVideoPath) {
|
|
|
|
|
this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`);
|
|
|
|
|
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [asset.encodedVideoPath] } });
|
2024-03-19 22:42:10 -04:00
|
|
|
await this.assetRepository.update({ id: asset.id, encodedVideoPath: null });
|
2024-09-27 18:10:39 -04:00
|
|
|
} else {
|
|
|
|
|
this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`);
|
2023-12-28 00:34:00 -05:00
|
|
|
}
|
|
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SKIPPED;
|
2023-05-26 15:43:24 -04:00
|
|
|
}
|
2023-04-04 10:48:02 -04:00
|
|
|
|
2024-09-27 18:10:39 -04:00
|
|
|
let command: TranscodeCommand;
|
2023-07-08 22:43:11 -04:00
|
|
|
try {
|
2024-05-27 15:20:07 -04:00
|
|
|
const config = BaseConfig.create(ffmpeg, await this.getDevices(), await this.hasMaliOpenCL());
|
|
|
|
|
command = config.getCommand(target, mainVideoStream, mainAudioStream);
|
2024-02-02 04:18:00 +01:00
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.error(`An error occurred while configuring transcoding options: ${error}`);
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2023-07-08 22:43:11 -04:00
|
|
|
}
|
2023-04-06 04:32:59 +01:00
|
|
|
|
2024-09-27 18:10:39 -04:00
|
|
|
if (ffmpeg.accel === TranscodeHWAccel.DISABLED) {
|
|
|
|
|
this.logger.log(`Encoding video ${asset.id} without hardware acceleration`);
|
|
|
|
|
} else {
|
|
|
|
|
this.logger.log(`Encoding video ${asset.id} with ${ffmpeg.accel.toUpperCase()} acceleration`);
|
|
|
|
|
}
|
|
|
|
|
|
2023-08-01 21:56:10 -04:00
|
|
|
try {
|
2024-05-27 15:20:07 -04:00
|
|
|
await this.mediaRepository.transcode(input, output, command);
|
2024-09-27 18:10:39 -04:00
|
|
|
} catch (error: any) {
|
|
|
|
|
this.logger.error(`Error occurred during transcoding: ${error.message}`);
|
|
|
|
|
if (ffmpeg.accel === TranscodeHWAccel.DISABLED) {
|
|
|
|
|
return JobStatus.FAILED;
|
2023-08-01 21:56:10 -04:00
|
|
|
}
|
2024-09-27 18:10:39 -04:00
|
|
|
this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled`);
|
2024-05-27 15:20:07 -04:00
|
|
|
const config = BaseConfig.create({ ...ffmpeg, accel: TranscodeHWAccel.DISABLED });
|
|
|
|
|
command = config.getCommand(target, mainVideoStream, mainAudioStream);
|
|
|
|
|
await this.mediaRepository.transcode(input, output, command);
|
2023-08-01 21:56:10 -04:00
|
|
|
}
|
2023-04-04 10:48:02 -04:00
|
|
|
|
2024-02-14 11:24:39 -05:00
|
|
|
this.logger.log(`Successfully encoded ${asset.id}`);
|
2023-04-04 10:48:02 -04:00
|
|
|
|
2024-03-19 22:42:10 -04:00
|
|
|
await this.assetRepository.update({ id: asset.id, encodedVideoPath: output });
|
2023-05-26 15:43:24 -04:00
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-04-04 10:48:02 -04:00
|
|
|
}
|
|
|
|
|
|
2023-08-29 05:01:42 -04:00
|
|
|
private getMainStream<T extends VideoStreamInfo | AudioStreamInfo>(streams: T[]): T {
|
2023-04-06 04:32:59 +01:00
|
|
|
return streams.sort((stream1, stream2) => stream2.frameCount - stream1.frameCount)[0];
|
|
|
|
|
}
|
|
|
|
|
|
2024-02-14 11:24:39 -05:00
|
|
|
private getTranscodeTarget(
|
|
|
|
|
config: SystemConfigFFmpegDto,
|
2024-05-27 15:20:07 -04:00
|
|
|
videoStream?: VideoStreamInfo,
|
|
|
|
|
audioStream?: AudioStreamInfo,
|
2024-02-14 11:24:39 -05:00
|
|
|
): TranscodeTarget {
|
2024-05-27 15:20:07 -04:00
|
|
|
if (!videoStream && !audioStream) {
|
2024-02-14 11:24:39 -05:00
|
|
|
return TranscodeTarget.NONE;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isAudioTranscodeRequired = this.isAudioTranscodeRequired(config, audioStream);
|
|
|
|
|
const isVideoTranscodeRequired = this.isVideoTranscodeRequired(config, videoStream);
|
|
|
|
|
|
|
|
|
|
if (isAudioTranscodeRequired && isVideoTranscodeRequired) {
|
|
|
|
|
return TranscodeTarget.ALL;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isAudioTranscodeRequired) {
|
|
|
|
|
return TranscodeTarget.AUDIO;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isVideoTranscodeRequired) {
|
|
|
|
|
return TranscodeTarget.VIDEO;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return TranscodeTarget.NONE;
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-27 15:20:07 -04:00
|
|
|
private isAudioTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream?: AudioStreamInfo): boolean {
|
|
|
|
|
if (!stream) {
|
2024-02-14 11:24:39 -05:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch (ffmpegConfig.transcode) {
|
|
|
|
|
case TranscodePolicy.DISABLED: {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
case TranscodePolicy.ALL: {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
case TranscodePolicy.REQUIRED:
|
|
|
|
|
case TranscodePolicy.OPTIMAL:
|
|
|
|
|
case TranscodePolicy.BITRATE: {
|
|
|
|
|
return !ffmpegConfig.acceptedAudioCodecs.includes(stream.codecName as AudioCodec);
|
|
|
|
|
}
|
|
|
|
|
default: {
|
|
|
|
|
throw new Error(`Unsupported transcode policy: ${ffmpegConfig.transcode}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-27 15:20:07 -04:00
|
|
|
private isVideoTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream?: VideoStreamInfo): boolean {
|
|
|
|
|
if (!stream) {
|
2024-02-14 11:24:39 -05:00
|
|
|
return false;
|
|
|
|
|
}
|
2023-04-06 04:32:59 +01:00
|
|
|
|
2023-06-10 00:15:12 -04:00
|
|
|
const scalingEnabled = ffmpegConfig.targetResolution !== 'original';
|
|
|
|
|
const targetRes = Number.parseInt(ffmpegConfig.targetResolution);
|
2024-02-14 11:24:39 -05:00
|
|
|
const isLargerThanTargetRes = scalingEnabled && Math.min(stream.height, stream.width) > targetRes;
|
|
|
|
|
const isLargerThanTargetBitrate = stream.bitrate > this.parseBitrateToBps(ffmpegConfig.maxBitrate);
|
|
|
|
|
|
|
|
|
|
const isTargetVideoCodec = ffmpegConfig.acceptedVideoCodecs.includes(stream.codecName as VideoCodec);
|
|
|
|
|
const isRequired = !isTargetVideoCodec || stream.isHDR;
|
2023-04-04 10:48:02 -04:00
|
|
|
|
|
|
|
|
switch (ffmpegConfig.transcode) {
|
2024-02-02 04:18:00 +01:00
|
|
|
case TranscodePolicy.DISABLED: {
|
2023-04-06 04:32:59 +01:00
|
|
|
return false;
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
|
case TranscodePolicy.ALL: {
|
2023-04-04 10:48:02 -04:00
|
|
|
return true;
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
|
case TranscodePolicy.REQUIRED: {
|
2024-02-14 11:24:39 -05:00
|
|
|
return isRequired;
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
|
case TranscodePolicy.OPTIMAL: {
|
2024-02-14 11:24:39 -05:00
|
|
|
return isRequired || isLargerThanTargetRes;
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
|
case TranscodePolicy.BITRATE: {
|
2024-02-14 11:24:39 -05:00
|
|
|
return isRequired || isLargerThanTargetBitrate;
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
|
default: {
|
2024-02-14 11:24:39 -05:00
|
|
|
throw new Error(`Unsupported transcode policy: ${ffmpegConfig.transcode}`);
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
2023-04-04 10:48:02 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-07-21 17:14:23 -04:00
|
|
|
private isRemuxRequired(ffmpegConfig: SystemConfigFFmpegDto, { formatName, formatLongName }: VideoFormat): boolean {
|
|
|
|
|
if (ffmpegConfig.transcode === TranscodePolicy.DISABLED) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const name = formatLongName === 'QuickTime / MOV' ? VideoContainer.MOV : (formatName as VideoContainer);
|
|
|
|
|
return name !== VideoContainer.MP4 && !ffmpegConfig.acceptedContainers.includes(name);
|
|
|
|
|
}
|
|
|
|
|
|
2023-09-25 19:18:47 -04:00
|
|
|
isSRGB(asset: AssetEntity): boolean {
|
|
|
|
|
const { colorspace, profileDescription, bitsPerSample } = asset.exifInfo ?? {};
|
|
|
|
|
if (colorspace || profileDescription) {
|
|
|
|
|
return [colorspace, profileDescription].some((s) => s?.toLowerCase().includes('srgb'));
|
|
|
|
|
} else if (bitsPerSample) {
|
|
|
|
|
// assume sRGB for 8-bit images with no color profile or colorspace metadata
|
|
|
|
|
return bitsPerSample === 8;
|
|
|
|
|
} else {
|
|
|
|
|
// assume sRGB for images with no relevant metadata
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-01-31 02:25:07 +01:00
|
|
|
|
2024-04-19 11:50:13 -04:00
|
|
|
private parseBitrateToBps(bitrateString: string) {
|
2024-01-31 02:25:07 +01:00
|
|
|
const bitrateValue = Number.parseInt(bitrateString);
|
|
|
|
|
|
2024-02-02 04:18:00 +01:00
|
|
|
if (Number.isNaN(bitrateValue)) {
|
2024-01-31 02:25:07 +01:00
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (bitrateString.toLowerCase().endsWith('k')) {
|
|
|
|
|
return bitrateValue * 1000; // Kilobits per second to bits per second
|
|
|
|
|
} else if (bitrateString.toLowerCase().endsWith('m')) {
|
2024-02-02 04:18:00 +01:00
|
|
|
return bitrateValue * 1_000_000; // Megabits per second to bits per second
|
2024-01-31 02:25:07 +01:00
|
|
|
} else {
|
|
|
|
|
return bitrateValue;
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-04-19 11:50:13 -04:00
|
|
|
|
|
|
|
|
private async shouldUseExtractedImage(extractedPath: string, targetSize: number) {
|
|
|
|
|
const { width, height } = await this.mediaRepository.getImageDimensions(extractedPath);
|
|
|
|
|
const extractedSize = Math.min(width, height);
|
|
|
|
|
|
|
|
|
|
return extractedSize >= targetSize;
|
|
|
|
|
}
|
2024-05-10 15:03:47 -04:00
|
|
|
|
|
|
|
|
private async getDevices() {
|
|
|
|
|
if (!this.devices) {
|
2024-05-27 15:20:07 -04:00
|
|
|
try {
|
|
|
|
|
this.devices = await this.storageRepository.readdir('/dev/dri');
|
|
|
|
|
} catch {
|
|
|
|
|
this.logger.debug('No devices found in /dev/dri.');
|
|
|
|
|
this.devices = [];
|
|
|
|
|
}
|
2024-05-10 15:03:47 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.devices;
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-27 15:20:07 -04:00
|
|
|
private async hasMaliOpenCL() {
|
|
|
|
|
if (this.maliOpenCL === undefined) {
|
2024-05-10 15:03:47 -04:00
|
|
|
try {
|
|
|
|
|
const maliIcdStat = await this.storageRepository.stat('/etc/OpenCL/vendors/mali.icd');
|
|
|
|
|
const maliDeviceStat = await this.storageRepository.stat('/dev/mali0');
|
2024-05-27 15:20:07 -04:00
|
|
|
this.maliOpenCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice();
|
2024-05-10 15:03:47 -04:00
|
|
|
} catch {
|
2024-09-27 18:10:39 -04:00
|
|
|
this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU decoding');
|
2024-05-27 15:20:07 -04:00
|
|
|
this.maliOpenCL = false;
|
2024-05-10 15:03:47 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-27 15:20:07 -04:00
|
|
|
return this.maliOpenCL;
|
2024-05-10 15:03:47 -04:00
|
|
|
}
|
2023-02-25 09:12:03 -05:00
|
|
|
}
|