import ChildProcess from 'child_process' ; import FS from 'fs' ; import Path from 'path' ; import Util from 'util' ; import type Stream from 'stream' ; import FFMpeg from 'fluent-ffmpeg' ; import GM from 'gm' ; import Rimraf from 'rimraf' ; import Config from '../config' ; import type { BaseMedia , Media , MediaType , Metadata , SegmentMetadata } from '@vimtur/common' ; import type { Response } from 'express' ; export interface Quality { quality : number ; copy : boolean ; } const LOW_PRIORITY = 15 ; export interface TranscoderOptions { input : string ; output : string | Stream . Writable ; outputOptions : string [ ] ; inputOptions ? : string [ ] ; important ? : boolean ; } type MetadataNameObject = { name ? : string | null ; description ? : string | null ; } ; type StringOrNameObject = string | null | MetadataNameObject ; interface ExternalMetadata { author ? : StringOrNameObject ; artist ? : StringOrNameObject ; album ? : StringOrNameObject ; title ? : StringOrNameObject ; content ? : StringOrNameObject ; } function getExternalMetadataField ( obj , field ) { switch ( typeof obj [ field ] ) { case 'string' : return obj [ field ] as string ; case 'object' : if ( typeof ( obj [ field ] as MetadataNameObject ) . name === 'string' ) { return ( obj [ field ] as MetadataNameObject ) . name as string ; } return undefined ; default : return undefined ; } } export class ImportUtils { public static async getFileCreationTime ( path ) { const stat = await Util . promisify ( FS . stat ) ( path ) ; if ( ! stat ) { throw new Error ( `Failed to stat file: ${ path }` ) ; } const creationDate = stat . birthtime || stat . mtime ; if ( ! creationDate ) { throw new Error ( `Failed to get creation date: ${ path }` ) ; } return Math . round ( creationDate . getTime ( ) / 1000 ) ; } public static async getFileExternalMetadata ( absolutePath ) { try { const file = await new Promise < string > ( ( resolve , reject ) => { FS . readFile ( `${ absolutePath }.json` , ( err , data ) => { if ( err ) { reject ( err ) ; } else { resolve ( data . toString ( ) ) ; } } ) ; } ) ; const json = JSON . parse ( file ) as ExternalMetadata ; const metadata = { artist : getExternalMetadataField ( json , 'artist' ) || getExternalMetadataField ( json , 'author' ) , album : getExternalMetadataField ( json , 'album' ) , title : getExternalMetadataField ( json , 'title' ) || getExternalMetadataField ( json , 'content' ) , } ; for ( const key of [ 'artist' , 'album' , 'title' ] as const ) { if ( ! metadata [ key ] ) { delete metadata [ key ] ; } } return metadata ; } catch ( err ) { return { } ; } } public static async getVideoMetadata ( absolutePath ) { const data = await Util . promisify ( FFMpeg . ffprobe as any ) ( absolutePath ) ; const mediaData = data . streams . find ( ( stream ) => stream . codec_type === 'video' ) ; if ( ! mediaData ) { throw new Error ( 'No video streams found to extract metadata from' ) ; } const externalMetadata = await ImportUtils . getFileExternalMetadata ( absolutePath ) ; const metadata = { length : Math . ceil ( data . format . duration ) , qualityCache : [ ] , width : mediaData . width || mediaData . coded_width , height : mediaData . height || mediaData . coded_height , codec : mediaData . codec_name , ... ( data . format . tags ? { artist : data . format . tags . artist || data . format . tags . album_artist || data . format . tags . ARTIST || externalMetadata . artist , album : data . format . tags . album || data . format . tags . ALBUM || externalMetadata . album , title : data . format . tags . title || data . format . tags . TITLE || externalMetadata . title , } : externalMetadata ) , } ; if ( ! metadata . artist ) { delete metadata . artist ; } if ( ! metadata . album ) { delete metadata . album ; } if ( ! metadata . title ) { delete metadata . title ; } return metadata ; } public static async getImageMetadata ( absolutePath ) { const gm = GM . subClass ( { imageMagick : true } ) ( absolutePath ) ; const size = ( await Util . promisify ( gm . size . bind ( gm ) ) ( ) ) as any ; const externalMetadata = await ImportUtils . getFileExternalMetadata ( absolutePath ) ; return { ... externalMetadata , width : size . width , height : size . height , } ; } public static getType ( filename ) { const ext = Path . extname ( filename || '' ) . split ( '.' ) ; switch ( ext [ ext . length - 1 ] . toLowerCase ( ) ) { case 'png' : case 'jpg' : case 'jpeg' : case 'bmp' : case 'webp' : return 'still' ; case 'gif' : return 'gif' ; case 'avi' : case 'mp4' : case 'flv' : case 'wmv' : case 'mov' : case 'webm' : case 'mpeg' : case 'mpg' : return 'video' ; default : throw new Error ( 'Unknown filetype' ) ; } } public static isMaxCopyEnabled ( ) { return Config . get ( ) . transcoder . maxCopyEnabled ; } public static getMinQualityForTranscode ( ) { return Config . get ( ) . transcoder . minQuality ; } public static getTranscodeQualities ( ) { return [ ... new Set ( [ ... Config . get ( ) . transcoder . cacheQualities , ... Config . get ( ) . transcoder . streamQualities , ] ) , ] ; } public static getMediaDesiredQualities ( media , qualities ? ) { if ( ! qualities ) { qualities = ImportUtils . getTranscodeQualities ( ) ; } const maxCopy = ImportUtils . isMaxCopyEnabled ( ) ; const minQualityForTranscode = ImportUtils . getMinQualityForTranscode ( ) ; if ( ! media . metadata ) { throw new Error ( 'Media metadata not found. Cannot calculate desired qualities.' ) ; } const sourceHeight = media . metadata . height ; const intermediate = [ ] ; for ( const quality of qualities ) { if ( sourceHeight <= minQualityForTranscode ) { intermediate . push ( sourceHeight ) ; continue ; } if ( quality > sourceHeight ) { intermediate . push ( sourceHeight ) ; continue ; } intermediate . push ( quality ) ; } const output = [ ] ; for ( const quality of intermediate ) { if ( ! output . find ( ( el ) => el . quality === quality ) ) { output . push ( { quality : quality , copy : quality === sourceHeight && maxCopy , } ) ; } } output . sort ( ( a , b ) => a . quality - b . quality ) ; if ( output . length === 0 ) { throw new Error ( `No desired qualities for - ${ media . hash }` ) ; } return output ; } public static setNice ( pid , priority ) { const renice = ChildProcess . spawn ( 'renice' , [ `${ priority }` , `${ pid }` ] ) ; renice . on ( 'exit' , ( code ) => { if ( code !== 0 ) { console . debug ( `Failed to set nice level of ${ pid } to ${ priority }: Exit code ${ code }` ) ; } } ) ; } public static async transcode ( options ) { return new Promise < void > ( ( resolve , reject ) => { const args = [ ] ; if ( options . inputOptions ) { args . push ( ... options . inputOptions ) ; } args . push ( ... [ '-i' , options . input ] ) ; args . push ( ... options . outputOptions ) ; if ( typeof options . output === 'string' ) { args . push ( options . output ) ; } else { args . push ( 'pipe:1' ) ; } const proc = ChildProcess . spawn ( 'ffmpeg' , args ) ; if ( ! options . important ) { if ( proc . pid === undefined ) { console . warn ( 'Failed to set transcode process to low priority. Missing PID.' ) ; } else { ImportUtils . setNice ( proc . pid , LOW_PRIORITY ) ; } } let err = '' ; proc . stderr . on ( 'data' , ( data ) => { err += data ; } ) ; proc . on ( 'exit' , ( code ) => { if ( code === 0 || code === 255 ) { resolve ( ) ; } else { if ( err . includes ( 'specified for output file #0 (pipe:1) has not been used for any stream' ) ) { resolve ( ) ; } else { console . error ( `FFMPEG error: code (${ code })` , err ) ; reject ( new Error ( err ) ) ; } } } ) ; if ( typeof options . output !== 'string' ) { options . output . on ( 'close' , ( ) => { setTimeout ( ( ) => { proc . kill ( 'SIGKILL' ) ; } , 20 ) ; } ) ; proc . stdout . pipe ( options . output , { end : true } ) ; } } ) ; } public static async deleteFolder ( path ) { console . log ( `Removing ${ path }` ) ; return Util . promisify ( Rimraf ) ( path ) ; } public static async exists ( path ) { try { await Util . promisify ( FS . access ) ( path , FS . constants . R_OK ) ; return true ; } catch ( err ) { return false ; } } public static async mkdir ( path ) { const exists = await ImportUtils . exists ( path ) ; if ( ! exists ) { console . log ( `Making directory ${ path }` ) ; try { await Util . promisify ( FS . mkdir ) ( path ) ; } catch ( err ) { if ( err . code !== 'EEXIST' ) { throw err ; } } } } public static calculateBandwidthFromQuality ( quality , media , round ) { if ( ! media . metadata ) { throw new Error ( `Can't calculate bandwidth without metadata: ${ media . hash }` ) ; } const resMultiplier = media . metadata . width / media . metadata . height ; const pixels = quality * quality * resMultiplier ; const bitrateMultiplier = Config . get ( ) . transcoder . bitrateMultiplier ; if ( ! round ) { return pixels * bitrateMultiplier ; } return Math . ceil ( ( pixels * bitrateMultiplier ) / 500000 ) * 500000 ; } public static generateStreamMasterPlaylist ( media ) { if ( ! media . metadata ) { throw new Error ( `Cannot stream media that hasn't been indexed: ${ media . hash }` ) ; } const mediaQuality = media . metadata . width > media . metadata . height ? media . metadata . height : media . metadata . width ; const streamQualities = Config . get ( ) . transcoder . streamQualities . filter ( ( quality ) => { return quality <= mediaQuality ; } ) ; const qualities = Array . from ( new Set ( [ ... streamQualities , ... ( media . metadata . qualityCache ?? [ ] ) ] ) ) . sort ( ) ; if ( qualities . length === 0 ) { qualities . push ( media . metadata . height ) ; } let data = '#EXTM3U' ; for ( const quality of qualities . sort ( ) ) { const bandwidth = ImportUtils . calculateBandwidthFromQuality ( quality , media , false ) ; const height = quality ; const width = Math . ceil ( ( media . metadata . width / media . metadata . height ) * height ) ; const resolution = `${ width }x${ height }` ; data = `${ data }\n#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=${ bandwidth },RESOLUTION=${ resolution }` ; data = `${ data }\n${ quality }/index.m3u8` ; } return data ; } public static async generateSegments ( media ) { if ( ! media . metadata ) { throw new Error ( 'Cannot generate playlist for media without metadatata' ) ; } if ( media . type !== 'video' ) { throw new Error ( 'Cannot stream non-video type' ) ; } if ( ! media . metadata . length ) { throw new Error ( `Can't stream 0 length video` ) ; } const keyframes = await ImportUtils . getKeyframes ( media ) ; const segments = { standard : [ ] , } ; if ( keyframes . length === 1 || media . metadata . length < 10 ) { segments . standard . push ( { start : 0 , end : media . metadata . length } ) ; } else { let lastTimeIndex = 0 ; for ( let i = 0 ; i < keyframes . length ; i ++ ) { if ( keyframes [ i ] - keyframes [ lastTimeIndex ] > 10 ) { segments . standard . push ( { start : keyframes [ lastTimeIndex ] , end : keyframes [ i ] } ) ; lastTimeIndex = i ; } else if ( i === keyframes . length - 1 ) { segments . standard . push ( { start : keyframes [ lastTimeIndex ] , end : media . metadata . length } ) ; } } } return segments ; } public static async generateStreamPlaylist ( media , segments ) { if ( ! media . metadata ) { throw new Error ( 'Cannot generate playlist for media without metadatata' ) ; } if ( media . type !== 'video' ) { throw new Error ( 'Cannot stream non-video type' ) ; } if ( ! media . metadata . length ) { throw new Error ( `Can't stream 0 length video` ) ; } let data = '' ; let longest = 0 ; for ( const segment of segments . standard ) { const length = segment . end - segment . start ; if ( length > longest ) { longest = length ; } data += `#EXTINF:${ length . toFixed ( 6 ) },\ndata.ts?start=${ segment . start }&end=${ segment . end }\n` ; } const header = `#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:${ Math . ceil ( longest ) }\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-MEDIA-SEQUENCE:0\n` ; return `${ header + data }#EXT-X-ENDLIST\n` ; } public static getRedundanctCaches ( desiredCachesInput , actualCaches ) { const desiredCaches = desiredCachesInput . map ( ( el ) => { return el . quality ; } ) ; const redundant = [ ] ; for ( const quality of actualCaches ) { if ( ! desiredCaches . includes ( quality ) && ! redundant . includes ( quality ) ) { redundant . push ( quality ) ; } } return redundant ; } public static async wait ( ) { return new Promise < void > ( ( resolve ) => setTimeout ( resolve , 0 ) ) ; } public static async isExifRotated ( path ) { try { const gm = GM . subClass ( { imageMagick : true } ) ( path ) ; const orientation = ( await Util . promisify ( gm . orientation . bind ( gm ) ) ( ) ) as any ; switch ( orientation . toLowerCase ( ) ) { case 'topleft' : case 'unknown' : return false ; default : return true ; } } catch ( err ) { console . error ( 'isExifRotated failed' , path , err ) ; return false ; } } public static loadImageAutoOrient ( path , response , scale ? ) { let gm = GM . subClass ( { nativeAutoOrient : true , imageMagick : true } ) ( path ) . autoOrient ( ) ; if ( scale ) { gm = gm . scale ( scale . width * 1.5 , scale . height * 1.5 ) . resize ( scale . width , scale . height ) ; } const format = path . toLowerCase ( ) . endsWith ( 'gif' ) ? 'GIF' : 'PNG' ; response . set ( 'Content-Type' , `image/${ format . toLowerCase ( ) }` ) ; gm . stream ( format ) . pipe ( response ) ; } private static async getKeyframes ( media ) { if ( media . type !== 'video' ) { throw new Error ( 'Cannot stream non-video type' ) ; } const results = await Util . promisify ( ChildProcess . exec ) ( `ffprobe -fflags +genpts -loglevel error -skip_frame nokey -select_streams v:0 -show_entries frame=pkt_pts_time -of csv=print_section=0 "${ media . absolutePath }"` ) ; return results . stdout . split ( '\n' ) . filter ( ( line ) => Boolean ( line ) ) . map ( ( line ) => Number ( line ) ) ; } }