diff --git a/index.js b/index.js index f7dae1d..b0150da 100644 --- a/index.js +++ b/index.js @@ -15,7 +15,16 @@ const Plex = require('./src/plex'); const channelCache = require('./src/channel-cache'); const constants = require('./src/constants') -console.log("dizqueTV Version: " + constants.VERSION_NAME) +console.log( +` \\ + dizqueTV ${constants.VERSION_NAME} +.------------. +|###:::||| o | +|###:::||| | +'###:::||| o | +'------------' +`); + for (let i = 0, l = process.argv.length; i < l; i++) { if ((process.argv[i] === "-p" || process.argv[i] === "--port") && i + 1 !== l) @@ -37,7 +46,7 @@ if (!fs.existsSync(process.env.DATABASE)) { if(!fs.existsSync(path.join(process.env.DATABASE, 'images'))) fs.mkdirSync(path.join(process.env.DATABASE, 'images')) -db.connect(process.env.DATABASE, ['channels', 'plex-servers', 'ffmpeg-settings', 'plex-settings', 'xmltv-settings', 'hdhr-settings', 'db-version']) +db.connect(process.env.DATABASE, ['channels', 'plex-servers', 'ffmpeg-settings', 'plex-settings', 'xmltv-settings', 'hdhr-settings', 'db-version', 'client-id']) initDB(db) @@ -65,7 +74,7 @@ let xmltvInterval = { if (plexServers[i].arChannels && channels.length !== 0) plex.RefreshChannels(channels, dvrs).then(() => { }, (err) => { console.error(err, i) }) }).catch( (err) => { - console.error("There was an error when fetching Plex DVRs. This means dizqueTV couldn't trigger Plex to update its TV guide." + err); + console.log("Couldn't tell Plex to refresh channels for some reason."); }); } }, (err) => { @@ -123,6 +132,9 @@ function initDB(db) { 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/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/loading-screen.png b/resources/loading-screen.png new file mode 100644 index 0000000..8bfda9a Binary files /dev/null and b/resources/loading-screen.png differ diff --git a/src/database-migration.js b/src/database-migration.js index ce85c81..e17ad4d 100644 --- a/src/database-migration.js +++ b/src/database-migration.js @@ -17,15 +17,29 @@ * but with time it will be worth it, really. * ***/ -const TARGET_VERSION = 300; + const TARGET_VERSION = 400; const STEPS = [ // [v, v2, x] : if the current version is v, call x(db), and version becomes v2 [ 0, 100, (db) => basicDB(db) ], [ 100, 200, (db) => commercialsRemover(db) ], [ 200, 300, (db) => appNameChange(db) ], + [ 300, 400, (db) => createDeviceId(db) ], ] +const { v4: uuidv4 } = require('uuid'); + +function createDeviceId(db) { + let deviceId = db['client-id'].find(); + if (deviceId.length == 0) { + let clientId = uuidv4().replace(/-/g,"").slice(0,16) + "-org-dizquetv-" + process.platform + let dev = { + clientId: clientId, + } + db['client-id'].save( dev ); + } +} + function appNameChange(db) { let xmltv = db['xmltv-settings'].find() @@ -66,7 +80,7 @@ function basicDB(db) { maxPlayableResolution: "1920x1080", maxTranscodeResolution: "1920x1080", videoCodecs: 'h264,hevc,mpeg2video', - audioCodecs: 'ac3', + audioCodecs: 'ac3,aac', maxAudioChannels: '2', audioBoost: '100', enableSubtitles: false, @@ -105,7 +119,7 @@ function basicDB(db) { let hdhrSettings = db['hdhr-settings'].find() if (hdhrSettings.length === 0) { db['hdhr-settings'].save({ - tunerCount: 1, + tunerCount: 2, autoDiscovery: true }) } diff --git a/src/ffmpeg.js b/src/ffmpeg.js index 40147e4..3dfa9f5 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -73,8 +73,9 @@ class FFMPEG extends events.EventEmitter { this.spawn( {errorTitle: 'offline'}, streamStats, undefined, `${duration}ms`, true, false, 'offline', false); } async spawn(streamUrl, streamStats, startTime, duration, limitRead, enableIcon, type, isConcatPlaylist) { + let ffmpegArgs = [ - `-threads`, this.opts.threads, + `-threads`, isConcatPlaylist? 1 : this.opts.threads, `-fflags`, `+genpts+discardcorrupt+igndts`]; if (limitRead === true) @@ -94,6 +95,17 @@ class FFMPEG extends events.EventEmitter { //TODO: Do something about missing audio stream if (!isConcatPlaylist) { + let inputFiles = 0; + let audioFile = -1; + let videoFile = -1; + let overlayFile = -1; + if ( typeof(streamUrl.errorTitle) === 'undefined') { + ffmpegArgs.push(`-i`, streamUrl); + videoFile = inputFiles++; + audioFile = videoFile; + } + + // When we have an individual stream, there is a pipeline of possible // filters to apply. // @@ -108,8 +120,8 @@ class FFMPEG extends events.EventEmitter { // Initially, videoComplex does nothing besides assigning the label // to the input stream var videoIndex = 'v'; - var audioComplex = `;[0:${audioIndex}]anull[audio]`; - var videoComplex = `;[0:${videoIndex}]null[video]`; + var audioComplex = `;[${audioFile}:${audioIndex}]anull[audio]`; + var videoComplex = `;[${videoFile}:${videoIndex}]null[video]`; // Depending on the options we will apply multiple filters // each filter modifies the current video stream. Adds a filter to // the videoComplex variable. The result of the filter becomes the @@ -197,11 +209,10 @@ class FFMPEG extends events.EventEmitter { audioComplex += ';[audioy]arealtime[audiox]'; currentVideo = "[videox]"; currentAudio = "[audiox]"; - } else { - ffmpegArgs.push(`-i`, streamUrl); } if (doOverlay) { ffmpegArgs.push(`-i`, `${this.channel.icon}` ); + overlayFile = inputFiles++; } // Resolution fix: Add scale filter, current stream becomes [siz] @@ -223,7 +234,7 @@ class FFMPEG extends events.EventEmitter { if (this.channel.iconDuration > 0) icnDur = `:enable='between(t,0,${this.channel.iconDuration})'` - videoComplex += `;[1:v]scale=${this.channel.iconWidth}:-1[icn];${currentVideo}[icn]overlay=${posAry[this.channel.iconPosition]}${icnDur}[comb]` + videoComplex += `;[${overlayFile}:v]scale=${this.channel.iconWidth}:-1[icn];${currentVideo}[icn]overlay=${posAry[this.channel.iconPosition]}${icnDur}[comb]` currentVideo = '[comb]'; } if (this.volumePercent != 100) { @@ -250,14 +261,14 @@ class FFMPEG extends events.EventEmitter { transcodeVideo = true; //this is useful so that it adds some lines below filterComplex += videoComplex; } else { - currentVideo = `0:${videoIndex}`; + currentVideo = `${videoFile}:${videoIndex}`; } // same with audio: if (currentAudio != '[audio]') { transcodeAudio = true; filterComplex += audioComplex; } else { - currentAudio = `0:${audioIndex}`; + currentAudio = `${audioFile}:${audioIndex}`; } //If there is a filter complex, add it. @@ -309,7 +320,7 @@ class FFMPEG extends events.EventEmitter { } else { //Concat stream is simpler and should always copy the codec ffmpegArgs.push( - `-probesize`, `100000000`, + `-probesize`, 32 /*`100000000`*/, `-i`, streamUrl, `-map`, `0:v`, `-map`, `0:${audioIndex}`, diff --git a/src/offline-player.js b/src/offline-player.js index 3ab458d..00d5b20 100644 --- a/src/offline-player.js +++ b/src/offline-player.js @@ -13,6 +13,11 @@ class OfflinePlayer { constructor(error, context) { this.context = context; this.error = error; + if (context.isLoading === true) { + context.channel = JSON.parse( JSON.stringify(context.channel) ); + context.channel.offlinePicture = `http://localhost:${process.env.PORT}/images/loading-screen.png`; + context.channel.offlineSoundtrack = undefined; + } this.ffmpeg = new FFMPEG(context.ffmpegSettings, context.channel); } diff --git a/src/plex-player.js b/src/plex-player.js index 985f7f6..d1c4e59 100644 --- a/src/plex-player.js +++ b/src/plex-player.js @@ -10,6 +10,8 @@ const EventEmitter = require('events'); const helperFuncs = require('./helperFuncs') const FFMPEG = require('./ffmpeg') +let USED_CLIENTS = {}; + class PlexPlayer { constructor(context) { @@ -17,9 +19,17 @@ class PlexPlayer { this.ffmpeg = null; this.plexTranscoder = null; this.killed = false; + let coreClientId = this.context.db['client-id'].find()[0].clientId; + let i = 0; + while ( USED_CLIENTS[coreClientId+"-"+i]===true) { + i++; + } + this.clientId = coreClientId+"-"+i; + USED_CLIENTS[this.clientId] = true; } cleanUp() { + USED_CLIENTS[this.clientId] = false; this.killed = true; if (this.plexTranscoder != null) { this.plexTranscoder.stopUpdatingPlex(); @@ -39,7 +49,7 @@ class PlexPlayer { try { let plexSettings = db['plex-settings'].find()[0]; - let plexTranscoder = new PlexTranscoder(plexSettings, channel, lineupItem); + let plexTranscoder = new PlexTranscoder(this.clientId, plexSettings, channel, lineupItem); this.plexTranscoder = plexTranscoder; let enableChannelIcon = this.context.enableChannelIcon; let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options diff --git a/src/plexTranscoder.js b/src/plexTranscoder.js index a945404..1fb8dd8 100644 --- a/src/plexTranscoder.js +++ b/src/plexTranscoder.js @@ -2,12 +2,12 @@ const { v4: uuidv4 } = require('uuid'); const axios = require('axios'); class PlexTranscoder { - constructor(settings, channel, lineupItem) { + constructor(clientId, settings, channel, lineupItem) { this.session = uuidv4() this.device = "channel-" + channel.number; this.deviceName = this.device; - this.clientIdentifier = this.session.replace(/-/g,"").slice(0,16) + "-org-dizquetv-" + process.platform; + this.clientIdentifier = clientId; this.product = "dizqueTV"; this.settings = settings @@ -60,7 +60,10 @@ class PlexTranscoder { stream.directPlay = true; } } - if (stream.directPlay) { + if (stream.directPlay || this.isAV1() ) { + if (! stream.directPlay) { + this.log("Plex doesn't support av1, so we are forcing direct play, including for audio because otherwise plex breaks the stream.") + } this.log("Direct play forced or native paths enabled") stream.directPlay = true this.setTranscodingArgs(stream.directPlay, true, false) @@ -78,14 +81,15 @@ class PlexTranscoder { await this.getDecision(stream.directPlay); stream.streamUrl = `${this.transcodeUrlBase}${this.transcodingArgs}` } else { + //This case sounds complex. Apparently plex is sending us just the audio, so we would need to get the video in a separate stream. this.log("Decision: Direct stream. Audio is being transcoded") + stream.separateVideoStream = (this.settings.streamPath === 'direct') ? this.file : this.plexFile; stream.streamUrl = `${this.transcodeUrlBase}${this.transcodingArgs}` } stream.streamStats = this.getVideoStats(); // use correct audio stream if direct play - let audioIndex = await this.getAudioIndex(); - stream.streamStats.audioIndex = (stream.directPlay) ? audioIndex : 'a' + stream.streamStats.audioIndex = (stream.directPlay) ? ( await this.getAudioIndex() ) : 'a' this.log(stream) @@ -178,6 +182,14 @@ lang=en` } } + isAV1() { + try { + return this.getVideoStats().videoCodec === 'av1'; + } catch (e) { + return false; + } + } + isDirectPlay() { try { return this.getVideoStats().videoDecision === "copy" && this.getVideoStats().audioDecision === "copy"; diff --git a/src/program-player.js b/src/program-player.js index 2097522..f3354ab 100644 --- a/src/program-player.js +++ b/src/program-player.js @@ -32,6 +32,11 @@ class ProgramPlayer { if (program.err instanceof Error) { console.log("About to play error stream"); this.delegate = new OfflinePlayer(true, context); + } else if (program.type === 'loading') { + console.log("About to play loading stream"); + /* loading */ + context.isLoading = true; + this.delegate = new OfflinePlayer(false, context); } else if (program.type === 'offline') { console.log("About to play offline stream"); /* offline */ diff --git a/src/svg/loading-screen.svg b/src/svg/loading-screen.svg new file mode 100644 index 0000000..cc65e82 --- /dev/null +++ b/src/svg/loading-screen.svg @@ -0,0 +1,107 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + Loading... + + diff --git a/src/video.js b/src/video.js index e8a331c..2c4a886 100644 --- a/src/video.js +++ b/src/video.js @@ -143,6 +143,11 @@ function video(db) { res.status(404).send("Channel doesn't exist") return } + let isLoading = false; + if ( (typeof req.query.first !== 'undefined') && (req.query.first=='0') ) { + isLoading = true; + } + let isFirst = false; if ( (typeof req.query.first !== 'undefined') && (req.query.first=='1') ) { isFirst = true; @@ -164,7 +169,14 @@ function video(db) { // Get video lineup (array of video urls with calculated start times and durations.) let t0 = (new Date()).getTime(); let lineupItem = channelCache.getCurrentLineupItem( channel.number, t0); - if (lineupItem == null) { + if (isLoading) { + lineupItem = { + type: 'loading', + streamDuration: 1000, + duration: 1000, + start: 0, + }; + } else if (lineupItem == null) { let prog = helperFuncs.getCurrentProgramAndTimeElapsed(t0, channel) if (prog.program.isOffline && channel.programs.length == 1) { @@ -207,7 +219,9 @@ function video(db) { } console.log("========================================================="); - channelCache.recordPlayback(channel.number, t0, lineupItem); + if (! isLoading) { + channelCache.recordPlayback(channel.number, t0, lineupItem); + } let playerContext = { lineupItem : lineupItem, @@ -280,6 +294,41 @@ function video(db) { }); }); + + router.get('/m3u8', (req, res) => { + res.type('text') + + // Check if channel queried is valid + if (typeof req.query.channel === 'undefined') { + res.status(500).send("No Channel Specified") + return + } + + let channelNum = parseInt(req.query.channel, 10) + let channel = channelCache.getChannelConfig(db, channelNum ); + if (channel.length === 0) { + res.status(500).send("Channel doesn't exist") + return + } + + // Maximum number of streams to concatinate beyond channel starting + // If someone passes this number then they probably watch too much television + let maxStreamsToPlayInARow = 100; + + var data = "#EXTM3U\n" + + let ffmpegSettings = db['ffmpeg-settings'].find()[0] + + if ( ffmpegSettings.enableFFMPEGTranscoding === true) { + data += `${req.protocol}://${req.get('host')}/stream?channel=${channelNum}&first=0\n`; + } + data += `${req.protocol}://${req.get('host')}/stream?channel=${channelNum}&first=1\n` + for (var i = 0; i < maxStreamsToPlayInARow - 1; i++) { + data += `${req.protocol}://${req.get('host')}/stream?channel=${channelNum}\n` + } + + res.send(data) + }) router.get('/playlist', (req, res) => { res.type('text') @@ -302,6 +351,11 @@ function video(db) { var data = "ffconcat version 1.0\n" + let ffmpegSettings = db['ffmpeg-settings'].find()[0] + + if ( ffmpegSettings.enableFFMPEGTranscoding === true) { + data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}&first=0'\n`; + } data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}&first=1'\n` for (var i = 0; i < maxStreamsToPlayInARow - 1; i++) { data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}'\n`