Started work on HLS livestreaming

This commit is contained in:
rainbow napkin 2025-05-11 08:19:30 -04:00
parent 93265b7890
commit c6de68b474
6 changed files with 193 additions and 29 deletions

View file

@ -13,6 +13,7 @@
"express": "^4.18.2", "express": "^4.18.2",
"express-session": "^1.18.0", "express-session": "^1.18.0",
"express-validator": "^7.2.0", "express-validator": "^7.2.0",
"hls.js": "^1.6.2",
"mongoose": "^8.4.3", "mongoose": "^8.4.3",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"nodemailer": "^6.9.16", "nodemailer": "^6.9.16",

View file

@ -65,6 +65,7 @@ module.exports = class{
socket.on("clear", (data) => {this.deleteRange(socket, data)}); socket.on("clear", (data) => {this.deleteRange(socket, data)});
socket.on("move", (data) => {this.moveMedia(socket, data)}); socket.on("move", (data) => {this.moveMedia(socket, data)});
socket.on("lock", () => {this.toggleLock(socket)}); socket.on("lock", () => {this.toggleLock(socket)});
socket.on("goLive", (data) => {this.goLive(socket, data)});
} }
//--- USER FACING QUEUEING FUNCTIONS --- //--- USER FACING QUEUEING FUNCTIONS ---
@ -128,32 +129,16 @@ module.exports = class{
} }
async stopMedia(socket){ async stopMedia(socket){
//Get the current channel from the database try{
const chanDB = await channelModel.findOne({name: socket.chan}); //Get the current channel from the database
const chanDB = await channelModel.findOne({name: socket.chan});
//Permcheck to make sure the user can fuck w/ the queue //Permcheck to make sure the user can fuck w/ the queue
if((!this.locked && await chanDB.permCheck(socket.user, 'scheduleMedia')) || await chanDB.permCheck(socket.user, 'scheduleAdmin')){ if((!this.locked && await chanDB.permCheck(socket.user, 'scheduleMedia')) || await chanDB.permCheck(socket.user, 'scheduleAdmin')){
await this.stop();
//If we're not currently playing anything
if(this.nowPlaying == null){
//If an originating socket was provided for this request
if(socket != null){
//Yell at the user for being an asshole
loggerUtils.socketErrorHandler(socket, "No media playing!", "queue");
}
//Ignore it
return false;
} }
}catch(err){
//Stop playing return loggerUtils.socketExceptionHandler(socket, err);
const stoppedMedia = this.nowPlaying;
//Get difference between current time and start time and set as early end
stoppedMedia.earlyEnd = (new Date().getTime() - stoppedMedia.startTime) / 1000;
//End the media
this.end();
} }
} }
@ -249,7 +234,90 @@ module.exports = class{
} }
} }
async goLive(socket, data){
//Grab the channel from DB
const chanDB = await channelModel.findOne({name:this.channel.name});
let title = "Livestream";
if(data != null && data.title != null){
//If the title is too long
if(!validator.isLength(data.title, {max:30})){
//Bitch, moan, complain...
loggerUtils.socketErrorHandler(socket, "Title too long!", "validation");
//and ignore it!
return;
}
//Set title
title = validator.escape(validator.trim(data.title));
//If we've got no title
if(title == null || title == ''){
title = "Livestream";
}
}
//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 ---
async stopScheduleTimers(){
//End any currently playing media media
await this.end();
//Clear sync timer
clearTimeout(this.syncTimer);
//Clear next timer
clearTimeout(this.nextTimer);
//Clear the pre-switch timer
clearTimeout(this.preSwitchTimer);
//Null out the sync timer
this.syncTimer = null;
//Null out the next playing item timer
this.nextTimer = null;
//Null out the pre-switch timer
this.preSwitchTimer = null;
}
getStart(start){ getStart(start){
//Pull current time //Pull current time
const now = new Date().getTime(); const now = new Date().getTime();
@ -421,8 +489,11 @@ module.exports = class{
//If we got a bad request //If we got a bad request
if(media == null){ if(media == null){
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){
@ -781,13 +852,18 @@ module.exports = class{
async end(quiet = false, noArchive = false, volatile = false, chanDB){ async end(quiet = false, noArchive = false, volatile = false, chanDB){
try{ try{
//If we're not playing anything
if(this.nowPlaying == null){
//Silently ignore the request
return;
}
//Call off any existing sync timer //Call off any existing sync timer
clearTimeout(this.syncTimer); clearTimeout(this.syncTimer);
//Clear out the sync timer //Clear out the sync timer
this.syncTimer = null; this.syncTimer = null;
//Keep a copy of whats playing for later when we need to clear the DB //Keep a copy of whats playing for later when we need to clear the DB
const wasPlaying = this.nowPlaying; const wasPlaying = this.nowPlaying;
@ -842,6 +918,40 @@ module.exports = class{
} }
} }
async stop(chanDB){
//If we wheren't handed a channel
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 wheren't handed a channel
if(chanDB == null){
//Complain about the lack of a channel
throw loggerUtils.exceptionSmith(`Channel not found!`, "queue");
}
//If we're not currently playing anything
if(this.nowPlaying == null){
//If an originating socket was provided for this request
if(socket != null){
//Yell at the user for being an asshole
throw loggerUtils.exceptionSmith(`No media playing`, "queue");
}
//Ignore it
return false;
}
//Stop playing
const stoppedMedia = this.nowPlaying;
//Get difference between current time and start time and set as early end
stoppedMedia.earlyEnd = (new Date().getTime() - stoppedMedia.startTime) / 1000;
//End the media
this.end();
}
getItemsBetweenEpochs(start, end){ getItemsBetweenEpochs(start, end){
//Create an empty array to hold found items //Create an empty array to hold found items
const foundItems = []; const foundItems = [];

View file

@ -168,9 +168,10 @@ app.use('/tooltip', tooltipRouter);
app.use('/api', apiRouter); app.use('/api', apiRouter);
//Static File Server //Static File Server
//Serve bootstrap icons //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')));
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')));
app.use('/lib/hls.js',express.static(path.join(__dirname, '../node_modules/hls.js/dist')));
//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,6 +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>
<%# 1st party code %> <%# 1st party code %>
<%# admin gunk %> <%# admin gunk %>
<script src="/js/adminUtils.js"></script> <script src="/js/adminUtils.js"></script>

View file

@ -158,8 +158,13 @@ class rawFileBase extends mediaHandler{
buildPlayer(){ buildPlayer(){
//Create player //Create player
this.video = document.createElement('video'); this.video = document.createElement('video');
//Enable controls
this.video.controls = true;
//Append it to page //Append it to page
this.player.videoContainer.appendChild(this.video); this.player.videoContainer.appendChild(this.video);
//Run derived method //Run derived method
super.buildPlayer(); super.buildPlayer();
} }
@ -488,4 +493,43 @@ class youtubeEmbedHandler extends mediaHandler{
this.iframe.style.pointerEvents = (lock ? "none" : ""); this.iframe.style.pointerEvents = (lock ? "none" : "");
} }
} }
}
class hlsBase extends rawFileBase{
constructor(client, player, media, type){
//Call derived constructor
super(client, player, media, type);
//Create property to hold HLS object
this.hls = null;
}
buildPlayer(){
//Call derived player
super.buildPlayer();
//Instantiate HLS object
this.hls = new Hls();
//Load HLS Stream
this.hls.loadSource(this.nowPlaying.url);
//Attatch hls object to video element
this.hls.attachMedia(this.video);
//Bind onMetadataLoad to MANIFEST_PARSED
this.hls.on(Hls.Events.MANIFEST_PARSED, this.onMetadataLoad.bind(this));
}
onMetadataLoad(){
//Start the video
this.video.play();
}
}
class hlsLiveStreamHandler extends hlsBase{
constructor(client, player, media){
//Call derived constructor
super(client, player, media, "livehls");
}
} }

View file

@ -89,13 +89,20 @@ class player{
}else{ }else{
//If we have a youtube video and the official embedded iframe player is selected //If we have a youtube video and the official embedded iframe player is selected
if(data.media.type == 'yt' && localStorage.getItem("ytPlayerType") == 'embed'){ if(data.media.type == 'yt' && localStorage.getItem("ytPlayerType") == 'embed'){
//Create a new yt handler for it
this.mediaHandler = new youtubeEmbedHandler(this.client, this, data.media); this.mediaHandler = new youtubeEmbedHandler(this.client, this, data.media);
//Sync to time stamp
this.mediaHandler.sync(data.timestamp);
//If we have an HLS Livestream
}else if(data.media.type == "livehls"){
//Create a new HLS Livestream Handler for it
this.mediaHandler = new hlsLiveStreamHandler(this.client, this, data.media);
//Otherwise, if we have a raw-file compatible source //Otherwise, if we have a raw-file compatible source
}else if(data.media.type == 'ia' || data.media.type == 'raw' || data.media.type == 'yt' || data.media.type == 'dm'){ }else if(data.media.type == 'ia' || data.media.type == 'raw' || data.media.type == 'yt' || data.media.type == 'dm'){
//Create a new raw file handler for it //Create a new raw file handler for it
this.mediaHandler = new rawFileHandler(client, this, data.media); this.mediaHandler = new rawFileHandler(client, this, data.media);
//Sync to time stamp //Sync to time stamp
this.mediaHandler.sync(data.timestamp); this.mediaHandler.sync(data.timestamp);
}else{ }else{
this.mediaHandler = new nullHandler(client, this); this.mediaHandler = new nullHandler(client, this);
} }