diff --git a/index.js b/index.js index 36ea844..13af0b0 100644 --- a/index.js +++ b/index.js @@ -187,7 +187,11 @@ app.listen(process.env.PORT, () => { }) function initDB(db, channelDB) { - dbMigration.initDB(db, channelDB); + if (!fs.existsSync(process.env.DATABASE + '/images/dizquetv.png')) { + let data = fs.readFileSync(path.resolve(path.join(__dirname, 'resources/dizquetv.png'))) + fs.writeFileSync(process.env.DATABASE + '/images/dizquetv.png', data) + } + dbMigration.initDB(db, channelDB, __dirname); if (!fs.existsSync(process.env.DATABASE + '/font.ttf')) { let data = fs.readFileSync(path.resolve(path.join(__dirname, 'resources/font.ttf'))) fs.writeFileSync(process.env.DATABASE + '/font.ttf', data) diff --git a/resources/dizquetv.png b/resources/dizquetv.png index bac3553..f3c0eec 100644 Binary files a/resources/dizquetv.png and b/resources/dizquetv.png differ diff --git a/resources/favicon.svg b/resources/favicon.svg index 679b6ea..8de1542 100644 --- a/resources/favicon.svg +++ b/resources/favicon.svg @@ -29,7 +29,7 @@ inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="3.0547013" - inkscape:cx="173.01248" + inkscape:cx="55.816079" inkscape:cy="84.726326" inkscape:document-units="mm" inkscape:current-layer="layer1" @@ -48,7 +48,7 @@ image/svg+xml - + @@ -58,7 +58,7 @@ id="layer1" transform="translate(0,-244.08278)"> + style="opacity:1;fill:#080808;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" /> addFPS(db) ], [ 601, 700, (db) => migrateWatermark(db) ], [ 700, 701, (db) => addScalingAlgorithm(db) ], - [ 701, 702, (db) => addDeinterlaceFilter(db) ] + [ 701, 703, (db,channels,dir) => reAddIcon(dir) ], + [ 703, 800, (db) => addDeinterlaceFilter(db) ], + // there was a bit of thing in which for a while 1.3.x migrated 701 to 702 using + // the addDeinterlaceFilter step. This 702 step no longer exists as a target + // but we have to migrate it to 800 using the reAddIcon. + [ 702, 800, (db,channels,dir) => reAddIcon(dir) ], ] const { v4: uuidv4 } = require('uuid'); @@ -333,7 +338,7 @@ function commercialsRemover(db) { } -function initDB(db, channelDB ) { +function initDB(db, channelDB, dir ) { if (typeof(channelDB) === 'undefined') { throw Error("???"); } @@ -348,7 +353,7 @@ function initDB(db, channelDB ) { ran = true; console.log("Migrating from db version " + dbVersion.version + " to: " + STEPS[i][1] + "..."); try { - STEPS[i][2](db, channelDB); + STEPS[i][2](db, channelDB, dir); if (typeof(dbVersion._id) === 'undefined') { db['db-version'].save( {'version': STEPS[i][1] } ); } else { @@ -398,7 +403,7 @@ function ffmpeg() { normalizeAudio: true, maxFPS: 60, scalingAlgorithm: "bicubic", - deinterlaceFilter: "none" + deinterlaceFilter: "none", } } @@ -757,6 +762,40 @@ function addScalingAlgorithm(db) { fs.writeFileSync( f, JSON.stringify( [ffmpegSettings] ) ); } +function moveBackup(path) { + if (fs.existsSync(`${process.env.DATABASE}${path}`) ) { + let i = 0; + while (fs.existsSync( `${process.env.DATABASE}${path}.bak.${i}`) ) { + i++; + } + fs.renameSync(`${process.env.DATABASE}${path}`, `${process.env.DATABASE}${path}.bak.${i}` ); + } +} + +function reAddIcon(dir) { + moveBackup('/images/dizquetv.png'); + let data = fs.readFileSync(path.resolve(path.join(dir, 'resources/dizquetv.png'))); + fs.writeFileSync(process.env.DATABASE + '/images/dizquetv.png', data); + + if (fs.existsSync(`${process.env.DATABASE}/images/pseudotv.png`) ) { + moveBackup('/images/pseudotv.png'); + let data = fs.readFileSync(path.resolve(path.join(dir, 'resources/dizquetv.png'))); + fs.writeFileSync(process.env.DATABASE + '/images/pseudotv.png', data); + } + + moveBackup('/images/generic-error-screen.png'); + data = fs.readFileSync(path.resolve(path.join(dir, 'resources/generic-error-screen.png'))) + fs.writeFileSync(process.env.DATABASE + '/images/generic-error-screen.png', data) + + moveBackup('/images/generic-offline-screen.png'); + data = fs.readFileSync(path.resolve(path.join(dir, 'resources/generic-offline-screen.png'))); + fs.writeFileSync(process.env.DATABASE + '/images/generic-offline-screen.png', data); + + moveBackup('/images/loading-screen.png'); + data = fs.readFileSync(path.resolve(path.join(dir, 'resources/loading-screen.png'))) + fs.writeFileSync(process.env.DATABASE + '/images/loading-screen.png', data) +} + function addDeinterlaceFilter(db) { let ffmpegSettings = db['ffmpeg-settings'].find()[0]; let f = path.join(process.env.DATABASE, 'ffmpeg-settings.json'); diff --git a/src/ffmpeg.js b/src/ffmpeg.js index f2403df..8fcd3cd 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -62,6 +62,10 @@ class FFMPEG extends events.EventEmitter { this.ensureResolution = this.opts.normalizeResolution; this.volumePercent = this.opts.audioVolumePercent; this.hasBeenKilled = false; + this.audioOnly = false; + } + setAudioOnly(audioOnly) { + this.audioOnly = audioOnly; } async spawnConcat(streamUrl) { return await this.spawn(streamUrl, undefined, undefined, undefined, true, false, undefined, true) @@ -108,8 +112,17 @@ class FFMPEG extends events.EventEmitter { `-threads`, isConcatPlaylist? 1 : this.opts.threads, `-fflags`, `+genpts+discardcorrupt+igndts`]; - if (limitRead === true) - ffmpegArgs.push(`-re`) + if ( + (limitRead === true) + && + ( + (this.audioOnly !== true) + || + ( typeof(streamUrl.errorTitle) === 'undefined') + ) + ) { + ffmpegArgs.push(`-re`); + } if (typeof startTime !== 'undefined') @@ -186,24 +199,27 @@ class FFMPEG extends events.EventEmitter { iH = this.wantedH; } + if ( this.audioOnly !== true) { ffmpegArgs.push("-r" , "24"); if ( streamUrl.errorTitle == 'offline' ) { ffmpegArgs.push( '-loop', '1', '-i', `${this.channel.offlinePicture}`, ); - videoComplex = `;[0:0]loop=loop=-1:size=1:start=0[looped];[looped]scale=${iW}:${iH}[videoy];[videoy]realtime[videox]`; + videoComplex = `;[${inputFiles++}:0]loop=loop=-1:size=1:start=0[looped];[looped]scale=${iW}:${iH}[videoy];[videoy]realtime[videox]`; } else if (this.opts.errorScreen == 'static') { ffmpegArgs.push( '-f', 'lavfi', '-i', `nullsrc=s=64x36`); videoComplex = `;geq=random(1)*255:128:128[videoz];[videoz]scale=${iW}:${iH}[videoy];[videoy]realtime[videox]`; + inputFiles++; } else if (this.opts.errorScreen == 'testsrc') { ffmpegArgs.push( '-f', 'lavfi', '-i', `testsrc=size=${iW}x${iH}`, ); videoComplex = `;realtime[videox]`; + inputFiles++; } else if (this.opts.errorScreen == 'text') { var sz2 = Math.ceil( (iH) / 33.0); var sz1 = Math.ceil( sz2 * 3. / 2. ); @@ -213,6 +229,7 @@ class FFMPEG extends events.EventEmitter { '-f', 'lavfi', '-i', `color=c=black:s=${iW}x${iH}` ); + inputFiles++; videoComplex = `;drawtext=fontfile=${process.env.DATABASE}/font.ttf:fontsize=${sz1}:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2:text='${streamUrl.errorTitle}',drawtext=fontfile=${process.env.DATABASE}/font.ttf:fontsize=${sz2}:fontcolor=white:x=(w-text_w)/2:y=(h+text_h+${sz3})/2:text='${streamUrl.subtitle}'[videoy];[videoy]realtime[videox]`; } else if (this.opts.errorScreen == 'blank') { @@ -220,14 +237,17 @@ class FFMPEG extends events.EventEmitter { '-f', 'lavfi', '-i', `color=c=black:s=${iW}x${iH}` ); + inputFiles++; videoComplex = `;realtime[videox]`; } else {//'pic' ffmpegArgs.push( '-loop', '1', '-i', `${this.errorPicturePath}`, ); - videoComplex = `;[0:0]scale=${iW}:${iH}[videoy];[videoy]realtime[videox]`; + inputFiles++; + videoComplex = `;[${videoFile+1}:0]scale=${iW}:${iH}[videoy];[videoy]realtime[videox]`; } + } let durstr = `duration=${streamStats.duration}ms`; //silent audioComplex = `;aevalsrc=0:${durstr}[audioy]`; @@ -239,14 +259,26 @@ class FFMPEG extends events.EventEmitter { ffmpegArgs.push('-i', `${this.channel.offlineSoundtrack}`); // I don't really understand why, but you need to use this // 'size' in order to make the soundtrack actually loop - audioComplex = `;[1:a]aloop=loop=-1:size=2147483647[audioy]`; + audioComplex = `;[${inputFiles++}:a]aloop=loop=-1:size=2147483647[audioy]`; } - } else if (this.opts.errorAudio == 'whitenoise') { - audioComplex = `;aevalsrc=-2+0.1*random(0):${durstr}[audioy]`; + } else if ( + (this.opts.errorAudio == 'whitenoise') + || + ( + !(this.opts.errorAudio == 'sine') + && + (this.audioOnly === true) //when it's in audio-only mode, silent stream is confusing for errors. + ) + ) { + audioComplex = `;aevalsrc=random(0):${durstr}[audioy]`; + this.volumePercent = Math.min(70, this.volumePercent); } else if (this.opts.errorAudio == 'sine') { - audioComplex = `;sine=f=440:${durstr}[audiox];[audiox]volume=-35dB[audioy]`; + audioComplex = `;sine=f=440:${durstr}[audioy]`; + this.volumePercent = Math.min(70, this.volumePercent); + } + if ( this.audioOnly !== true ) { + ffmpegArgs.push('-pix_fmt' , 'yuv420p' ); } - ffmpegArgs.push('-pix_fmt' , 'yuv420p' ); audioComplex += ';[audioy]arealtime[audiox]'; currentVideo = "[videox]"; currentAudio = "[audiox]"; @@ -319,7 +351,7 @@ class FFMPEG extends events.EventEmitter { } // Channel watermark: - if (doOverlay) { + if (doOverlay && (this.audioOnly !== true) ) { var pW =watermark.width; var w = Math.round( pW * iW / 100.0 ); var mpHorz = watermark.horizontalMargin; @@ -361,7 +393,8 @@ class FFMPEG extends events.EventEmitter { currentAudio = '[boosted]'; } // Align audio is just the apad filter applied to audio stream - if (this.apad) { + if (this.apad && (this.audioOnly !== true) ) { + //it doesn't make much sense to pad audio when there is no video audioComplex += `;${currentAudio}apad=whole_dur=${streamStats.duration}ms[padded]`; currentAudio = '[padded]'; } else if (this.audioChannelsSampleRate) { @@ -382,11 +415,13 @@ class FFMPEG extends events.EventEmitter { } else { console.log(resizeMsg) } - if (currentVideo != '[video]') { - transcodeVideo = true; //this is useful so that it adds some lines below - filterComplex += videoComplex; - } else { - currentVideo = `${videoFile}:${videoIndex}`; + if (this.audioOnly !== true) { + if (currentVideo != '[video]') { + transcodeVideo = true; //this is useful so that it adds some lines below + filterComplex += videoComplex; + } else { + currentVideo = `${videoFile}:${videoIndex}`; + } } // same with audio: if (currentAudio != '[audio]') { @@ -403,15 +438,18 @@ class FFMPEG extends events.EventEmitter { ffmpegArgs.push('-shortest'); } } - + if (this.audioOnly !== true) { + ffmpegArgs.push( + '-map', currentVideo, + `-c:v`, (transcodeVideo ? this.opts.videoEncoder : 'copy'), + `-sc_threshold`, `1000000000`, + ); + } ffmpegArgs.push( - '-map', currentVideo, '-map', currentAudio, - `-c:v`, (transcodeVideo ? this.opts.videoEncoder : 'copy'), `-flags`, `cgop+ilme`, - `-sc_threshold`, `1000000000` ); - if ( transcodeVideo ) { + if ( transcodeVideo && (this.audioOnly !== true) ) { // add the video encoder flags ffmpegArgs.push( `-b:v`, `${this.opts.videoBitrate}k`, @@ -453,8 +491,11 @@ class FFMPEG extends events.EventEmitter { //Concat stream is simpler and should always copy the codec ffmpegArgs.push( `-probesize`, 32 /*`100000000`*/, - `-i`, streamUrl, - `-map`, `0:v`, + `-i`, streamUrl ); + if (this.audioOnly !== true) { + ffmpegArgs.push( `-map`, `0:v` ); + } + ffmpegArgs.push( `-map`, `0:${audioIndex}`, `-c`, `copy`, `-muxdelay`, this.opts.concatMuxDelay, diff --git a/src/offline-player.js b/src/offline-player.js index 4cd0361..38d846e 100644 --- a/src/offline-player.js +++ b/src/offline-player.js @@ -19,6 +19,7 @@ class OfflinePlayer { context.channel.offlineSoundtrack = undefined; } this.ffmpeg = new FFMPEG(context.ffmpegSettings, context.channel); + this.ffmpeg.setAudioOnly(this.context.audioOnly); } cleanUp() { @@ -37,7 +38,7 @@ class OfflinePlayer { } else { ff = await ffmpeg.spawnOffline(duration); } - ff.pipe(outStream); + ff.pipe(outStream, {'end':false} ); ffmpeg.on('end', () => { emitter.emit('end'); @@ -45,8 +46,33 @@ class OfflinePlayer { ffmpeg.on('close', () => { emitter.emit('close'); }); - ffmpeg.on('error', (err) => { - emitter.emit('error', err); + ffmpeg.on('error', async (err) => { + //wish this code wasn't repeated. + if (! this.error ) { + console.log("Replacing failed stream with error stream"); + ff.unpipe(outStream); + ffmpeg.removeAllListeners('data'); + ffmpeg.removeAllListeners('end'); + ffmpeg.removeAllListeners('error'); + ffmpeg.removeAllListeners('close'); + ffmpeg = new FFMPEG(this.context.ffmpegSettings, this.context.channel); // Set the transcoder options + ffmpeg.setAudioOnly(this.context.audioOnly); + ffmpeg.on('close', () => { + emitter.emit('close'); + }); + ffmpeg.on('end', () => { + emitter.emit('end'); + }); + ffmpeg.on('error', (err) => { + emitter.emit('error', err ); + }); + + ff = await ffmpeg.spawnError('oops', 'oops', Math.min(duration, 60000) ); + ff.pipe(outStream); + } else { + emitter.emit('error', err); + } + }); return emitter; } catch(err) { diff --git a/src/plex-player.js b/src/plex-player.js index 9bde669..4874bbd 100644 --- a/src/plex-player.js +++ b/src/plex-player.js @@ -62,6 +62,7 @@ class PlexPlayer { this.plexTranscoder = plexTranscoder; let watermark = this.context.watermark; let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options + ffmpeg.setAudioOnly( this.context.audioOnly ); this.ffmpeg = ffmpeg; let streamDuration; if (typeof(lineupItem.streamDuration)!=='undefined') { @@ -97,13 +98,14 @@ class PlexPlayer { emitter.emit('close'); }); ffmpeg.on('error', async (err) => { - console.log("Replacing failed stream with error streram"); + console.log("Replacing failed stream with error stream"); ff.unpipe(outStream); ffmpeg.removeAllListeners('data'); ffmpeg.removeAllListeners('end'); ffmpeg.removeAllListeners('error'); ffmpeg.removeAllListeners('close'); ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options + ffmpeg.setAudioOnly(this.context.audioOnly); ffmpeg.on('close', () => { emitter.emit('close'); }); diff --git a/src/plexTranscoder.js b/src/plexTranscoder.js index ca40040..8106572 100644 --- a/src/plexTranscoder.js +++ b/src/plexTranscoder.js @@ -1,5 +1,6 @@ const { v4: uuidv4 } = require('uuid'); const axios = require('axios'); +const fs = require('fs'); class PlexTranscoder { constructor(clientId, server, settings, channel, lineupItem) { @@ -73,6 +74,14 @@ class PlexTranscoder { // Update transcode decision for session await this.getDecision(stream.directPlay); stream.streamUrl = (this.settings.streamPath === 'direct') ? this.file : this.plexFile; + if(this.settings.streamPath === 'direct') { + fs.access(this.file, fs.F_OK, (err) => { + if (err) { + throw Error("Can't access this file", err); + return + } + }) + } if (typeof(stream.streamUrl) == 'undefined') { throw Error("Direct path playback is not possible for this program because it was registered at a time when the direct path settings were not set. To fix this, you must either revert the direct path setting or rebuild this channel."); } @@ -291,10 +300,6 @@ lang=en` } async getDecisionUnmanaged(directPlay) { - if (this.settings.streamPath === 'direct') { - console.log("Skip get transcode decision because direct path is enabled"); - return; - } let res = await axios.get(`${this.server.uri}/video/:/transcode/universal/decision?${this.transcodingArgs}`, { headers: { Accept: 'application/json' } }) diff --git a/src/svg/dizquetv.svg b/src/svg/dizquetv.svg index 4224dbe..074a661 100644 --- a/src/svg/dizquetv.svg +++ b/src/svg/dizquetv.svg @@ -16,9 +16,9 @@ id="svg8" inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)" sodipodi:docname="dizquetv.svg" - inkscape:export-filename="/home/vx/dev/dizqueanimation/01.png" - inkscape:export-xdpi="245.75999" - inkscape:export-ydpi="245.75999"> + inkscape:export-filename="/home/vx/dev/pseudotv/resources/dizquetv.png" + inkscape:export-xdpi="240" + inkscape:export-ydpi="240"> image/svg+xml - + @@ -58,7 +58,7 @@ id="layer1" transform="translate(0,-244.08278)"> + style="opacity:1;fill:#080808;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" /> { + let concat = async (req, res, audioOnly) => { // Check if channel queried is valid if (typeof req.query.channel === 'undefined') { res.status(500).send("No Channel Specified") @@ -75,6 +75,7 @@ function video( channelDB , fillerDB, db) { console.log(`\r\nStream starting. Channel: ${channel.number} (${channel.name})`) let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options + ffmpeg.setAudioOnly(audioOnly); let stopped = false; function stop() { @@ -109,9 +110,16 @@ function video( channelDB , fillerDB, db) { }) let channelNum = parseInt(req.query.channel, 10) - let ff = await ffmpeg.spawnConcat(`http://localhost:${process.env.PORT}/playlist?channel=${channelNum}`); + let ff = await ffmpeg.spawnConcat(`http://localhost:${process.env.PORT}/playlist?channel=${channelNum}&audioOnly=${audioOnly}`); ff.pipe(res ); - }) + }; + router.get('/video', async(req, res) => { + return await concat(req, res, false); + } ); + router.get('/radio', async(req, res) => { + return await concat(req, res, true); + } ); + // Stream individual video to ffmpeg concat above. This is used by the server, NOT the client router.get('/stream', async (req, res) => { // Check if channel queried is valid @@ -119,6 +127,8 @@ function video( channelDB , fillerDB, db) { res.status(400).send("No Channel Specified") return } + let audioOnly = ("true" == req.query.audioOnly); + console.log(`/stream audioOnly=${audioOnly}`); let session = parseInt(req.query.session); let m3u8 = (req.query.m3u8 === '1'); let number = parseInt(req.query.channel); @@ -296,6 +306,7 @@ function video( channelDB , fillerDB, db) { channel: combinedChannel, db: db, m3u8: m3u8, + audioOnly : audioOnly, } let player = new ProgramPlayer(playerContext); @@ -416,6 +427,7 @@ function video( channelDB , fillerDB, db) { let ffmpegSettings = db['ffmpeg-settings'].find()[0] let sessionId = StreamCount++; + let audioOnly = ("true" == req.query.audioOnly); if ( (ffmpegSettings.enableFFMPEGTranscoding === true) @@ -423,12 +435,14 @@ function video( channelDB , fillerDB, db) { && (ffmpegSettings.normalizeAudioCodec === true) && (ffmpegSettings.normalizeResolution === true) && (ffmpegSettings.normalizeAudio === true) + && (audioOnly !== true) /* loading screen is pointless in audio mode (also for some reason it makes it fail when codec is aac, and I can't figure out why) */ ) { - data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}&first=0&session=${sessionId}'\n`; + //loading screen + data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}&first=0&session=${sessionId}&audioOnly=${audioOnly}'\n`; } - data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}&first=1&session=${sessionId}'\n` + data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}&first=1&session=${sessionId}&audioOnly=${audioOnly}'\n` for (var i = 0; i < maxStreamsToPlayInARow - 1; i++) { - data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}&session=${sessionId}'\n` + data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}&session=${sessionId}&audioOnly=${audioOnly}'\n` } res.send(data) diff --git a/web/public/index.html b/web/public/index.html index a495695..3b20003 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -13,7 +13,9 @@