From c6de68b47474af5dbd93246355c9265e83fc3d8a Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sun, 11 May 2025 08:19:30 -0400 Subject: [PATCH] Started work on HLS livestreaming --- package.json | 1 + src/app/channel/media/queue.js | 164 +++++++++++++++++++++++++++------ src/server.js | 3 +- src/views/channel.ejs | 1 + www/js/channel/mediaHandler.js | 44 +++++++++ www/js/channel/player.js | 9 +- 6 files changed, 193 insertions(+), 29 deletions(-) diff --git a/package.json b/package.json index ecc3f6a..c3c1bc6 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "express": "^4.18.2", "express-session": "^1.18.0", "express-validator": "^7.2.0", + "hls.js": "^1.6.2", "mongoose": "^8.4.3", "node-cron": "^3.0.3", "nodemailer": "^6.9.16", diff --git a/src/app/channel/media/queue.js b/src/app/channel/media/queue.js index af7cc67..6638f57 100644 --- a/src/app/channel/media/queue.js +++ b/src/app/channel/media/queue.js @@ -65,6 +65,7 @@ module.exports = class{ socket.on("clear", (data) => {this.deleteRange(socket, data)}); socket.on("move", (data) => {this.moveMedia(socket, data)}); socket.on("lock", () => {this.toggleLock(socket)}); + socket.on("goLive", (data) => {this.goLive(socket, data)}); } //--- USER FACING QUEUEING FUNCTIONS --- @@ -128,32 +129,16 @@ module.exports = class{ } async stopMedia(socket){ - //Get the current channel from the database - const chanDB = await channelModel.findOne({name: socket.chan}); + try{ + //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 - if((!this.locked && await chanDB.permCheck(socket.user, 'scheduleMedia')) || await chanDB.permCheck(socket.user, 'scheduleAdmin')){ - - //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; + //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')){ + await this.stop(); } - - //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(); + }catch(err){ + return loggerUtils.socketExceptionHandler(socket, err); } } @@ -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 --- + 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){ //Pull current time const now = new Date().getTime(); @@ -421,8 +489,11 @@ module.exports = class{ //If we got a bad request if(media == null){ try{ - //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){ + //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(chanDB == null){ @@ -781,13 +852,18 @@ module.exports = class{ async end(quiet = false, noArchive = false, volatile = false, chanDB){ try{ + //If we're not playing anything + if(this.nowPlaying == null){ + //Silently ignore the request + return; + } + //Call off any existing sync timer clearTimeout(this.syncTimer); //Clear out the sync timer this.syncTimer = null; - //Keep a copy of whats playing for later when we need to clear the DB 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){ //Create an empty array to hold found items const foundItems = []; diff --git a/src/server.js b/src/server.js index fc32a3d..f6dcd98 100644 --- a/src/server.js +++ b/src/server.js @@ -168,9 +168,10 @@ app.use('/tooltip', tooltipRouter); app.use('/api', apiRouter); //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/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 app.use(express.static(path.join(__dirname, '../www'))); diff --git a/src/views/channel.ejs b/src/views/channel.ejs index 2bd8cc9..d66fdc3 100644 --- a/src/views/channel.ejs +++ b/src/views/channel.ejs @@ -34,6 +34,7 @@ along with this program. If not, see . %> <%- include('partial/scripts', {user}); %> <%# 3rd party code %> + <%# 1st party code %> <%# admin gunk %> diff --git a/www/js/channel/mediaHandler.js b/www/js/channel/mediaHandler.js index 6b03c30..4f64926 100644 --- a/www/js/channel/mediaHandler.js +++ b/www/js/channel/mediaHandler.js @@ -158,8 +158,13 @@ class rawFileBase extends mediaHandler{ buildPlayer(){ //Create player this.video = document.createElement('video'); + + //Enable controls + this.video.controls = true; + //Append it to page this.player.videoContainer.appendChild(this.video); + //Run derived method super.buildPlayer(); } @@ -488,4 +493,43 @@ class youtubeEmbedHandler extends mediaHandler{ 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"); + } } \ No newline at end of file diff --git a/www/js/channel/player.js b/www/js/channel/player.js index 55b8387..009013c 100644 --- a/www/js/channel/player.js +++ b/www/js/channel/player.js @@ -89,13 +89,20 @@ class player{ }else{ //If we have a youtube video and the official embedded iframe player is selected 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); + //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 }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 this.mediaHandler = new rawFileHandler(client, this, data.media); //Sync to time stamp - this.mediaHandler.sync(data.timestamp); + this.mediaHandler.sync(data.timestamp); }else{ this.mediaHandler = new nullHandler(client, this); }