1058 lines
40 KiB
JavaScript
1058 lines
40 KiB
JavaScript
/*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 <https://www.gnu.org/licenses/>.*/
|
|
|
|
//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){
|
|
//Queueing Functions
|
|
socket.on("queue", (data) => {this.queueURL(socket, data)});
|
|
socket.on("stop", (data) => {this.stopMedia(socket)}); //needs perms
|
|
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)});
|
|
|
|
|
|
socket.on("chanDump", () => {this.chanDump(socket)});
|
|
}
|
|
|
|
chanDump(socket){
|
|
socket.emit('chanDump',
|
|
{
|
|
nowPlaying: this.nowPlaying,
|
|
schedule: Array.from(this.schedule),
|
|
//timer: this.nextTimer
|
|
}
|
|
)
|
|
}
|
|
|
|
|
|
//--- 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
async stopMedia(socket){
|
|
//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;
|
|
}
|
|
|
|
//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();
|
|
console.log(`Media set to end due at the request of ${socket.user}`);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
//If we fell through, just return input
|
|
return start;
|
|
}
|
|
|
|
refreshNextTimer(volatile = false){
|
|
//Grab the next item
|
|
const nextItem = this.getNextItem();
|
|
|
|
//Get current item
|
|
const currentItem = this.getItemAtEpoch()
|
|
|
|
//Clear out any stale timer to prevent ghost queueing
|
|
clearTimeout(this.nextTimer);
|
|
|
|
//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);
|
|
console.log("starting now from refreshNextTimer")
|
|
//Otherwise, if we have a next item
|
|
//CODE CHANGE CODE CHANGE
|
|
//Changes this over to an else if so refreshNextTimer wouldn't start it twice when there was no current item or next item
|
|
}else if(nextItem != null){
|
|
//Calculate the amount of time in ms that the next item will start in
|
|
const startsIn = nextItem.startTime - new Date().getTime();
|
|
|
|
//Set the next timer
|
|
this.nextTimer = setTimeout(()=>{this.start(nextItem, nextItem.startTimeStamp, volatile)}, startsIn);
|
|
console.log(`next item '${nextItem.title}' timer set to start in ${startsIn} seconds`);
|
|
}else{
|
|
console.log('next timer is unset');
|
|
}
|
|
}
|
|
|
|
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);
|
|
console.log("Media ended due to removal of media");
|
|
//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
|
|
*/
|
|
|
|
for(let mediaObj of media){
|
|
//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);
|
|
|
|
//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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
//If we fucked with the DB during the main loop
|
|
if(chanDB != null && !volatile){
|
|
try{
|
|
//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 true to let everyone know this shit worked
|
|
return true;
|
|
}
|
|
|
|
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);
|
|
console.log("Media ended due to start of new media");
|
|
}
|
|
|
|
//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);
|
|
console.log('kicking off sync timer from start');
|
|
|
|
//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 been cleared out
|
|
if(this.nowPlaying == null){
|
|
//Fuck off and don't bother
|
|
return;
|
|
}
|
|
|
|
//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);
|
|
console.log('re-kicking sync timer from sync');
|
|
}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);
|
|
console.log(`Media set to end by sync function in ${leftover} milliseconds`);
|
|
}
|
|
}
|
|
|
|
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 DB
|
|
if(chanDB.media.nowPlaying.uuid == wasPlaying.uuid){
|
|
//Take it out
|
|
await chanDB.media.nowPlaying.deleteOne();
|
|
}
|
|
|
|
//NOTE: Keep an eye on this
|
|
//It seems this was part of my original design (don't remember was high)
|
|
//Though it is weird we keep it in both this.schedule and this.nowPlaying at the same time...
|
|
//More testing will have to be done...
|
|
//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);
|
|
}
|
|
}
|
|
} |