import { exec as execCallback , execFile } from 'child_process' ; import { createWriteStream } from 'fs' ; import { promisify } from 'util' ; import fs from 'fs/promises' ; const exec = promisify ( execCallback ) ; import { settings , args } from './helpers' ; import { htmlToText } from 'html-to-text' ; import sanitize from 'sanitize-filename' ; import builder from 'xmlbuilder' ; import { nPad } from '@inrixia/helpers/math' ; import { fApi } from './FloatplaneAPI' ; import type { FilePathFormattingOptions } from './types' ; import type { BlogPost } from 'floatplane/creator' ; import type Request from 'got/dist/source/core' ; import type Channel from './Channel' ; export default class Video { public guid : BlogPost [ 'guid' ] ; public title : BlogPost [ 'title' ] ; public description : BlogPost [ 'text' ] ; public releaseDate : Date ; public thumbnail : BlogPost [ 'thumbnail' ] ; public videoAttachments : BlogPost [ 'videoAttachments' ] ; public channel : Channel ; constructor ( video , channel ) { this . channel = channel ; this . guid = video . guid ; this . videoAttachments = video . attachmentOrder . filter ( ( a ) => video . videoAttachments . includes ( a ) ) ; this . title = video . title ; this . description = video . text ; this . releaseDate = new Date ( video . releaseDate ) ; this . thumbnail = video . thumbnail ; } private formatString ( string ) { const formatLookup = { '%channelTitle%' : this . channel . title , '%episodeNumber%' : this . channel . lookupVideoDB ( this . guid ) . episodeNo . toString ( ) , '%year%' : this . releaseDate . getFullYear ( ) . toString ( ) , '%month%' : nPad ( this . releaseDate . getMonth ( ) + 1 ) , '%day%' : nPad ( this . releaseDate . getDate ( ) ) , '%hour%' : nPad ( this . releaseDate . getHours ( ) ) , '%minute%' : nPad ( this . releaseDate . getMinutes ( ) ) , '%second%' : nPad ( this . releaseDate . getSeconds ( ) ) , '%videoTitle%' : this . title . replace ( / - /g , ' ' ) . replace ( /\//g , ' ' ) . replace ( /\\/g , ' ' ) , } ; for ( const [ match , value ] of Object . entries ( formatLookup ) ) { string = string . replace ( new RegExp ( match , 'g' ) , value ) ; } return string ; } private get fullPath ( ) : string { return this . formatString ( settings . filePathFormatting ) ; } private get folderPath ( ) : string { return this . fullPath . split ( '/' ) . slice ( 0 , - 1 ) . join ( '/' ) ; } public get filePath ( ) : string { return `${ this . folderPath }/${ sanitize ( this . fullPath . split ( '/' ) . slice ( - 1 ) [ 0 ] ) }` ; } /**
     * Get the suffix for a video file if there are multiple videoAttachments for this video
     */ private multiPartSuffix = ( attachmentIndex ) : string => `${ this . videoAttachments . length !== 1 ? ` - Part ${ + attachmentIndex + 1 }` : '' }` ; get expectedSize ( ) : number | undefined { return this . channel . lookupVideoDB ( this . guid ) . expectedSize ; } set expectedSize ( expectedSize ) { this . channel . lookupVideoDB ( this . guid ) . expectedSize = expectedSize ; } static getFileBytes = async ( path ) : Promise < number > => ( await fs . stat ( path ) . catch ( ( ) => ( { size : - 1 } ) ) ) . size ; public fileBytes = async ( extension ) : Promise < number > => { let bytes = 0 ; for ( const i in this . videoAttachments ) { bytes += await Video . getFileBytes ( `${ this . filePath }${ this . multiPartSuffix ( i ) }.${ extension }` ) ; } return bytes ; } ; public isDownloaded = async ( ) : Promise < boolean > => ( await this . isMuxed ( ) ) || ( await this . fileBytes ( 'partial' ) ) === this . expectedSize ; public isMuxed = async ( ) : Promise < boolean > => ( await this . fileBytes ( 'mp4' ) ) === this . expectedSize ; public async download ( quality , allowRangeQuery = true ) { if ( await this . isDownloaded ( ) ) throw new Error ( `Attempting to download "${ this . title }" video already downloaded!` ) ; await fs . mkdir ( this . folderPath , { recursive : true } ) ; if ( settings . extras . downloadArtwork && this . thumbnail !== undefined ) { const artworkFile = `${ this . filePath }${ settings . artworkSuffix }.png` ; fApi . got . stream ( this . thumbnail . path ) . pipe ( createWriteStream ( artworkFile ) ) . once ( 'end' , ( ) => fs . utimes ( artworkFile , new Date ( ) , this . releaseDate ) ) ; } if ( settings . extras . saveNfo ) { let seasonNumberFormat = '%year%%month%' ; let episodeNumberFormat = '%day%%hour%%minute%%second%' ; const seRegEx = new RegExp ( ' - S(.+)E(.+) - ' , '' ) ; const xRegEx = new RegExp ( ' - (.+)X(.+) - ' , 'i' ) ; const seMatch = seRegEx . exec ( settings . filePathFormatting ) ; const xMatch = xRegEx . exec ( settings . filePathFormatting ) ; if ( seMatch !== null ) { seasonNumberFormat = seMatch [ 1 ] ; episodeNumberFormat = seMatch [ 2 ] ; } else if ( xMatch !== null ) { seasonNumberFormat = xMatch [ 1 ] ; episodeNumberFormat = xMatch [ 2 ] ; } const nfo = builder . create ( 'episodedetails' ) . ele ( 'title' ) . text ( this . title ) . up ( ) . ele ( 'showtitle' ) . text ( this . channel . title ) . up ( ) . ele ( 'description' ) . text ( htmlToText ( this . description ) ) . up ( ) . ele ( 'plot' ) . text ( htmlToText ( this . description ) ) . up ( ) . ele ( 'aired' ) . text ( this . releaseDate . getFullYear ( ) . toString ( ) + '-' + nPad ( this . releaseDate . getMonth ( ) + 1 ) + '-' + nPad ( this . releaseDate . getDate ( ) ) ) . up ( ) . ele ( 'season' ) . text ( this . formatString ( seasonNumberFormat ) ) . up ( ) . ele ( 'episode' ) . text ( this . formatString ( episodeNumberFormat ) ) . up ( ) . end ( { pretty : true } ) ; await fs . writeFile ( `${ this . filePath }.nfo` , nfo , 'utf8' ) ; await fs . utimes ( `${ this . filePath }.nfo` , new Date ( ) , this . releaseDate ) ; } let writeStreamOptions , requestOptions , downloadedBytes ; if ( this . videoAttachments . length === 1 ) { if ( allowRangeQuery && this . expectedSize !== undefined && ( downloadedBytes = await this . fileBytes ( 'partial' ) ) !== - 1 ) { [ writeStreamOptions , requestOptions ] = [ { start : downloadedBytes , flags : 'r+' } , { headers : { range : `bytes=${ downloadedBytes }-${ this . expectedSize }` } } ] ; } } let downloadRequests = [ ] ; for ( const i in this . videoAttachments ) { const cdnInfo = await fApi . cdn . delivery ( 'download' , this . videoAttachments [ i ] ) ; const downloadEdge = cdnInfo . edges [ Math . floor ( Math . random ( ) * cdnInfo . edges . length ) ] ; if ( settings . floatplane . downloadEdge !== '' ) downloadEdge . hostname = settings . floatplane . downloadEdge ; const availableQualities = cdnInfo . resource . data . qualityLevels . map ( ( quality ) => quality . name ) . sort ( ( a , b ) => + b - + a ) ; const downloadQuality = availableQualities . includes ( quality ) ? quality : availableQualities [ availableQualities . length - 1 ] ; const downloadRequest = fApi . got . stream ( `https://${ downloadEdge . hostname }${ cdnInfo . resource . uri . replace ( '{qualityLevels}' , downloadQuality ) . replace ( '{token}' , cdnInfo . resource . data . token ) }` , requestOptions ) ; downloadRequest . pipe ( createWriteStream ( `${ this . filePath }${ this . multiPartSuffix ( i ) }.partial` , writeStreamOptions ) ) ; if ( this . expectedSize === undefined ) downloadRequest . once ( 'downloadProgress' , ( progress ) => ( this . expectedSize = progress . total ) ) ; downloadRequests . push ( downloadRequest ) ; } return downloadRequests ; } public async markCompleted ( ) { if ( ! ( await this . isMuxed ( ) ) ) throw new Error ( `Cannot mark ${ this . title } as completed as video file size is not correct. Expected: ${ this . expectedSize } bytes, Got: ${ await this . fileBytes ( 'mp4' ) } bytes...` ) ; return this . channel . markVideoCompleted ( this . guid , this . releaseDate . toString ( ) ) ; } public async muxffmpegMetadata ( ) { if ( ! this . isDownloaded ( ) ) throw new Error ( `Cannot mux ffmpeg metadata for ${ this . title } as its not downloaded. Expected: ${ this . expectedSize }, Got: ${ await this . fileBytes ( 'partial' ) } bytes...` ) ; await Promise . all ( this . videoAttachments . map ( ( a , i ) => new Promise ( ( resolve , reject ) => execFile ( args . headless === true ? './ffmpeg' : './db/ffmpeg' , [ '-i' , `${ this . filePath }${ this . multiPartSuffix ( i ) }.partial` , '-metadata' , `title=${ this . title }${ this . multiPartSuffix ( i ) }` , '-metadata' , `AUTHOR=${ this . channel . title }` , '-metadata' , `YEAR=${ this . releaseDate }` , '-metadata' , `date=${ this . releaseDate . getFullYear ( ) . toString ( ) + nPad ( this . releaseDate . getMonth ( ) + 1 ) + nPad ( this . releaseDate . getDate ( ) ) }` , '-metadata' , `description=${ htmlToText ( this . description ) }` , '-metadata' , `synopsis=${ htmlToText ( this . description ) }` , '-c:a' , 'copy' , '-c:v' , 'copy' , `${ this . filePath }${ this . multiPartSuffix ( i ) }.mp4` , ] , ( error , stdout ) => { if ( error !== null ) reject ( error ) ; else resolve ( stdout ) ; } ) ) ) ) ; this . expectedSize = await this . fileBytes ( 'mp4' ) ; await this . markCompleted ( ) ; for ( const i in this . videoAttachments ) { await fs . unlink ( `${ this . filePath }${ this . multiPartSuffix ( i ) }.partial` ) ; await fs . utimes ( `${ this . filePath }${ this . multiPartSuffix ( i ) }.mp4` , new Date ( ) , this . releaseDate ) ; } } public async postProcessingCommand ( ) { const result = await exec ( this . formatString ( settings . postProcessingCommand ) ) ; if ( result . stderr !== '' ) throw new Error ( result . stderr ) ; } }