diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..955be31 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at vexorian@gmail.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/README.md b/README.md index dbd87be..5d6c6f0 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# dizqueTV 0.0.60 +# dizqueTV 0.0.61 ![Discord](https://img.shields.io/discord/711313431457693727?logo=discord&logoColor=fff&style=flat-square) ![GitHub top language](https://img.shields.io/github/languages/top/vexorian/dizquetv?logo=github&style=flat-square) ![Docker Pulls](https://img.shields.io/docker/pulls/vexorian/dizquetv?logo=docker&logoColor=fff&style=flat-square) Create live TV channel streams from media on your Plex servers. 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/constants.js b/src/constants.js index 817c712..4a3a1c9 100644 --- a/src/constants.js +++ b/src/constants.js @@ -2,5 +2,5 @@ module.exports = { SLACK: 9999, TVGUIDE_MAXIMUM_PADDING_LENGTH_MS: 30*60*1000, - VERSION_NAME: "0.0.60" + VERSION_NAME: "0.0.61" } 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 25a4245..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}`, @@ -321,7 +332,7 @@ class FFMPEG extends events.EventEmitter { ffmpegArgs.push(`-metadata`, `service_provider="dizqueTV"`, `-metadata`, - `service_name="${this.channel.name}`, + `service_name="${this.channel.name}"`, `-f`, `mpegts`); //t should be before output 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` diff --git a/web/directives/channel-config.js b/web/directives/channel-config.js index 41adfbe..0d542ad 100644 --- a/web/directives/channel-config.js +++ b/web/directives/channel-config.js @@ -796,6 +796,13 @@ module.exports = function ($timeout, $location) { selectedPrograms[i].commercials = [] scope.channel.programs = scope.channel.programs.concat(selectedPrograms) updateChannelDuration() + setTimeout( + () => { + scope.$apply( () => { + scope.minProgramIndex = Math.max(0, scope.channel.programs.length - 100); + } ) + }, 0 + ); } scope.selectProgram = (index) => { scope.selectedProgram = index; diff --git a/web/directives/plex-library.js b/web/directives/plex-library.js index c906a62..2e32c3f 100644 --- a/web/directives/plex-library.js +++ b/web/directives/plex-library.js @@ -13,7 +13,17 @@ module.exports = function (plex, dizquetv, $timeout) { if ( typeof(scope.limit) == 'undefined') { scope.limit = 1000000000; } + scope.pending = 0; + scope.allowedIndexes = []; + for (let i = -10; i <= -1; i++) { + scope.allowedIndexes.push(i); + } scope.selection = [] + scope.wait = (t) => { + return new Promise((resolve, reject) => { + $timeout(resolve,t); + }); + } scope.selectServer = function (server) { scope.plexServer = server updateLibrary(server) @@ -31,15 +41,36 @@ module.exports = function (plex, dizquetv, $timeout) { scope.visible = false } } - scope.selectItem = (item) => { - return new Promise((resolve, reject) => { - $timeout(async () => { - item.streams = await plex.getStreams(scope.plexServer, item.key) - scope.selection.push(JSON.parse(angular.toJson(item))) - scope.$apply() - resolve() - }, 0) - }) + scope.selectItem = async (item, single) => { + await scope.wait(0); + scope.pending += 1; + try { + item.streams = await plex.getStreams(scope.plexServer, item.key) + scope.selection.push(JSON.parse(angular.toJson(item))) + } finally { + scope.pending -= 1; + } + if (single) { + scope.$apply() + } + } + scope.selectLibrary = async (library) => { + await scope.fillNestedIfNecessary(library); + let p = library.nested.length; + scope.pending += library.nested.length; + try { + for (let i = 0; i < library.nested.length; i++) { + //await scope.selectItem( library.nested[i] ); + if (library.nested[i].type !== 'collection') { + await scope.selectShow( library.nested[i] ); + } + scope.pending -= 1; + p -= 1; + } + } finally { + scope.pending -= p; + scope.$apply() + } } dizquetv.getPlexServers().then((servers) => { if (servers.length === 0) { @@ -66,10 +97,14 @@ module.exports = function (plex, dizquetv, $timeout) { console.log(err) }) } - scope.getNested = (list) => { + scope.fillNestedIfNecessary = async (x, isLibrary) => { + if ( (typeof(x.nested) === 'undefined') && (x.type !== 'collection') ) { + x.nested = await plex.getNested(scope.plexServer, x.key, isLibrary); + } + } + scope.getNested = (list, isLibrary) => { $timeout(async () => { - if (typeof list.nested === 'undefined') - list.nested = await plex.getNested(scope.plexServer, list.key) + await scope.fillNestedIfNecessary(list, isLibrary); list.collapse = !list.collapse scope.$apply() }, 0) @@ -78,34 +113,53 @@ module.exports = function (plex, dizquetv, $timeout) { scope.selectSeason = (season) => { return new Promise((resolve, reject) => { $timeout(async () => { - if (typeof season.nested === 'undefined') - season.nested = await plex.getNested(scope.plexServer, season.key) - for (let i = 0, l = season.nested.length; i < l; i++) - await scope.selectItem(season.nested[i]) - scope.$apply() - resolve() + await scope.fillNestedIfNecessary(season); + let p = season.nested.length; + scope.pending += p; + try { + for (let i = 0, l = season.nested.length; i < l; i++) { + await scope.selectItem(season.nested[i], false) + scope.pending -= 1; + p -= 1; + } + resolve(); + } catch (e) { + reject(e); + } finally { + scope.pending -= p; + scope.$apply() + } }, 0) }) } scope.selectShow = (show) => { return new Promise((resolve, reject) => { $timeout(async () => { - if (typeof show.nested === 'undefined') - show.nested = await plex.getNested(scope.plexServer, show.key) - for (let i = 0, l = show.nested.length; i < l; i++) - await scope.selectSeason(show.nested[i]) - scope.$apply() - resolve() + await scope.fillNestedIfNecessary(show); + let p = show.nested.length; + scope.pending += p; + try { + for (let i = 0, l = show.nested.length; i < l; i++) { + await scope.selectSeason(show.nested[i]) + scope.pending -= 1; + p -= 1; + } + resolve(); + } catch (e) { + reject(e); + } finally { + scope.pending -= p; + scope.$apply() + } }, 0) }) } scope.selectPlaylist = async (playlist) => { return new Promise((resolve, reject) => { $timeout(async () => { - if (typeof playlist.nested === 'undefined') - playlist.nested = await plex.getNested(scope.plexServer, playlist.key) + await scope.fillNestedIfNecessary(playlist); for (let i = 0, l = playlist.nested.length; i < l; i++) - await scope.selectItem(playlist.nested[i]) + await scope.selectItem(playlist.nested[i], false) scope.$apply() resolve() }, 0) diff --git a/web/public/style.css b/web/public/style.css index 23d50e8..8c58dcc 100644 --- a/web/public/style.css +++ b/web/public/style.css @@ -72,6 +72,7 @@ font-size: 80%; font-weight: 400; font-family: monospace; + white-space: nowrap; } .program-row { align-items: start; @@ -92,4 +93,26 @@ font-size: .875rem; line-height: 1.0; margin-right: 0.5rem; +} + +.loader { + width: 1em; + height: 1em; + border: 0.3em solid #f3f3f3; + border-radius: 50%; + display: inline-block; + border-top: 0.25em solid #3498db; + -webkit-animation: spin 2s linear infinite; /* Safari */ + animation: spin 2s linear infinite; +} + +/* Safari */ +@-webkit-keyframes spin { + 0% { -webkit-transform: rotate(0deg); } + 100% { -webkit-transform: rotate(360deg); } +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } } \ No newline at end of file diff --git a/web/public/templates/ffmpeg-settings.html b/web/public/templates/ffmpeg-settings.html index 6fa279f..e558b14 100644 --- a/web/public/templates/ffmpeg-settings.html +++ b/web/public/templates/ffmpeg-settings.html @@ -66,11 +66,13 @@ Some possible values are: - Intel Quick Sync: h264_qsv, mpeg2_qsv - NVIDIA: GPU: h264_nvenc + h264 with Intel Quick Sync: h264_qsv + MPEG2 with Intel Quick Sync: mpeg2_qsv + NVIDIA: h264_nvenc MPEG2: mpeg2video (default) H264: libx264 MacOS: h264_videotoolbox + Hardware encoders are not supported in docker for now
diff --git a/web/public/templates/plex-library.html b/web/public/templates/plex-library.html index 2a47d17..4270bce 100644 --- a/web/public/templates/plex-library.html +++ b/web/public/templates/plex-library.html @@ -33,16 +33,19 @@