From 8c8b2a6f0b942ae084c3819cdadde475a81597f0 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sun, 18 May 2025 17:47:47 -0400 Subject: [PATCH] Livestream Database Handling for Overwrite mode complete. Schedule goes back to pre-stream state if server crashes/stops. --- src/app/channel/media/queue.js | 218 ++++++++++++++---- src/app/channel/media/queuedMedia.js | 6 +- src/schemas/channel/channelSchema.js | 6 +- www/css/panel/queue.css | 5 + www/css/theme/movie-night.css | 1 + .../channel/panels/queuePanel/queuePanel.js | 13 +- 6 files changed, 194 insertions(+), 55 deletions(-) diff --git a/src/app/channel/media/queue.js b/src/app/channel/media/queue.js index 096a823..0609a6d 100644 --- a/src/app/channel/media/queue.js +++ b/src/app/channel/media/queue.js @@ -49,6 +49,8 @@ module.exports = class{ this.preSwitchTimer = null; //Create variable to hold currently playing media object this.nowPlaying = null; + //Create variable to hold item that was playing during the last liveStream (can't check against full duration since it might've been stopped for other reasons) + this.liveRemainder = null; //Create variable to lock standard queuing functions during livestreams this.streamLock = false; @@ -267,8 +269,15 @@ module.exports = class{ throw loggerUtils.exceptionSmith(`Unable to find channel document ${this.channel.name} while queue item!`, "queue"); } - //Capture currently playing object - const wasPlaying = this.nowPlaying; + //If something is playing + if(this.nowPlaying != null){ + //Capture currently playing object + this.liveRemainder = this.nowPlaying; + chanDB.media.liveRemainder = this.nowPlaying.uuid; + + //Save the chanDB + await chanDB.save(); + } //Kill schedule timers to prevent items from starting during the stream await this.stopScheduleTimers(); @@ -309,13 +318,6 @@ module.exports = class{ //Throw stream lock this.streamLock = true; - //If something was playing - if(wasPlaying != null){ - //Force it back into the schedule w/ saveLate enabled from nowPlaying, since it was ended with noArchive, effectively deleting it - //This is also the easiest way to bring nowPlaying media back into the schedule, since end wants to archive it :P - await this.scheduleMedia([wasPlaying], socket, chanDB, true, false, false, true); - } - //Broadcast new media object to users this.sendMedia(); }catch(err){ @@ -326,7 +328,7 @@ module.exports = class{ //--- INTERNAL USE ONLY QUEUEING FUNCTIONS --- async stopScheduleTimers(noArchive = true){ //End any currently playing media media w/o archiving - await this.end(false, noArchive); + await this.stop(); //Clear sync timer clearTimeout(this.syncTimer); @@ -417,7 +419,7 @@ module.exports = class{ } } - async removeRange(start = new Date().getTime() - 60 * 1000, end = new Date().getTime(), socket){ + async removeRange(start = new Date().getTime() - 60 * 1000, end = new Date().getTime(), socket, noUnfinished = false){ //If we're streamlocked if(this.streamLock){ //If an originating socket was provided for this request @@ -430,7 +432,7 @@ module.exports = class{ } //Find items within given range - const foundItems = this.getItemsBetweenEpochs(start, end); + const foundItems = this.getItemsBetweenEpochs(start, end, noUnfinished); try{ //DO everything ourselves since we don't have a fance end() function to do it @@ -969,7 +971,7 @@ module.exports = class{ //If we're ending an HLS Livestream if(wasPlaying.type == "livehls"){ //Redirect to the endLivestream function - return this.endLivestream(chanDB); + return this.endLivestream(wasPlaying, chanDB) } //If we're not in volatile mode and we're not ending a livestream @@ -987,7 +989,7 @@ module.exports = class{ } //If we haven't changed 'nowPlaying' in the play list - if(chanDB.media.nowPlaying.uuid == wasPlaying.uuid){ + if(chanDB.media.nowPlaying != null && chanDB.media.nowPlaying.uuid == wasPlaying.uuid){ //Take it out await chanDB.media.nowPlaying.deleteOne(); } @@ -1016,29 +1018,7 @@ module.exports = class{ } } - async endLivestream(chanDB){ - try{ - //Disable stream lock - this.streamLock = false; - - //Refresh next timer - this.refreshNextTimer(); - - //This is where I'd stick the IF statetement I'd add to switch between overwrite - await this.livestreamOverwriteSchedule(chanDB) - - //Broadcast Queue - this.broadcastQueue(); - //ACK - }catch(err){ - //Broadcast queue - this.broadcastQueue(); - //Handle the error - loggerUtils.localExceptionHandler(err); - } - } - - async livestreamOverwriteSchedule(chanDB){ + async endLivestream(wasPlaying, chanDB){ try{ //If we wheren't handed a channel if(chanDB == null){ @@ -1051,14 +1031,117 @@ module.exports = class{ //FUCK throw loggerUtils.exceptionSmith(`Unable to find channel document ${this.channel.name} while ending queue item!`, "queue"); } + //Disable stream lock + this.streamLock = false; + + //We don't have to here since someone else will do it for us :) + chanDB.media.liveRemainder = null; + + //This is where I'd stick the IF statetement I'd add to switch between overwrite + await this.livestreamOverwriteSchedule(wasPlaying, chanDB) + + //Refresh next timer + this.refreshNextTimer(); + + //Broadcast Queue + this.broadcastQueue(); + //ACK }catch(err){ + //Broadcast queue + this.broadcastQueue(); + //Handle the error + loggerUtils.localExceptionHandler(err); + } + } + + async livestreamOverwriteSchedule(wasPlaying, chanDB){ + try{ + //Get current epoch + const now = new Date().getTime() + + //If we wheren't handed a channel + 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(chanDB == null){ + //FUCK + throw loggerUtils.exceptionSmith(`Unable to find channel document ${this.channel.name} while ending queue item!`, "queue"); + } + + //mark overwrite job as finished so we don't run two sets of logic, as one needs to do a null check before it can run it's conditional + //while the other needs to run regardless of this.liveRemainders definition + let finished = false; + + //Set duration from start and end time + wasPlaying.duration = (now - wasPlaying.startTime) / 1000; + + //Throw the livestream into the archive + chanDB.media.archived.push(wasPlaying); + + //Save the DB + await chanDB.save(); + + //If we have a live remainder + if(this.liveRemainder != null){ + //If the item hasn't ended + if(finished = (this.liveRemainder.getEndTime(true) > now)){ + //Rip out early end + this.liveRemainder.earlyEnd = undefined; + + //regenerate UUID to differentiate between this and the original item + this.liveRemainder.genUUID(); + + //Re-schedule the remainder + await this.scheduleMedia([this.liveRemainder], undefined, chanDB); + } + } + + //if "THIS ISN'T OVER, PUNK!" + if(!finished){ + //Pull item from end + const wasPlayingDuringEnd = this.getItemAtEpoch(now); + + //If we ended in the middle of something + if(wasPlayingDuringEnd != null){ + const difference = (now - wasPlayingDuringEnd.startTime); + + //Take item out + await this.removeMedia(wasPlayingDuringEnd.uuid, null, chanDB); + + //Push the item up to match the difference + wasPlayingDuringEnd.startTime += difference; + + //re-set start time stamp based on media start and stream end + wasPlayingDuringEnd.startTimeStamp = Math.round(difference / 1000); + + //Make unique, true + wasPlayingDuringEnd.genUUID(); + + //Re-schedule media now that it's been cut + await this.scheduleMedia([wasPlayingDuringEnd], null, chanDB); + } + + //Remove all the in-betweeners + await this.removeRange(wasPlaying.startTime, now, null, true); + } + + + //Null out live remainder for the next stream + this.liveRemainder = null; + }catch(err){ + //Null out live remainder for the next stream + this.liveRemainder = null; + //Handle the error loggerUtils.localExceptionHandler(err); } } - stop(){ + stop(socket){ //If we're not currently playing anything if(this.nowPlaying == null){ //If an originating socket was provided for this request @@ -1073,14 +1156,17 @@ module.exports = class{ //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; + //Ignore early end for livestreams + if(this.nowPlaying.type != 'livehls'){ + //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, noUnfinished = false){ //Create an empty array to hold found items const foundItems = []; @@ -1088,8 +1174,11 @@ module.exports = class{ for(let item of this.schedule){ //If the item starts after our start date and before our end date if(item[0] >= start && item[0] <= end ){ - //Add the current item to the list - foundItems.push(item[1]); + //If we're allowed to add unifnished items, or the item has finished + if(!noUnfinished || item[1].getEndTime() <= end){ + //Add the current item to the list + foundItems.push(item[1]); + } } } @@ -1207,6 +1296,12 @@ module.exports = class{ //If the media started within the last 24 hours if(media.startTime > yesterday){ + //If we're sending out the live remainder during a live stream + if(this.liveRemainder != null && media.uuid.toString() == this.liveRemainder.uuid.toString()){ + //Throw out the early end before sending it off, so it looks like it hasn't been cut off yet (smoke n mirrors :P) + media.earlyEnd = null; + } + //Add it to the schedule array as if it where part of the actual schedule map schedule.push([media.startTime, media]); //Otherwise if it's older @@ -1277,7 +1372,7 @@ module.exports = class{ //Add record to new schedule newSched.push(record); - //Re-Schedule it in RAM + //Re-Schedule it in RAM, with the start function running w/ DB transactions enabled, since it won't happen right away await this.scheduleMedia([mediaObj], null, chanDB, true, true, false); }else{ //If the media should be playing now @@ -1285,7 +1380,7 @@ module.exports = class{ //Save record to nowPlaying in the DB chanDB.media.nowPlaying = record; - //Re-Schedule it in RAM + //Schedule the fucker in RAM, w/ the start function also running in RAM-Only mode await this.scheduleMedia([mediaObj], null, chanDB, true, true, true); //If it's been ended }else{ @@ -1295,6 +1390,39 @@ module.exports = class{ } } + //If we have a remainder from a livestream + if(chanDB.media.liveRemainder){ + //Iterate backwards through the archive to pull the newest first, since that's probably where this fucker is + for(let archiveIndex = (chanDB.media.archived.length - 1); archiveIndex > 0; archiveIndex--){ + //Grab the current media object + const archivedMedia = chanDB.media.archived[archiveIndex]; + + //If the current object matches our remainder UUID + if((archivedMedia.uuid.toString() == chanDB.media.liveRemainder.toString())){ + //Null out any early end + archivedMedia.earlyEnd = null; + + //Re-hydrate the item + const archivedMediaObject = archivedMedia.rehydrate(); + + //if we still have a video to finish + if(archivedMediaObject.getEndTime() > now){ + //Set the fucker as now playing + chanDB.media.nowPlaying = archivedMediaObject; + + //Schedule the fucker in RAM, w/ the start function also running in RAM-Only mode + this.scheduleMedia([archivedMediaObject], null, chanDB, true, true, true); + + //Splice the fucker out of the archive + chanDB.media.archived.splice(archiveIndex, 1); + } + + //Break out of the loop + break; + } + } + } + //Update schedule to only contain what hasn't been played yet chanDB.media.scheduled = newSched; diff --git a/src/app/channel/media/queuedMedia.js b/src/app/channel/media/queuedMedia.js index 85fc365..fe4c68e 100644 --- a/src/app/channel/media/queuedMedia.js +++ b/src/app/channel/media/queuedMedia.js @@ -18,7 +18,7 @@ along with this program. If not, see .*/ const media = require('./media'); module.exports = class extends media{ - constructor(title, fileName, url, id, type, duration, rawLink, startTime, startTimeStamp, earlyEnd, uuid){ + constructor(title, fileName, url, id, type, duration, rawLink, startTime, startTimeStamp = 0, earlyEnd, uuid){ //Call derived constructor super(title, fileName, url, id, type, duration, rawLink); //Set media start time @@ -77,9 +77,9 @@ module.exports = class extends media{ this.uuid = crypto.randomUUID(); } - getEndTime(){ + getEndTime(fullTime = false){ //If we have an early ending - if(this.earlyEnd == null){ + if(this.earlyEnd == null || fullTime){ //Calculate our ending return this.startTime + ((this.duration - this.startTimeStamp) * 1000); }else{ diff --git a/src/schemas/channel/channelSchema.js b/src/schemas/channel/channelSchema.js index 235023f..fd2d4ad 100644 --- a/src/schemas/channel/channelSchema.js +++ b/src/schemas/channel/channelSchema.js @@ -111,7 +111,11 @@ const channelSchema = new mongoose.Schema({ scheduled: [queuedMediaSchema], //We should consider moving archived media and channel playlists to their own collections/models for preformances sake archived: [queuedMediaSchema], - playlists: [playlistSchema] + playlists: [playlistSchema], + liveRemainder: { + type: mongoose.SchemaTypes.UUID, + required: false + } }, //Thankfully we don't have to keep track of alts, ips, or deleted users so this should be a lot easier than site-wide bans :P banList: [channelBanSchema] diff --git a/www/css/panel/queue.css b/www/css/panel/queue.css index d3907ca..378b3fc 100644 --- a/www/css/panel/queue.css +++ b/www/css/panel/queue.css @@ -104,6 +104,11 @@ div.queue-entry{ user-select: none; } + +div.queue-entry.live{ + z-index: 2; +} + div.queue-entry p{ z-index: 2; pointer-events: none; diff --git a/www/css/theme/movie-night.css b/www/css/theme/movie-night.css index 8e17813..de764eb 100644 --- a/www/css/theme/movie-night.css +++ b/www/css/theme/movie-night.css @@ -47,6 +47,7 @@ along with this program. If not, see .*/ --danger0-alt2: rgb(242, 189, 189); --danger-glow0: 2px 2px 3px var(--danger0), -2px 2px 3px var(--danger0), 2px -2px 3px var(--danger0), -2px -2px 3px var(--danger0); --danger-glow0-alt1: 2px 2px 3px var(--danger0-alt1), -2px 2px 3px var(--danger0-alt1), 2px -2px 3px var(--danger0-alt1), -2px -2px 3px var(--danger0-alt1); + --danger-glow0-smol: 2px 2px -1px var(--danger0), -2px 2px -1px var(--danger0), 2px -2px -1px var(--danger0), -2px -2px -1px var(--danger0); --timer-glow: -2px 1px 3px var(--danger0-alt1), 2px -1px 3px var(--danger0-alt1); diff --git a/www/js/channel/panels/queuePanel/queuePanel.js b/www/js/channel/panels/queuePanel/queuePanel.js index 1ef06c9..e2c7be4 100644 --- a/www/js/channel/panels/queuePanel/queuePanel.js +++ b/www/js/channel/panels/queuePanel/queuePanel.js @@ -616,8 +616,8 @@ class queuePanel extends panelObj{ menuMap.set("Play now", ()=>{this.client.socket.emit('move', {uuid: entry[1].uuid})}); //Add 'Move To...' option to context menu menuMap.set("Move To...", (event)=>{new reschedulePopup(event, this.client, entry[1], null, this.ownerDoc)}); - //Otherwise, if the item is currently playing - }else if(this.getMediaEnd(entry[1]) > now.getTime()){ + //Otherwise, if the item is currently playing (confirm with UUID since time might not always be reliable, such as during livestreams) + }else if(entry[1].uuid == this.client.player.mediaHandler.nowPlaying.uuid){ //Add 'Stop' option to context menu menuMap.set("Stop", ()=>{this.client.socket.emit('stop')}); //Add the Now Playing glow, not the prettiest place to add this, but why let a good conditional go to waste? @@ -1068,8 +1068,8 @@ class queuePanel extends panelObj{ const entryTitle = document.createElement('p'); entryTitle.textContent = utils.unescapeEntities(nowPlaying.title); - //Set entry div bottom-border location based on current time - entryDiv.style.bottom = `${this.offsetByDate(date, true)}px` + //Set entry div bottom-border location based on current time, round to match time marker + entryDiv.style.bottom = `${Math.round(this.offsetByDate(date, true))}px` //Assembly entryDiv entryDiv.appendChild(entryTitle); @@ -1100,8 +1100,8 @@ class queuePanel extends panelObj{ //Append entry div to queue container this.queueContainer.appendChild(entryDiv); }else{ - //Update existing entry - staleEntry.style.bottom = `${this.offsetByDate(date, true)}px` + //Update existing entry, round offset to match time marker + staleEntry.style.bottom = `${Math.round(this.offsetByDate(date, true))}px` } //Keep tooltip date seperate so it re-calculates live duration properly @@ -1206,6 +1206,7 @@ class queuePanel extends panelObj{ } getMediaEnd(media){ + console.log(media); //If we have an early end if(media.earlyEnd != null){ return media.startTime + (media.earlyEnd * 1000);