diff --git a/index.js b/index.js index 30fc1e4..bbab982 100644 --- a/index.js +++ b/index.js @@ -212,6 +212,10 @@ function initDB(db, channelDB) { let data = fs.readFileSync(path.resolve(path.join(__dirname, 'resources/generic-offline-screen.png'))) fs.writeFileSync(process.env.DATABASE + '/images/generic-offline-screen.png', data) } + if (!fs.existsSync(process.env.DATABASE + '/images/generic-music-screen.png')) { + let data = fs.readFileSync(path.resolve(path.join(__dirname, 'resources/generic-music-screen.png'))) + fs.writeFileSync(process.env.DATABASE + '/images/generic-music-screen.png', data) + } if (!fs.existsSync(process.env.DATABASE + '/images/loading-screen.png')) { let data = fs.readFileSync(path.resolve(path.join(__dirname, 'resources/loading-screen.png'))) fs.writeFileSync(process.env.DATABASE + '/images/loading-screen.png', data) diff --git a/resources/generic-music-screen.png b/resources/generic-music-screen.png new file mode 100644 index 0000000..4efa71f Binary files /dev/null and b/resources/generic-music-screen.png differ diff --git a/src/ffmpeg.js b/src/ffmpeg.js index 8fcd3cd..e264cf6 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -111,6 +111,7 @@ class FFMPEG extends events.EventEmitter { let ffmpegArgs = [ `-threads`, isConcatPlaylist? 1 : this.opts.threads, `-fflags`, `+genpts+discardcorrupt+igndts`]; + let stillImage = false; if ( (limitRead === true) @@ -185,28 +186,57 @@ class FFMPEG extends events.EventEmitter { } // prepare input streams - if ( typeof(streamUrl.errorTitle) !== 'undefined') { + if ( ( typeof(streamUrl.errorTitle) !== 'undefined') || (streamStats.audioOnly) ) { doOverlay = false; //never show icon in the error screen // for error stream, we have to generate the input as well this.apad = false; //all of these generate audio correctly-aligned to video so there is no need for apad this.audioChannelsSampleRate = true; //we'll need these - if (this.ensureResolution) { - //all of the error strings already choose the resolution to - //match iW x iH , so with this we save ourselves a second - // scale filter - iW = this.wantedW; - iH = this.wantedH; + //all of the error strings already choose the resolution to + //match iW x iH , so with this we save ourselves a second + // scale filter + iW = this.wantedW; + iH = this.wantedH; + + if (this.audioOnly !== true) { + ffmpegArgs.push("-r" , "24"); + let pic = null; + + //does an image to play exist? + if ( + (typeof(streamUrl.errorTitle) === 'undefined') + && + (streamStats.audioOnly) + ) { + pic = streamStats.placeholderImage; + } else if ( streamUrl.errorTitle == 'offline') { + pic = `${this.channel.offlinePicture}`; + } else if ( this.opts.errorScreen == 'pic' ) { + pic = `${this.errorPicturePath}`; } - if ( this.audioOnly !== true) { - ffmpegArgs.push("-r" , "24"); - if ( streamUrl.errorTitle == 'offline' ) { + if (pic != null) { ffmpegArgs.push( - '-loop', '1', - '-i', `${this.channel.offlinePicture}`, + '-i', pic, ); - videoComplex = `;[${inputFiles++}:0]loop=loop=-1:size=1:start=0[looped];[looped]scale=${iW}:${iH}[videoy];[videoy]realtime[videox]`; + if ( + (typeof duration === 'undefined') + && + (typeof(streamStats.duration) !== 'undefined' ) + ) { + //add 150 milliseconds just in case, exact duration seems to cut out the last bits of music some times. + duration = `${streamStats.duration + 150}ms`; + } + videoComplex = `;[${inputFiles++}:0]loop=loop=-1:size=1:start=0[looped]`; + videoComplex += `;[looped]format=yuv420p[formatted]`; + let stream = "scaled"; + videoComplex +=`;[formatted]scale=w=${iW}:h=${iH}:force_original_aspect_ratio=1[scaled]`; + if (this.ensureResolution) { + stream = "padded"; + videoComplex += `;[scaled]pad=${iW}:${iH}:(ow-iw)/2:(oh-ih)/2[padded]`; + } + videoComplex +=`;[${stream}]realtime[videox]`; + stillImage = true; } else if (this.opts.errorScreen == 'static') { ffmpegArgs.push( '-f', 'lavfi', @@ -232,23 +262,17 @@ class FFMPEG extends events.EventEmitter { 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') { + } else { //blank ffmpegArgs.push( '-f', 'lavfi', '-i', `color=c=black:s=${iW}x${iH}` ); inputFiles++; videoComplex = `;realtime[videox]`; - } else {//'pic' - ffmpegArgs.push( - '-loop', '1', - '-i', `${this.errorPicturePath}`, - ); - inputFiles++; - videoComplex = `;[${videoFile+1}:0]scale=${iW}:${iH}[videoy];[videoy]realtime[videox]`; } } let durstr = `duration=${streamStats.duration}ms`; + if (typeof(streamUrl.errorTitle) !== 'undefined') { //silent audioComplex = `;aevalsrc=0:${durstr}[audioy]`; if ( streamUrl.errorTitle == 'offline' ) { @@ -280,8 +304,9 @@ class FFMPEG extends events.EventEmitter { ffmpegArgs.push('-pix_fmt' , 'yuv420p' ); } audioComplex += ';[audioy]arealtime[audiox]'; - currentVideo = "[videox]"; currentAudio = "[audiox]"; + } + currentVideo = "[videox]"; } if (doOverlay) { if (watermark.animated === true) { @@ -297,9 +322,13 @@ class FFMPEG extends events.EventEmitter { let algo = this.opts.scalingAlgorithm; let resizeMsg = ""; if ( + (!streamStats.audioOnly) + && + ( (this.ensureResolution && ( streamStats.anamorphic || (iW != this.wantedW || iH != this.wantedH) ) ) || isLargerResolution(iW, iH, this.wantedW, this.wantedH) + ) ) { //scaler stuff, need to change the size of the video and also add bars // calculate wanted aspect ratio @@ -444,6 +473,9 @@ class FFMPEG extends events.EventEmitter { `-c:v`, (transcodeVideo ? this.opts.videoEncoder : 'copy'), `-sc_threshold`, `1000000000`, ); + if (stillImage) { + ffmpegArgs.push('-tune', 'stillimage'); + } } ffmpegArgs.push( '-map', currentAudio, @@ -506,14 +538,14 @@ class FFMPEG extends events.EventEmitter { `service_provider="dizqueTV"`, `-metadata`, `service_name="${this.channel.name}"`, - `-f`, `mpegts`); + ); - //t should be before output + //t should be before -f if (typeof duration !== 'undefined') { - ffmpegArgs.push(`-t`, duration) + ffmpegArgs.push(`-t`, `${duration}`); } - ffmpegArgs.push(`pipe:1`) + ffmpegArgs.push(`-f`, `mpegts`, `pipe:1`) let doLogs = this.opts.logFfmpeg && !isConcatPlaylist; if (this.hasBeenKilled) { @@ -521,6 +553,7 @@ class FFMPEG extends events.EventEmitter { } this.ffmpeg = spawn(this.ffmpegPath, ffmpegArgs, { stdio: ['ignore', 'pipe', (doLogs?process.stderr:"ignore") ] } ); if (this.hasBeenKilled) { + console.log("Send SIGKILL to ffmpeg"); this.ffmpeg.kill("SIGKILL"); return; } diff --git a/src/plex-player.js b/src/plex-player.js index 4874bbd..d21bcdc 100644 --- a/src/plex-player.js +++ b/src/plex-player.js @@ -124,11 +124,7 @@ class PlexPlayer { return emitter; } catch(err) { - if (err instanceof Error) { - throw err; - } else { - return Error("Error when playing plex program: " + JSON.stringify(err) ); - } + return Error("Error when playing plex program: " + JSON.stringify(err) ); } } } diff --git a/src/plexTranscoder.js b/src/plexTranscoder.js index 8106572..2c3fbc6 100644 --- a/src/plexTranscoder.js +++ b/src/plexTranscoder.js @@ -35,6 +35,10 @@ class PlexTranscoder { this.updateInterval = 30000 this.updatingPlex = undefined this.playState = "stopped" + this.albumArt = { + attempted : false, + path: null, + } } async getStream(deinterlace) { @@ -53,7 +57,7 @@ class PlexTranscoder { } else { try { this.log("Setting transcoding parameters") - this.setTranscodingArgs(stream.directPlay, true, deinterlace) + this.setTranscodingArgs(stream.directPlay, true, deinterlace, true) await this.getDecision(stream.directPlay); if (this.isDirectPlay()) { stream.directPlay = true; @@ -110,13 +114,14 @@ class PlexTranscoder { return stream } - setTranscodingArgs(directPlay, directStream, deinterlace) { + setTranscodingArgs(directPlay, directStream, deinterlace, firstTry) { let resolution = (directStream) ? this.settings.maxPlayableResolution : this.settings.maxTranscodeResolution let bitrate = (directStream) ? this.settings.directStreamBitrate : this.settings.transcodeBitrate let mediaBufferSize = (directStream) ? this.settings.mediaBufferSize : this.settings.transcodeMediaBufferSize let subtitles = (this.settings.enableSubtitles) ? "burn" : "none" // subtitle options: burn, none, embedded, sidecar let streamContainer = "mpegts" // Other option is mkv, mkv has the option of copying it's subs for later processing - let isDirectPlay = (directPlay) ? '1' : '0' + let isDirectPlay = (directPlay) ? '1' : (firstTry? '': '0'); + let hasMDE = '1'; let videoQuality=`100` // Not sure how this applies, maybe this works if maxVideoBitrate is not set let profileName=`Generic` // Blank profile, everything is specified through X-Plex-Client-Profile-Extra @@ -166,7 +171,7 @@ X-Plex-Token=${this.server.accessToken}&\ X-Plex-Client-Profile-Extra=${clientProfile_enc}&\ protocol=${this.settings.streamProtocol}&\ Connection=keep-alive&\ -hasMDE=1&\ +hasMDE=${hasMDE}&\ path=${this.key}&\ mediaIndex=0&\ partIndex=0&\ @@ -206,6 +211,9 @@ lang=en` isDirectPlay() { try { + if (this.getVideoStats().audioOnly) { + return this.getVideoStats().audioDecision === "copy"; + } return this.getVideoStats().videoDecision === "copy" && this.getVideoStats().audioDecision === "copy"; } catch (e) { console.log("Error at decision:" , e); @@ -217,7 +225,6 @@ lang=en` let ret = {} try { let streams = this.decisionJson.MediaContainer.Metadata[0].Media[0].Part[0].Stream - ret.duration = parseFloat( this.decisionJson.MediaContainer.Metadata[0].Media[0].Part[0].duration ); streams.forEach(function (_stream, $index) { // Video @@ -257,6 +264,14 @@ lang=en` } catch (e) { console.log("Error at decision:" , e); } + if (typeof(ret.videoCodec) === 'undefined') { + ret.audioOnly = true; + ret.placeholderImage = (this.albumArt.path != null) ? + ret.placeholderImage = this.albumArt.path + : + ret.placeholderImage = `http://localhost:${process.env.PORT}/images/generic-music-screen.png` + ; + } this.log("Current video stats:") this.log(ret) @@ -300,23 +315,56 @@ lang=en` } async getDecisionUnmanaged(directPlay) { - let res = await axios.get(`${this.server.uri}/video/:/transcode/universal/decision?${this.transcodingArgs}`, { + let url = `${this.server.uri}/video/:/transcode/universal/decision?${this.transcodingArgs}`; + let res = await axios.get(url, { headers: { Accept: 'application/json' } }) this.decisionJson = res.data; - this.log("Recieved transcode decision:") + this.log("Received transcode decision:"); this.log(res.data) // Print error message if transcode not possible // TODO: handle failure better - let transcodeDecisionCode = res.data.MediaContainer.transcodeDecisionCode - if (!(directPlay || transcodeDecisionCode == "1001")) { + let transcodeDecisionCode = res.data.MediaContainer.transcodeDecisionCode; + if ( + ( typeof(transcodeDecisionCode) === 'undefined' ) + ) { + this.decisionJson.MediaContainer.transcodeDecisionCode = 'novideo'; + console.log("Audio-only file detected"); + await this.tryToGetAlbumArt(); + } else if (!(directPlay || transcodeDecisionCode == "1001")) { console.log(`IMPORTANT: Recieved transcode decision code ${transcodeDecisionCode}! Expected code 1001.`) console.log(`Error message: '${res.data.MediaContainer.transcodeDecisionText}'`) } } + async tryToGetAlbumArt() { + try { + if(this.albumArt.attempted ) { + return; + } + this.albumArt.attempted = true; + + this.log("Try to get album art:"); + let url = `${this.server.uri}${this.key}?${this.transcodingArgs}`; + let res = await axios.get(url, { + headers: { Accept: 'application/json' } + }); + let mediaContainer = res.data.MediaContainer; + if (typeof(mediaContainer) !== 'undefined') { + for( let i = 0; i < mediaContainer.Metadata.length; i++) { + console.log("got art: " + mediaContainer.Metadata[i].thumb ); + this.albumArt.path = `${this.server.uri}${mediaContainer.Metadata[i].thumb}?${this.transcodingArgs}`; + } + } + } catch (err) { + console.error("Error when getting album art", err); + } + + + } + async getDecision(directPlay) { try { await this.getDecisionUnmanaged(directPlay); diff --git a/src/svg/generic-music-screen.svg b/src/svg/generic-music-screen.svg new file mode 100644 index 0000000..8ba273b --- /dev/null +++ b/src/svg/generic-music-screen.svg @@ -0,0 +1,115 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + +