Started work on HLS Livestreaming. Created basic HLS Livestream Media Handler.

This commit is contained in:
rainbow napkin 2025-05-12 17:54:47 -04:00
parent c6de68b474
commit dd00a11b92
6 changed files with 208 additions and 72 deletions

View file

@ -235,67 +235,76 @@ module.exports = class{
} }
async goLive(socket, data){ async goLive(socket, data){
//Grab the channel from DB try{
const chanDB = await channelModel.findOne({name:this.channel.name}); //Grab the channel from DB
const chanDB = await channelModel.findOne({name:this.channel.name});
let title = "Livestream"; let title = "Livestream";
if(data != null && data.title != null){ if(data != null && data.title != null){
//If the title is too long //If the title is too long
if(!validator.isLength(data.title, {max:30})){ if(!validator.isLength(data.title, {max:30})){
//Bitch, moan, complain... //Bitch, moan, complain...
loggerUtils.socketErrorHandler(socket, "Title too long!", "validation"); loggerUtils.socketErrorHandler(socket, "Title too long!", "validation");
//and ignore it! //and ignore it!
return; return;
}
//Set title
title = validator.escape(validator.trim(data.title));
//If we've got no title
if(title == null || title == ''){
title = "Livestream";
}
} }
//Set title //If we couldn't find the channel
title = validator.escape(validator.trim(data.title)); if(chanDB == null){
//FUCK
//If we've got no title throw loggerUtils.exceptionSmith(`Unable to find channel document ${this.channel.name} while queue item!`, "queue");
if(title == null || title == ''){
title = "Livestream";
} }
//Kill schedule timers to prevent items from starting during the stream
await this.stopScheduleTimers();
//Syntatic sugar because I'm lazy :P
const streamURL = chanDB.settings.streamURL;
if(streamURL == ''){
throw loggerUtils.exceptionSmith('This channel\'s HLS Livestream Source has not been set!', 'queue');
}
//Pull filename from streamURL
let filename = streamURL.match(/^.+\..+\/(.+)$/);
//If we're streaming from the root of the domain
if(filename == null){
//Set filename to root
filename = '/';
}else{
//Otherwise, hand over the filename
filename = filename[1];
}
//Create queued media object from stream URL and set it to nowPlaying
this.nowPlaying = new queuedMedia(
title,
filename,
streamURL,
streamURL,
"livehls",
0,
streamURL,
new Date().getTime()
);
//Broadcast new media object to users
this.sendMedia();
}catch(err){
return loggerUtils.socketExceptionHandler(socket, err);
} }
//If we couldn't find the channel
if(chanDB == null){
//FUCK
throw loggerUtils.exceptionSmith(`Unable to find channel document ${this.channel.name} while queue item!`, "queue");
}
//Kill schedule timers to prevent items from starting during the stream
await this.stopScheduleTimers();
//Syntatic sugar because I'm lazy :P
const streamURL = chanDB.settings.streamURL;
//Pull filename from streamURL
let filename = streamURL.match(/^.+\..+\/(.+)$/);
//If we're streaming from the root of the domain
if(filename == null){
//Set filename to root
filename = '/';
}else{
//Otherwise, hand over the filename
filename = filename[1];
}
//Create queued media object from stream URL and set it to nowPlaying
this.nowPlaying = new queuedMedia(
title,
filename,
streamURL,
streamURL,
"livehls",
0,
streamURL,
new Date().getTime()
);
//Broadcast new media object to users
this.sendMedia();
} }
//--- INTERNAL USE ONLY QUEUEING FUNCTIONS --- //--- INTERNAL USE ONLY QUEUEING FUNCTIONS ---
@ -553,8 +562,11 @@ module.exports = class{
//otherwise //otherwise
}else{ }else{
try{ try{
//DO everything ourselves since we don't have a fance end() function to do it //If we wheren't handed a channel
chanDB = await channelModel.findOne({name:this.channel.name}); if(chanDB == null){
//DO everything ourselves since we don't have a fance end() function to do it
chanDB = await channelModel.findOne({name:this.channel.name});
}
//If we couldn't find the channel //If we couldn't find the channel
if(chanDB == null){ if(chanDB == null){
@ -814,6 +826,9 @@ module.exports = class{
//Send play signal out to the channel //Send play signal out to the channel
this.sendMedia(); this.sendMedia();
//Kill existing sync timers to prevent kicking-off ghost timer loops
clearTimeout(this.syncTimer);
//Kick off the sync timer //Kick off the sync timer
this.syncTimer = setTimeout(this.sync.bind(this), this.syncDelta); this.syncTimer = setTimeout(this.sync.bind(this), this.syncDelta);
@ -879,9 +894,19 @@ module.exports = class{
this.server.io.in(this.channel.name).emit('end', {}); this.server.io.in(this.channel.name).emit('end', {});
} }
//If we're ending an HLS Livestream
if(wasPlaying.type == "livehls"){
//Redirect to the endLivestream function
return this.endLivestream(chanDB);
}
//If we're not in volatile mode and we're not ending a livestream
if(!volatile){ if(!volatile){
//Now that everything is clean, we can take our time with the DB :P //If we wheren't handed a channel
chanDB = await channelModel.findOne({name:this.channel.name}); if(chanDB == null){
//Now that everything is clean, we can take our time with the DB :P
chanDB = await channelModel.findOne({name:this.channel.name});
}
//If we couldn't find the channel //If we couldn't find the channel
if(chanDB == null){ if(chanDB == null){
@ -898,12 +923,13 @@ module.exports = class{
//Take it out of the active schedule //Take it out of the active schedule
this.schedule.delete(wasPlaying.startTime); this.schedule.delete(wasPlaying.startTime);
//If archiving is enabled
if(!noArchive){ if(!noArchive){
//Add the item to the channel archive //Add the item to the channel archive
chanDB.media.archived.push(wasPlaying); chanDB.media.archived.push(wasPlaying);
} }
//broadcast queue using unsaved archive //broadcast queue using unsaved archive, run this before chanDB.save() for better responsiveness
this.broadcastQueue(chanDB); this.broadcastQueue(chanDB);
//Save our changes to the DB //Save our changes to the DB
@ -918,6 +944,22 @@ module.exports = class{
} }
} }
async endLivestream(chanDB){
try{
//Refresh next timer
this.refreshNextTimer();
//Broadcast Queue
this.broadcastQueue();
//ACK
}catch(err){
//Broadcast queue
this.broadcastQueue();
//Handle the error
loggerUtils.localExceptionHandler(err);
}
}
async stop(chanDB){ async stop(chanDB){
//If we wheren't handed a channel //If we wheren't handed a channel
if(chanDB == null){ if(chanDB == null){

View file

@ -160,8 +160,6 @@ app.use('/passwordReset', passwordResetRouter);
app.use('/emailChange', emailChangeRouter); app.use('/emailChange', emailChangeRouter);
//Panel //Panel
app.use('/panel', panelRouter); app.use('/panel', panelRouter);
//Popup
//app.use('/popup', popupRouter);
//tooltip //tooltip
app.use('/tooltip', tooltipRouter); app.use('/tooltip', tooltipRouter);
//Bot-Ready //Bot-Ready
@ -169,9 +167,9 @@ app.use('/api', apiRouter);
//Static File Server //Static File Server
//Serve client-side libraries //Serve client-side libraries
app.use('/lib/bootstrap-icons',express.static(path.join(__dirname, '../node_modules/bootstrap-icons'))); app.use('/lib/bootstrap-icons',express.static(path.join(__dirname, '../node_modules/bootstrap-icons'))); //Icon set
app.use('/lib/altcha',express.static(path.join(__dirname, '../node_modules/altcha/dist_external'))); app.use('/lib/altcha',express.static(path.join(__dirname, '../node_modules/altcha/dist_external'))); //Self-Hosted PoW-based Captcha
app.use('/lib/hls.js',express.static(path.join(__dirname, '../node_modules/hls.js/dist'))); app.use('/lib/hls.js',express.static(path.join(__dirname, '../node_modules/hls.js/dist'))); //HLS Media Handler
//Server public 'www' folder //Server public 'www' folder
app.use(express.static(path.join(__dirname, '../www'))); app.use(express.static(path.join(__dirname, '../www')));

View file

@ -34,7 +34,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. %>
<%- include('partial/scripts', {user}); %> <%- include('partial/scripts', {user}); %>
<%# 3rd party code %> <%# 3rd party code %>
<script src="/socket.io/socket.io.min.js"></script> <script src="/socket.io/socket.io.min.js"></script>
<script src="/lib/hls.js/hls.js"></script> <script src="/lib/hls.js/hls.min.js"></script>
<%# 1st party code %> <%# 1st party code %>
<%# admin gunk %> <%# admin gunk %>
<script src="/js/adminUtils.js"></script> <script src="/js/adminUtils.js"></script>

View file

@ -172,6 +172,11 @@ textarea{
box-shadow: var(--danger-glow0-alt1); box-shadow: var(--danger-glow0-alt1);
} }
.critical-danger-text{
color: var(--danger0-alt1);
text-shadow: var(--danger-glow0);
}
.danger-link, .danger-text{ .danger-link, .danger-text{
color: var(--danger0); color: var(--danger0);
} }

View file

@ -141,6 +141,10 @@ class mediaHandler{
//reset self act flag //reset self act flag
this.selfAct = false; this.selfAct = false;
} }
onBuffer(){
this.selfAct = true;
}
} }
//Basic building blocks for anything that touches a <video> tag //Basic building blocks for anything that touches a <video> tag
@ -184,7 +188,7 @@ class rawFileBase extends mediaHandler{
this.video.load(); this.video.load();
//Set it back to the proper time //Set it back to the proper time
this.video.currentTime = timestamp; this.video.currentTime = this.lastTimestamp;
//Play the video //Play the video
this.video.play(); this.video.play();
@ -234,18 +238,22 @@ class nullHandler extends rawFileBase{
} }
start(){ start(){
//call derived start function
super.start();
//Lock the player //Lock the player
this.setPlayerLock(true); this.setPlayerLock(true);
//Set the static placeholder //Set the static placeholder
this.video.src = '/video/static.webm'; this.video.src = '/video/static.webm';
//Set video title manually
this.player.title.textContent = 'Channel Off Air';
//play the placeholder video //play the placeholder video
this.video.play(); this.video.play();
} }
setVideoTitle(title){
this.player.title.textContent = `Channel Off Air`;
}
} }
//Basic building blocks needed for proper time-synchronized raw-file playback //Basic building blocks needed for proper time-synchronized raw-file playback
@ -263,10 +271,14 @@ class rawFileHandler extends rawFileBase{
super.defineListeners(); super.defineListeners();
this.video.addEventListener('pause', this.onPause.bind(this)); this.video.addEventListener('pause', this.onPause.bind(this));
this.video.addEventListener('seeking', this.onSeek.bind(this)); this.video.addEventListener('seeked', this.onSeek.bind(this));
this.video.addEventListener('waiting', this.onBuffer.bind(this));
} }
start(){ start(){
//Call derived start
super.start();
//Set video //Set video
this.video.src = this.nowPlaying.rawLink; this.video.src = this.nowPlaying.rawLink;
@ -505,7 +517,7 @@ class hlsBase extends rawFileBase{
} }
buildPlayer(){ buildPlayer(){
//Call derived player //Call derived buildPlayer function
super.buildPlayer(); super.buildPlayer();
//Instantiate HLS object //Instantiate HLS object
@ -522,6 +534,14 @@ class hlsBase extends rawFileBase{
} }
onMetadataLoad(){ onMetadataLoad(){
//Call derived method
super.onMetadataLoad();
}
start(){
//Call derived method
super.start();
//Start the video //Start the video
this.video.play(); this.video.play();
} }
@ -531,5 +551,74 @@ class hlsLiveStreamHandler extends hlsBase{
constructor(client, player, media){ constructor(client, player, media){
//Call derived constructor //Call derived constructor
super(client, player, media, "livehls"); super(client, player, media, "livehls");
//Create variable to determine if we need to resync after next seek
this.reSync = false;
this.video.addEventListener('pause', this.onPause.bind(this));
this.video.addEventListener('seeked', this.onSeek.bind(this));
this.video.addEventListener('waiting', this.onBuffer.bind(this));
}
sync(){
//Kick the video back on if it was paused
this.video.play();
//Pull video duration
const duration = this.video.duration;
//Ignore bad timestamps
if(duration > 0){
//Seek to the end to sync up w/ the livestream
this.video.currentTime = duration;
}
}
setVideoTitle(title){
//Add title as text content for security :P
this.player.title.textContent = `: ${title}`;
//Create glow span
const glowSpan = document.createElement('span');
//Fill glow span content
glowSpan.textContent = "🔴LIVE";
//Set glowspan class
glowSpan.classList.add('critical-danger-text');
//Inject glowspan into title in a way that allows it to be easily replaced
this.player.title.prepend(glowSpan);
}
onBuffer(event){
//Call derived function
super.onBuffer(event);
//If we're synced by the end of buffering
if(this.player.syncLock){
//Throw flag to manually sync since this works entirely differently from literally every other fucking media source
this.reSync = true;
}
}
onSeek(event){
//Call derived method
super.onSeek(event);
//Calculate distance to end of stream
const difference = this.video.duration - this.video.currentTime;
//If we where buffering under sync lock
if(this.reSync){
//Set reSync to false
this.reSync = false;
//If the difference is bigger than streamSyncTolerance
if(difference > this.player.streamSyncTolerance){
//Sync manually since we have no timestamp, and therefore the player won't do it for us
this.sync();
}
}
} }
} }

View file

@ -42,6 +42,8 @@ class player{
//Numbers //Numbers
this.syncTolerance = 0.4; this.syncTolerance = 0.4;
//Might seem weird to keep this here instead of the HLS handler, but remember we may want to support other livestream services in the future...
this.streamSyncTolerance = 2;
this.syncDelta = 6; this.syncDelta = 6;
this.volume = 1; this.volume = 1;