Unverified Commit 5f540dd8 authored by Dispatch's avatar Dispatch Committed by GitHub
Browse files

Overhaul Project (#4)

Fixed, updated, and improved most of the project.
parent 6cd86539
......@@ -25,7 +25,7 @@ To learn more, read below, or see the [FAQ](docs/faq.md), [API guide](docs/api.m
![Architecture diagram](docs/assets/arch.png "Architecture diagram")
### Web interface screenshot
![Web interface screenshot](docs/assets/screenshot.png "Web interface screenshot")
![Web interface screenshot](docs/assets/brave-screenshot.png "Web interface screenshot")
This web interface is optional; Brave can be controlled via the API or startup config file.
......@@ -79,7 +79,9 @@ Supported overlay types:
There can be any number of mixers. They can take any number of inputs (including the output from another mixer). It can send to any number of outputs. [Read more about mixers.](docs/mixers.md)
## Project status
This project is still work in progress, and has not been thoroughly tested or used any any production environments.
This project is still work in progress, and has not been thoroughly tested ~~or used any any production environments~~.
This project breaks *a lot* and is mid process of a nearly complete overhaul to fix, upgrade, and improve all areas of this project to make it semi viable in a production environment.
## Installation
First, install the dependencies, and then clone this repo.
......@@ -103,7 +105,7 @@ Then get the package id from the end of the build process:
Then run the new docker image:
`docker run --name brave --rm -t -i -p 5000:5000 PackageHashNumber`
Optionally you can also mount a local directory interal to the docker instance:
Optionally you can also mount a local directory integral to the docker instance:
`-v /path/in/host:/videos`
## How to use
......
from brave.inputs.input import Input
from gi.repository import Gst
import brave.config as config
#import streamlink
import brave.exceptions
#from __future__ import unicode_literals
import youtube_dl
......@@ -21,19 +21,19 @@ class MyLogger(object):
pass
def warning(self, msg):
# print(msg)
#print(msg)
pass
def error(self, msg):
print(msg)
class YoutubeDLInput(Input):
class YoutubeDLInput( Input ):
'''
Handles input via URI.
This can be anything Playbin accepts, including local files and remote streams.
'''
def permitted_props(self):
def permitted_props( self ):
return {
**super().permitted_props(),
'uri': {
......@@ -42,29 +42,48 @@ class YoutubeDLInput(Input):
'buffer_duration': {
'type': 'int',
'default': 1000000000
'default': 1000000000,
},
'loop': {
'type': 'bool',
'default': False
'default': False,
},
'position': {
'type': 'int'
},
'volume': {
'type': 'float',
'default': 1.0
'default': 1.0,
},
'width': {
'type': 'int'
'type': 'int',
},
'height': {
'type': 'int'
'type': 'int',
},
'disablevideo': {
'type': 'bool',
'default': False,
},
'title':{
'type': 'str',
'default': '',
},
'channel':{
'type': 'str',
'default':'no channel set'
}
'default': 'no channel set',
},
'format':{
'type': 'str',
'default': 'no format set',
},
'fps': {},
'categories': {},
'thumbnail': {},
'view_count': { 'default': 0 },
'format_note': { 'default': 'none' },
'protocol': { 'default': 'none' },
}
def create_elements(self):
......@@ -75,37 +94,57 @@ class YoutubeDLInput(Input):
# should do a check of the url by passing it through the stream link script
# https://github.com/ytdl-org/youtube-dl/blob/master/README.md#embedding-youtube-dl
self.suri = ''
try:
ydl_opts = {
'simulate': True,
'noplaylist' : True,
'forceurl' : True,
'logger': MyLogger(),
}
with youtube_dl.YoutubeDL(ydl_opts) as ydl:
ydl.download([self.uri])
global channel_val
meta = ydl.extract_info(self.uri,download=False)
channel_val = meta['uploader']
self.channel = channel_val
# should then try to get the meta data out that we want like channel and description
#self.playbin.set_property('channel', 'test channel')
#streams = streamlink.streams(self.uri)
global purl
self.stream = purl
#tstream = streams['best']
self.suri = purl
except:
pass
is_rtmp = self.suri.startswith('rtmp')
playbin_element = 'playbin' if is_rtmp else 'playbin'
# Filter for just audio formats when video is disabled
ytFormats = 'best[height<=720][fps<=?30][ext=webm]/best[height<=720][fps<=?30][ext=mp4]/best[height<=720][fps<=?30]/best[height<=720]/best'
ydl_opts = {
'format': ytFormats,
'simulate': True,
'noplaylist' : True,
'forceurl' : True,
'logger': MyLogger(),
}
with youtube_dl.YoutubeDL(ydl_opts) as ydl:
ydl.download([self.uri])
meta = ydl.extract_info(self.uri, download=False)
global ytdl_url
ytdl_url = meta.get( 'url' )
self.stream = ytdl_url
self.suri = ytdl_url
global channel_val
channel_val = meta.get( 'uploader' )
self.channel = channel_val
self.format = meta.get( 'format' )
self.title = meta.get( 'title')
self.fps = meta.get( 'fps')
self.categories = meta.get( 'categories')
self.thumbnail = meta.get( 'thumbnail')
self.view_count = meta.get( 'view_count')
self.format_note = meta.get( 'format_note')
self.protocol = meta.get( 'protocol')
# should then try to get the meta data out that we want like channel and description
#self.playbin.set_property('channel', 'test channel')
#global purl
#self.stream = purl
#self.suri = purl
# Potentially add back playbin3?
#is_rtmp = self.suri.startswith('rtmp')
#playbin_element = 'playbin' if is_rtmp else 'playbin'
playbin_element = 'playbin'
self.create_pipeline_from_string(playbin_element)
self.playsink = self.pipeline.get_by_name('playsink')
self.playbin = self.playsink.parent
self.playbin.set_property('uri', self.suri)
self.playbin.connect('about-to-finish', self.__on_about_to_finish)
......@@ -128,8 +167,7 @@ class YoutubeDLInput(Input):
self.playsink.set_property('audio-sink', fakesink)
def create_video_elements(self):
bin_as_string = ('videoconvert ! videoscale ! capsfilter name=capsfilter ! '
'queue ! ' + self.default_video_pipeline_string_end())
bin_as_string = ( 'videoconvert ! videoscale ! capsfilter name=capsfilter ! queue ! ' + self.default_video_pipeline_string_end() )
bin = Gst.parse_bin_from_description(bin_as_string, True)
self.capsfilter = bin.get_by_name('capsfilter')
......@@ -145,6 +183,9 @@ class YoutubeDLInput(Input):
self.playsink.set_property('audio-sink', bin)
self.final_audio_tee = bin.get_by_name('final_audio_tee')
def has_video(self):
return False if self.disablevideo else config.enable_video()
def on_pipeline_start(self):
'''
Called when the stream starts
......@@ -152,7 +193,7 @@ class YoutubeDLInput(Input):
for connection in self.dest_connections():
connection.unblock_intersrc_if_ready()
# If the user has asked ot start at a certain timespot, do it now
# If the user has asked ot start at a certain timestamp, do it now
# (as the position cannot be set until the pipeline is PAUSED/PLAYING):
self._handle_position_seek()
......@@ -185,12 +226,17 @@ class YoutubeDLInput(Input):
props = {}
for (audioOrVideo, element) in elements.items():
if not element:
MyLogger.error('YT-dl missing element!')
return
caps = element.get_static_pad('sink').get_current_caps()
if not caps:
MyLogger.error('YT-dl missing caps!')
return
size = caps.get_size()
if size == 0:
MyLogger.error('YT-dl caps size is 0!')
return
structure = caps.get_structure(0)
......@@ -225,7 +271,7 @@ class YoutubeDLInput(Input):
and 'percent' (the amount of buffering retrieved, 100=full buffer)
'''
query_buffer = Gst.Query.new_buffering(Gst.Format.PERCENT)
result = self.pipeline.query(query_buffer)
result = self.pipeline.query(query_buffer) if hasattr(self, 'pipeline') else None
return query_buffer.parse_buffering_percent() if result else None
def summarise(self, for_config_file=False):
......@@ -234,6 +280,7 @@ class YoutubeDLInput(Input):
'''
s = super().summarise(for_config_file)
global channel_val
global format_val
if not for_config_file:
#s['channel'] = channel_val
......
......@@ -11,11 +11,11 @@ class TextOverlay(Overlay):
**super().permitted_props(),
'text': {
'type': 'str',
'default': 'Default text'
'default': 'Default text',
},
'font_size': {
'type': 'int',
'default': 44
'default': 18,
},
'valignment': {
'type': 'str',
......@@ -24,13 +24,34 @@ class TextOverlay(Overlay):
'top': 'Top',
'center': 'Center',
'bottom': 'Bottom',
'baseline': 'Baseline'
}
'baseline': 'Baseline',
},
},
'halignment': {
'type': 'str',
'default': 'left',
'permitted_values': {
'left': 'Left',
'center': 'Center',
'right': 'Right',
},
},
'outline': {
'type': 'bool',
'default': False,
},
'shadow': {
'type': 'bool',
'default': True,
},
'shaded_background': {
'type': 'bool',
'default': False,
},
'visible': {
'type': 'bool',
'default': False
}
'default': False,
},
}
def create_elements(self):
......@@ -40,6 +61,8 @@ class TextOverlay(Overlay):
def set_element_values_from_props(self):
self.element.set_property('text', self.text)
self.element.set_property('valignment', self.valignment)
self.element.set_property('halignment', 'left')
self.element.set_property('halignment', self.halignment)
self.element.set_property('font-desc', 'Sans, %d' % self.font_size)
self.element.set_property('shaded-background', True)
self.element.set_property('draw-outline', self.outline)
self.element.set_property('draw-shadow', self.shadow)
self.element.set_property('shaded-background', self.shaded_background)
......@@ -7,8 +7,8 @@ enable_audio: true
##
## Default width and height (If ommitted, it will be 640,360)
##
default_mixer_width: 640
default_mixer_height: 360
default_mixer_width: 1280
default_mixer_height: 720
##
## DEFAULT INPUTS
......
......@@ -25,6 +25,12 @@
<!-- Add pipeline -->
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-success" id="nav-add-input">
Input
</button>
<button type="button" class="btn btn-sm btn-success" id="nav-add-ytdl">
YouTubeDL
</button>
<button type="button" class="btn btn-sm btn-info" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Add <i class="fas fa-sm fa-plus"></i>
</button>
......@@ -79,7 +85,7 @@
</div>
<!-- CPU Usage -->
<div class="text-right" id="cpu-stats"></div>
<div class="text-right text-monospace" id="cpu-stats"></div>
</div>
</nav>
</header>
......@@ -93,6 +99,9 @@
<div class="input-group">
<input type="text" id="quickaddrebox">
<div class="input-group-append">
<button type="button" class="btn btn-sm btn-success" id="quick-add-ytdl">
YouTubeDL
</button>
<button type="button" class="btn btn-sm btn-primary" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Quick add <i class="fas fa-sm fa-plus"></i>
</button>
......@@ -114,7 +123,7 @@
<div class="row" id="cards"></div>
</div>
<div style="clear:both"></div>
<div style="clear: both;"></div>
<!-- Primary Modal (for adding and editing things) -->
<div class="modal fade" id="primary-modal" tabindex="-1" role="dialog" aria-hidden="true">
......@@ -132,7 +141,7 @@
</div>
</div>
<div id="top-message" class="alert alert-warning alert-dismissible fade" role="alert" style="z-index: 1000">
<div id="top-message" class="alert alert-warning alert-dismissible fade show" role="alert" style="z-index: 2000">
<div></div>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true"><i class="fas fa-times"></i></span>
......
......@@ -23,146 +23,154 @@ components.mutedButton = () => $(`<a href="#" class="fas fa-volume-off" title="U
components.unmutedButton = () => $(`<a href="#" class="fas fa-volume-up" title="Mute"></a>`);
components.stateIcon = ( state, currentState ) => {
const selected = state === currentState;
const icons = {
'PLAYING': 'fa-play',
'PAUSED': 'fa-pause',
'READY': 'fa-stop',
'NULL': 'fa-exclamation-triangle'
};
const iconName = icons[state];
return `<a href="#" class="fas ${iconName} ${selected ? '' : ' icon-unselected'}" data-state="${state}" ></a>`
const selected = state === currentState;
const icons = {
'PLAYING': 'fa-play',
'PAUSED': 'fa-pause',
'READY': 'fa-stop',
'NULL': 'fa-exclamation-triangle'
};
const iconName = icons[state];
return `<a href="#" class="fas ${iconName} ${selected ? '' : ' icon-unselected'}" data-state="${state}" ></a>`
};
components.openCards = {};
components.card = (block) => {
const card = $('<div class="block-card"></div>');
const header = $('<div class="block-card-head"></div>');
if (block.title) header.append(block.title);
if (block.options) {
const options = $('<div class="option-icons"></div>');
options.append(block.options);
header.append(options)
}
card.append(header);
if (block.state) card.append(block.state);
if (block.mixOptions) card.append(block.mixOptions);
const cardBody = $('<div class="block-card-body"></div>');
cardBody.append(block.body);
if (!components.openCards[block.title]) cardBody.css('display', 'none');
const setToggleMsg = (target) => { target.html(components.openCards[block.title] ? components.hideDetails() : components.showDetails()) };
const toggleSwitch = $('<a href="#">Toggle</a>').click((change) => {
cardBody.toggle(components.openCards[block.title] = !components.openCards[block.title]);
setToggleMsg($(change.target));
return false
});
setToggleMsg(toggleSwitch);
card
.append($('<div />')
.addClass('block-card-toggle')
.append(toggleSwitch));
card
.append(cardBody);
return $('<div class="block-card-outer col-xl-3 col-lg-4 col-md-6 col-12"></div>')
.append(card)
const card = $('<div class="block-card"></div>');
const header = $('<div class="block-card-head"></div>');
if ( block.title ) header.append( block.title );
if ( block.options ) {
const options = $('<div class="option-icons"></div>');
options.append( block.options );
header.append( options )
}
card.append( header );
if (block.state) card.append(block.state);
if (block.mixOptions) card.append(block.mixOptions);
const cardBody = $('<div class="block-card-body"></div>');
cardBody.append( block.body );
if ( !components.openCards[block.title] ) cardBody.css( 'display', 'none' );
const setToggleMsg = (target) => { target.html(components.openCards[block.title] ? components.hideDetails() : components.showDetails()) };
const toggleSwitch = $('<a href="#">Toggle</a>').click((change) => {
cardBody.toggle(components.openCards[block.title] = !components.openCards[block.title]);
setToggleMsg($(change.target));
return false;
});
setToggleMsg(toggleSwitch);
card
.append($('<div />')
.addClass('block-card-toggle')
.append(toggleSwitch));
card
.append(cardBody);
return $('<div class="block-card-outer col-xl-3 col-lg-4 col-md-6 col-12"></div>')
.append(card)
};
components.stateBox = (item, onClick) => {
const stateBoxDetails = components._stateIcons(item);
stateBoxDetails.value.click( change => {
const state = change.target.dataset.state;
onClick(item .id, state );
return false;
});
let msg = stateBoxDetails.value;
if (item.position) msg.append(' ', prettyDuration(item.position));
return $('<div></div>')
.append(msg)
.addClass(stateBoxDetails.className)
const stateBoxDetails = components._stateIcons(item);
stateBoxDetails.value.click( change => {
const state = change.target.dataset.state;
onClick(item .id, state );
return false;
});
let msg = stateBoxDetails.value;
if (item.position) msg.append(' ', prettyDuration(item.position));
return $('<div></div>')
.append(msg)
.addClass(stateBoxDetails.className)
};
components._stateIcons = (item) => {
let desc = ' ' + item.state;
if ( item.state === 'PAUSED' && item.hasOwnProperty('buffering_percent' ) && item.buffering_percent !== 100 ) {
desc = ' BUFFERING (' + item.buffering_percent + '%)'
}
else if ( item.desired_state && item.desired_state !== item.state ) {
desc = ' ' + item.state + ' &rarr; ' + item.desired_state
}
const allIcons = $('<div class="state-icons"></div>')
.append([
components.stateIcon('NULL', item.state),
components.stateIcon('READY', item.state),
components.stateIcon('PAUSED', item.state),
components.stateIcon('PLAYING', item.state),
desc,
]);
return { value: allIcons, className: item.state }
let desc = ' ' + item.state;
if ( item.state === 'PAUSED' && item.hasOwnProperty('buffering_percent' ) && item.buffering_percent !== 100 ) {
desc = ' BUFFERING (' + item.buffering_percent + '%)'
}
else if ( item.desired_state && item.desired_state !== item.state ) {
desc = ' ' + item.state + ' &rarr; ' + item.desired_state
}
const allIcons = $('<div class="state-icons"></div>')
.append([
components.stateIcon('NULL', item.state),
components.stateIcon('READY', item.state),
components.stateIcon('PAUSED', item.state),
components.stateIcon('PLAYING', item.state),
desc,
]);
return { value: allIcons, className: item.state }
};
components.volumeInput = (volume) => {
const DEFAULT_VOLUME = 1.0;
if ( volume === undefined || volume === null ) volume = DEFAULT_VOLUME;
volume *= 100; // as it's a percentage
return formGroup({
id: 'input-volume',
label: 'Volume',
name: 'volume',
type: 'range',
'data-slider-min': 0,
'data-slider-max': 100,
'data-slider-step': 10,
'data-slider-value': volume,
});
const DEFAULT_VOLUME = 1.0;
if ( volume === undefined || volume === null ) volume = DEFAULT_VOLUME;
volume *= 100; // as it's a percentage
const min = 0, max = 100, step = 1;
return formGroup({
id: 'input-volume',
label: 'Volume',
name: 'volume',
type: 'range',
'min': min,
'max': max,
'step': step,
'value': volume,
'data-slider-min': min,
'data-slider-max': max,
'data-slider-step': step,
'data-slider-value': volume,
});
};
components.hideDetails = () => '<i class="fas fa-caret-down"></i> Hide details';
components.showDetails = () => '<i class="fas fa-caret-right"></i> Show details';
components.getMixOptions = (src) => {
return mixersHandler.items
.map(mixer => {
if (!mixer.sources) return;
if (src === mixer) return;
const foundThis = mixer.sources.find(x => x.uid === src.uid);
const inMix = foundThis && foundThis.in_mix ? 'In mix' : 'Not in mix';
const div = $('<div class="mix-option"></div>');
if (foundThis && foundThis.in_mix) {
div.addClass('mix-option-showing');
const removeButton = components.removeButton();
removeButton.click(() => {
mixersHandler.remove(mixer, src);
return false;
});
const buttons = $('<div class="option-icons"></div>');
buttons.append([removeButton]);
div.append(buttons)
}
else {
div.addClass('mix-option-hidden');
const cutButton = components.cutButton();
cutButton.click(() => {
mixersHandler.cut(mixer, src);
return false;