/*Canopy - The next generation of stoner streaming software Copyright (C) 2024-2025 Rainbownapkin and the TTN Community This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see .*/ //NPM imports const validator = require('validator'); //Local imports const queuedMedia = require('./queuedMedia'); const yanker = require('../../../utils/media/yanker'); const loggerUtils = require('../../../utils/loggerUtils'); const channelModel = require('../../../schemas/channel/channelSchema'); module.exports = class{ constructor(server, chanDB, channel){ //Set server this.server = server //Set channel this.channel = channel; //Create map to hold currently queued media this.schedule = new Map(); //Create variable to hold sync delta in ms this.syncDelta = 1000; //Create variable to hold current timestamp within the video this.timestamp = 0; //Create variable to hold sync timer this.syncTimer = null; //Create variable to hold next playing item timer this.nextTimer = null; //Create variable to hold currently playing media object this.nowPlaying = null; //create boolean to hold schedule lock this.locked = false; //Rehydrate channel queue from database this.rehydrateQueue(chanDB); } defineListeners(socket){ socket.on("queue", (data) => {this.queueURL(socket, data)}); socket.on("stop", (data) => {this.stopMedia(socket)}); socket.on("delete", (data) => {this.deleteMedia(socket, data)}); socket.on("clear", (data) => {this.deleteRange(socket, data)}); socket.on("move", (data) => {this.moveMedia(socket, data)}); socket.on("lock", () => {this.toggleLock(socket)}); } //--- USER FACING QUEUEING FUNCTIONS --- async queueURL(socket, data){ //Get the current channel from the database const chanDB = await channelModel.findOne({name: socket.chan}); if((!this.locked && await chanDB.permCheck(socket.user, 'scheduleMedia')) || await chanDB.permCheck(socket.user, 'scheduleAdmin')){ try{ //Set url let url = data.url; //If we where given a bad URL if(!validator.isURL(url)){ //Attempt to fix the situation by encoding it url = encodeURI(url); //If it's still bad if(!validator.isURL(url)){ //Bitch, moan, complain... loggerUtils.socketErrorHandler(socket, "Bad URL!", "validation"); //and ignore it! return; } } //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 const title = validator.escape(validator.trim(data.title)); //set start let start = this.getStart(data.start); //Pull media list const mediaList = await yanker.yankMedia(url, title); //If we didn't find any media if(mediaList == null || mediaList.length <= 0){ //Bitch, moan, complain... loggerUtils.socketErrorHandler(socket, "No media found!", "queue"); //and ignore it! return; } //Convert media list let queuedMediaList = queuedMedia.fromMediaArray(mediaList, start); //schedule the media this.scheduleMedia(queuedMediaList, socket); }catch(err){ return loggerUtils.socketExceptionHandler(socket, err); } } } stopMedia(socket){ //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; } //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(); } async deleteMedia(socket, data){ //Get the current channel from the database const chanDB = await channelModel.findOne({name: socket.chan}); if((!this.locked && await chanDB.permCheck(socket.user, 'scheduleMedia')) || await chanDB.permCheck(socket.user, 'scheduleAdmin')){ try{ //If we don't have a valid UUID if(!validator.isUUID(data.uuid)){ //Bitch, moan, complain... loggerUtils.socketErrorHandler(socket, "Bad UUID!", "queue"); //and ignore it! return; } //Remove media by UUID await this.removeMedia(data.uuid, socket); }catch(err){ return loggerUtils.socketExceptionHandler(socket, err); } } } async deleteRange(socket, data){ //Get the current channel from the database const chanDB = await channelModel.findOne({name: socket.chan}); if((!this.locked && await chanDB.permCheck(socket.user, 'clearSchedule')) || await chanDB.permCheck(socket.user, 'scheduleAdmin')){ try{ //If start time isn't an integer if(data.start != null && !validator.isInt(String(data.start))){ //Bitch, moan, complain... loggerUtils.socketErrorHandler(socket, "Bad start date!", "queue"); //and ignore it! return; } //If end time isn't an integer if(data.end != null && !validator.isInt(String(data.end))){ //Bitch, moan, complain... loggerUtils.socketErrorHandler(socket, "Bad end date!", "queue"); //and ignore it! return; } this.removeRange(data.start, data.end, socket); }catch(err){ return loggerUtils.socketExceptionHandler(socket, err); } } } async moveMedia(socket, data){ //Get the current channel from the database const chanDB = await channelModel.findOne({name: socket.chan}); if((!this.locked && await chanDB.permCheck(socket.user, 'scheduleMedia')) || await chanDB.permCheck(socket.user, 'scheduleAdmin')){ try{ //If we don't have a valid UUID if(!validator.isUUID(data.uuid)){ //Bitch, moan, complain... loggerUtils.socketErrorHandler(socket, "Bad UUID!", "queue"); //and ignore it! return; } //If start time isn't an integer if(data.start != null && !validator.isInt(String(data.start))){ //Null out time to tell the later parts of the function to start it now data.start = undefined; } //Move media by UUID this.rescheduleMedia(data.uuid, data.start, socket); }catch(err){ return loggerUtils.socketExceptionHandler(socket, err); } } } async toggleLock(socket){ //Get the current channel from the database const chanDB = await channelModel.findOne({name: socket.chan}); //If the user is a schedule admin if(await chanDB.permCheck(socket.user, 'scheduleAdmin')){ //Toggle the schedule lock this.locked = !this.locked; //Update schedule lock status for everyone in the channel this.server.io.in(this.channel.name).emit("lock", {locked: this.locked}); } } //--- INTERNAL USE ONLY QUEUEING FUNCTIONS --- getStart(start){ //Pull current time const now = new Date().getTime(); //If start time is null, or it isn't a valid integer after the current epoch if(start == null || !validator.isInt(String(start), {min: now})){ //Get last item from schedule const lastItem = (Array.from(this.schedule)[this.schedule.size - 1]); //if we have a last item if(lastItem != null){ //If the last item has ended if(lastItem[1].getEndTime() < now){ //Throw it on in five ms return now; //If it hasn't ended yet }else{ //Throw it on five ms after the last item return lastItem[1].getEndTime() + 5; } //If we don't have a last item }else{ //Throw it on in five ms return now; } } } refreshNextTimer(volatile = false){ //Grab the next item const nextItem = this.getNextItem(); //If we have no next item if(nextItem == null){ //Get current item const currentItem = this.getItemAtEpoch() //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, volatile); } //otherwise if we have an item }else{ //Calculate the amount of time in ms that the next item will start in const startsIn = nextItem.startTime - new Date().getTime(); //Clear out any item that might be up next clearTimeout(this.nextTimer); //Set the next timer this.nextTimer = setTimeout(()=>{this.start(nextItem, nextItem.startTimeStamp, volatile)}, startsIn); } } async removeRange(start = new Date().getTime() - 60 * 1000, end = new Date().getTime(), socket){ //Find items within given range const foundItems = this.getItemsBetweenEpochs(start, end); 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); } } } async rescheduleMedia(uuid, start = new Date().getTime(), socket){ //Find our media, don't remove it yet since we want to do some more testing first 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 move non-existant item!", "queue"); } //Ignore it return; } //If someone is trying to re-schedule something that starts in the past if(media.startTime < new Date().getTime()){ //If an originating socket was provided for this request if(socket != null){ //If the item is currently playing if(media.getEndTime() > new Date().getTime()){ //Yell at the user for being an asshole loggerUtils.socketErrorHandler(socket, "You cannot move an actively playing video!", "queue"); //Otherwise, if it's already ended }else{ //Yell at the user for being an asshole loggerUtils.socketErrorHandler(socket, "You cannot alter the past!", "queue"); } } //Ignore it return; } //Remove the media from the schedule await this.removeMedia(uuid); //Grab the old start time for safe keeping const oldStart = media.startTime; //Set media time media.startTime = start; //Reset the start time stamp for re-calculation media.startTimeStamp = 0; //Attempt to schedule media at given time //Otherwise, if it returns false for fuckup if(!(await this.scheduleMedia([media], socket))){ //Reset start time media.startTime = oldStart; //Reset the start time stamp for re-calculation media.startTimeStamp = 0; //Schedule in old slot this.scheduleMedia([media], socket, null, true); } } async removeMedia(uuid, socket, chanDB){ //Get requested media const media = this.getItemByUUID(uuid); //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 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; } //Take the item out of the schedule map this.schedule.delete(media.startTime); //Refresh next timer this.refreshNextTimer(); //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; } async scheduleMedia(media, 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. Maps seem like a good choice, if it wheren't for the issue of keeping them ordered... That's where this comes in. You see if we temporarily store it in a sparse array and convert into a map, we can quickly and easily create a properly sorted schedule map that, out side of adding items, behaves normally. Also a note on preformance: While .forEach ONLY runs through populated items in sparse arrays, many JS implementations run through them in the background, simply skipping them before executing the provided function. Looping through object.keys(arr), however, avoids this entirely, since it ONLY loops through defiened items within the array. No skipped empties for your runtime to worry about. Even more preformance benefits can be had by using a real for loop on the arrays keys, skipping the overhead of forEach entirely. This might seem gross but it completely avoids the computational workload of a sorting algo, especially when you consider that, no matter what, re-ordering the schedule map would've required us to iterate through and rebuild the map anyways... Also it looks like due to implementation limitations, epochs stored as MS are too large for array elements, so we store them there as seconds. This also means that our current implementation will break exactly on unix epoch 4294967295 (Feb 7, 2106 6:28:15 AM UTC) Hopefully javascript arrays will allow for larger lengths by then. If not blame the W3C :P If for some reason they haven't and we're not dead, we could probably implement an object that wraps a 2d array and set/gets it using modulo/devision/multiplication Further Reading: https://stackoverflow.com/questions/59480871/foreach-vs-object-keys-foreach-performance-on-sparse-arrays https://community.appsmith.com/content/blog/dark-side-foreach-why-you-should-think-twice-using-it */ let mediaObj = media[0]; //If someone is trying to schedule something that starts and ends in the past if((mediaObj.getEndTime() < new Date().getTime()) && !force){ //If an originating socket was provided for this request if(socket != null){ //Yell at the user for being an asshole loggerUtils.socketErrorHandler(socket, "You cannot alter the past!", "queue"); } return false; } //If the item has already started if((mediaObj.startTime < new Date().getTime()) && !force){ //Set time stamp to existing timestamp plus the difference between the orginal start-date and now const calculatedTimeStamp = mediaObj.startTimeStamp + ((new Date().getTime() - mediaObj.startTime) / 1000) //If the calculated time stamp is more than negligible, and therefore not simply caused by serverside processing time if(calculatedTimeStamp > 5){ //Set the media timestamp mediaObj.startTimeStamp = calculatedTimeStamp; //Start the item now mediaObj.startTime = new Date().getTime(); } } //If there's already something queued right now if(this.getItemAtEpoch(mediaObj.startTime) != null || this.getItemAtEpoch(mediaObj.getEndTime())){ //If an originating socket was provided for this request if(socket != null){ //Yell at the user for being an asshole loggerUtils.socketErrorHandler(socket, "This time slot has already been taken in the queue!", "queue"); } //Ignore it return false; } //Create an empty temp array to sparsley populate with our schedule const tempSchedule = []; //Create new map to replace our current schedule map const newSchedule = new Map(); //For every item that's already been scheduled for(let item of this.schedule){ //add it to the slot corresponding to it's start epoch in seconds tempSchedule[Math.round(item[0] / 1000)] = item[1]; } //Inject the media object into the slot corresponding to it's epoch in the temp schedule array tempSchedule[Math.round(mediaObj.startTime / 1000)] = mediaObj; //For every populated key in our array for(let startTime of Object.keys(tempSchedule)){ //Add item to replacement schedule map newSchedule.set(tempSchedule[startTime].startTime, tempSchedule[startTime]); } //Replace the existing schedule map with our new one this.schedule = newSchedule; //Broadcast the channel queue this.broadcastQueue(); //Refresh the next timer to ensure whatever comes on next is right 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, volatile = false){ //If something is already playing if(this.nowPlaying != null){ //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 this.timestamp = timestamp; //Set current playing media this.nowPlaying = mediaObj; //if DB transactions are enabled if(!volatile){ try{ //Get our channel const chanDB = await channelModel.findOne({name: this.channel.name}); //If nowPlaying isn't null and isn't what we're about to throw on if(chanDB.media.nowPlaying != null && chanDB.media.nowPlaying.uuid.toString != mediaObj.uuid){ //Archive whats already in there since we're about to clobber the fuck out of it chanDB.media.archived.push(chanDB.media.nowPlaying); } //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 this.sendMedia(); //Kick off the sync timer this.syncTimer = setTimeout(this.sync.bind(this), this.syncDelta); //Setup the next video this.refreshNextTimer(); //return media object for use return mediaObj; } sync(){ //Send sync signal out to the channel this.server.io.in(this.channel.name).emit("sync", {timestamp: this.timestamp}); //If the media has over a second to go if((this.timestamp + 1) < this.nowPlaying.duration){ //Increment the time stamp this.timestamp += (this.syncDelta / 1000); //Call the sync function in another second this.syncTimer = setTimeout(this.sync.bind(this), this.syncDelta); }else{ //Get leftover video length in ms const leftover = (this.nowPlaying.duration - this.timestamp) * 1000; //Call the end function once the video is over this.syncTimer = setTimeout(this.end.bind(this), leftover); } } async end(quiet = false, noArchive = false, volatile = false, chanDB){ try{ //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; //Clear now playing this.nowPlaying = null; //Clear timestamp this.timestamp = 0; //If we're not being quiet if(!quiet){ //Tell everyone of the end-times this.server.io.in(this.channel.name).emit('end', {}); } 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 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); } }catch(err){ this.broadcastQueue(); loggerUtils.localExceptionHandler(err); } } getItemsBetweenEpochs(start, end){ //Create an empty array to hold found items const foundItems = []; //Loop through scheduled items 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]); } } //Return any found items return foundItems; } getItemAtEpoch(epoch = new Date().getTime()){ //Loop through scheduled items for(let item of this.schedule){ //If we're past or at the start time and at or before the end time if(item[0] <= epoch && item[1].getEndTime() >= epoch){ //return the current item return item[1] } } //If we fell through the loop return null return null; } getLastItem(epoch = new Date().getTime()){ //Create variable to hold the last item let last; //Loop through scheduled items for(let item of this.schedule){ //If we've stumbled on to the next item if(item[0] >= epoch){ //Break the loop break; //If we've stumbled upon an item that is currently playing }else if(item[1].getEndTime() >= epoch){ //Break the loop break; //If we made it through this iteration without breaking the loop } //Set current item to last item last = item[1]; } //If the loop has been broken or fallen through, return last. return last; } getNextItem(epoch = new Date().getTime()){ //Iterate through the schedule for(let item of this.schedule){ if(item[0] >= epoch){ //Pull the scheduled media object from the map entry array return item[1]; } } } getItemByUUID(uuid){ //Iterate through the schedule for(let item of this.schedule){ //If the uuid matches if(item[1].uuid == uuid){ //return the found item return item[1]; } } } sendMedia(socket){ //Create data object const data = { media: this.nowPlaying, timestamp: this.timestamp } //If a socket is specified if(socket != null){ //Send data out to specified socket socket.emit("start", data); //Otherwise }else{ //Send that shit out to the entire channel this.server.io.in(this.channel.name).emit("start", data); } } async broadcastQueue(chanDB){ this.server.io.in(this.channel.name).emit('queue',{queue: await this.prepQueue(chanDB)}); } async prepQueue(chanDB){ 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 rehydrating queue!`); } //Create an empty array to hold our schedule let schedule = []; //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 let media = chanDB.media.archived[mediaIndex].rehydrate(); //If the media started within the last 24 hours if(media.startTime > yesterday){ //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 }else{ //Then we should be done as archived items are added as they are played/end. //No newer items should be beyond this point! break; } } //Concatonate the actual schedule to the items we pulled out of the archive return it return schedule.concat(Array.from(this.schedule)); //If we can't get shit from the database }catch(err){ //Complain loggerUtils.localExceptionHandler(err); //broadcast what we can from RAM return Array.from(this.schedule); } } async rehydrateQueue(chanDB){ 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 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 int oa queued media object const wasPlaying = chanDB.media.nowPlaying.rehydrate(); //If the media hasn't ended yet if(wasPlaying.getEndTime() > now){ //Re-Schedule it in RAM await this.scheduleMedia([wasPlaying], null, chanDB, true, 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){ //bitch about it in the server console loggerUtils.localExceptionHandler(err); } } }