/*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 config = require('../../../../config.json');
const queuedMedia = require('./queuedMedia');
const yanker = require('../../../utils/media/yanker');
const loggerUtils = require('../../../utils/loggerUtils');
const channelModel = require('../../../schemas/channel/channelSchema');
const permissionModel = require('../../../schemas/permissionSchema');

/**
 * 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)});

        //If debug mode is enabled
        if(config.debug){
            socket.on("dumpQueue", (data) => {this.dumpQueue(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,{require_valid_protocol: true})){
                    //Attempt to fix the situation by encoding it
                    url = encodeURI(url);

                    //If it's still bad
                    if(!validator.isURL(url,{require_valid_protocol: true})){
                        //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);
        }
    }

    async dumpQueue(socket, data){
        //If we somehow got here while config.debug is disabled, or the user isn't allowed to preform server-side debugging
        if(!(config.debug && await permissionModel.permCheck(socket.user, "debug"))){
            //FUCKIN' CHEESE IT!
            return;
        }

        //If a full data dump was requested
        if(data != null && data.full){
            //Pull the channel DB doc
            const chanDB = await channelModel.findOne({name:this.channel.name});

            //Cook and emit a new object from all of the data
            socket.emit("dumpQueue", {
                cache: {
                    schedule: Array.from(this.schedule),
                    nowPlaying: this.nowPlaying
                },
                DB: {
                    schedule: chanDB.media.scheduled,
                    nowPlaying: chanDB.media.nowPlaying,
                    archived: chanDB.media.archived,
                }
            });

            //DONE.
            return;
        }

        //Otherwise, just dump whats in RAM
        socket.emit("dumpQueue", {
            cache: {
                schedule: Array.from(this.schedule),
                nowPlaying: this.nowPlaying
            }
        });
    }

    //--- 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
            let media = this.getItemByUUID(uuid);
            //Create value to hold old media in-case somethin' fucks up
            let oldMedia = null;

            //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 the item is currently playing
                if(media.getEndTime() > new Date().getTime()){
                    //Set old media
                    oldMedia = media;
                    //Dupe media for the rest of the function
                    media = media.clone();

                    //Stop current item
                    await this.stop(socket, chanDB);

                //Otherwise, if it's already ended 
                }else{
                    //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");
                    }

                    //Ignore it
                    return;
                }
            //If the item has yet to be played
            }else{
                //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;

                //If there's a cut-off from moving a now-playing item
                if(oldMedia != null){
                    //Remove it from the queue to prevent ghost items
                    this.removeMedia(oldMedia.uuid, socket, chanDB, true);
                }


                //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 and this.save() calls if true, good for internal function calls
     * @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);

                    //If saving is disabled
                    if(!noScheduling){
                        //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
            //VOLATILE END
            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();

                //Broadcast queue since we ended our media item early
                this.broadcastQueue(chanDB);
            }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, also prevents queue broadcasting so the calling code can broadcast once it's done.
     * @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)
            }


            //Moved this from the block below to prevent accidental over-caching
            //We may need to throw this into it's own conditional if it causes issues
            //Take it out of the active schedule
            //Ultimitaly though, if something is voltaile it should handle saving chanDB on its own, so this should be a non-issue
            this.schedule.delete(wasPlaying.startTime);

            //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();
                }

                //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();
            }
        }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 :)
            //Reminder for those of us reading this in the future since I'm a dipshit: this only clears the DB liveRemainder, NOT the RAM backed variable
            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 (from the current 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 (from the current 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
                //TODO: Account for liveRemainder
                this.liveRemainder.startTimeStamp = 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
     * @param {Mongoose.Document} chanDB - Pass through Channel Document to save on DB Transactions
     * @returns returns false if there is nothing to stop
     */
    async stop(socket, chanDB){
        //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
        await this.end(false, false, false, chanDB);
    }

    /**
     * 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){
        //Broadcast queue to authenticated sockets within the channels room inside the protected 'queue-broadcast' namespace
        this.server.queueBroadcastManager.namespace.in(this.channel.name).emit('queue',{queue: await this.prepQueue(chanDB)});
    }

    /**
     * Broadcasts channel queue to a single socket
     * @param {Mongoose.Document} chanDB - Pass through Channel Document to save on DB Transactions
     * @param {Socket} socket - Socket to send queue to
     */
    async emitQueue(chanDB, socket){
        //Broadcast queue to authenticated sockets within the channels room inside the protected 'queue-broadcast' namespace
        socket.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 temporary schedule array as if it where part of the actual schedule map
                    schedule.unshift([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()

        //End the media;

        //if something fucked up
        }catch(err){
            //bitch about it in the server console
            loggerUtils.localExceptionHandler(err);
        }
    }
}

module.exports = queue;