Merge pull request #30 from vexorian/main20200814

Merge version 0.0.61 into main
This commit is contained in:
vexorian 2020-08-14 23:48:18 -04:00 committed by GitHub
commit ac60c32d2c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 519 additions and 67 deletions

76
CODE_OF_CONDUCT.md Normal file
View File

@ -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

View File

@ -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.

View File

@ -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)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -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"
}

View File

@ -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
})
}

View File

@ -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

View File

@ -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);
}

View File

@ -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

View File

@ -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";

View File

@ -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 */

107
src/svg/loading-screen.svg Normal file
View File

@ -0,0 +1,107 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="1920"
height="1080"
viewBox="0 0 507.99999 285.75001"
version="1.1"
id="svg8"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="loading-screen.svg"
inkscape:export-filename="/home/vx/dev/pseudotv/resources/loading-screen.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.37433674"
inkscape:cx="1004.7641"
inkscape:cy="545.11626"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:window-width="1920"
inkscape:window-height="1056"
inkscape:window-x="0"
inkscape:window-y="24"
inkscape:window-maximized="1"
units="px" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Capa 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-11.249983)">
<rect
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:3.20000029;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect836"
width="508"
height="285.75"
x="0"
y="11.249983" />
<g
id="g6050"
transform="translate(-8.4960767,30.053154)">
<rect
transform="rotate(0.52601418)"
y="85.000603"
x="214.56714"
height="73.832573"
width="32.814484"
id="rect4518"
style="opacity:1;fill:#9cbc28;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
<rect
transform="rotate(1.4727575)"
style="opacity:1;fill:#289bbc;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4520"
width="32.81448"
height="73.832573"
x="248.74632"
y="80.901688" />
<rect
transform="rotate(-3.2986121)"
y="103.78287"
x="269.35843"
height="73.832588"
width="32.814476"
id="rect4522"
style="opacity:1;fill:#bc289b;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
</g>
<text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:10.58333302px;line-height:125%;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
x="228.70648"
y="210.99644"
id="text939"><tspan
sodipodi:role="line"
id="tspan937"
x="228.70648"
y="210.99644"
style="fill:#f9f9f9;stroke-width:0.26458332px">Loading...</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -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`

View File

@ -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;

View File

@ -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)

View File

@ -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); }
}

View File

@ -66,11 +66,13 @@
<label>Video Encoder</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.videoEncoder" ria-describedby="videoEncoderHelp"/>
<small id="videoEncoderHelp" class="form-text text-muted">Some possible values are:</small>
<small id="videoEncoderHelp" class="form-text text-muted">Intel Quick Sync: h264_qsv, mpeg2_qsv</small>
<small id="videoEncoderHelp" class="form-text text-muted">NVIDIA: GPU: h264_nvenc</small>
<small id="videoEncoderHelp" class="form-text text-muted">h264 with Intel Quick Sync: h264_qsv</small>
<small id="videoEncoderHelp" class="form-text text-muted">MPEG2 with Intel Quick Sync: mpeg2_qsv</small>
<small id="videoEncoderHelp" class="form-text text-muted">NVIDIA: h264_nvenc</small>
<small id="videoEncoderHelp" class="form-text text-muted">MPEG2: mpeg2video (default)</small>
<small id="videoEncoderHelp" class="form-text text-muted">H264: libx264</small>
<small id="videoEncoderHelp" class="form-text text-muted">MacOS: h264_videotoolbox</small>
<small id="videoEncoderHelp" class="form-text text-muted">Hardware encoders are not supported in docker for now</small>
</div>
<div class="col-sm-1" />
<div class="col-sm-4">

View File

@ -33,16 +33,19 @@
<hr />
<ul class="list-group list-group-root plex-panel" ng-init="setHeight = {'height': height + 'px'}" ng-style="setHeight" lazy-img-container>
<li class="list-group-item" ng-repeat="a in libraries">
<div class="{{ displayImages ? 'w_images' : 'wo_images' }}" ng-click="getNested(a);">
<div class="flex-container {{ displayImages ? 'w_images' : 'wo_images' }}" ng-click="getNested(a, true);">
<span class="fa {{ a.collapse ? 'fa-chevron-down' : 'fa-chevron-right' }} tab"></span>
<img ng-if="displayImages" lazy-img="{{a.icon}}" />
<span>{{a.title}}</span><!-- Library -->
<span ng-if="a.type === 'show' || a.type === 'movie'" class="flex-pull-right" ng-click='$event.stopPropagation(); selectLibrary(a)'>
<span class="fa fa-plus btn"></span>
</span>
</div>
<ul ng-if="a.collapse" class="list-group">
<li class="list-group-item {{ b.type !== 'movie' ? 'list-group-item-secondary' : 'list-group-item-video' }}"
ng-repeat="b in a.nested">
<div class="flex-container"
ng-click="b.type !== 'movie' ? getNested(b) : selectItem(b)">
ng-click="b.type !== 'movie' ? getNested(b) : selectItem(b, true)">
<span ng-if="b.type === 'movie'" class="fa fa-plus-circle tab"></span>
<span ng-if="b.type !== 'movie'" class="tab"></span>
<span ng-if="b.type !== 'movie'" class="fa {{ b.collapse ? 'fa-chevron-down' : 'fa-chevron-right' }} tab"></span>
@ -54,7 +57,7 @@
<span ng-if="b.type === 'playlist'" class="flex-pull-right" ng-click="$event.stopPropagation(); selectPlaylist(b);">
<span class="fa fa-plus btn"></span>
</span>
<span ng-if="b.type === 'show'" class="flex-pull-right" ng-click="$event.stopPropagation(); selectShow(b);">
<span ng-if="b.type === 'show' || b.type === 'collection'" class="flex-pull-right" ng-click="$event.stopPropagation(); selectShow(b);">
<span class="fa fa-plus btn"></span>
</span>
</div>
@ -62,7 +65,7 @@
<li ng-repeat="c in b.nested"
class="list-group-item {{ c.type !== 'movie' && c.type !== 'episode' ? 'list-group-item-dark' : 'list-group-item-video' }}">
<div class="flex-container"
ng-click="c.type !== 'movie' && c.type !== 'episode' ? getNested(c) : selectItem(c)">
ng-click="c.type !== 'movie' && c.type !== 'episode' ? getNested(c) : selectItem(c, true)">
<span ng-if="c.type === 'movie' || c.type === 'episode'"
class="fa fa-plus-circle tab"></span>
<span ng-if="c.type !== 'movie' && c.type !== 'episode'"
@ -85,7 +88,7 @@
<ul ng-if="c.collapse" class="list-group">
<li class="list-group-item list-group-item-video"
ng-repeat="d in c.nested">
<div class="flex-container" ng-click="selectItem(d)">
<div class="flex-container" ng-click="selectItem(d, true)">
<span class="fa fa-plus-circle tab"></span>
<img ng-if="displayImages" lazy-img="{{d.episodeIcon}}" />
E{{ d.episode.toString().padStart(2,'0')}} - {{d.title}}
@ -101,13 +104,15 @@
</li>
</ul>
<hr/>
<h6>Selected Items</h6>
<div class="loader" ng-if="pending &gt; 0" ></div> <h6 style='display:inline-block'>Selected Items</h6>
<div class="text-info small" ng-show='selection.length &gt; 10'>{{ selection.length }} elements added in total. Only the last 10 elements are displayed:</div>
<ul class="list-group list-group-root" style="height: 180px; overflow-y: scroll" dnd-list="selection" scroll-glue>
<div ng-if="selection.length === 0">Select media items from your plex library above.</div>
<li class="list-group-item" ng-repeat="x in selection" style="cursor:default;" dnd-draggable="x" dnd-moved="selection.splice($index, 1)" dnd-effect-allowed="move">
{{ (x.type !== 'episode') ? x.title : (x.showTitle + ' - S' + x.season.toString().padStart(2,'0') + 'E' + x.episode.toString().padStart(2,'0'))}}
<li ng-if="selection.length + x &gt;= 0" class="list-group-item" ng-repeat="x in allowedIndexes" style="cursor:default;" dnd-draggable="x" dnd-moved="selection.splice(selection.length + x, 1)" dnd-effect-allowed="move">
{{ (selection[selection.length + x].type !== 'episode') ? selection[selection.length + x].title : (selection[selection.length + x].showTitle + ' - S' + selection[selection.length + x].season.toString().padStart(2,'0') + 'E' + selection[selection.length + x].episode.toString().padStart(2,'0'))}}
<span class="pull-right">
<span class="btn fa fa-trash" ng-click="selection.splice($index,1)"></span>
<span class="btn fa fa-trash" ng-click="selection.splice(selection.length + x,1)"></span>
</span>
</li>
</ul>

View File

@ -1,7 +1,7 @@
const Plex = require('../../src/plex');
module.exports = function ($http, $window, $interval) {
return {
let exported = {
login: async () => {
const headers = {
'Accept': 'application/json',
@ -119,11 +119,12 @@ module.exports = function ($http, $window, $interval) {
return streams
})
},
getNested: async (server, key) => {
getNested: async (server, key, includeCollections) => {
var client = new Plex(server)
const res = await client.Get(key)
var nested = []
var seenFiles = {};
var collections = {};
for (let i = 0, l = typeof res.Metadata !== 'undefined' ? res.Metadata.length : 0; i < l; i++) {
// Skip any videos (movie or episode) without a duration set...
if (typeof res.Metadata[i].duration === 'undefined' && (res.Metadata[i].type === "episode" || res.Metadata[i].type === "movie"))
@ -178,11 +179,65 @@ module.exports = function ($http, $window, $interval) {
program.episode = 1
program.season = 1
}
if (typeof (res.Metadata[i].Collection) !== 'undefined') {
let coll = res.Metadata[i].Collection;
for (let j = 0; j < coll.length; j++) {
let tag = coll[j].tag;
if ( (typeof(tag)!== "undefined") && (tag.length > 0) ) {
let collection = collections[tag];
if (typeof(collection) === 'undefined') {
collection = [];
collections[tag] = collection;
}
collection.push( program );
}
}
}
nested.push(program)
}
if (includeCollections === true) {
let nestedCollections = [];
let keys = [];
Object.keys(collections).forEach(function(key,index) {
keys.push(key);
});
for (let k = 0; k < keys.length; k++) {
let key = keys[k];
if (collections[key].length <= 1) {
//it's pointless to include it.
continue;
}
let collection = {
title: key,
key: "#collection",
icon : "",
type : "collection",
nested: collections[key],
}
if (res.viewGroup === 'show') {
collection.title = collection.title + " Collection";
//nest the seasons directly because that's way too many depth levels already
let shows = collection.nested;
let collectionContents = [];
for (let i = 0; i < shows.length; i++) {
let seasons = await exported.getNested(server, shows[i].key, false);
for (let j = 0; j < seasons.length; j++) {
seasons[j].title = shows[i].title + " - " + seasons[j].title;
collectionContents.push(seasons[j]);
}
}
collection.nested = collectionContents;
}
nestedCollections.push( collection );
}
nested = nestedCollections.concat(nested);
}
return nested
}
}
return exported;
}
function msToTime(duration) {