From 8a273d8055c807c3cf63033f2b25e61005e3079c Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Sat, 15 Feb 2025 11:02:58 -0500 Subject: [PATCH] Finished up with Persistent queue storage in database. --- src/app/channel/media/queue.js | 355 ++++++++++++++++++++++------ www/css/theme/movie-night.css | 4 + www/js/channel/panels/queuePanel.js | 17 +- 3 files changed, 296 insertions(+), 80 deletions(-) diff --git a/src/app/channel/media/queue.js b/src/app/channel/media/queue.js index 306f9fc..b398601 100644 --- a/src/app/channel/media/queue.js +++ b/src/app/channel/media/queue.js @@ -159,13 +159,13 @@ module.exports = class{ //If we don't have a valid UUID if(!validator.isUUID(data.uuid)){ //Bitch, moan, complain... - loggerUtils.socketErrorHandler(socket, "Bad UUID!", "validation"); + loggerUtils.socketErrorHandler(socket, "Bad UUID!", "queue"); //and ignore it! return; } //Remove media by UUID - this.removeMedia(data.uuid, socket); + await this.removeMedia(data.uuid, socket); }catch(err){ return loggerUtils.socketExceptionHandler(socket, err); } @@ -181,7 +181,7 @@ module.exports = class{ //If we don't have a valid UUID if(!validator.isUUID(data.uuid)){ //Bitch, moan, complain... - loggerUtils.socketErrorHandler(socket, "Bad UUID!", "validation"); + loggerUtils.socketErrorHandler(socket, "Bad UUID!", "queue"); //and ignore it! return; } @@ -246,7 +246,7 @@ module.exports = class{ this.scheduleMedia(mediaObj, socket); } - refreshNextTimer(){ + refreshNextTimer(volatile = false){ //Grab the next item const nextItem = this.getNextItem(); @@ -258,7 +258,7 @@ module.exports = class{ //If we have a current item and it isn't currently playing if(currentItem != null && (this.nowPlaying == null || currentItem.uuid != this.nowPlaying.uuid)){ //Start the found item at w/ a pre-calculated time stamp to reflect the given start time - this.start(currentItem, Math.round((new Date().getTime() - currentItem.startTime) / 1000) + currentItem.startTimeStamp); + this.start(currentItem, Math.round((new Date().getTime() - currentItem.startTime) / 1000) + currentItem.startTimeStamp, volatile); } //otherwise if we have an item }else{ @@ -268,22 +268,44 @@ module.exports = class{ //Clear out any item that might be up next clearTimeout(this.nextTimer); //Set the next timer - this.nextTimer = setTimeout(()=>{this.start(nextItem, nextItem.startTimeStamp)}, startsIn); + this.nextTimer = setTimeout(()=>{this.start(nextItem, nextItem.startTimeStamp, volatile)}, startsIn); } } - 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){ //Find items within given range const foundItems = this.getItemsBetweenEpochs(start, end); - //For each item - for(let item of foundItems){ - //Remove media - this.removeMedia(item.uuid, socket); + try{ + //DO everything ourselves since we don't have a fance end() function to do it + const chanDB = await channelModel.findOne({name:this.channel.name}); + + //If we couldn't find the channel + if(chanDB == null){ + //FUCK + throw new Error(`Unable to find channel document ${this.channel.name} while queue item!`); + } + + //For each item + for(let item of foundItems){ + //Remove media, passing down chanDB so we're not looking again and again + await this.removeMedia(item.uuid, socket, chanDB); + } + + }catch(err){ + //If this was originated by someone + if(socket != null){ + //Bitch at them + loggerUtils.socketExceptionHandler(socket, err); + //If not + }else{ + //Bitch to the console + loggerUtils.localExceptionHandler(err); + } } } - rescheduleMedia(uuid, start = new Date().getTime() + 5, socket){ + async rescheduleMedia(uuid, start = new Date().getTime() + 5, socket){ //Find our media, don't remove it yet since we want to do some more testing first const media = this.getItemByUUID(uuid); @@ -320,7 +342,7 @@ module.exports = class{ } //Remove the media from the schedule - this.removeMedia(uuid); + await this.removeMedia(uuid); //Grab the old start time for safe keeping const oldStart = media.startTime; @@ -333,7 +355,7 @@ module.exports = class{ //Attempt to schedule media at given time //Otherwise, if it returns false for fuckup - if(!this.scheduleMedia(media, socket)){ + if(!(await this.scheduleMedia(media, socket))){ //Reset start time media.startTime = oldStart; @@ -341,39 +363,112 @@ module.exports = class{ media.startTimeStamp = 0; //Schedule in old slot - this.scheduleMedia(media, socket, true); + this.scheduleMedia(media, socket, null, true); } } - removeMedia(uuid, socket){ + async removeMedia(uuid, socket, chanDB){ //Get requested media const media = this.getItemByUUID(uuid); //If we got a bad request if(media == null){ - //If an originating socket was provided for this request - if(socket != null){ - //Yell at the user for being an asshole - loggerUtils.socketErrorHandler(socket, "Cannot delete non-existant item!", "queue"); + 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 couldn't find the channel + if(chanDB == null){ + //FUCK + throw new Error(`Unable to find channel document ${this.channel.name} while queue item!`); + } + + //Keep a copy of the archive that hasn't been changed + const preArchive = chanDB.media.archived; + + //Filter out the requested item from the archive + chanDB.media.archived = chanDB.media.archived.filter((record)=>{ + return record.uuid.toString() != uuid; + }) + + //If nothing changed in the archive + if(preArchive.length == chanDB.media.archived.length){ + //If an originating socket was provided for this request + if(socket != null){ + //Yell at the user for being an asshole + loggerUtils.socketErrorHandler(socket, "Cannot delete non-existant item!", "queue"); + } + //Otherwise + }else{ + //Broadcast changes + this.broadcastQueue(chanDB); + + //Save changes to the DB + await chanDB.save(); + } + }catch(err){ + //If this was originated by someone + if(socket != null){ + //Bitch at them + loggerUtils.socketExceptionHandler(socket, err); + //If not + }else{ + //Bitch to the console + loggerUtils.localExceptionHandler(err); + } } + //Ignore it return false; } - //If we're currently playing the requested item. - if(this.nowPlaying != null && this.nowPlaying.uuid == uuid){ - //End playback - this.end(); - } - - //Take the item out of the schedule + //Take the item out of the schedule map this.schedule.delete(media.startTime); //Refresh next timer this.refreshNextTimer(); - //Broadcast the channel queue - this.broadcastQueue(); + //If we're currently playing the requested item. + if(this.nowPlaying != null && this.nowPlaying.uuid == uuid){ + //End playback + this.end(false, true); + //otherwise + }else{ + 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 couldn't find the channel + if(chanDB == null){ + //FUCK + throw new Error(`Unable to find channel document ${this.channel.name} while queue item!`); + } + + //Filter media out by UUID + chanDB.media.scheduled = chanDB.media.scheduled.filter((record) => { + return record.uuid != uuid; + }); + + await chanDB.save(); + + //Broadcast the channel + this.broadcastQueue(chanDB); + + }catch(err){ + //Broadcast the channel + this.broadcastQueue(); + + //If this was originated by someone + if(socket != null){ + //Bitch at them + loggerUtils.socketExceptionHandler(socket, err); + //If not + }else{ + //Bitch to the console + loggerUtils.localExceptionHandler(err); + } + } + } //return found media in-case our calling function needs it :P return media; @@ -400,12 +495,9 @@ module.exports = class{ //End the media this.end(); - - //Broadcast the channel queue - this.broadcastQueue(); } - scheduleMedia(mediaObj, socket, force = false){ + async scheduleMedia(mediaObj, socket, chanDB, force = false, volatile = false, startVolatile = false){ /* This is a fun method and I think it deserves it's own little explination... Since we're working with a time based schedule, using start epochs as keys for our iterable seemed the best option I don't want to store everything in a sparse array because that *feels* icky, and would probably be a pain in the ass. @@ -490,17 +582,54 @@ module.exports = class{ this.broadcastQueue(); //Refresh the next timer to ensure whatever comes on next is right - this.refreshNextTimer(); + this.refreshNextTimer(startVolatile); + + //If media has more than a minute before starting and DB transactions are enabled + if(mediaObj.startTime - new Date().getTime() > 1000 && !volatile){ + //fuck you yoda you fucking nerd + try{ + //If we didn't get handed a freebie + if(chanDB == null){ + //Go out and get it done ourselves + chanDB = await channelModel.findOne({name:this.channel.name}); + } + + //If we couldn't find the channel + if(chanDB == null){ + //FUCK + throw new Error(`Unable to find channel document ${this.channel.name} while saving item to queue!`); + } + + //Add media to the persistant schedule + chanDB.media.scheduled.push(mediaObj); + + //Save the database + chanDB.save(); + + //If something fucked up + }catch(err){ + //If this was originated by someone + if(socket != null){ + //Bitch at them + loggerUtils.socketExceptionHandler(socket, err); + //If not + }else{ + //Bitch to the console + loggerUtils.localExceptionHandler(err); + } + } + } //return media object for use return mediaObj; } - async start(mediaObj, timestamp = mediaObj.startTimeStamp){ + async start(mediaObj, timestamp = mediaObj.startTimeStamp, volatile = false){ //If something is already playing if(this.nowPlaying != null){ - //Silently end the media - this.end(true); + //Silently end the media in RAM so the database isn't stepping on itself up ahead + //Alternatively we could've used await, but then we'd be doubling up on DB transactions :P + this.end(true, true, true); } //reset current timestamp @@ -509,17 +638,31 @@ module.exports = class{ //Set current playing media this.nowPlaying = mediaObj; - try{ - //Get our channel - const chanDB = await channelModel.findOne({name: this.channel.name}); + //if DB transactions are enabled + if(!volatile){ + try{ + //Get our channel + const chanDB = await channelModel.findOne({name: this.channel.name}); - //Set the now playing queued media document - chanDB.media.nowPlaying = mediaObj; + //If nowPlaying isn't null + if(chanDB.media.nowPlaying != null){ + //Archive whats already in there since we're about to clobber the fuck out of it + chanDB.media.archived.push(chanDB.media.nowPlaying); + } - //Save the channel - await chanDB.save(); - }catch(err){ - loggerUtils.localExceptionHandler(err); + //Set the now playing queued media document + chanDB.media.nowPlaying = mediaObj; + + //Filter media out of schedule by UUID + chanDB.media.scheduled = chanDB.media.scheduled.filter((record) => { + return record.uuid != mediaObj.uuid; + }); + + //Save the channel + await chanDB.save(); + }catch(err){ + loggerUtils.localExceptionHandler(err); + } } //Send play signal out to the channel @@ -555,7 +698,7 @@ module.exports = class{ } } - async end(quiet = false){ + async end(quiet = false, noArchive = false, volatile = false, chanDB){ try{ //Call off any existing sync timer clearTimeout(this.syncTimer); @@ -579,30 +722,41 @@ module.exports = class{ this.server.io.in(this.channel.name).emit('end', {}); } - //Now that everything is clean, we can take our time with the DB :P - const chanDB = await channelModel.findOne({name:this.channel.name}); + if(!volatile){ + //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 new Error(`Unable to find channel document ${this.channel.name} while ending queue item!`); + //If we couldn't find the channel + if(chanDB == null){ + //FUCK + throw new Error(`Unable to find channel document ${this.channel.name} while ending queue item!`); + } + + //If we haven't changed 'nowPlaying' in the play list + if(chanDB.media.nowPlaying.uuid == wasPlaying.uuid){ + //Take it out + await chanDB.media.nowPlaying.deleteOne(); + } + + //Take it out of the active schedule + this.schedule.delete(wasPlaying.startTime); + + if(!noArchive){ + //Add the item to the channel archive + chanDB.media.archived.push(wasPlaying); + } + + //broadcast queue using unsaved archive + this.broadcastQueue(chanDB); + + //Save our changes to the DB + await chanDB.save(); + }else{ + //broadcast queue using unsaved archive + this.broadcastQueue(chanDB); } - - //If we haven't changed 'nowPlaying' in the play list - if(chanDB.media.nowPlaying.uuid == wasPlaying.uuid){ - //Take it out - await chanDB.media.nowPlaying.deleteOne(); - } - - //Take it out of the active schedule - this.schedule.delete(wasPlaying.startTime); - - //Add the item to the channel archive - chanDB.media.archived.push(wasPlaying); - - //Save our changes to the DB - await chanDB.save(); }catch(err){ + this.broadcastQueue(); loggerUtils.localExceptionHandler(err); } } @@ -704,7 +858,7 @@ module.exports = class{ } async broadcastQueue(chanDB){ - this.server.io.in(this.channel.name).emit('queue',{queue: await this.prepQueue()}); + this.server.io.in(this.channel.name).emit('queue',{queue: await this.prepQueue(chanDB)}); } async prepQueue(chanDB){ @@ -727,11 +881,10 @@ module.exports = class{ //Get yestedays epoch const yesterday = new Date().setDate(new Date().getDate() - 1); - //Iterate through the channel archive backwards to save time for(let mediaIndex = chanDB.media.archived.length - 1; mediaIndex >= 0; mediaIndex--){ //Grab the current media record - const media = chanDB.media.archived[mediaIndex]; + let media = chanDB.media.archived[mediaIndex].rehydrate(); //If the media started within the last 24 hours if(media.startTime > yesterday){ @@ -770,14 +923,66 @@ module.exports = class{ throw new Error(`Unable to find channel document ${this.channel.name} while rehydrating queue!`); } + const now = new Date().getTime(); + + //Next: Update this function to handle ended items + //If something was playing if(chanDB.media.nowPlaying != null){ - //Rehydrate the currently playing item + //Rehydrate the currently playing item int oa queued media object const wasPlaying = chanDB.media.nowPlaying.rehydrate(); - //Schedule it - this.scheduleMedia(wasPlaying, null, true); - } + //If the media hasn't ended yet + if(wasPlaying.getEndTime() > now){ + //Re-Schedule it in RAM + await this.scheduleMedia(wasPlaying, null, chanDB, true, true); + //Otherwise, if it has + }else{ + //Null out nowPlaying + chanDB.media.nowPlaying = null; + + //Archive the bitch + chanDB.media.archived.push(wasPlaying); + } + } + + //Create a new array to hold the new schedule so we only have to write to the DB once. + let newSched = []; + + //For every saved scheduled item + for(let record of chanDB.media.scheduled){ + //Rehydrate the current record into a queued media object + const mediaObj = record.rehydrate(); + + //If the item hasn't started + if(mediaObj.startTime > now){ + //Add record to new schedule + newSched.push(record); + + + //Re-Schedule it in RAM + await this.scheduleMedia(mediaObj, null, chanDB, true, true, false); + }else{ + //If the media should be playing now + if(mediaObj.getEndTime() > now){ + //Save record to nowPlaying in the DB + chanDB.media.nowPlaying = record; + + //Re-Schedule it in RAM + await this.scheduleMedia(mediaObj, null, chanDB, true, true, true); + //If it's been ended + }else{ + //Archive ended media + chanDB.media.archived.push(record); + } + } + } + + //Update schedule to only contain what hasn't been played yet + chanDB.media.scheduled = newSched; + + //Save the DB + await chanDB.save(); //if something fucked up }catch(err){ diff --git a/www/css/theme/movie-night.css b/www/css/theme/movie-night.css index d555679..9e9561c 100644 --- a/www/css/theme/movie-night.css +++ b/www/css/theme/movie-night.css @@ -535,6 +535,10 @@ div.now-playing{ text-shadow: var(--focus-glow0); } +div.archived{ + color: var(--bg2-alt1); +} + /* altcha theming*/ div.altcha{ box-shadow: 4px 4px 1px var(--bg1-alt0) inset; diff --git a/www/js/channel/panels/queuePanel.js b/www/js/channel/panels/queuePanel.js index 264d1e0..8a5a5e1 100644 --- a/www/js/channel/panels/queuePanel.js +++ b/www/js/channel/panels/queuePanel.js @@ -566,7 +566,7 @@ class queuePanel extends panelObj{ `Source: ${entry[1].type}`, `Duration: ${entry[1].duration}`, `Start Time: ${new Date(entry[1].startTime).toLocaleString()}${entry[1].startTimeStamp == 0 ? '' : ' (Started Late)'}`, - `End Time: ${new Date(this.getMediaEnd(entry[1])).toLocaleString()}` + `End Time: ${new Date(this.getMediaEnd(entry[1])).toLocaleString()}${entry[1].earlyEnd == null ? '' : ' (Ended Early)'}` ]){ //Create a 'p' node const component = document.createElement('p'); @@ -587,19 +587,23 @@ class queuePanel extends panelObj{ //Create context menu map const menuMap = new Map(); + const now = new Date(); //If the item hasn't started yet - if(entry[1].startTime > new Date().getTime()){ + if(entry[1].startTime > now.getTime()){ //Add 'Play' option to context menu 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]) > new Date().getTime()){ + }else if(this.getMediaEnd(entry[1]) > now.getTime()){ //Add 'Stop' option to context menu menuMap.set("Stop", ()=>{this.client.socket.emit('stop', {uuid: entry[1].uuid})}); //Add the Now Playing glow, not the prettiest place to add this, but why let a good conditional go to waste? entryDiv.classList.add('now-playing'); + //Otherwise, if the item has been archived + }else{ + entryDiv.classList.add('archived'); } //Add 'Delete' option to context menu @@ -609,8 +613,11 @@ class queuePanel extends panelObj{ //Add 'Copy URL' option to context menu menuMap.set("Copy URL", ()=>{navigator.clipboard.writeText(entry[1].url);}) - //Setup drag n drop - entryDiv.addEventListener('mousedown', clickEntry.bind(this)); + //If the item hasn't yet ended + if(this.getMediaEnd(entry[1]) > now.getTime()){ + //Setup drag n drop + entryDiv.addEventListener('mousedown', clickEntry.bind(this)); + } //Setup context menu entryDiv.addEventListener('contextmenu', (event)=>{