1834 lines
75 KiB
HTML
1834 lines
75 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>JSDoc: Source: app/channel/media/queue.js</title>
|
|
|
|
<script src="scripts/prettify/prettify.js"> </script>
|
|
<script src="scripts/prettify/lang-css.js"> </script>
|
|
<!--[if lt IE 9]>
|
|
<script src="//html5shiv.googlecode.com/svn/trunk/html5.js"></script>
|
|
<![endif]-->
|
|
<link type="text/css" rel="stylesheet" href="styles/prettify-tomorrow.css">
|
|
<link type="text/css" rel="stylesheet" href="styles/jsdoc-default.css">
|
|
</head>
|
|
|
|
<body>
|
|
|
|
<div id="main">
|
|
|
|
<h1 class="page-title">Source: app/channel/media/queue.js</h1>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<section>
|
|
<article>
|
|
<pre class="prettyprint source linenums"><code>/*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');
|
|
|
|
/**
|
|
* Object represneting a single channel's media queue
|
|
*/
|
|
class queue{
|
|
/**
|
|
* Instantiates a new media queue for a given channel
|
|
* @param {channelManager} server - Parent server object
|
|
* @param {Document} chanDB - Related Channel Document from DB
|
|
* @param {activeChannel} channel - Parent Channel object for desired channel queue
|
|
*/
|
|
constructor(server, chanDB, channel){
|
|
/**
|
|
* Parent Server Object
|
|
*/
|
|
this.server = server
|
|
/**
|
|
* Parent Chennel Object for desired channel queue
|
|
*/
|
|
this.channel = channel;
|
|
|
|
/**
|
|
* Map containing current schedule
|
|
*/
|
|
this.schedule = new Map();
|
|
|
|
/**
|
|
* Sync Delta in MS
|
|
*/
|
|
this.syncDelta = 1000;
|
|
/**
|
|
* Current Timestamp in Media
|
|
*/
|
|
this.timestamp = 0;
|
|
//Delay between pre-switch function call and start of media
|
|
//This should be enough time to do things like pre-fetch updated raw links from youtube
|
|
/**
|
|
* Time before media switch to run pre-switch method call against next media
|
|
*/
|
|
this.preSwitchDelta = 10 * 1000;
|
|
|
|
/**
|
|
* Syncronization Timer
|
|
*/
|
|
this.syncTimer = null;
|
|
/**
|
|
* Next Media Timer
|
|
*/
|
|
this.nextTimer = null;
|
|
/**
|
|
* Next Media Pre-Switch Timer
|
|
*/
|
|
this.preSwitchTimer = null;
|
|
/**
|
|
* Currently Playing Media Item
|
|
*/
|
|
this.nowPlaying = null;
|
|
/**
|
|
* Media interrupted by current live-stream
|
|
*/
|
|
this.liveRemainder = null;
|
|
/**
|
|
* Current live-stream schedule mode
|
|
*/
|
|
this.liveMode = null;
|
|
|
|
/**
|
|
* Locks scheduling functionality during livestreams
|
|
*/
|
|
this.streamLock = false;
|
|
/**
|
|
* Locks schedule upon admin request
|
|
*/
|
|
this.locked = false;
|
|
|
|
//Rehydrate channel queue from database
|
|
this.rehydrateQueue(chanDB);
|
|
}
|
|
|
|
/**
|
|
* Defines server-side socket.io listeners for newly connected sockets
|
|
* @param {Socket} socket - Newly connected socket to define listeners against
|
|
*/
|
|
defineListeners(socket){
|
|
//Queueing Functions
|
|
socket.on("queue", (data) => {this.queueURL(socket, data)});
|
|
socket.on("stop", () => {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("goLive", (data) => {this.goLive(socket, data)});
|
|
}
|
|
|
|
//--- USER FACING QUEUEING FUNCTIONS ---
|
|
/**
|
|
* Accepts new URL's to queue from the client
|
|
* @param {Socket} socket - Socket we're receiving the URL from
|
|
* @param {Object} data - Event payload
|
|
*/
|
|
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));
|
|
|
|
//Pull media list
|
|
const mediaList = await yanker.yankMedia(url, title);
|
|
|
|
//set start
|
|
let start = this.getStart(data.start);
|
|
|
|
//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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Processes requests to stop currently playing media from client
|
|
* @param {Socket} socket - Socket we received the request from
|
|
*/
|
|
async stopMedia(socket){
|
|
try{
|
|
//Get the current channel from the database
|
|
const chanDB = await channelModel.findOne({name: socket.chan});
|
|
|
|
//Permcheck to make sure the user can fuck w/ the queue
|
|
if((!this.locked && await chanDB.permCheck(socket.user, 'scheduleMedia')) || await chanDB.permCheck(socket.user, 'scheduleAdmin')){
|
|
await this.stop();
|
|
}
|
|
}catch(err){
|
|
return loggerUtils.socketExceptionHandler(socket, err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Processes client requests to delete queued media
|
|
* @param {Socket} socket - Requesting socket
|
|
* @param {Object} data - Event payload
|
|
*/
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Processes request to delete a range of media items from the queue
|
|
* @param {Socket} socket - Requesting socket
|
|
* @param {Object} data - Event payload
|
|
*/
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Processes request to move queued media item
|
|
* @param {Socket} socket - Requesting socket
|
|
* @param {Object} data - Event payload
|
|
*/
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle client request to (un)lock queue
|
|
* @param {Socket} socket - Requesting socket
|
|
*/
|
|
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});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle client request to start an HLS live stream
|
|
* @param {Socket} socket - Requesting socket
|
|
* @param {Object} data - Event payload
|
|
*/
|
|
async goLive(socket, data){
|
|
try{
|
|
let title = "Livestream";
|
|
|
|
if(data != null && data.title != null){
|
|
//If the title is too long
|
|
if(!validator.isLength(data.title, {max:30})){
|
|
//Bitch, moan, complain...
|
|
loggerUtils.socketErrorHandler(socket, "Title too long!", "validation");
|
|
//and ignore it!
|
|
return;
|
|
}
|
|
|
|
//Set title
|
|
title = validator.escape(validator.trim(data.title));
|
|
|
|
//If we've got no title
|
|
if(title == null || title == ''){
|
|
title = "Livestream";
|
|
}
|
|
}
|
|
|
|
//Grab the channel from DB
|
|
const 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 queue item!`, "queue");
|
|
}
|
|
|
|
//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();
|
|
|
|
//Syntatic sugar because I'm lazy :P
|
|
const streamURL = chanDB.settings.streamURL;
|
|
|
|
if(streamURL == ''){
|
|
throw loggerUtils.exceptionSmith('This channel\'s HLS Livestream Source has not been set!', 'queue');
|
|
}
|
|
|
|
|
|
//Pull filename from streamURL
|
|
let filename = streamURL.match(/^.+\..+\/(.+)$/);
|
|
|
|
//If we're streaming from the root of the domain
|
|
if(filename == null){
|
|
//Set filename to root
|
|
filename = '/';
|
|
}else{
|
|
//Otherwise, hand over the filename
|
|
filename = filename[1];
|
|
}
|
|
|
|
//Create queued media object from stream URL and set it to nowPlaying
|
|
this.nowPlaying = new queuedMedia(
|
|
title,
|
|
filename,
|
|
streamURL,
|
|
streamURL,
|
|
"livehls",
|
|
0,
|
|
streamURL,
|
|
new Date().getTime()
|
|
);
|
|
|
|
//Validate mode input, and default to overwrite
|
|
this.liveMode = (data.mode == "pushback") ? "pushback" : "overwrite";
|
|
|
|
//Throw stream lock
|
|
this.streamLock = true;
|
|
|
|
//Broadcast new media object to users
|
|
this.sendMedia();
|
|
}catch(err){
|
|
return loggerUtils.socketExceptionHandler(socket, err);
|
|
}
|
|
}
|
|
|
|
//--- INTERNAL USE ONLY QUEUEING FUNCTIONS ---
|
|
/**
|
|
* Clears and scheduling timers
|
|
* @param {Boolean} noArchive - Disables Archiving
|
|
*/
|
|
async stopScheduleTimers(noArchive = true){
|
|
//End any currently playing media media w/o archiving
|
|
await this.stop();
|
|
|
|
//Clear sync timer
|
|
clearTimeout(this.syncTimer);
|
|
//Clear next timer
|
|
clearTimeout(this.nextTimer);
|
|
//Clear the pre-switch timer
|
|
clearTimeout(this.preSwitchTimer);
|
|
|
|
//Null out the sync timer
|
|
this.syncTimer = null;
|
|
//Null out the next playing item timer
|
|
this.nextTimer = null;
|
|
//Null out the pre-switch timer
|
|
this.preSwitchTimer = null;
|
|
}
|
|
|
|
/**
|
|
* Validates start times, and replaces bad ones with 5ms in the future
|
|
* @param {Number} start - Start time to validate by JS Epoch (millis)
|
|
* @returns Start time as JS Epoch (millis)
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Calculates next item to play, and sets timer to play it at it's scheduled start
|
|
* @param {Boolean} volatile - Disables DB Transactions if true
|
|
*/
|
|
refreshNextTimer(volatile = false){
|
|
//If we're streamlocked
|
|
if(this.streamLock){
|
|
//Stop while we're ahead since the stream hasn't ended yet
|
|
return;
|
|
}
|
|
|
|
//Grab the next item
|
|
const nextItem = this.getNextItem();
|
|
|
|
//Get current item
|
|
const currentItem = this.getItemAtEpoch()
|
|
|
|
//Clear out any stale timers to prevent ghost queueing
|
|
clearTimeout(this.nextTimer);
|
|
clearTimeout(this.preSwitchTimer);
|
|
|
|
//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);
|
|
//If we have a next item
|
|
}else if(nextItem != null){
|
|
//Get current time as epoch
|
|
const now = new Date().getTime();
|
|
//Calculate the amount of time in ms that the next item will start in
|
|
const startsIn = nextItem.startTime - now;
|
|
//Calculate when the pre-switch timer would be called
|
|
const preSwitchTime = nextItem.startTime - this.preSwitchDelta;
|
|
//Calculate how long the pre-switch timer will be called in
|
|
const preSwitchIn = preSwitchTime - now;
|
|
|
|
//If we have enough time to call the pre-switch timer
|
|
if(preSwitchIn > this.preSwitchDelta){
|
|
//Set the pre-switch timer
|
|
this.preSwitchTimer = setTimeout(()=>{this.preSwitch(nextItem)}, preSwitchIn);
|
|
}
|
|
|
|
//Set the next timer
|
|
this.nextTimer = setTimeout(()=>{this.start(nextItem, nextItem.startTimeStamp, volatile)}, startsIn);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes range of media items from the queue
|
|
* @param {Number} start - Start date by JS Epoch (millis)
|
|
* @param {Number} end - End date by JS Epoch (millis)
|
|
* @param {Socket} socket - Requesting Socket
|
|
* @param {Boolean} noUnfinished - Set to true to include items that may be currently playing
|
|
*/
|
|
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
|
|
if(socket != null){
|
|
//Yell at the user for being an asshole
|
|
loggerUtils.socketErrorHandler(socket, "You cannot edit the schedule while livestreaming!", "queue");
|
|
}
|
|
|
|
//Stop while we're ahead since the stream hasn't ended yet
|
|
return;
|
|
}
|
|
|
|
//Find items within given range
|
|
const foundItems = this.getItemsBetweenEpochs(start, end, noUnfinished);
|
|
|
|
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 loggerUtils.exceptionSmith(`Unable to find channel document ${this.channel.name} while queue item!`, "queue");
|
|
}
|
|
|
|
//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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reschedules a media item
|
|
* @param {String} uuid - UUID of item to reschedule
|
|
* @param {Number} start - New start time by JS Epoch (Millis)
|
|
* @param {Socket} socket - Requesting Socket
|
|
* @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access
|
|
*/
|
|
async rescheduleMedia(uuid, start = new Date().getTime(), socket, chanDB){
|
|
//If we're streamlocked
|
|
if(this.streamLock){
|
|
//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 edit the schedule while livestreaming!", "queue");
|
|
}
|
|
//Stop while we're ahead since the stream hasn't ended yet
|
|
return;
|
|
}
|
|
|
|
try{
|
|
//If we wheren't handed a channel
|
|
if(chanDB == null){
|
|
//DO everything ourselves since we don't have a fance end() function to do it
|
|
chanDB = await channelModel.findOne({name:this.channel.name});
|
|
}
|
|
|
|
//If we couldn't find the channel
|
|
if(chanDB == null){
|
|
//FUCK
|
|
throw loggerUtils.exceptionSmith(`Unable to find channel document ${this.channel.name} while queue item!`, "queue");
|
|
}
|
|
|
|
//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, socket, chanDB);
|
|
|
|
//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, with noSave enabled
|
|
if(!(await this.scheduleMedia([media], socket, chanDB))){
|
|
//Reset start time
|
|
media.startTime = oldStart;
|
|
|
|
//Reset the start time stamp for re-calculation
|
|
media.startTimeStamp = 0;
|
|
|
|
//Schedule in old slot with noSave enabled
|
|
await this.scheduleMedia([media], socket, chanDB, true);
|
|
}
|
|
|
|
}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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes a media item
|
|
* @param {String} uuid - UUID of item to reschedule
|
|
* @param {Socket} socket - Requesting Socket
|
|
* @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access
|
|
* @param {Boolean} noScheduling - Disables schedule timer refresh if true
|
|
* @returns {Media} Deleted Media Item
|
|
*/
|
|
async removeMedia(uuid, socket, chanDB, noScheduling = false){
|
|
//If we're streamlocked
|
|
if(this.streamLock){
|
|
//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 edit the schedule while livestreaming!", "queue");
|
|
}
|
|
//Stop while we're ahead since the stream hasn't ended yet
|
|
return;
|
|
}
|
|
|
|
//Get requested media
|
|
const media = this.getItemByUUID(uuid);
|
|
|
|
//If we got a bad request
|
|
if(media == null){
|
|
try{
|
|
//If we wheren't handed a channel
|
|
if(chanDB == null){
|
|
//DO everything ourselves since we don't have a fance end() function to do it
|
|
chanDB = await channelModel.findOne({name:this.channel.name});
|
|
}
|
|
|
|
//If we couldn't find the channel
|
|
if(chanDB == null){
|
|
//FUCK
|
|
throw loggerUtils.exceptionSmith(`Unable to find channel document ${this.channel.name} while queue item!`, "queue");
|
|
}
|
|
|
|
//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);
|
|
|
|
if(!noScheduling){
|
|
//Refresh next timer
|
|
this.refreshNextTimer();
|
|
}
|
|
|
|
//If we're currently playing the requested item.
|
|
if(this.nowPlaying != null && this.nowPlaying.uuid == uuid){
|
|
//If scheduling is enabled
|
|
if(!noScheduling){
|
|
//End playback
|
|
this.end(false, true);
|
|
}
|
|
//otherwise
|
|
}else{
|
|
try{
|
|
//If we wheren't handed a channel
|
|
if(chanDB == null){
|
|
//DO everything ourselves since we don't have a fance end() function to do it
|
|
chanDB = await channelModel.findOne({name:this.channel.name});
|
|
}
|
|
|
|
//If we couldn't find the channel
|
|
if(chanDB == null){
|
|
//FUCK
|
|
throw loggerUtils.exceptionSmith(`Unable to find channel document ${this.channel.name} while queue item!`, "queue");
|
|
}
|
|
|
|
//Filter media out by UUID
|
|
chanDB.media.scheduled = chanDB.media.scheduled.filter((record) => {
|
|
return record.uuid != uuid;
|
|
});
|
|
|
|
//If saving is enabled (seperate from all DB transactions since caller function may want modifications but handle saving on its own)
|
|
if(!noScheduling){
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Schedules a Media Item
|
|
*
|
|
* 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
|
|
*
|
|
* @param {Media} - Media item to schedule
|
|
* @param {Socket} socket - Requesting Socket
|
|
* @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access
|
|
* @param {Boolean} force - Ignore certain conditions that would prevent scehduling and play the bitch anyways, used for internal function calls
|
|
* @param {Boolean} volatile - Prevent DB Writes, used for internal function calls
|
|
* @param {Boolean} startVolatile - Runs refreshNextTimer calls without DB writes, used for internal function calls
|
|
* @param {Boolean} saveLate - Saves items even if they're about to, or have already started. Used for internal function calls
|
|
* @param {Boolean} noSave - Allows function to edit Channel Document, but not save. Used for internal function calls in which the channel document is passed through, but will be saved immediatly after the scheduleMedia() call.
|
|
*/
|
|
async scheduleMedia(media, socket, chanDB, force = false, volatile = false, startVolatile = false, saveLate = false, noSave = false){
|
|
//If we're streamlocked and this isn't being forced
|
|
if(this.streamLock && !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 edit the schedule while livestreaming!", "queue");
|
|
}
|
|
//Stop while we're ahead since the stream hasn't ended yet
|
|
return;
|
|
}
|
|
|
|
for(let mediaObj of media){
|
|
const now = new Date().getTime();
|
|
|
|
//If someone is trying to schedule something that starts and ends in the past
|
|
if((mediaObj.getEndTime() < now) && !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 < now) && !force){
|
|
//Set time stamp to existing timestamp plus the difference between the orginal start-date and now
|
|
const calculatedTimeStamp = mediaObj.startTimeStamp + ((now - 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 = now;
|
|
}
|
|
}
|
|
|
|
//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;
|
|
}
|
|
|
|
//If we start in less than 10 seconds
|
|
if((mediaObj.startTime - this.preSwitchDelta) < now){
|
|
//Asyncrhounosly Check if we need to refresh the raw link
|
|
this.handleRawRefresh(mediaObj);
|
|
}
|
|
|
|
//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 OR saveLate is enabled and DB transactions are enabled
|
|
if((mediaObj.startTime - new Date().getTime() > 1000 || saveLate) && !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 loggerUtils.exceptionSmith(`Unable to find channel document ${this.channel.name} while saving item to queue!`, "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{
|
|
//If saving is enabled (seperate from all DB transactions since caller function may want modifications but handle saving on its own)
|
|
if(!noSave){
|
|
//Save the database
|
|
await 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;
|
|
}
|
|
|
|
/**
|
|
* Called 10 seconds before media begins to play
|
|
* @param {queuedMedia} mediaObj - Media object that's about to play
|
|
*/
|
|
async preSwitch(mediaObj){
|
|
this.handleRawRefresh(mediaObj);
|
|
}
|
|
|
|
/**
|
|
* Refreshes expired raw links before media plays
|
|
* @param {queuedMedia} mediaObj - Media object that's about to play
|
|
* @returns {queuedMedia} passes through Media object with updated link upon success
|
|
*/
|
|
async handleRawRefresh(mediaObj){
|
|
//Check if media needs a new raw link and update if it does
|
|
if(await yanker.refreshRawLink(mediaObj)){
|
|
//If the fetch took so god damned long we've already started the video (isn't 10 seconds enough?)
|
|
if(this.nowPlaying != null && this.nowPlaying.uuid == mediaObj.uuid){
|
|
//Tell the clients to update the raw file for the current item fore.st-style, as it probably got sent out with a stale link
|
|
this.server.io.in(this.channel.name).emit("updateCurrentRawFile", {file: mediaObj.rawLink});
|
|
}
|
|
|
|
//Return media obj to tell of success
|
|
return mediaObj;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Kicks off a media item
|
|
* @param {queuedMedia} mediaObj - Media object that's about to play
|
|
* @param {Number} timestamp - Media start timestamp in seconds
|
|
* @param {Boolean} volatile - Disables DB Transactions
|
|
*/
|
|
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;
|
|
|
|
//You might think it'd make sense to remove this item from the schedule like we will in the DB to keep it in nowPlaying only
|
|
//That however, is not how this was originally made, and updating it would take more work and break more shit than is worth
|
|
//Especially since one could argue that not deleting now-playing items in the RAM schedule makes it easier to iterate through
|
|
//We can get away with doing so in the DB since it only needs to be iterated once,
|
|
//If I was to re-do this from scratch I'd probably at least try it out with it deleting them here
|
|
//But agin, this is months in, not really worth it since it doesn't really cause any issues...
|
|
|
|
//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();
|
|
|
|
//Kill existing sync timers to prevent kicking-off ghost timer loops
|
|
clearTimeout(this.syncTimer);
|
|
|
|
//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;
|
|
}
|
|
|
|
/**
|
|
* Sends a syncronization ping out to client Sockets and increments the tracked timestamp by the Synchronization Delta
|
|
* Called auto-magically by the Synchronization Timer
|
|
*/
|
|
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);
|
|
}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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* End currently playing media
|
|
* @param {Boolean} quiet - Enable to prevent ending the media client-side
|
|
* @param {Boolean} noArchive - Enable to prevent ended media from being written to channel archive. Deletes media if Volatile is false
|
|
* @param {Boolean} volatile - Enable to prevent DB Transactions
|
|
* @param {Mongoose.Document} chanDB - Pass through Channel Document to save on DB Transactions
|
|
*/
|
|
async end(quiet = false, noArchive = false, volatile = false, chanDB){
|
|
try{
|
|
//If we're not playing anything
|
|
if(this.nowPlaying == null){
|
|
//Silently ignore the request
|
|
return;
|
|
}
|
|
|
|
//Call off any existing sync timer
|
|
clearTimeout(this.syncTimer);
|
|
|
|
//Clear out the sync timer
|
|
this.syncTimer = null;
|
|
|
|
//Keep a copy of whats playing for later when we need to clear the DB
|
|
const wasPlaying = this.nowPlaying;
|
|
|
|
//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 we're ending an HLS Livestream
|
|
if(wasPlaying.type == "livehls"){
|
|
//Redirect to the endLivestream function
|
|
return this.endLivestream(wasPlaying, chanDB)
|
|
}
|
|
|
|
//If we're not in volatile mode and we're not ending a livestream
|
|
if(!volatile){
|
|
//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");
|
|
}
|
|
|
|
//If we haven't changed 'nowPlaying' in the play list
|
|
if(chanDB.media.nowPlaying != null && 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 archiving is enabled
|
|
if(!noArchive){
|
|
//Add the item to the channel archive
|
|
chanDB.media.archived.push(wasPlaying);
|
|
}
|
|
|
|
//broadcast queue using unsaved archive, run this before chanDB.save() for better responsiveness
|
|
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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ends running Livestream
|
|
* @param {queuedMedia} wasPlaying - Media object that was playing while we started the Livestream
|
|
* @param {Mongoose.Document} chanDB - Pass through Channel Document to save on DB Transactions
|
|
*/
|
|
async endLivestream(wasPlaying, chanDB){
|
|
try{
|
|
//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");
|
|
}
|
|
//Disable stream lock
|
|
this.streamLock = false;
|
|
|
|
//We don't have to save here since someone else will do it for us :)
|
|
chanDB.media.liveRemainder = null;
|
|
|
|
//Get current epoch
|
|
const now = new Date().getTime()
|
|
|
|
//Set duration from start and end time
|
|
wasPlaying.duration = (now - wasPlaying.startTime) / 1000;
|
|
|
|
//If we're in pushback mode
|
|
if(this.liveMode == "pushback"){
|
|
await this.livestreamPushbackSchedule(wasPlaying, chanDB);
|
|
//Otherwise
|
|
}else{
|
|
//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();
|
|
|
|
//Null out live mode
|
|
this.liveMode = null;
|
|
|
|
//Broadcast Queue
|
|
this.broadcastQueue();
|
|
//ACK
|
|
}catch(err){
|
|
//Broadcast queue
|
|
this.broadcastQueue();
|
|
//Handle the error
|
|
loggerUtils.localExceptionHandler(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Overwrites livestream over scheduled media content after it has ended
|
|
* @param {queuedMedia} wasPlaying - Media object that was playing while we started the Livestream
|
|
* @param {Mongoose.Document} chanDB - Pass through Channel Document to save on DB Transactions
|
|
*/
|
|
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;
|
|
|
|
//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);
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* Pushes back any missed content scheduled during Livestream after Livestream has ended.
|
|
* @param {queuedMedia} wasPlaying - Media object that was playing while we started the Livestream
|
|
* @param {Mongoose.Document} chanDB - Pass through Channel Document to save on DB Transactions
|
|
*/
|
|
async livestreamPushbackSchedule(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");
|
|
}
|
|
|
|
//Throw the livestream into the archive
|
|
chanDB.media.archived.push(wasPlaying);
|
|
|
|
//Set the current place to schedule items at 5ms after the end of the live stream
|
|
let curPlace = wasPlaying.getEndTime() + 5;
|
|
const newSched = [];
|
|
|
|
//if we have a live remainder
|
|
if(this.liveRemainder != null){
|
|
//Set item to continue where it left off
|
|
this.liveRemainder.startTimeStamp = this.liveRemainder.earlyEnd;
|
|
|
|
//Rip out the early end so it finish up
|
|
this.liveRemainder.earlyEnd = null;
|
|
|
|
//Generate new UUID for uniqueness
|
|
this.liveRemainder.genUUID();
|
|
|
|
//Set start time to the end of the stream
|
|
this.liveRemainder.startTime = curPlace;
|
|
|
|
//Reset starter time to end of current item + 5ms
|
|
curPlace = this.liveRemainder.getEndTime(true) + 5;
|
|
|
|
//Throw live remainder into the new schedule
|
|
newSched.push(this.liveRemainder);
|
|
|
|
//Null out live remainder for the next stream
|
|
this.liveRemainder = null;
|
|
chanDB.liveRemainder = null;
|
|
}
|
|
|
|
//Iterate through objects in schedule
|
|
for(const entry of this.schedule){
|
|
//Pull media object from map entry
|
|
const mediaObj = entry[1];
|
|
|
|
//Remove media from queue without calling chanDB.save() to make room before we move everything
|
|
await this.removeMedia(mediaObj.uuid, null, chanDB, true);
|
|
|
|
mediaObj.genUUID();
|
|
|
|
//Change start time to current starter place
|
|
mediaObj.startTime = curPlace;
|
|
|
|
//Throw item into the temp sched
|
|
newSched.push(mediaObj);
|
|
|
|
//Set cur place to 5ms after the item we just queued
|
|
curPlace = mediaObj.getEndTime() + 5;
|
|
}
|
|
|
|
//Schedule the moved schedule, letting scheduleMedia save our changes for us, starting w/o saves to prevent over-saving
|
|
await this.scheduleMedia(newSched, null, chanDB);
|
|
}catch(err){
|
|
//Null out live remainder for the next stream
|
|
this.liveRemainder = null;
|
|
|
|
//Handle the error
|
|
loggerUtils.localExceptionHandler(err);
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* Stops currently playing media item
|
|
* @param {Socket} socket - Requesting Socket
|
|
* @returns returns false if there is nothing to stop
|
|
*/
|
|
stop(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
|
|
throw loggerUtils.exceptionSmith(`No media playing`, "queue");
|
|
}
|
|
//Ignore it
|
|
return false;
|
|
}
|
|
|
|
//Stop playing
|
|
const stoppedMedia = this.nowPlaying;
|
|
|
|
//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();
|
|
}
|
|
|
|
/**
|
|
* Returns scheduled media between two given datetimes
|
|
* @param {Number} start - Start date by JS Epoch (Millis)
|
|
* @param {Number} end - End date by JS Epoch (Millis)
|
|
* @param {Boolean} noUnfinished - Enable to include currently playing media
|
|
* @returns {queuedMedia} Found Media Objects
|
|
*/
|
|
getItemsBetweenEpochs(start, end, noUnfinished = false){
|
|
//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 ){
|
|
//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]);
|
|
}
|
|
}
|
|
}
|
|
|
|
//Return any found items
|
|
return foundItems;
|
|
}
|
|
|
|
/**
|
|
* Gets a media item by epoch
|
|
* @param {Number} epoch - Date to check by JS Epoch (Millis)
|
|
* @returns {queuedMedia} found media item
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Gets last item from a given epoch
|
|
* @param {Number} epoch - Date to check by JS Epoch (Millis), defaults to now
|
|
* @returns Last played item
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Gets next item from a given epoch
|
|
* @param {Number} epoch - Date to check by JS Epoch (Millis), defaults to now
|
|
* @returns {queuedMedia} Next item on the schedule
|
|
*/
|
|
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];
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get Scheduled Item by UUID
|
|
* @param {String} uuid - UUID of item to reschedule
|
|
* @returns {queuedMedia} found item
|
|
*/
|
|
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];
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send media update to a specific socket or broadcast it to the entire channel
|
|
* @param {Socket} socket - Requesting Socket
|
|
*/
|
|
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);
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* Broadcasts channel queue
|
|
* @param {Mongoose.Document} chanDB - Pass through Channel Document to save on DB Transactions
|
|
*/
|
|
async broadcastQueue(chanDB){
|
|
this.server.io.in(this.channel.name).emit('queue',{queue: await this.prepQueue(chanDB)});
|
|
}
|
|
|
|
/**
|
|
* Prepares channel queue for network transmission
|
|
* @param {Mongoose.Document} chanDB - Pass through Channel Document to save on DB Transactions
|
|
* @returns de-hydrated scehdule information
|
|
*/
|
|
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 loggerUtils.exceptionSmith(`Unable to find channel document ${this.channel.name} while rehydrating queue!`, "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){
|
|
//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
|
|
}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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rehydrates media schedule from DB
|
|
* @param {Mongoose.Document} chanDB - Pass through Channel Document to save on DB Transactions
|
|
*/
|
|
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 loggerUtils.exceptionSmith(`Unable to find channel document ${this.channel.name} while rehydrating queue!`, "queue");
|
|
}
|
|
|
|
//Get current time
|
|
const now = new Date().getTime();
|
|
|
|
//If something was playing
|
|
if(chanDB.media.nowPlaying != null && chanDB.media.nowPlaying.type != 'livehls'){
|
|
//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 since we'll only be keeping select items
|
|
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, 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
|
|
if(mediaObj.getEndTime() > now){
|
|
//Save record to nowPlaying in the DB
|
|
chanDB.media.nowPlaying = record;
|
|
|
|
//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{
|
|
//Archive ended media
|
|
chanDB.media.archived.push(record);
|
|
}
|
|
}
|
|
}
|
|
|
|
//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;
|
|
|
|
//Save the DB
|
|
await chanDB.save();
|
|
|
|
//if something fucked up
|
|
}catch(err){
|
|
//bitch about it in the server console
|
|
loggerUtils.localExceptionHandler(err);
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = queue;</code></pre>
|
|
</article>
|
|
</section>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
<nav>
|
|
<h2><a href="index.html">Home</a></h2><h3>Classes</h3><ul><li><a href="activeChannel.html">activeChannel</a></li><li><a href="channelManager.html">channelManager</a></li><li><a href="chat.html">chat</a></li><li><a href="chatBuffer.html">chatBuffer</a></li><li><a href="chatHandler.html">chatHandler</a></li><li><a href="commandPreprocessor.html">commandPreprocessor</a></li><li><a href="commandProcessor.html">commandProcessor</a></li><li><a href="connectedUser.html">connectedUser</a></li><li><a href="media.html">media</a></li><li><a href="playlistHandler.html">playlistHandler</a></li><li><a href="queue.html">queue</a></li><li><a href="queuedMedia.html">queuedMedia</a></li><li><a href="tokebot.html">tokebot</a></li></ul><h3>Global</h3><ul><li><a href="global.html#authenticateSession">authenticateSession</a></li><li><a href="global.html#cache">cache</a></li><li><a href="global.html#channelBanSchema">channelBanSchema</a></li><li><a href="global.html#channelPermissionSchema">channelPermissionSchema</a></li><li><a href="global.html#channelSchema">channelSchema</a></li><li><a href="global.html#chatSchema">chatSchema</a></li><li><a href="global.html#comparePassword">comparePassword</a></li><li><a href="global.html#consoleWarn">consoleWarn</a></li><li><a href="global.html#daysToExpire">daysToExpire</a></li><li><a href="global.html#emailChangeSchema">emailChangeSchema</a></li><li><a href="global.html#emoteSchema">emoteSchema</a></li><li><a href="global.html#errorHandler">errorHandler</a></li><li><a href="global.html#errorMiddleware">errorMiddleware</a></li><li><a href="global.html#escapeRegex">escapeRegex</a></li><li><a href="global.html#exceptionHandler">exceptionHandler</a></li><li><a href="global.html#exceptionSmith">exceptionSmith</a></li><li><a href="global.html#failedAttempts">failedAttempts</a></li><li><a href="global.html#fetchMetadata">fetchMetadata</a></li><li><a href="global.html#fetchVideoMetadata">fetchVideoMetadata</a></li><li><a href="global.html#fetchYoutubeMetadata">fetchYoutubeMetadata</a></li><li><a href="global.html#fetchYoutubePlaylistMetadata">fetchYoutubePlaylistMetadata</a></li><li><a href="global.html#flairSchema">flairSchema</a></li><li><a href="global.html#genCaptcha">genCaptcha</a></li><li><a href="global.html#getLoginAttempts">getLoginAttempts</a></li><li><a href="global.html#getMediaType">getMediaType</a></li><li><a href="global.html#hashIP">hashIP</a></li><li><a href="global.html#hashPassword">hashPassword</a></li><li><a href="global.html#kickoff">kickoff</a></li><li><a href="global.html#killSession">killSession</a></li><li><a href="global.html#lifetime">lifetime</a></li><li><a href="global.html#localExceptionHandler">localExceptionHandler</a></li><li><a href="global.html#mailem">mailem</a></li><li><a href="global.html#markLink">markLink</a></li><li><a href="global.html#maxAttempts">maxAttempts</a></li><li><a href="global.html#mediaSchema">mediaSchema</a></li><li><a href="global.html#passwordResetSchema">passwordResetSchema</a></li><li><a href="global.html#permissionSchema">permissionSchema</a></li><li><a href="global.html#playlistMediaProperties">playlistMediaProperties</a></li><li><a href="global.html#playlistSchema">playlistSchema</a></li><li><a href="global.html#processExpiredAttempts">processExpiredAttempts</a></li><li><a href="global.html#queuedProperties">queuedProperties</a></li><li><a href="global.html#rankEnum">rankEnum</a></li><li><a href="global.html#refreshRawLink">refreshRawLink</a></li><li><a href="global.html#schedule">schedule</a></li><li><a href="global.html#securityCheck">securityCheck</a></li><li><a href="global.html#sendAddressVerification">sendAddressVerification</a></li><li><a href="global.html#socketCriticalExceptionHandler">socketCriticalExceptionHandler</a></li><li><a href="global.html#socketErrorHandler">socketErrorHandler</a></li><li><a href="global.html#socketExceptionHandler">socketExceptionHandler</a></li><li><a href="global.html#spent">spent</a></li><li><a href="global.html#statSchema">statSchema</a></li><li><a href="global.html#throttleAttempts">throttleAttempts</a></li><li><a href="global.html#tokeCommandSchema">tokeCommandSchema</a></li><li><a href="global.html#transporter">transporter</a></li><li><a href="global.html#typeEnum">typeEnum</a></li><li><a href="global.html#userBanSchema">userBanSchema</a></li><li><a href="global.html#userSchema">userSchema</a></li><li><a href="global.html#verify">verify</a></li><li><a href="global.html#yankMedia">yankMedia</a></li><li><a href="global.html#ytdlpFetch">ytdlpFetch</a></li></ul>
|
|
</nav>
|
|
|
|
<br class="clear">
|
|
|
|
<footer>
|
|
Documentation generated by <a href="https://github.com/jsdoc/jsdoc">JSDoc 4.0.4</a> on Sat Sep 06 2025 00:47:13 GMT-0400 (Eastern Daylight Time)
|
|
</footer>
|
|
|
|
<script> prettyPrint(); </script>
|
|
<script src="scripts/linenumber.js"> </script>
|
|
</body>
|
|
</html>
|