2023-10-11 04:14:44 +02:00
import {
AssetEntity ,
AssetPathType ,
AssetType ,
Colorspace ,
TranscodeHWAccel ,
TranscodePolicy ,
VideoCodec ,
} from '@app/infra/entities' ;
2023-07-09 04:43:11 +02:00
import { Inject , Injectable , Logger , UnsupportedMediaTypeException } from '@nestjs/common' ;
2023-05-22 20:05:06 +02:00
import { usePagination } from '../domain.util' ;
2023-10-09 16:25:03 +02:00
import { IBaseJob , IEntityJob , JOBS_ASSET_PAGINATION_SIZE , JobName , QueueName } from '../job' ;
import {
AudioStreamInfo ,
IAssetRepository ,
IJobRepository ,
IMediaRepository ,
2023-10-11 04:14:44 +02:00
IMoveRepository ,
2023-10-09 16:25:03 +02:00
IPersonRepository ,
IStorageRepository ,
ISystemConfigRepository ,
VideoCodecHWConfig ,
VideoStreamInfo ,
WithoutProperty ,
} from '../repositories' ;
import { StorageCore , StorageFolder } from '../storage' ;
import { SystemConfigFFmpegDto } from '../system-config' ;
2023-04-04 16:48:02 +02:00
import { SystemConfigCore } from '../system-config/system-config.core' ;
2023-08-07 22:35:25 +02:00
import { H264Config , HEVCConfig , NVENCConfig , QSVConfig , ThumbnailConfig , VAAPIConfig , VP9Config } from './media.util' ;
2023-09-08 08:49:43 +02:00
2023-02-25 15:12:03 +01:00
@Injectable ( )
export class MediaService {
private logger = new Logger ( MediaService . name ) ;
2023-04-04 16:48:02 +02:00
private configCore : SystemConfigCore ;
2023-09-25 17:07:21 +02:00
private storageCore : StorageCore ;
2023-02-25 15:12:03 +01:00
constructor (
@Inject ( IAssetRepository ) private assetRepository : IAssetRepository ,
2023-09-08 08:49:43 +02:00
@Inject ( IPersonRepository ) private personRepository : IPersonRepository ,
2023-02-25 15:12:03 +01:00
@Inject ( IJobRepository ) private jobRepository : IJobRepository ,
@Inject ( IMediaRepository ) private mediaRepository : IMediaRepository ,
@Inject ( IStorageRepository ) private storageRepository : IStorageRepository ,
2023-09-03 08:21:51 +02:00
@Inject ( ISystemConfigRepository ) configRepository : ISystemConfigRepository ,
2023-10-11 04:14:44 +02:00
@Inject ( IMoveRepository ) moveRepository : IMoveRepository ,
2023-04-04 16:48:02 +02:00
) {
2023-10-09 02:51:03 +02:00
this . configCore = SystemConfigCore . create ( configRepository ) ;
2023-10-23 17:52:21 +02:00
this . storageCore = StorageCore . create ( assetRepository , moveRepository , personRepository , storageRepository ) ;
2023-04-04 16:48:02 +02:00
}
2023-02-25 15:12:03 +01:00
2023-09-25 17:07:21 +02:00
async handleQueueGenerateThumbnails ( { force } : IBaseJob ) {
2023-05-26 21:43:24 +02:00
const assetPagination = usePagination ( JOBS_ASSET_PAGINATION_SIZE , ( pagination ) = > {
return force
? this . assetRepository . getAll ( pagination )
: this . assetRepository . getWithout ( pagination , WithoutProperty . THUMBNAIL ) ;
} ) ;
2023-03-20 16:55:28 +01:00
2023-05-26 21:43:24 +02:00
for await ( const assets of assetPagination ) {
for ( const asset of assets ) {
2023-06-18 05:22:31 +02:00
if ( ! asset . resizePath || force ) {
await this . jobRepository . queue ( { name : JobName.GENERATE_JPEG_THUMBNAIL , data : { id : asset.id } } ) ;
continue ;
}
if ( ! asset . webpPath ) {
await this . jobRepository . queue ( { name : JobName.GENERATE_WEBP_THUMBNAIL , data : { id : asset.id } } ) ;
}
if ( ! asset . thumbhash ) {
await this . jobRepository . queue ( { name : JobName.GENERATE_THUMBHASH_THUMBNAIL , data : { id : asset.id } } ) ;
}
2023-03-20 16:55:28 +01:00
}
}
2023-09-08 08:49:43 +02:00
const people = force ? await this . personRepository . getAll ( ) : await this . personRepository . getAllWithoutThumbnail ( ) ;
for ( const person of people ) {
2023-09-26 09:03:22 +02:00
if ( ! person . faceAssetId ) {
const face = await this . personRepository . getRandomFace ( person . id ) ;
if ( ! face ) {
continue ;
}
await this . personRepository . update ( { id : person.id , faceAssetId : face.assetId } ) ;
2023-09-08 08:49:43 +02:00
}
2023-09-26 09:03:22 +02:00
await this . jobRepository . queue ( { name : JobName.GENERATE_PERSON_THUMBNAIL , data : { id : person.id } } ) ;
2023-09-08 08:49:43 +02:00
}
2023-05-26 21:43:24 +02:00
return true ;
}
2023-04-12 03:28:25 +02:00
2023-09-25 17:07:21 +02:00
async handleQueueMigration() {
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 ) {
for ( const asset of assets ) {
await this . jobRepository . queue ( { name : JobName.MIGRATE_ASSET , data : { id : asset.id } } ) ;
}
}
const people = await this . personRepository . getAll ( ) ;
for ( const person of people ) {
await this . jobRepository . queue ( { name : JobName.MIGRATE_PERSON , data : { id : person.id } } ) ;
}
return true ;
}
async handleAssetMigration ( { id } : IEntityJob ) {
const [ asset ] = await this . assetRepository . getByIds ( [ id ] ) ;
if ( ! asset ) {
return false ;
}
2023-10-11 04:14:44 +02:00
await this . storageCore . moveAssetFile ( asset , AssetPathType . JPEG_THUMBNAIL ) ;
await this . storageCore . moveAssetFile ( asset , AssetPathType . WEBP_THUMBNAIL ) ;
await this . storageCore . moveAssetFile ( asset , AssetPathType . ENCODED_VIDEO ) ;
2023-09-25 17:07:21 +02:00
return true ;
}
2023-05-26 21:43:24 +02:00
async handleGenerateJpegThumbnail ( { id } : IEntityJob ) {
const [ asset ] = await this . assetRepository . getByIds ( [ id ] ) ;
2023-04-12 03:28:25 +02:00
if ( ! asset ) {
2023-05-26 21:43:24 +02:00
return false ;
2023-04-12 03:28:25 +02:00
}
2023-02-25 15:12:03 +01:00
2023-09-03 08:21:51 +02:00
const resizePath = await this . generateThumbnail ( asset , 'jpeg' ) ;
await this . assetRepository . save ( { id : asset.id , resizePath } ) ;
return true ;
}
2023-05-26 21:43:24 +02:00
2023-10-11 04:14:44 +02:00
private async generateThumbnail ( asset : AssetEntity , format : 'jpeg' | 'webp' ) {
const { thumbnail , ffmpeg } = await this . configCore . getConfig ( ) ;
const size = format === 'jpeg' ? thumbnail.jpegSize : thumbnail.webpSize ;
const path =
2023-10-23 17:52:21 +02:00
format === 'jpeg' ? StorageCore . getLargeThumbnailPath ( asset ) : StorageCore . getSmallThumbnailPath ( asset ) ;
2023-10-11 04:14:44 +02:00
this . storageCore . ensureFolders ( path ) ;
2023-06-15 05:42:35 +02:00
switch ( asset . type ) {
case AssetType . IMAGE :
2023-10-11 04:14:44 +02:00
const colorspace = this . isSRGB ( asset ) ? Colorspace.SRGB : thumbnail.colorspace ;
const thumbnailOptions = { format , size , colorspace , quality : thumbnail.quality } ;
await this . mediaRepository . resize ( asset . originalPath , path , thumbnailOptions ) ;
2023-06-15 05:42:35 +02:00
break ;
2023-10-11 04:14:44 +02:00
2023-06-15 05:42:35 +02: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 ) ;
const config = { . . . ffmpeg , targetResolution : size.toString ( ) } ;
const options = new ThumbnailConfig ( config ) . getOptions ( mainVideoStream , mainAudioStream ) ;
await this . mediaRepository . transcode ( asset . originalPath , path , options ) ;
2023-06-15 05:42:35 +02:00
break ;
2023-10-11 04:14:44 +02:00
2023-09-03 08:21:51 +02:00
default :
throw new UnsupportedMediaTypeException ( ` Unsupported asset type for thumbnail generation: ${ asset . type } ` ) ;
2023-05-26 21:43:24 +02:00
}
2023-09-03 08:21:51 +02:00
this . logger . log (
` Successfully generated ${ format . toUpperCase ( ) } ${ asset . type . toLowerCase ( ) } thumbnail for asset ${ asset . id } ` ,
) ;
return path ;
}
2023-02-25 15:12:03 +01:00
2023-07-09 04:43:11 +02:00
async handleGenerateWebpThumbnail ( { id } : IEntityJob ) {
2023-05-26 21:43:24 +02:00
const [ asset ] = await this . assetRepository . getByIds ( [ id ] ) ;
2023-09-05 01:24:55 +02:00
if ( ! asset ) {
2023-05-26 21:43:24 +02:00
return false ;
2023-02-25 15:12:03 +01:00
}
2023-09-03 08:21:51 +02:00
const webpPath = await this . generateThumbnail ( asset , 'webp' ) ;
2023-07-10 19:56:45 +02:00
await this . assetRepository . save ( { id : asset.id , webpPath } ) ;
2023-05-26 21:43:24 +02:00
return true ;
2023-02-25 15:12:03 +01:00
}
2023-04-04 16:48:02 +02:00
2023-06-18 05:22:31 +02:00
async handleGenerateThumbhashThumbnail ( { id } : IEntityJob ) : Promise < boolean > {
const [ asset ] = await this . assetRepository . getByIds ( [ id ] ) ;
if ( ! asset ? . resizePath ) {
return false ;
}
const thumbhash = await this . mediaRepository . generateThumbhash ( asset . resizePath ) ;
await this . assetRepository . save ( { id : asset.id , thumbhash } ) ;
return true ;
}
2023-04-04 16:48:02 +02:00
async handleQueueVideoConversion ( job : IBaseJob ) {
const { force } = job ;
2023-05-26 21:43:24 +02: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 ) {
for ( const asset of assets ) {
await this . jobRepository . queue ( { name : JobName.VIDEO_CONVERSION , data : { id : asset.id } } ) ;
2023-04-04 16:48:02 +02:00
}
}
2023-05-26 21:43:24 +02:00
return true ;
}
2023-04-11 15:56:52 +02:00
2023-05-26 21:43:24 +02:00
async handleVideoConversion ( { id } : IEntityJob ) {
const [ asset ] = await this . assetRepository . getByIds ( [ id ] ) ;
2023-07-05 07:36:16 +02:00
if ( ! asset || asset . type !== AssetType . VIDEO ) {
2023-05-26 21:43:24 +02:00
return false ;
2023-04-11 15:56:52 +02:00
}
2023-04-04 16:48:02 +02:00
2023-05-26 21:43:24 +02: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 21:43:24 +02:00
const { videoStreams , audioStreams , format } = await this . mediaRepository . probe ( input ) ;
2023-08-29 11:01:42 +02:00
const mainVideoStream = this . getMainStream ( videoStreams ) ;
const mainAudioStream = this . getMainStream ( audioStreams ) ;
2023-05-26 21:43:24 +02:00
const containerExtension = format . formatName ;
2023-07-09 22:15:34 +02:00
if ( ! mainVideoStream || ! containerExtension ) {
2023-05-26 21:43:24 +02:00
return false ;
}
2023-04-04 16:48:02 +02:00
2023-05-26 21:43:24 +02:00
const { ffmpeg : config } = await this . configCore . getConfig ( ) ;
2023-04-04 16:48:02 +02:00
2023-05-26 21:43:24 +02:00
const required = this . isTranscodeRequired ( asset , mainVideoStream , mainAudioStream , containerExtension , config ) ;
if ( ! required ) {
return false ;
}
2023-04-04 16:48:02 +02:00
2023-07-09 04:43:11 +02:00
let transcodeOptions ;
try {
2023-08-29 11:01:42 +02:00
transcodeOptions = await this . getCodecConfig ( config ) . then ( ( c ) = > c . getOptions ( mainVideoStream , mainAudioStream ) ) ;
2023-07-09 04:43:11 +02:00
} catch ( err ) {
this . logger . error ( ` An error occurred while configuring transcoding options: ${ err } ` ) ;
return false ;
}
2023-04-06 05:32:59 +02:00
2023-07-09 04:43:11 +02:00
this . logger . log ( ` Start encoding video ${ asset . id } ${ JSON . stringify ( transcodeOptions ) } ` ) ;
2023-08-02 03:56:10 +02:00
try {
await this . mediaRepository . transcode ( input , output , transcodeOptions ) ;
} catch ( err ) {
this . logger . error ( err ) ;
2023-08-29 11:01:42 +02:00
if ( config . accel !== TranscodeHWAccel . DISABLED ) {
2023-08-02 03:56:10 +02:00
this . logger . error (
` Error occurred during transcoding. Retrying with ${ config . accel . toUpperCase ( ) } acceleration disabled. ` ,
) ;
}
config . accel = TranscodeHWAccel . DISABLED ;
2023-08-29 11:01:42 +02:00
transcodeOptions = await this . getCodecConfig ( config ) . then ( ( c ) = > c . getOptions ( mainVideoStream , mainAudioStream ) ) ;
2023-08-02 03:56:10 +02:00
await this . mediaRepository . transcode ( input , output , transcodeOptions ) ;
}
2023-04-04 16:48:02 +02:00
2023-05-26 21:43:24 +02:00
this . logger . log ( ` Encoding success ${ asset . id } ` ) ;
2023-04-04 16:48:02 +02:00
2023-05-26 21:43:24 +02:00
await this . assetRepository . save ( { id : asset.id , encodedVideoPath : output } ) ;
return true ;
2023-04-04 16:48:02 +02:00
}
2023-08-29 11:01:42 +02:00
private getMainStream < T extends VideoStreamInfo | AudioStreamInfo > ( streams : T [ ] ) : T {
2023-04-06 05:32:59 +02:00
return streams . sort ( ( stream1 , stream2 ) = > stream2 . frameCount - stream1 . frameCount ) [ 0 ] ;
}
private isTranscodeRequired (
2023-04-09 03:35:54 +02:00
asset : AssetEntity ,
2023-04-06 05:32:59 +02:00
videoStream : VideoStreamInfo ,
2023-07-09 22:15:34 +02:00
audioStream : AudioStreamInfo | null ,
2023-04-06 05:32:59 +02:00
containerExtension : string ,
ffmpegConfig : SystemConfigFFmpegDto ,
) : boolean {
if ( ! videoStream . height || ! videoStream . width ) {
2023-04-04 16:48:02 +02:00
this . logger . error ( 'Skipping transcode, height or width undefined for video stream' ) ;
return false ;
}
2023-04-06 05:32:59 +02:00
const isTargetVideoCodec = videoStream . codecName === ffmpegConfig . targetVideoCodec ;
const isTargetContainer = [ 'mov,mp4,m4a,3gp,3g2,mj2' , 'mp4' , 'mov' ] . includes ( containerExtension ) ;
2023-07-09 22:15:34 +02:00
const isTargetAudioCodec = audioStream == null || audioStream . codecName === ffmpegConfig . targetAudioCodec ;
2023-08-02 03:56:10 +02:00
this . logger . verbose (
` ${ asset . id } : AudioCodecName ${ audioStream ? . codecName ? ? 'None' } , AudioStreamCodecType ${
audioStream ? . codecType ? ? 'None'
} , containerExtension $ { containerExtension } ` ,
) ;
2023-04-06 05:32:59 +02:00
const allTargetsMatching = isTargetVideoCodec && isTargetAudioCodec && isTargetContainer ;
2023-06-10 06:15:12 +02:00
const scalingEnabled = ffmpegConfig . targetResolution !== 'original' ;
const targetRes = Number . parseInt ( ffmpegConfig . targetResolution ) ;
const isLargerThanTargetRes = scalingEnabled && Math . min ( videoStream . height , videoStream . width ) > targetRes ;
2023-04-04 16:48:02 +02:00
switch ( ffmpegConfig . transcode ) {
2023-07-09 04:43:11 +02:00
case TranscodePolicy . DISABLED :
2023-04-06 05:32:59 +02:00
return false ;
2023-07-09 04:43:11 +02:00
case TranscodePolicy . ALL :
2023-04-04 16:48:02 +02:00
return true ;
2023-07-09 04:43:11 +02:00
case TranscodePolicy . REQUIRED :
2023-08-07 22:35:25 +02:00
return ! allTargetsMatching || videoStream . isHDR ;
2023-04-04 16:48:02 +02:00
2023-07-09 04:43:11 +02:00
case TranscodePolicy . OPTIMAL :
2023-08-07 22:35:25 +02:00
return ! allTargetsMatching || isLargerThanTargetRes || videoStream . isHDR ;
2023-04-04 16:48:02 +02:00
default :
return false ;
}
}
2023-08-02 03:56:10 +02:00
async getCodecConfig ( config : SystemConfigFFmpegDto ) {
if ( config . accel === TranscodeHWAccel . DISABLED ) {
return this . getSWCodecConfig ( config ) ;
}
return this . getHWCodecConfig ( config ) ;
}
private getSWCodecConfig ( config : SystemConfigFFmpegDto ) {
2023-07-09 04:43:11 +02:00
switch ( config . targetVideoCodec ) {
case VideoCodec . H264 :
return new H264Config ( config ) ;
case VideoCodec . HEVC :
return new HEVCConfig ( config ) ;
case VideoCodec . VP9 :
return new VP9Config ( config ) ;
default :
throw new UnsupportedMediaTypeException ( ` Codec ' ${ config . targetVideoCodec } ' is unsupported ` ) ;
2023-05-22 20:07:43 +02:00
}
}
2023-08-02 03:56:10 +02:00
private async getHWCodecConfig ( config : SystemConfigFFmpegDto ) {
let handler : VideoCodecHWConfig ;
let devices : string [ ] ;
switch ( config . accel ) {
case TranscodeHWAccel . NVENC :
handler = new NVENCConfig ( config ) ;
break ;
case TranscodeHWAccel . QSV :
devices = await this . storageRepository . readdir ( '/dev/dri' ) ;
handler = new QSVConfig ( config , devices ) ;
break ;
case TranscodeHWAccel . VAAPI :
devices = await this . storageRepository . readdir ( '/dev/dri' ) ;
handler = new VAAPIConfig ( config , devices ) ;
break ;
default :
throw new UnsupportedMediaTypeException ( ` ${ config . accel . toUpperCase ( ) } acceleration is unsupported ` ) ;
}
if ( ! handler . getSupportedCodecs ( ) . includes ( config . targetVideoCodec ) ) {
throw new UnsupportedMediaTypeException (
` ${ config . accel . toUpperCase ( ) } acceleration does not support codec ' ${ config . targetVideoCodec . toUpperCase ( ) } '. Supported codecs: ${ handler . getSupportedCodecs ( ) } ` ,
) ;
}
return handler ;
}
2023-09-03 08:21:51 +02:00
2023-09-26 01:18:47 +02: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 ;
}
}
2023-02-25 15:12:03 +01:00
}