HJ-OTMOP / HJ Admin Toolkit

// ==UserScript==
// @name         HJ Admin Toolkit
// @namespace    http://tampermonkey.net/
// @version      0.6.4
// @description  A suite of functions aimed at automating the admin process in the HANDJOB thread
// @author       HJ-OTMOP
// @license MIT
// @updateURL    https://openuserjs.org/meta/HJ-OTMOP/HJ_Admin_Toolkit.meta.js
// @downloadURL  https://openuserjs.org/install/HJ-OTMOP/HJ_Admin_Toolkit.user.js
// @copyright    2018 - 2021, HJ-OTMOP (https://openuserjs.org/users/HJ-OTMOP)
// @icon         https://ptpimg.me/6uob9q.png
// @match        https://passthepopcorn.me/*threadid=13617*
// @match        https://passthepopcorn.me/upload.php*
// @match        https://passthepopcorn.me/torrents.php*
// @require      http://code.jquery.com/jquery-3.3.1.min.js
// @connect      imdb.com
// @connect      handjob-ptp.xyz
// @run-at       document-end
// @grant        GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_listValues
// ==/UserScript==

// Changelog 0.6.4:
//		- Shifted to using subdomain, rather than folder for API
// Changelog 0.6.3:
//		- Script will now auto-logout when receiving a signal to do so from the API
// Changelog 0.6.2:
//		- Fix for HTML inside mediainfo scan issues not rendering on the recent group encodes window
//		- Switched the order of the 'Show/Hide Info Panel' and 'Scan Storage Management' buttons in the recent group encodes window
//		- Added a title to be the default recent encodes status message
//		- In the authorisation checker, forum posts can now be viewed inline by clicking their row
// Changelog 0.6.1:
//		- Hovering over the recent encodes action buttons will now display their function in the status display
// Changelog 0.6.0:
//      - Completely re-written "Check Recent Encodes" functionality and UI
//		- New Material Design-inspired UI
//		- Live recent encode data is now pulled from handjob-ptp.xyz
//		- Automated login creates an API session for any PTP user
//		- API now handles provided data, allowing for faster browsing of recent uploads
//		- Easy pagination links allow you to quickly traverse results
//		- Easier search filter allow you to search by title, filename, director, rank and username
//		- Scan data now persists across sessions until manually cleared
//		- Authorisation Checker now has increased accuracy

(function () {
	'use strict';
	const PROD_ROOT = 'https://api.handjob-ptp.xyz';
	const DEV_ROOT = 'http://localhost:9090';
	const ROOT = PROD_ROOT;
	const PTP = 'https://passthepopcorn.me';
	let _access_token,
		groupEncodes,
		_AntiCsrfToken,
		_user = {},
		_logged_in = false,
		_defaultRecentEncodesMessage = 'Recent HANDJOB Group Encodes';

	const userNode = document.querySelector('#header #userinfo a.user-info-bar__link');
	_user.username = userNode.textContent.trim();
	_user.userid = parseInt(userNode.href.split('id=').pop(), 10);
	_AntiCsrfToken = document.body.getAttribute('data-anticsrftoken');

	const accessToken = GM_getValue('accessToken');
	if (accessToken) {
		_logged_in = true;
		_access_token = accessToken;
	}

	async function logoutFromAPI() {
		ADMIN_ajaxRequest({
			url: ROOT + '/auth/logout',
			headers: {
				'Content-Type': 'application/json',
				Accept: 'application/json',
				Authorization: `Bearer ${_access_token}`,
			},
		})
			.then((res) => {
				GM_deleteValue('accessToken');
				location.reload();
			})
			.catch(console.log);
	}

	function getLoginCredentials(ctx) {
		let stepsTotal = 6,
			stepsComplete = 0,
			progress = 0;
		ctx.inputs.progress.style.width = '0%';
		ctx.inputs.progress.classList.add('active');

		const cb = () => {
			ctx.inputs.progress.classList.remove('active');
			ctx.inputs.progress.removeEventListener('transitionend', cb);
		};

		const progressStep = () => {
			stepsComplete++;
			progress = Math.ceil((100 / stepsTotal) * stepsComplete);
			ctx.inputs.progress.style.width = progress + 1 + '%';
		};

		return new Promise(async (resolve, reject) => {
			const data = { username: _user.username, userid: _user.userid };
			// 1. Get a verification token from the API
			ctx.scanning = 'Logging in to API';
			const { verificationToken } = (await ADMIN_ajaxRequest({
				url: ROOT + '/auth/verification-token',
				method: 'POST',
				headers: {
					'Content-Type': 'application/json',
					Accept: 'application/json',
				},
				data: JSON.stringify(data),
			}).catch((e) => reject())) || { verificationToken: null };
			progressStep();
			if (!verificationToken) return reject();

			// 2. Find a recent forum post by the user to use for verifying token
			const recentPostsURL = `${PTP}/userhistory.php?action=posts&userid=${_user.userid}&showunread=0&group=0`;

			const recentPostsRaw = await ADMIN_ajaxRequest({
				url: recentPostsURL,
			}).catch((e) => reject());
			progressStep();
			const recentPosts = parseHTMLtoDOM(recentPostsRaw);
			const postForVerification = Array.from(recentPosts.querySelectorAll('div.forum-post')).pop();
			const postForVerificationNode = postForVerification.querySelector(
				'.forum-post__heading > span:first-child > a'
			);
			const threadId = parseInt(postForVerificationNode.href.match(/(?:threadid=)(\d+)/)[1], 10);
			const postId = parseInt(postForVerificationNode.href.match(/(?:postid=)(\d+)/)[1], 10);

			if (!threadId || !postId) return reject();

			// 3. Retrieve the content of the post from PTP so it can be reverted afterwards
			const postContent = await ADMIN_ajaxRequest({
				url: `${PTP}/forums.php?action=get_post&post=${postId}`,
			}).catch((e) => reject());
			progressStep();

			if (!postContent) return reject();

			// 4. Add the verification token and POST the updated post content to PTP
			ctx.scanning = 'Verifying login details';
			const newPostContent = postContent + `\n\n[size=1][hide= ]${verificationToken}[/hide][/size]`;

			const formData = new FormData();
			formData.append('AntiCsrfToken', _AntiCsrfToken);
			formData.append('body', newPostContent);
			formData.append('post', postId);
			formData.append('key', '1');
			const postForVerificationEditURL = `${PTP}/forums.php?action=takeedit`;

			const postEdited = await ADMIN_ajaxRequest({
				method: 'POST',
				data: formData,
				url: postForVerificationEditURL,
			}).catch((e) => reject());
			progressStep();

			// 5. Send a GET request to the API with the information about where to find the verification token
			const { accessToken } = (await ADMIN_ajaxRequest({
				url: `${ROOT}/auth/verification-token/${threadId}/${postId}/${verificationToken}`,
				headers: {
					Accept: 'application/json',
				},
			}).catch((e) => reject())) || { accessToken: null };
			progressStep();

			if (!accessToken) return reject();

			// 6. Revert the edits to the user's forum post
			formData.delete('body');
			formData.append('body', postContent);

			const postReverted = await ADMIN_ajaxRequest({
				method: 'POST',
				data: formData,
				url: postForVerificationEditURL,
			}).catch((e) => reject());
			progressStep();

			GM_setValue('accessToken', accessToken);
			_access_token = accessToken;
			_logged_in = true;
			ctx.scanning = 'Login complete. Welcome ' + _user.username;
			ctx.inputs.progress.addEventListener('transitionend', cb);

			// 7. Profit
			resolve();
		});
	}

	// ## Start Section for functions for 'Check Recent HANDJOB Encodes' function ##

	class GroupEncodes {
		// TODO List
		// 1.) Method to clear saved scans
		constructor() {
			this.style();
			this.json = null;
			this.pagination = {
				json: null,
			};
			this.columns = [
				'title',
				'uploaded',
				'codec',
				'source',
				'res',
				'filename',
				'member',
				'rank',
				'auth',
				'scan',
			];
			this.elements();
			this.framework();
			this.loader = null;
			this.loadingCallback = null;
			this._scanning = null;
			this._showInfo = false;
			this._loading = false;
			this._orderBy = GM_getValue('groupEncodesOrderBy', 'uploaded_at');
			this._asc = GM_getValue('groupEncodesAsc', false);
			this._batchActive = false;
		}

		/*
	************************************************
			   GETTERS AND SETTERS
	************************************************
	*/

		/**
		 * Return list of DB results filtering to show only those without a MI scan status
		 * @memberof GroupEncodes
		 * @returns { Object[] }
		 */
		get filtered() {
			return this.json.filter((item) => item.scanStatus === '');
		}

		/**
		 * Is the Mi scanner batch process running?
		 * @memberof GroupEncodes
		 * @returns { Boolean }
		 */
		get batchActive() {
			return this._batchActive;
		}
		/**
		 * Scan batch scanner status and activate side-effects
		 * @memberof GroupEncodes
		 */
		set batchActive(v) {
			this._batchActive = v;
			// Show the progress bar depending on value
			this.inputs.progress.classList.toggle('active', v);
			const progressNode = document.querySelector('.progress-outer');
			const cb = () => {
				progressNode.removeEventListener('transitionend', cb);
				progressNode.querySelector('.progress-inner').style.width = '0%';
			};
			if (v === false) {
				progressNode.addEventListener('transitionend', cb);
			}
			progressNode.classList.toggle('active', v);
			// Start or stop the batch scanner depending on value
			if (v === true) this.batchMiScan();
		}

		/**
		 * The column by which database results should be ordered
		 * @memberof GroupEncodes
		 * @returns { string }
		 */
		get orderBy() {
			return this._orderBy;
		}
		/**
		 * Set and save to GM localStorage
		 * @memberof GroupEncodes
		 */
		set orderBy(v) {
			GM_setValue('groupEncodesOrderBy', v);
			this._orderBy = v;
		}

		/**
		 * Order database results by ascending order
		 * @memberof GroupEncodes
		 * @returns { boolean }
		 */
		get asc() {
			return this._asc;
		}
		/**
		 * Set and save to GM localStorage
		 * @memberof GroupEncodes
		 */
		set asc(v) {
			GM_setValue('groupEncodesAsc', v);
			this._asc = v;
		}

		/**
		 * Show the loader in the information window
		 * @memberof GroupEncodes
		 * @returns { boolean }
		 */
		get loading() {
			return this._loading;
		}
		/**
		 * Set loader status and activate side-effects
		 * @memberof GroupEncodes
		 */
		set loading(v) {
			this._loading = v;
			if (v === false) {
				// Callback to ensure loader gracefully fades out before removing it from the DOM
				this.loader.addEventListener('transitionend', () => {
					this.loader.parentNode.removeChild(this.loader);
					// Run the callback, usually to populate the container that the loader was showing in
					if (this.loadingCallback) this.loadingCallback();
					// Remove loader HTMLElement from class object
					this.loader = null;
				});
				// Start the transition to remove loader
				this.loader.classList.remove('show');
			}
		}

		/**
		 * Show the information window: used for scan results and authorisation searches
		 * @memberof GroupEncodes
		 * @returns { boolean }
		 */
		get showInfo() {
			return this._showInfo;
		}
		/**
		 * Set information window status and activate side-effects
		 * @memberof GroupEncodes
		 */
		set showInfo(v) {
			this._showInfo = v;
			const cb = () => {
				const viewer = document.querySelector('#forum-post-viewer');
				if (viewer) document.querySelector('#forum-post-viewer').innerHTML = '';
				this.framework.info.removeEventListener('transitionend', cb);
			};
			// Show the window based on value
			this.framework.info.addEventListener('transitionend', cb);
			this.framework.info.classList.toggle('show', v);
			// Change the button's icon depending on value
			if (v === false) {
				this.controls.info.querySelector('i').textContent = 'unfold_more';
			} else {
				this.controls.info.querySelector('i').textContent = 'unfold_less';
			}
		}

		/**
		 * The text to show in the status bar
		 * @memberof GroupEncodes
		 * @returns { string }
		 */
		get scanning() {
			return this._scanning;
		}
		/**
		 * Set and transition to new status text
		 * @memberof GroupEncodes
		 */
		set scanning(v) {
			if (v === this._scanning) return false;
			this._scanning = v;
			// Callback: Set new status text and fade-in
			const cb = () => {
				this.inputs.display.removeEventListener('transitionend', cb);
				this.inputs.display.innerHTML = v;
				this.inputs.display.style.opacity = 1;
			};
			// Fade-out the existing status text
			this.inputs.display.addEventListener('transitionend', cb);
			if (this.inputs.display.style.opacity == 0) cb();
			else this.inputs.display.style.opacity = 0;
		}

		/*
	************************************************
					 CLICK HANDLERS
	************************************************
	*/

		/**
		 * The central click-handler function. All clicks inside the display are routed here
		 * @param { EventObject } ev - the click event object
		 * @memberof GroupEncodes
		 */
		click(ev) {
			// If clicked element has a ripple class, activate it
			if (ev.target.classList.contains('hj-ripple')) this.ripple(ev);

			// Close the modal
			if (ev.target.classList.contains('hj-close')) this.hide();
			// Clear the scan storage cache
			else if (ev.target.classList.contains('hja__cache-button')) this.clearScanCache(ev.target);
			// Toggle the information window display
			else if (ev.target.classList.contains('hj-close-info')) this.showInfo = !this.showInfo;
			// Toggle the status of the batch scanner
			// See "set batchActive()" for side-effects.
			else if (ev.target.classList.contains('controls')) this.batchActive = !this.batchActive;
			// Load information regarding an upload's authorisation check
			else if (ev.target.classList.contains('hj-auth-check'))
				this.showAuthorisation(ev.target.closest('tr').getAttribute('data-row-id'));
			// Change tab inside an Authorisation Check display
			else if (ev.target.classList.contains('tablinks')) this.changeTab(ev);
			// Handle links for the DB pagination
			else if (ev.target.classList.contains('group-encodes-pagination')) this.paginationLink(ev);
			// Handle links for the DB results ordering column / order
			else if (ev.target.classList.contains('group-encodes--header-sort')) this.sortingLink(ev);
			// Load mediainfo scan information for an upload
			else if (ev.target.classList.contains('hj-scan-view'))
				this.showMediainfoScan(ev.target.closest('tr').dataset.rowId);
			// Inside MI Scan info window, handle the refresh of a MI scan
			else if (ev.target.classList.contains('hj-refresh-scan-button')) this.refreshScan(ev);
			// Logout of HANDJOB API session
			else if (ev.target.classList.contains('hja__logout')) this.logout();
			// Authorisation check forum post links
			else if (ev.target.closest('.hja__group-encodes--forum-message-row')) this.loadForumPost(ev);
			// Results row click, create a mediainfo scan
			else if (ev.target.closest('.hja__group-encodes--scan-row')) this.singleMiScan(ev);
		}

		logout() {
			this.hide();
			logoutFromAPI();
		}

		// Show the Group Encodes modal window
		async show() {
			// Activate PTP's body scroll lock
			document.body.classList.add('lightbox__scroll-lock');
			this.container.classList.add('show');
			if (!this.json && _logged_in === true) {
				const container = Create('div.hja__group-encodes--loader-container');
				const loader = container.appendChild(Create('div.loader.show'));
				this.framework.content.appendChild(container);
				await this.load();
			} else if (!this.json) {
				const container = Create('div.hja__group-encodes--login-container');
				container.innerHTML = `<h3>Login required</h3>
			<div>HANDJOB group encode records are now stored on a separate API which requires:
			<ol><li>Verifying that you're a member of PTP</li><li>Confirming your PTP username</li></ol>
			None of your PTP credentials are ever sent to the API server, only your username and userid (via encrypted https). No IP addresses or browser information are logged.</div>`;
				const loginButton = container.appendChild(Create('button.hj-ripple.primary'));
				loginButton.textContent = 'Login';
				this.framework.content.appendChild(container);

				loginButton.addEventListener('click', () => {
					loginButton.disabled = true;
					getLoginCredentials(this)
						.then(() => this.load())
						.catch((e) => {
							container.innerHTML = `<h3>Error logging in</h3><div>There was an error logging you into the HANDJOB API. Please try again later.</div><div style="margin-top: 1em;">If the problem persists, contact The Godfather.</div>`;
							loginButton.disabled = false;
							container.appendChild(loginButton);
						});
				});
			}
		}

		// Hide the Group Encodes modal window
		hide() {
			// Deactivate PTP's body scroll lock
			document.body.classList.remove('lightbox__scroll-lock');
			this.container.classList.remove('show');
		}

		// Handle alterations to the footer options that require a data reflow
		editFooterOption(option, ev) {
			switch (option) {
				case 'limit':
					GM_setValue('groupEncodesLimit', ev.target.value);
					break;
			}
			this.load();
		}

		/**
		 * Handle a data request for when the sorting order/column is changed
		 * @param { EventObject } ev - the click event object
		 * @memberof GroupEncodes
		 */
		sortingLink(ev) {
			// Get data value from row
			const newOrderBy = ev.target.dataset.sort;

			// If the user clicked the current orderBy col, reverse the sort order
			if (this.orderBy == newOrderBy) this.asc = !this.asc;
			// Else set a new orderBy column
			else this.orderBy = newOrderBy;

			// Send new load request and refresh page display
			this.load({ page: this.pagination.json.page });
		}

		/**
		 * Send a load request when a page link is clicked
		 * @param { EventObject } ev - The click event object
		 * @memberof GroupEncodes
		 */
		paginationLink(ev) {
			const page = ev.target.getAttribute('data-page');
			this.load({ page });
		}

		/**
		 * Adds a ripple effect to buttons/links with a class of "hj-ripple"
		 * @param { EventObject } e - The event object
		 * @memberof GroupEncodes
		 */
		ripple(e) {
			const el = e.target;
			const remove = (node) => (node ? node.parentElement.removeChild(node) : null);
			remove(el.querySelector('.ripple'));

			const pos = el.getBoundingClientRect();

			let X = e.pageX - pos.left;
			let Y = e.pageY - pos.top;
			let rippleDiv = document.createElement('div');
			rippleDiv.classList.add('ripple');
			rippleDiv.setAttribute('style', 'top:' + Y + 'px; left:' + X + 'px;');
			let customColor = el.getAttribute('data-ripple-color');
			if (customColor) rippleDiv.style.background = customColor;
			el.appendChild(rippleDiv);

			rippleDiv.addEventListener('animationend', () => {
				remove(rippleDiv);
			});
		}

		/**
		 * Clear mediainfo scans from GM localStorage by scan timestamp
		 * @param { HTMLElement } target - the button which was clicked
		 * @memberof GroupEncodes
		 */
		clearScanCache(target) {
			const age = target.dataset.cacheClearTime;
			let cutoff;
			switch (age) {
				case 'all':
					cutoff = Date.now();
					break;
				case '30':
					cutoff = Date.now() - 1000 * 60 * 60 * 24 * 30;
					break;
				case '15':
					cutoff = Date.now() - 1000 * 60 * 60 * 24 * 15;
					break;
				default:
					cutoff = 0;
			}
			const values = GM_listValues();
			const filtered =
				values && Array.isArray(values) === true
					? values.filter((item) => /^torrentScan_/i.test(item) === true)
					: [];

			const toDelete = filtered.filter((item) => {
				const scan = GM_getValue(item);
				const timestamp = scan.timestamp;
				return !timestamp || timestamp < cutoff;
			});

			toDelete.forEach((scan) => {
				GM_deleteValue(scan);
			});
			this.scanning = `Deleted ${toDelete.length} scans from storage`;
			this.load({ page: this.pagination.json.page });
		}

		/*
	************************************************
					AJAX FUNCTIONS
	************************************************
	*/

		/**
		 * Send a new results request to the DB
		 * @param { object } input - Single object input
		 * @param { number } input.page - The page of results to request from the DB
		 * @returns Promise<void>
		 * @memberof GroupEncodes
		 */
		load({ page = 1 } = {}) {
			const filter = this.inputs.filter.value || '';
			const limit = GM_getValue('groupEncodesLimit') || 25;
			const orderBy = GM_getValue('groupEncodesOrderBy') || 'uploaded_at';
			const asc = GM_getValue('groupEncodesAsc') || false;
			const order = asc === true ? 'asc=1&' : '';
			return new Promise((resolve, reject) => {
				ADMIN_ajaxRequest({
					url: `${ROOT}/group-encodes?${order}orderBy=${orderBy}&limit=${limit}&page=${page}&filter=${filter}`,
					headers: {
						Accept: 'application/json',
						Authorization: `Bearer ${_access_token}`,
					},
				})
					.then((result) => {
						this.json = result.data;
						this.pagination.json = result.pagination;
						// Update the table display
						this.update();
						// Remove the loader spinner from the filter input container
						this.inputs.filterInputContainer.classList.remove('loading');
						resolve();
					})
					.catch((err) => {
						console.log(err);
					});
			});
		}

		/*
	************************************************
				  FRAMEWORK SETUP
	************************************************
	*/

		// Set up the basic container and click handler
		elements() {
			this.container = document.querySelector('#group-encodes');
			this.container.addEventListener('click', this.click.bind(this), { passive: true });
		}

		// Set up the initial framework required to display all information
		framework() {
			this.framework = {};
			this.controls = {};
			this.inputs = {};
			this.framework.status = Create('div.hja__group-encodes--status');
			this.framework.info = Create('div.hja__group-encodes--info');
			this.framework.info.innerHTML = `
		<div class="hja__group-encodes--info--scan-container">
			<div class="hja__group-encodes--scan-results hj-card">
				<header style="background-color: #1091ec !important">Group Encodes: Upload Information Window</header>
				<section>
					<div style="text-align: center; width: 100%;">
						Use the action buttons in the 'AUTH' and 'SCAN' columns of the table to show - where available - information in this window.
					</div>
				</section>
			</div>
		</div>`;
			this.framework.content = Create('div.hja__group-encodes--content');
			this.framework.footer = Create('footer.hja__group-encodes--footer');

			this.status();
			this.footer();

			Object.values(this.framework).forEach((item) => {
				this.container.appendChild(item);
			});
		}

		hover(target, show) {
			const title = target.dataset.title;
			if (show === true) this.scanning = title;
			else this.scanning = _defaultRecentEncodesMessage;
		}

		// Set up the status bar
		status() {
			const appActions = Create('div.hja__recent-encodes--app-actions');

			const logout = appActions.appendChild(Create('button.hj-ripple.icon.warning.hja__logout'));
			logout.innerHTML = "<i class='material-icons'>power_settings_new</i>";
			logout.setAttribute('data-title', 'Logout from HANDJOB API');
			this.controls.logout = logout;

			const storageSpeedDial = appActions.appendChild(Create('div.speed-dial-container'));
			storageSpeedDial.innerHTML = `
		<div class="speed-dial-activator">
		  <button class="icon primary" data-title="Remove saved mediainfo scans">
			<i class="material-icons open">sd_card</i>
			<i class="material-icons close">close</i>
		  </button>
		</div>
		<div class="speed-dial-menu-items">
		<button
			class="icon accent hja__cache-button"
			data-label="Delete scans aged 60+ days"
			data-cache-clear-time="60"
		>
			<i class="material-icons">hourglass_full</i>
		</button>
		<button
			class="icon accent hja__cache-button"
			data-label="Delete scans aged 30+ days"
			data-cache-clear-time="30"
		>
			<i class="material-icons">hourglass_bottom</i>
		</button>
		<button
			class="icon accent hja__cache-button"
			data-label="Delete scans aged 15+ days"
			data-cache-clear-time="15"
		>
			<i class="material-icons">hourglass_top</i>
		</button>
		<button class="icon accent hja__cache-button" data-label="Delete all scans" data-cache-clear-time="all">
			<i class="material-icons">delete</i>
		  </button>
		</div>`;

			const infoControl = appActions.appendChild(Create('button.hj-ripple.icon.primary.hj-close-info'));
			infoControl.innerHTML = "<i class='material-icons'>unfold_more</i>";
			infoControl.setAttribute('data-title', 'Show/hide information panel');
			this.controls.info = infoControl;

			const status = Create('div.hja__group-encodes--status-container');
			status.innerHTML = `<div class="progress-outer">
								<div class="progress-inner">
									<div id="scanner-display" class="progress-label">${_defaultRecentEncodesMessage}</div>
									<div class="controls controls-start" data-title="Start batch mediainfo scan">
										<i class="material-icons">play_circle_outline</i>
									</div>
									<div class="controls controls-stop" data-title="Stop batch mediainfo scan">
										<i class="material-icons">pause_circle_outline</i>
									</div>
								</div>
							</div>`;

			const spacer = Create('div');
			this.inputs.display = status.querySelector('#scanner-display');
			this.inputs.progress = status.querySelector('.progress-inner');
			const close = Create('button.hj-ripple.icon.error.hj-close');
			close.setAttribute('data-ripple-color', '#F44336');
			close.setAttribute('data-title', 'Close recent encodes window');
			close.innerHTML = "<i class='material-icons'>close</i>";

			[...Array.from(appActions.querySelectorAll('button:not(.hja__cache-button)')), close].forEach((item) => {
				item.addEventListener('mouseenter', (ev) => this.hover(ev.target, true));
				item.addEventListener('mouseleave', (ev) => this.hover(ev.target, false));
			});

			[appActions, status, close].forEach((x) => {
				this.framework.status.appendChild(x);
			});
		}

		// Set up the table footer
		footer() {
			const limit = GM_getValue('groupEncodesLimit') || 25;
			const limitOptions = [
				{ value: 10 },
				{ value: 25 },
				{ value: 50 },
				{ value: 75 },
				{ value: 100 },
				{ value: 250 },
				{ value: 500 },
				{ value: 0, text: 'All' },
			];
			const limitOptionsHTML = limitOptions.reduce((acc, current) => {
				acc += `<option value="${current.value}"`;
				acc += limit == current.value ? ' selected>' : '>';
				acc += current.text ? current.text : current.value;
				acc += '</option>';
				return acc;
			}, '');

			// Limit select input section
			const limitNode = Create('div.hja__group-encodes--footer-limit');
			const limitSelect = Create('select.select-css');
			const limitSelectLabel = Create('label');
			limitSelectLabel.setAttribute('for', 'limit-select');
			limitSelectLabel.style.marginRight = '0.5em';
			limitSelectLabel.style.whiteSpace = 'nowrap';
			limitSelectLabel.textContent = 'Results per page: ';
			limitSelect.name = 'limit-select';
			limitSelect.innerHTML = limitOptionsHTML;
			limitSelect.addEventListener('change', (ev) => this.editFooterOption('limit', ev));
			limitNode.appendChild(limitSelectLabel);
			limitNode.appendChild(limitSelect);

			// Search filter input section
			const filterNode = Create('div.hja__group-encodes--footer-filter');
			const filterLabel = filterNode.appendChild(Create('label'));
			const filterInputContainer = Create('div.hja__group-encodes--footer-filter-input-container');
			const filterPrepend = filterInputContainer.appendChild(Create('div.input-prepend'));
			filterPrepend.innerHTML = `<i class="material-icons">search</i>`;
			const filterInput = filterInputContainer.appendChild(Create('input'));
			filterNode.appendChild(filterInputContainer);
			filterInput.setAttribute('type', 'text');
			filterInput.name = 'filter';
			filterLabel.setAttribute('for', 'filter');
			filterLabel.style.marginRight = '0.5em';
			filterLabel.textContent = 'Filter: ';

			this.pagination.links = Create('div.hja__group-encodes--footer-pagination-links');

			// Create a debounced function bound to this instance for handling search filter input
			const loadBound = this.load.bind(this);
			const fn = debounce(loadBound, 500);

			this.inputs.filter = filterInput;
			this.inputs.filterInputContainer = filterInputContainer;
			this.inputs.filter.addEventListener('input', () => {
				filterInputContainer.classList.add('loading');
				fn();
			});

			// Add all elements to the footer container
			[limitNode, filterNode, this.pagination.links].forEach((item) => this.framework.footer.appendChild(item));
		}

		// Set up and refresh the table headers / Show sort arrows where applicable
		headers(node) {
			this.columns.forEach((label) => {
				const col = Create('th.hja__group-encodes--col');
				switch (label) {
					case 'auth':
						col.classList.add('col-centre');
						break;
					case 'uploaded':
						col.classList.add('group-encodes--header-sort');
						col.setAttribute('data-sort', 'uploaded_at');
						break;
					case 'member':
						col.classList.add('group-encodes--header-sort');
						col.setAttribute('data-sort', 'username');
						break;
					case 'rank':
						col.classList.add('group-encodes--header-sort');
						col.setAttribute('data-sort', 'rank_order');
						break;
					case 'scan':
						break;
					default:
						col.classList.add('group-encodes--header-sort');
						col.setAttribute('data-sort', label);
				}
				const divNode = col.appendChild(Create('div'));
				const labelNode = divNode.appendChild(Create('span'));
				labelNode.textContent = label;

				if (this.orderBy === col.dataset.sort) {
					const arrow = Create('i.material-icons');
					arrow.textContent = this.asc == true ? 'arrow_drop_up' : 'arrow_drop_down';
					divNode.appendChild(arrow);
				}

				node.appendChild(col);
			});
		}

		/*
	************************************************
					  MI SCANNER
	************************************************
	*/

		/**
		 * Pause execution of the script for a speicied amount of time, used to increase delay between Ajax calls
		 * @param { number } ms - Number of milliseconds to pause for
		 * @returns { Promise<null> }
		 * @memberof GroupEncodes
		 */
		delay(ms) {
			return new Promise((resolve) => setTimeout(resolve, ms));
		}

		/**
		 * Start the batch MI Scanner process
		 * @memberof GroupEncodes
		 * @returns { void }
		 */
		async batchMiScan() {
			const total = this.filtered.length;
			let complete = 0,
				progress = 0;
			this.inputs.progress.style.width = '0%';
			if (total === 0) {
				this.scanning = 'No uploads left to scan from this page';
				this.batchActive = false;
			} else {
				this.inputs.progress.classList.add('active');
				for (let [idx, upload] of this.json.entries()) {
					// If the upload has already been scanned, skip to the next one

					if (upload.scanStatus) continue;
					// If batch scan has been turned off, cancel loop
					if (this.batchActive !== true) break;

					this.scanning = 'Scanning: ' + upload.filename;
					await this.singleMiScan(undefined, idx);
					// Update progress display information
					complete++;
					progress = Math.ceil((100 / total) * complete);
					this.inputs.progress.style.width = progress + 1 + '%';
					// Pause execution to prevent spamming of PTP's servers
					await this.delay(1000);
				}

				const cb = () => {
					this.inputs.progress.classList.remove('active');
					this.inputs.progress.removeEventListener('transitionend', cb);
				};
				this.inputs.progress.addEventListener('transitionend', cb);

				if (total === complete) this.scanning = 'Scanning complete';
				else this.scanning = 'Scanning cancelled';

				this.batchActive = false;
			}
		}

		/**
		 * Called from the MI scan information window, update scan information with a new scan
		 * @param {*} ev
		 * @memberof GroupEncodes
		 * @returns { void }
		 */
		async refreshScan(ev) {
			const rowId = ev.target.dataset.rowid;
			const row = this.framework.content.querySelector(`[data-row-id="${rowId}"].hja__group-encodes--scan-row`);
			// Reset the scan status of the row to enable it to be updated
			row.setAttribute('data-scan', '');
			await this.singleMiScan(undefined, rowId);
			// Update the information window
			this.showMediainfoScan(rowId);
			// Update the status display
			this.scanning = 'Scanning complete';
		}

		async singleMiScan(ev, setRow = null) {
			// Get the DOM row element
			const row =
				setRow === null
					? ev.target.closest('.hja__group-encodes--scan-row')
					: this.framework.content.querySelector(`[data-row-id="${setRow}"].hja__group-encodes--scan-row`);
			const rowId = row.dataset.rowId;
			const scanStatus = row.dataset.scan;
			// If the row has already been scanned, abort the process here
			if (scanStatus && scanStatus !== '') return false;
			// If called from click handler, run a pass of the progress bar loader
			if (setRow === null) this.inputs.progress.classList.add('indeterminate');
			const { groupid, torrentid, filename } = this.json[rowId];
			const scanRaw = await this.scanMediainfo({ groupid, torrentid, filename });
			// Batch: will animate a flash of colour on the row that's just been scanned
			if (setRow !== null) row.classList.add('complete');
			// Filter out the extraneous information from the MI Scanner, reducing size to save output
			const scan = scanRaw ? this.cleanScan(scanRaw) : 'deleted';
			// Save scan data to GM's localStorage
			GM_setValue('torrentScan_' + torrentid, scan);

			// Update scan status in the DOM row
			const info =
				scan === 'deleted'
					? 'deleted'
					: scan.errors > 0
					? 'error'
					: scan.warnings > 0
					? 'warning'
					: scan.advisories > 0
					? 'advisory'
					: 'pass';
			row.setAttribute('data-scan', info);
			this.json[rowId].scanStatus = info;

			const cb = () => {
				this.inputs.progress.removeEventListener('animationiteration', cb);
				this.inputs.progress.classList.remove('indeterminate');
				this.scanning += ' ... Complete';
			};
			if (setRow === null) this.inputs.progress.addEventListener('animationiteration', cb);
		}

		/**
		 * Remove extraneous scan information from object, required to allow saving to localStorage
		 * @param { object } scanRaw - An instance of the Mediainfo Scan class
		 * @returns { object } Minimal scan results
		 * @memberof GroupEncodes
		 */
		cleanScan(scanRaw) {
			const scan = {
				scans: [],
				issues: scanRaw.issues,
				errors: scanRaw.errors,
				warnings: scanRaw.warnings,
				advisories: scanRaw.advisories,
				cover: scanRaw.cover,
				timestamp: scanRaw.timestamp,
			};
			// Iterate over each mediainfo log scan on the upload page
			scanRaw.scans.forEach((item) => {
				// If there was a problem reading the log/scan, skip to the next one
				if (!item.data || !item.data.filename) return true;
				// Create a new feedback object
				const fb = {
					data: {
						filename: item.data.filename,
						metatitle: item.data.metatitle,
					},
					feedback: {},
				};

				// Iterate over the feedback sections ( e.g. audio / video / x264 / chapters etc. )
				for (const [key, value] of Object.entries(item.feedback)) {
					// Skips the guides property, used in other scripts
					if (key === 'guides') continue;
					const i = item.feedback[key];
					// If no issues are found in this section, skip to the next one.
					if (i.errors.length === 0 && i.warnings.length === 0 && i.advisories.length === 0) continue;

					fb.feedback[key] = {};
					if (i.errors.length > 0) fb.feedback[key].errors = i.errors;
					if (i.warnings.length > 0) fb.feedback[key].warnings = i.warnings;
					if (i.advisories.length > 0) fb.feedback[key].advisories = i.advisories;
				}

				scan.scans.push(fb);
			});
			return scan;
		}

		/**
		 * Retrieve MI logs from PTP torrent page and create scans of each
		 * @param { object } input - Single object input
		 * @param { number } input.torrentid - The PTP torrent ID of the upload
		 * @param { number } input.groupid - The PTP group ID of the upload
		 * @param { number } input.filename - The filename of the uploaded torrent
		 * @returns { object } - Scan results object
		 * @memberof GroupEncodes
		 */
		scanMediainfo({ torrentid, groupid, filename }) {
			this.scanning = 'Scanning: ' + truncate({ str: filename, maxLength: 90 });
			const url = `${PTP}/torrents.php?id=${groupid}&torrentid=${torrentid}`;
			return new Promise((resolve, reject) => {
				ADMIN_ajaxRequest({ url }).then((res) => {
					const dom = parseHTMLtoDOM(res);
					const cover = dom.querySelector('div.sidebar .box_albumart img.sidebar-cover-image').src;
					const el = dom.querySelector('tr#torrent_' + torrentid + ' td > div:last-child');
					if (!el) return resolve(null);

					const scans = [];
					let warnings = 0,
						errors = 0,
						advisories = 0,
						issues = 0;

					Array.from(el.querySelectorAll('table.mediainfo')).forEach((mi) => {
						const miScan = populateMiScan(mi, true);
						scans.push(miScan);
					});

					if (scans.length === 0) {
						return resolve(null);
					}
					const sections = [
						'filename',
						'metatitle',
						'x264',
						'video',
						'audio',
						'subtitles',
						'chapters',
						'misc',
					];
					for (let scan of scans) {
						for (let i = 0; i < sections.length; i++) {
							errors += scan.feedback[sections[i]].errors.length;
							warnings += scan.feedback[sections[i]].warnings.length;
							advisories += scan.feedback[sections[i]].advisories.length;
						}
					}
					issues = errors + warnings + advisories;

					const results = {
						scans,
						issues,
						errors,
						warnings,
						advisories,
						cover,
						timestamp: Date.now(),
					};

					resolve(results);
				});
			});
		}

		// Load the mediainfo scan results for a row into the information window
		showMediainfoScan(row) {
			const json = this.json[row];
			const scan = json ? GM_getValue('torrentScan_' + json.torrentid) : 'deleted';
			const container = Create('div.hja__group-encodes--info--scan-container');

			this.framework.info.innerHTML = '';
			this.framework.info.appendChild(container);
			this.showInfo = true;

			const scanDisplay = Create('div.hja__group-encodes--scan-results.hj-card');
			const summary = () => {
				let t = [];
				if (scan.errors > 0) t.push(`${scan.errors} error${scan.errors !== 1 ? 's' : ''}`);
				if (scan.warnings > 0) t.push(`${scan.warnings} warning${scan.warnings !== 1 ? 's' : ''}`);
				if (scan.advisories > 0) t.push(`${scan.advisories} advisor${scan.advisories !== 1 ? 'ies' : 'y'}`);

				if (t.length > 1) {
					const last = t.pop();
					return t.join(', ') + ' and ' + last;
				} else return t[0];
			};

			if (json)
				scanDisplay.innerHTML = `<header>Mediainfo scan results for ${truncate({
					str: json.filename,
					maxLength: 100,
					split: true,
				})}<div style="position:absolute;right:20px;">
		<button
			class="hj-ripple icon secondary hj-refresh-scan-button"
			data-torrentid="${json.torrentid}"
			data-groupid="${json.groupid}"
			data-filename="${json.filename}"
			data-rowid="${row}"
		>
			<i class="material-icons" style="color:white;">replay</i>
		</button>
		<a href="/torrents.php?id=${json.groupid}&torrentid=${json.torrentid}" target="_blank">
			<button class="hj-ripple icon secondary">
				<i class="material-icons" style="color:white;">link</i>
			</button>
		</a>
	</div>
</header>
<section>
	<div class="flex-col" style="padding-right: 18px;">
		<article>
			<table class="hja__group-encodes--miscan-table">
				<tr>
					<td>Title: </td>
					<td>${json.title}</td>
				</tr>
				<tr>
					<td>Year: </td>
					<td>${json.year}</td>
				</tr>
				<tr>
					<td>Resolution: </td>
					<td>${json.res}</td>
				</tr>
				<tr>
					<td>Uploaded: </td>
					<td>
						${new Date(json.uploaded_at).toLocaleString(undefined, {
							weekday: 'short',
							day: 'numeric',
							month: 'long',
							year: 'numeric',
							hour: '2-digit',
							minute: '2-digit',
						})}
					</td>
				</tr>
				<tr>
					<td>Uploader: </td>
					<td>${json.username} – [ ${json.rank_full} ]</td>
				</tr>
				<tr>
					<td class="autofit">Scanned on: </td>
					<td>
						${new Date(scan.timestamp).toLocaleString(undefined, {
							weekday: 'short',
							day: 'numeric',
							month: 'long',
							year: 'numeric',
							hour: '2-digit',
							minute: '2-digit',
						})}
					</td>
				</tr>
				<tr>
					<td>Summary: </td>
					<td>
						${summary()}
					</td>
				</tr>
			</table>
		</article>
		<article id="scan-results">
		</article>
	</div>
	<aside>
		<figure>
			<img class="cover" src="${scan.cover}" />
		</figure>
	</aside>
</section>`;
			else
				scanDisplay.innerHTML = `<header>This torrent has been deleted</header>
<section >
	<div style="text-align: center; width: 100%;">
		This torrent has been deleted from PTP since the previous scan.
	</div>
</section>`;

			container.appendChild(scanDisplay);
			const scanNode = container.querySelector('article#scan-results');
			if (json) this.insertScanResults(scanNode, scan);

			setTimeout(
				() =>
					this.container.scrollTo({
						top: 0,
						behavior: 'smooth',
					}),
				300
			);
		}

		// Information Window: add formatted MI scan results to page
		insertScanResults(container, scan) {
			const icons = {
				filename: 'label',
				metatitle: 'short_text',
				video: 'theaters',
				audio: 'volume_up',
				subtitles: 'subtitles',
				x264: 'developer_board',
				misc: 'more_horiz',
				chapters: 'playlist_play',
			};

			for (let result of scan.scans) {
				const header = container.appendChild(Create('header.hja__group-encodes--miscan-results--header'));
				header.innerHTML = result.data.filename;

				for (let [key, section] of Object.entries(result.feedback)) {
					const rowClass = section.errors ? '.errors' : section.warnings ? '.warnings' : '.advisories';
					const row = container.appendChild(
						Create('div.hja__group-encodes--miscan-results--section-heading' + rowClass)
					);
					row.innerHTML = `<i class='material-icons'>${icons[key]}</i> <span style="text-transform: capitalize;margin-left:1em;">${key}</span>`;

					for (let [severity, issues] of Object.entries(section)) {
						issues.forEach((issue) => {
							const line = Create('div.hja__group-encodes--miscan-results--section-issue.' + severity);
							line.innerHTML = issue.output;
							container.appendChild(line);
						});
					}
				}
			}
		}

		/*
	************************************************
			   AUTHORISATION WINDOW
	************************************************
	*/

		/**
		 * Authorisation Check window, handle tab transitions
		 * @param { EventObject } ev - The click event object
		 * @returns { void }
		 * @memberof GroupEncodes
		 */
		changeTab(ev) {
			const tabID = ev.target.getAttribute('data-tab-id');
			let i, tabcontent, tablinks;
			tabcontent = document.querySelectorAll('.tab-content-item.active');
			for (i = 0; i < tabcontent.length; i++) {
				tabcontent[i].classList.remove('active');
			}

			// Get all elements with class="tablinks" and remove the class "active"
			tablinks = document.getElementsByClassName('tablinks');
			for (i = 0; i < tablinks.length; i++) {
				tablinks[i].classList.remove('active');
			}

			// Clear the forum post viewer
			document.querySelector('#forum-post-viewer').innerHTML = '';

			// Show the current tab, and add an "active" class to the link that opened the tab
			document.getElementById(tabID).classList.add('active');
			ev.target.classList.add('active');
		}

		// Run a search via PTP's forum search function
		checkAuth(query = '', user = '') {
			return new Promise((resolve, reject) => {
				let url = `/forums.php?action=search&search=${query}&user=${user}&threadid=13617`;
				ADMIN_ajaxRequest({ url, method: 'GET' }).then((res) => {
					const results = [];
					const dom = parseHTMLtoDOM(res);
					Array.from(dom.querySelectorAll('div.thin > table.table--panel-like tbody tr')).forEach((item) => {
						const contents = item.querySelector('td:nth-child(2)');
						const dateCont = item.querySelector('td:nth-child(3)');
						const forumDate = dateCont.querySelector('span.time').getAttribute('title');
						const forumLink = contents.querySelector('a.forum-topic__go-to-last-read').href;
						const postID = parseInt(forumLink.split('#post').pop(), 10);
						const threadID = parseInt(forumLink.match(/(?:threadid=)(\d+)/i)[1], 10);
						const forumPost = contents.querySelector('span:nth-child(2)').textContent;
						const forumAuthor = contents.querySelector('span.last_poster').querySelector('a.username')
							.textContent;
						results.push({ forumLink, forumPost, forumAuthor, forumDate, threadID, postID });
					});
					resolve(results);
				});
			});
		}

		/**
		 * Load an authorisation check into the Information Window
		 * @param { HTMLElement } row - The row of which to show an authorisation check
		 * @returns { void }
		 * @memberof GroupEncodes
		 */
		async showAuthorisation(row) {
			this.framework.info.innerHTML = '';
			const container = Create('div.hja__group-encodes--info--auth-container');
			const loader = container.appendChild(Create('div.loader.show'));
			this.framework.info.appendChild(container);
			this.loader = loader;
			this.showInfo = true;
			this.loading = true;

			const json = this.json[row];
			// Search for exact matches by the uploader
			const a = this.checkAuth(json.filename, json.username);
			// Search for exact match replies
			const b = this.checkAuth(json.filename);
			// Search for potential replies
			const c = this.checkAuth('@' + json.username);
			// Search for potential matches by the uploader
			const d = this.checkAuth('approval', json.username);

			setTimeout(
				() =>
					this.container.scrollTo({
						top: 0,
						behavior: 'smooth',
					}),
				300
			);

			// Send all 4 search requests to PTP and then process results
			Promise.all([a, b, c, d]).then((results) => {
				results = this.filterAuthResults(results, json);

				this.loadingCallback = () => {
					const resultsDisplay = Create('div.hja__group-encodes--auth-results.hj-card');
					const tableHeader = `<table class="hja__group-encodes--auth-table"><thead><tr><th class="hja__group-encodes--col">Author</th><th  class="hja__group-encodes--col">Post Date</th><th class="hja__group-encodes--col">Preview</th><th class="table-actions">Link</th></tr></thead><tbody>`;

					// Function to generate a table for the display of authorisation post results
					const generateAuthTable = ({ noData, results }) => {
						let html = tableHeader;
						if (results.length === 0) {
							html += `<tr class="hja__group-encodes--row"><td colspan="4"  class="hja__group-encodes--col" style="padding-top: 3rem; font-size: 1rem; font-style: italic; text-align: center;">${noData}</td></tr>`;
						}
						results.forEach((post) => {
							html += `<tr class="hja__group-encodes--forum-message-row" data-post-id="${
								post.postID
							}" data-thread-id="${post.threadID}">
						<td class="hja__group-encodes--col">${post.forumAuthor}</td>
						<td class="hja__group-encodes--col" style="white-space: nowrap">${new Date(post.forumDate).toLocaleString()}</td>
						<td class="hja__group-encodes--col">${post.forumPost}</td>
						<td class="table-actions"><a href="${
							post.forumLink
						}" target="_blank"><button class="hj-ripple primary icon small"><i class="material-icons">link</i></button></a>
								</tr>`;
						});
						html += `</tbody></table>`;
						return html;
					};

					const approvalRequests = () => {
						return generateAuthTable({ noData: 'No approval requests found', results: results[0] });
					};

					const potentialApprovalRequests = () => {
						return generateAuthTable({
							noData: 'No potential approval requests found',
							results: results[3],
						});
					};

					const exactReplies = () => {
						return generateAuthTable({ noData: 'No exact replies found', results: results[1] });
					};

					const potentialReplies = () => {
						return generateAuthTable({ noData: 'No potential replies found', results: results[2] });
					};

					resultsDisplay.innerHTML = `
				<header>Authorisation check for ${json.username}'s ${json.res === 'Other' ? 'DVDRip' : json.res} encode of ${
						json.title
					} [${json.year}]</header>
			<div class="tab-container">
				<div class="tab-controls">
					<div class="tablinks hj-ripple active" data-tab-id="approval-requests">
						<i class="material-icons">speaker_notes</i>
						<div>Approval Requests</div>
					</div>
					<div class="tablinks hj-ripple" data-tab-id="potential-approval-requests">
						<i class="material-icons">question_answer</i>
						<div>Potential Approval Requests</div>
					</div>
					<div class="tablinks hj-ripple" data-tab-id="exact-replies">
						<i class="material-icons">comment</i>
						<div>Exact Replies</div>
					</div>
					<div class="tablinks hj-ripple" data-tab-id="potential-replies">
						<i class="material-icons" style="transform: scaleX(-1)">feedback</i>
						<div>Potential Replies</div>
					</div>
				  </div>
				<div class="tab-content">
					<div id="approval-requests" class="tab-content-item active">
						${approvalRequests()}
					</div>

					<div id="potential-approval-requests" class="tab-content-item">
						${potentialApprovalRequests()}
					</div>

					<div id="exact-replies" class="tab-content-item">
						${exactReplies()}
					</div>

					<div id="potential-replies" class="tab-content-item">
						${potentialReplies()}
					</div>

					<div id="forum-post-viewer"></div>
				</div>
			</div>`;
					container.appendChild(resultsDisplay);
				};

				// Remove the loader
				this.loading = false;
			});
		}

		/**
		 * Loads the clicked forum post in the authorisation checker window
		 * @param { EventObject } ev - The click event object
		 * @memberof GroupEncodes
		 */
		async loadForumPost(ev) {
			if (/button/i.test(ev.target.tagName) === true) return false;
			const viewer = document.querySelector('#forum-post-viewer');
			viewer.innerHTML = '';
			const row = ev.target.closest('.hja__group-encodes--forum-message-row');
			const postID = row.dataset.postId;
			const threadID = row.dataset.threadId;
			const raw = await ADMIN_ajaxRequest({
				url: `${PTP}/forums.php?action=viewthread&threadid=${threadID}&postid=${postID}`,
			}).catch((e) => (this.scanning = 'Error loading forum post'));
			const page = parseHTMLtoDOM(raw);
			const postNode = page.querySelector('#post' + postID);

			const avatar = postNode.querySelector('.forum-post__avatar__image').src;
			const username = postNode.querySelector('.forum-post__heading a.username').textContent;
			const relativeTime = postNode.querySelector('.forum-post__heading span.time').textContent;
			const content = postNode.querySelector('.forum-post__bodyguard').innerHTML;

			const container = Create('div.hja__group-encodes--forum-post-container.hja__scrollbar');
			const header = Create('div.hja__group-encodes--forum-post-header');
			const footer = Create('div.hja__group-encodes--forum-post-header');
			const authorAndBody = container.appendChild(Create('div.hja__group-encodes--forum-post-author-and-body'));
			const author = authorAndBody.appendChild(Create('div.hja__group-encodes--forum-post-avatar'));
			const body = authorAndBody.appendChild(Create('div.hja__group-encodes--forum-post-body'));

			header.innerHTML = `Posted by ${username}, ${relativeTime} <button id="forum-post-viewer-close" class="hj-ripple icon small"><i class="material-icons">close</i></button>`;
			const button = header.querySelector('#forum-post-viewer-close');
			button.addEventListener('click', () => (viewer.innerHTML = ''));

			author.innerHTML = `<img src="${avatar}" />`;

			body.innerHTML = content;

			viewer.appendChild(header);
			viewer.appendChild(container);
			viewer.appendChild(footer);
		}

		/**
		 * @description
		 * @param { Object[] } results - Search results from "async showAuthorisation()"
		 * @param { Object } json - JSON data for the current row
		 * @returns { Object[] } - The filtered results Array
		 * @memberof GroupEncodes
		 */
		filterAuthResults(results, json) {
			results.forEach((result) => {
				result.forEach((post) => {
					post.forumDate = new Date(post.forumDate + ' UTC').getTime();
				});
			});
			// Filter results for Member's posts
			const initialPost =
				results[0] && results[0][0] ? results[0][0].forumDate : json.uploaded_at - 1000 * 60 * 60 * 24 * 14;
			// Filter results for exact replies
			results[1] = results[1]
				.filter((post) => post.forumAuthor !== json.username)
				.filter((post) => post.postID !== 376903)
				.slice(0, 10);
			const matchedPostIDs = results[1].map((post) => post.postID);
			const matchedRequestIDs = results[0].map((post) => post.postID);
			// Filter results for potential replies
			results[2] = results[2]
				.filter((post) => post.forumAuthor !== json.username)
				.filter((post) => post.forumDate > initialPost && post.forumDate < json.uploaded_at)
				.filter((post) => !matchedPostIDs.includes(post.postID));

			results[3] = results[3]
				.filter(
					(post) =>
						post.forumDate > json.uploaded_at - 1000 * 60 * 60 * 24 * 14 &&
						post.forumDate < json.uploaded_at
				)
				.filter((post) => !matchedRequestIDs.includes(post.postID))
				.slice(0, 10);

			return results;
		}

		/*
	************************************************
				   DISPLAY UPDATE
	************************************************
	*/

		/**
		 * Updates the main display after a load request
		 * @memberof GroupEncodes
		 */
		update() {
			// A few formatting functions to convert DB output to human
			const format = {
				source: (v) => (v === 'bluray' ? 'Blu-Ray' : v === 'dvd' ? 'DVD' : v),
				check: (v) =>
					v === 'associates'
						? '<button class="hj-ripple icon small primary hj-auth-check"><i class="material-icons">fact_check</i></button>'
						: '',
			};

			const table = Create('table.hja__group-encodes--table');
			const thead = table.appendChild(Create('thead'));
			const header = thead.appendChild(Create('tr.hja__group-encodes--header'));

			this.headers(header);
			const tbody = table.appendChild(Create('tbody'));

			if (this.json.length === 0) {
				const row = Create('tr.hja__group-encodes--row');
				row.innerHTML = `<td colspan="100" style="text-align: center; padding-top: 3em; padding-bottom: 1em;"><em>No encodes found</em></td>`;
				tbody.appendChild(row);
			}

			this.json.forEach((item, idx) => {
				const scan = GM_getValue('torrentScan_' + item.torrentid);
				const scanInfo = !scan
					? ''
					: scan === 'deleted'
					? 'deleted'
					: scan.errors > 0
					? 'error'
					: scan.warnings > 0
					? 'warning'
					: scan.advisories > 0
					? 'advisory'
					: 'pass';
				item.scanStatus = scanInfo;
				const row = Create('tr.hja__group-encodes--row.hja__group-encodes--scan-row');
				row.setAttribute('data-scan', scanInfo);
				row.setAttribute('data-row-id', idx);
				const fullTitle = `${item.title} [${item.year}]`;
				const truncatedTitle = truncate({
					str: item.title,
					split: true,
					maxLength: 50,
				});
				const truncatedFilename = truncate({
					str: item.filename,
					maxLength: 60,
					split: true,
				});
				let html = '';
				html += `<td class="hja__group-encodes--col"`;
				// If the title has been truncated, use a tooltip to display to original title on hover
				if (truncatedTitle != item.title) html += ` data-tooltip="${fullTitle}">`;
				else html += '>';
				html += `${truncatedTitle} [${item.year}]</td>`;
				html += `<td class="hja__group-encodes--col">${new Date(item.uploaded_at).toLocaleString()}</td>`;
				html += `<td class="hja__group-encodes--col">${item.codec}</td>`;
				html += `<td class="hja__group-encodes--col">${format.source(item.source)}</td>`;
				html += `<td class="hja__group-encodes--col">${item.res}</td>`;
				html += `<td class="hja__group-encodes--col hja__group-encodes--col-filename"`;
				// If the filename has been truncated, use a tooltip to display the original filename on hover
				if (truncatedFilename != item.filename) html += ` data-tooltip="${item.filename}">`;
				else html += '>';
				html += `<div>${truncatedFilename}</div></td>`;
				html += `<td class="hja__group-encodes--col">${item.username}</td>`;
				html += `<td class="hja__group-encodes--col">${item.rank_full}</td>`;
				html += `<td class="hja__group-encodes--col col-centre py-0">${format.check(item.rank_short)}</td>`;
				html += `<td class="hja__group-encodes--col hja__group-encodes--col-scan col-centre py-0">
						<button class="hj-ripple icon small primary hj-scan-view">
							<i></i>
						</button>
					</td>`;
				row.innerHTML = html;
				tbody.appendChild(row);
			});

			this.framework.content.innerHTML = '';
			this.framework.content.appendChild(table);

			// Update the pagination data and links in the footer
			this.updatePagination();
		}

		/**
		 * Refresh pagination data and links in the table footer
		 * @memberof GroupEncodes
		 */
		updatePagination() {
			const { start, end, page, totalPages: total } = this.pagination.json;
			this.pagination.links.innerHTML = '';
			this.pagination.total = Create('div.hja__group-encodes--footer-pagination');
			this.pagination.total.innerHTML = `${start} to ${end} of ${this.pagination.json.total}`;
			const pageButton = () => Create('button.hj-ripple.secondary.icon.light.group-encodes-pagination');

			const p = page > 1 ? page - 1 : null;
			const n = page < total ? page + 1 : null;

			const first = pageButton();
			first.innerHTML = `<i class="material-icons">first_page</i>`;
			first.setAttribute('data-page', 1);
			if (page === 1) first.setAttribute('disabled', true);

			const prev = pageButton();
			prev.innerHTML = `<i class="material-icons">keyboard_arrow_left</i>`;
			if (p) prev.setAttribute('data-page', p);
			else prev.setAttribute('disabled', true);

			const next = pageButton();
			next.innerHTML = `<i class="material-icons">keyboard_arrow_right</i>`;
			if (n) next.setAttribute('data-page', n);
			else next.setAttribute('disabled', true);

			const last = pageButton();
			last.innerHTML = `<i class="material-icons">last_page</i>`;
			last.setAttribute('data-page', total);
			if (page === total) last.setAttribute('disabled', true);

			[first, prev, this.pagination.total, next, last].forEach((item) => this.pagination.links.appendChild(item));
		}

		/*
	************************************************
						 CSS
	************************************************
	*/

		/**
		 * Inject the styles required for this Class
		 * @memberof GroupEncodes
		 */
		style() {
			const css = `
.hja__group-encodes--footer-limit {
display: flex;
align-items: center;
width: 200px;
}
.select-css {
display: inline-block;
font-family: "Roboto", sans-serif;
font-weight: 700;
color: #444;
line-height: 1.3;
padding: .3em 1em .2em .8em;
width: 100%;
max-width: 100%; /* useful when width is set to anything other than 100% */
box-sizing: border-box;
margin: 0;
border: 1px solid #aaa;
box-shadow: 0 1px 0 1px rgba(0,0,0,.04);
border-radius: .5em;
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
background-color: #fff;
background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23007CB2%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E'),
	linear-gradient(to bottom, #ffffff 0%,#e5e5e5 100%);
background-repeat: no-repeat, repeat;
/* arrow icon position (1em from the right, 50% vertical) , then gradient position*/
background-position: right .7em top 50%, 0 0;
/* icon size, then gradient */
background-size: .65em auto, 100%;
}
/* Hide arrow icon in IE browsers */
.select-css::-ms-expand {
display: none;
}
/* Hover style */
.select-css:hover {
border-color: #888;
}
/* Focus style */
.select-css:focus {
border-color: #aaa;
/* It'd be nice to use -webkit-focus-ring-color here but it doesn't work on box-shadow */
box-shadow: 0 0 1px 3px rgba(59, 153, 252, .7);
box-shadow: 0 0 0 3px -moz-mac-focusring;
color: #222;
outline: none;
}

/* Set options to normal weight */
.select-css option {
font-weight:normal;
}

/* Support for rtl text, explicit support for Arabic and Hebrew */
*[dir="rtl"] .select-css, :root:lang(ar) .select-css, :root:lang(iw) .select-css {
background-position: left .7em top 50%, 0 0;
padding: .6em .8em .5em 1.4em;
}

/* Disabled styles */
.select-css:disabled, .select-css[aria-disabled=true] {
color: graytext;
background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22graytext%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E'),
	linear-gradient(to bottom, #ffffff 0%,#e5e5e5 100%);
}

.select-css:disabled:hover, .select-css[aria-disabled=true] {
border-color: #aaa;
}

.speed-dial-container button.icon {
border-radius: 50%;
border: transparent;
height: 50px;
width: 50px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.15s ease-in;
}

.speed-dial-container .primary,
.hj-close-info.primary {
background-color: #1091ec !important;
color: white !important;
}

.warning {
transition: background-color 150ms ease-in;
background-color: #E65100 !important;
color: white !important;
}

.warning:hover {
background-color: #B71C1C !important;
}

.speed-dial-container .primary:hover,
.hj-close-info.primary:hover {
background-color: #0D47A1 !important;
}

.speed-dial-container .accent {
background-color: #C2185B !important;
color: white !important;
}

.speed-dial-container .accent:hover {
background-color: #880E4F !important;
}

.speed-dial-container .speed-dial-activator i.open {
display: block;
}
.speed-dial-container:hover .speed-dial-activator i.open {
display: none;
}

.speed-dial-container .speed-dial-activator i.close {
display: none;
}

.speed-dial-container:hover .speed-dial-activator i.close {
display: block;
}

.hj-close {
margin-right: 5px;
margin-top: 5px;
}

.hja__recent-encodes--app-actions {
padding-left: 10px;
display: flex;
min-height: 100%;
align-items: center;
}

.hja__recent-encodes--app-actions button {
margin-right: 10px;
}

.speed-dial-container {
display: inline-block;
position: relative;
z-index: 1;
}

.speed-dial-container > .speed-dial-menu-items {
z-index: -1;
transform: scale(0) translateY(-25px);
transform-origin: top;
transition: transform 150ms ease-in;
}

.speed-dial-container:hover > .speed-dial-menu-items {
transform: scale(1);
}

.speed-dial-menu-items {
position: absolute;
width: 100%;
display: flex;
flex-flow: column;
align-items: center;
}

.speed-dial-menu-items button.icon {
width: 35px !important;
height: 35px !important;
margin-top: 5px;
position: relative;
overflow: visible !important;
transition: filter 150ms ease-in;
}

.speed-dial-menu-items button.icon:hover {
filter: drop-shadow(2px 2px 5px #777);
}

.speed-dial-menu-items button::after {
color: rgba(255,255,255,0.9) !important;
content: attr(data-label);
position: absolute;
left: 100%;
transform: scaleX(0);
transition: transform 150ms ease-in;
transform-origin: left;
text-transform: none;
background: #880E4F;
padding: 2px 16px;
border-radius: 0 4px 4px 0;
z-index: -1;
margin-left: -8px;
white-space: nowrap;
}

.speed-dial-menu-items button:hover::after {
transform: scaleX(1);
}

.speed-dial-menu-items .icon i {
font-size: 18px;
}

#group-encodes {
overflow-x: hidden;
box-sizing: border-box;
font-family: "Roboto", sans-serif;
color: rgba(0,0,0,0.6);
font-size: 14px;
position: fixed;
width: 100%;
height: 100%;
background: white;
top: 0;
left: 0;
z-index: 9999;
transform-origin: center;
transition: all 300ms ease-in-out;
}

#group-encodes.show {
transform: scale(1) !important;
opacity: 1 !important;
}

.hja__group-encodes--scan-results.hj-card {
padding-top: 32px;
}

.flex-col {
display: flex;
flex-flow: column;
width: 100%;
}

.hja__group-encodes--footer-filter {
display: flex;
align-items: center;
}

.hja__group-encodes--footer-filter-input-container {
display: flex;
align-items: center;
border: 1px solid rgba(0,0,0,0.1);
border-radius: 4px;
height: 30px;
transition: background-color 300ms ease-in;
}

.hja__group-encodes--footer-filter-input-container:focus-within {
background-color: rgba(16, 145, 236, 0.1);
}

.hja__group-encodes--footer-filter-input-container input {
border: 0;
padding: 2px 6px;
background-color: transparent;
width: 250px;
}

.hja__group-encodes--footer-filter-input-container input:focus {
outline: 0;
}

#forum-post-viewer {
margin-right: 2rem;
padding-top: 2rem;
}

#forum-post-viewer .mediainfo > tbody > tr,
#forum-post-viewer .mediainfo__section > tbody > tr {
background-color: #E0F2F1;
}

#forum-post-viewer a {
color: #009688;
transition: color 50ms ease-in;
}

#forum-post-viewer blockquote {
border: 1px solid #B2DFDB;
border-radius: 4px;
background-color: #E0F2F1;
-webkit-box-shadow: 0 3px 1px -2px rgba(0,0,0,.2),0 2px 2px 0 rgba(0,0,0,.14),0 1px 5px 0 rgba(0,0,0,.12);
box-shadow: 0 3px 1px -2px rgba(0,0,0,.2),0 2px 2px 0 rgba(0,0,0,.14),0 1px 5px 0 rgba(0,0,0,.12);
}

#forum-post-viewer a:hover {
color: #004D40;
}

.hja__scrollbar::-webkit-scrollbar {
width: 11px;
}

.hja__scrollbar {
scrollbar-width: thin;
scrollbar-color: #009688 #B2DFDB;
}

.hja__scrollbar::-webkit-scrollbar-track {
background: #B2DFDB;
}

.hja__scrollbar::-webkit-scrollbar-thumb {
background-color: #009688;
border-radius: 6px;
border: 3px solid #B2DFDB;
}

.hja__group-encodes--forum-post-container {
display: flex;
flex-direction: column;
width: 100%;
max-height: 700px;
overflow-x: auto;
border-left: 1px solid #009688;
}

.hja__group-encodes--forum-post-header {
padding: 8px 16px;
background-color: #009688;
color: white;
min-height: 30px;
position: relative;
}

.hja__group-encodes--forum-post-header button {
color: white !important;
position: absolute !important;
right: 10px;
top: 50%;
transform: translateY(-50%);
}

.hja__group-encodes--forum-post-author-and-body {
display: flex;
width: 100%;
min-height: 250px;
}

.hja__group-encodes--forum-post-avatar {
min-width: 150px;
max-width: 150px;
position: absolute;
}

.hja__group-encodes--forum-post-avatar img {
max-width: 100%;
}

.hja__group-encodes--forum-post-body {
padding: 8px 16px;
margin-left: 150px;
}

.input-prepend {
width: 30px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0,0,0,0.1);
color: black;
border-radius: 4px 0 0 4px;
}

.hja__group-encodes--footer-filter-input-container::after {
content: "";
position: absolute;
border: 4px solid rgba(0,0,0,0.2);
border-top: 4px solid #1091ec; /* Blue */
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
opacity: 0;
transition: opacity 150ms ease-in;
left: 4px;
}

.hja__group-encodes--footer-filter-input-container.loading::after {
opacity: 1;
}

.hja__group-encodes--footer-filter-input-container.loading i.material-icons {
display: none;
}

.hja__group-encodes--scan-results section {
display: flex;
flex-flow: row;
justify-content: space-between;
padding: 2rem;
}

.hja__group-encodes--miscan-table td {
padding: 4px 8px;
}

.hja__group-encodes--miscan-table tr:nth-child(odd) {
background-color: #F5F5F5;
}

.hja__group-encodes--miscan-table {
width: 100%;
}

.hja__group-encodes--scan-results section figure img.cover {
max-height: 400px;
}

.hja__group-encodes--scan-results section figure {
margin: 0;
}

.hja__group-encodes--scan-results article {
width: 100%;
}

td.autofit {
width: 1px;
white-space: nowrap;
padding-right: 2rem;
}

.hja__group-encodes--scan-row.complete {
animation-name: row-flash;
animation-iteration-count: 1;
animation-duration: 0.5s;
}

@keyframes row-flash {
50% {
	background-color: #1091ec;
	color: white;
}
}

[data-scan="deleted"].hja__group-encodes--scan-row,
[data-scan="pass"].hja__group-encodes--scan-row,
[data-scan="advisory"].hja__group-encodes--scan-row,
[data-scan="warning"].hja__group-encodes--scan-row,
[data-scan="error"].hja__group-encodes--scan-row {
cursor: default !important;
}

[data-scan=""].hja__group-encodes--scan-row:hover,
.hja__group-encodes--auth-table tbody tr:hover {
background-color: #FFEBEE !important;
}

.hja__group-encodes--auth-table tbody tr {
transition: background-color 30ms ease-in;
cursor: pointer;
}

[data-scan="deleted"] .hja__group-encodes--col-filename {
color: #F44336;
position: relative;
}

[data-scan="deleted"] .hja__group-encodes--col-filename::before {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
content: "This torrent has been deleted";
padding: 4px 8px;
}

[data-scan="deleted"] .hja__group-encodes--col-filename > div {
display: none;
}

[data-scan=""] .hja__group-encodes--col-scan button {
display: none !important;
}

.hja__group-encodes--col-scan button {
display: flex;
align-items: center;
justify-content: center;
}

.hja__group-encodes--col-scan button i::before {
font-family: "Material Icons";
text-transform: none;
font-weight: normal;
font-style: normal;
-moz-font-feature-settings: 'liga';
font-feature-settings: 'liga';
transition: color .15s ease-in-out;
}

#scanner-display {
transition: opacity 100ms ease-in-out;
}

[data-scan="pass"] .hja__group-encodes--col-scan button,
[data-scan="deleted"] .hja__group-encodes--col-scan button {
pointer-events: none;
}

[data-scan="pass"] .hja__group-encodes--col-scan button i::before {
content: "\\e5ca"; /* check */
color: #388E3C;
}

[data-scan="advisory"] .hja__group-encodes--col-scan button i::before {
content: "\\e925"; /* pan_tool */
color: #1091ec;
}

[data-scan="warning"] .hja__group-encodes--col-scan button i::before {
content: "\\e645"; /* priority_high */
color: #FF9800;
}

[data-scan="error"] .hja__group-encodes--col-scan button i::before {
content: "\\e14b"; /* block */
color: #F44336;
}

[data-scan="deleted"] .hja__group-encodes--col-scan button i::before {
content: "\\e872"; /* delete */
color: #F44336;
}

.hja__group-encodes--scan-row {
transition: background-color 50ms ease-out;
cursor: pointer;
}

.hja__group-encodes--auth-table {
width: 100%;
}

.hja__group-encodes--auth-table thead {
background-color: white;
color: #1091ec;
}

footer.hja__group-encodes--footer {
width: 100%;
display: flex;
flex-flow: row;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
}

.hja__group-encodes--info--auth-container,
.hja__group-encodes--info--scan-container,
.hja__group-encodes--loader-container,
.hja__group-encodes--login-container  {
padding: 2rem;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}

.hja__group-encodes--login-container {
min-height: 50vh;
display: flex;
flex-flow: column;
align-items: center;
}

.hja__group-encodes--login-container button {
margin-top: 2em;
}

.hja__group-encodes--info--scan-container {
max-width: 1300px;
margin: 0 auto;
}

.group-encodes-pagination:disabled {
color: rgba(0,0,0,0.2) !important;
}

.loader {
border: 16px solid #f3f3f3;
border-radius: 50%;
border-top: 16px solid #1091ec;
border-bottom: 16px solid #1091ec;
width: 120px;
height: 120px;
-webkit-animation: spin 2s linear infinite;
animation: spin 2s linear infinite;
opacity: 0;
pointer-events: none;
transition: opacity 150ms ease-in;
margin: 100px 0;
}

.loader.show {
opacity: 1;
pointer-events: all;
}

@keyframes loader-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

.hja__group-encodes--info {
width: 100%;
max-height: 0;
overflow: hidden;
transition: max-height 300ms ease-in-out;
}

.group-encodes--header-sort {
cursor: pointer;
transition: background-color 150ms ease-in;
}

.group-encodes--header-sort:hover {
background-color: rgba(255,255,255,0.3);
}

.group-encodes--header-sort > div {
pointer-events: none;
display: flex;
align-items: center;
}

group-encodes--header-sort i {
margin-left: 8px;
}

.hja__group-encodes--info.show {
max-height: 1500px;
}

.table-actions {
text-align: center;
}

tbody .table-actions {
padding: 0 2rem;
}

.hja__group-encodes--status-container {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
}

.group-encodes-pagination {
border-radius: 2px !important;
}

.hja__group-encodes--footer-pagination {
margin: 0 1em;
}

.progress-outer {
margin-left: -100px;
width: 1000px;
border-radius: 4px;
min-height: 30px;
background-color: #E0F2F1;
position: relative;
overflow: hidden;
}

.progress-outer .controls {
position: absolute;
right: 40px;
top: 50%;
transform: translateY(-50%);
line-height: 0;
cursor: pointer;
transition: opacity 300ms ease-in;
}

.progress-outer.active .controls-start {
opacity: 0;
pointer-events: none;
}

.progress-outer.active .controls-stop {
opacity: 1;
}

.progress-outer:not(.active) .controls-start {
opacity: 1;
}

.progress-outer:not(.active) .controls-stop {
opacity: 0;
pointer-events: none;
}

.progress-inner {
height: 30px;
}

.progress-inner.active {
transition: width 1000ms linear;
background-image: linear-gradient(to right, #FFCC80 0%, #FFCC80 99%, transparent);
width: 0%;
}

.progress-inner.indeterminate {
width: 25%;
background-image: linear-gradient(to right, rgba(255,255,255,0), #FFCC80, rgba(255,255,255,0));
animation-name: progress-indeterminate;
animation-duration: 1.7s;
animation-iteration-count: infinite;
}

@keyframes progress-indeterminate {
from {
	margin-left: -25%;
}
to {
	margin-left: 100%;
}
}

[data-tooltip] {
position: relative;
}

[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
top: 0;
left: 5px;
background-color: #1091ec;
color: white;
padding: 4px 8px;
opacity: 0;
transition: opacity 150ms ease-in;
transition-delay: 0.5s;
border-radius: 4px;
white-space: nowrap;
box-shadow: 0 3px 5px -1px rgba(0,0,0,.2),0 5px 8px 0 rgba(0,0,0,.14),0 1px 14px 0 rgba(0,0,0,.12) !important;
}

[data-tooltip]:hover::after {
opacity: 1;
}

.progress-label {
color: rgba(0,0,0,0.6);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
white-space: nowrap;
}

.ripple {
position: absolute;
background: #fff;
border-radius: 50%;
width: 5px;
height: 5px;
animation: rippleEffect ease-in 0.4s 1;
opacity: 0;
z-index: 9;
}

.hja__group-encodes--table {
width: 100%;
}

tbody .hja__group-encodes--row:nth-child(even) {
background-color: #F5F5F5;
}

.hja__group-encodes--status {
display: flex;
justify-content: space-between;
flex: 1;
min-height: 50px;
}

.hja__group-encodes--header {
background-color: #009688;
color: rgba(255,255,255,0.9);
}

thead .hja__group-encodes--col {
text-transform: uppercase;
white-space: nowrap;
}

.hja__group-encodes--col {
padding: 4px 8px;
}

.hja__group-encodes--col.col-centre {
text-align: center;
}

tbody .hja__group-encodes--col {
font-size: 0.7rem;
}

.py-0 {
padding-top: 0 !important;
padding-bottom: 0 !important;
}

#group-encodes button {
position: relative;
overflow: hidden;
text-transform: uppercase;
display: inline-flex;
font-weight: 400;
color: #fff;
justify-content: center;
align-items: center;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
background-color: transparent;
border: 1px solid transparent;
	border-top-color: transparent;
	border-right-color: transparent;
	border-bottom-color: transparent;
	border-left-color: transparent;
padding: .375rem .75rem;
border-radius: .25rem;
transition: color .15s ease-in-out, background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;
cursor: pointer;
}

#group-encodes button:disabled {
pointer-events: none;
}

#group-encodes button i, div.hj-ripple i, i.material-icons {
pointer-events: none;
}

#group-encodes button.primary {
background-color: #1091ec;
color: #fff;
}

#group-encodes button:hover.primary {
background-color: #1976D2;
color: #fff;
}


#group-encodes button:disabled.primary {
background-color: #CFD8DC;
color: black;
}

#group-encodes button:not(.icon):hover {
color: #fff;
background-color: #0069d9;
border-color: #0062cc;
}

#group-encodes button.icon {
background-color: transparent;
border-color: transparent;
border-radius: 50%;
color: rgba(0,0,0,0.7);
padding: 0;
width: 40px;
height: 40px;
}

#group-encodes button.icon.small {
width: 22px;
height: 22px;
}

#group-encodes button.icon.small i {
font-size: 16px;
}

#group-encodes button.icon.error:hover {
background-color: rgba(255, 82, 82, 0.4);
color: #fff;
}

#group-encodes button.icon.primary:hover {
background-color: rgba(25, 118, 210,0.4);
color: #fff;
}

#group-encodes button.icon.primary:hover i::before {
color: #fff !important;
}

#group-encodes button.icon.secondary:hover {
background-color: rgba(255, 255, 255,0.2);
color: #fff;
}

#group-encodes button.icon.secondary.light:hover {
background-color: rgba(0, 0, 0, 0.1);
color: rgba(0,0,0,0.7);
}

@keyframes rippleEffect {
from {
	transform: scale(1);
	opacity: 0.4;
}
to {
	transform: scale(100);
	opacity: 0;
}
}

.hja__group-encodes--auth-results,
.hja__group-encodes--scan-results {
width: 100%;
display: flex;
flex-flow: column;
}

.hja__group-encodes--scan-results {
margin-top: 1em;
}

.hja__group-encodes--auth-results > header,
.hja__group-encodes--scan-results > header {
height: 64px;
width: 100%;
background-color: #1091ec;
color: white;
display: flex;
justify-content: center;
align-items: center;
}

.hja__group-encodes--scan-results > header {
background-color: #673AB7 !important;
}

.hja__group-encodes--footer-pagination-links {
display: flex;
flex-flow: row;
align-items: center;
justify-content: flex-end;
}

/* Style the tab */
.tab-container {
width: 100%;
display: flex;
flex-flow: row;
}

.tab-controls {
padding: 1rem 2rem;
}

.tablinks {
color: black;
position: relative;
overflow: hidden;
padding: 1rem 2rem;
display: flex;
flex-flow: column;
justify-content: flex-start;
align-items: center;
cursor: pointer;
transition: all 150ms ease-in;
border-radius: 4px;
}

.tablinks div {
pointer-events: none;
}

.tablinks i {
margin-bottom: 1rem;
}

.tablinks:hover {
background-color: rgba(0,0,0,0.1);
}

.tablinks.active {
background-color: #1091ec;
color: white;
}

.tab-controls {
display: flex;
flex-flow: column;
}

.tab-content {
flex: 1;
}

.tab-content-item:not(.active) {
display: none;
}

.hj-card {
display: block;
max-width: 100%;
outline: none;
text-decoration: none;
-webkit-transition-property: opacity,-webkit-box-shadow;
transition-property: opacity,-webkit-box-shadow;
transition-property: box-shadow,opacity;
transition-property: box-shadow,opacity,-webkit-box-shadow;
overflow-wrap: break-word;
position: relative;
white-space: normal;
-webkit-transition: -webkit-box-shadow .28s cubic-bezier(.4,0,.2,1);
transition: -webkit-box-shadow .28s cubic-bezier(.4,0,.2,1);
transition: box-shadow .28s cubic-bezier(.4,0,.2,1);
transition: box-shadow .28s cubic-bezier(.4,0,.2,1),-webkit-box-shadow .28s cubic-bezier(.4,0,.2,1);
will-change: box-shadow;
-webkit-box-shadow: 0 3px 1px -2px rgba(0,0,0,.2),0 2px 2px 0 rgba(0,0,0,.14),0 1px 5px 0 rgba(0,0,0,.12);
box-shadow: 0 3px 1px -2px rgba(0,0,0,.2),0 2px 2px 0 rgba(0,0,0,.14),0 1px 5px 0 rgba(0,0,0,.12);
border-radius: 4px;
position: relative;
padding-top: 64px;
padding-bottom: 16px;
}

.hj-card > header {
width: 90%;
margin-left: 5%;
position: absolute;
top: -32px;
border-radius: 4px;

-webkit-transition: -webkit-box-shadow .28s cubic-bezier(.4,0,.2,1);
transition: -webkit-box-shadow .28s cubic-bezier(.4,0,.2,1);
transition: box-shadow .28s cubic-bezier(.4,0,.2,1);
transition: box-shadow .28s cubic-bezier(.4,0,.2,1),-webkit-box-shadow .28s cubic-bezier(.4,0,.2,1);
will-change: box-shadow;
-webkit-box-shadow: 0 3px 1px -2px rgba(0,0,0,.2),0 2px 2px 0 rgba(0,0,0,.14),0 1px 5px 0 rgba(0,0,0,.12);
box-shadow: 0 3px 1px -2px rgba(0,0,0,.2),0 2px 2px 0 rgba(0,0,0,.14),0 1px 5px 0 rgba(0,0,0,.12);
}

.hja__group-encodes--miscan-results--header {
padding: 8px;
background-color: #1091ec;
color: white;
padding-left: 2em;
}

.hja__group-encodes--miscan-results--header:first-child {
border-radius: 4px 4px 0 0;
}

.hja__group-encodes--miscan-results--section-heading {
margin: 1em 2em;
display: flex;
align-items: center;
padding: 0.5em 1em;
}

.hja__group-encodes--miscan-results--section-heading.errors {
background-color: #C62828;
color: white;
}

.hja__group-encodes--miscan-results--section-heading.warnings {
background-color: #EF6C00;
color: white;
}

.hja__group-encodes--miscan-results--section-heading.advisories {
background-color: #607D8B;
color: white;
}

.hja__group-encodes--miscan-results--section-issue {
position: relative;
padding: 0.5em 1em;
margin: 1em 2em;
}

.hja__group-encodes--miscan-results--section-issue::after {
font-family: "Material Icons";
font-size: 24px;
text-transform: none;
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
}

.hja__group-encodes--miscan-results--section-issue.warnings::after {
content: "warning";
color: #EF6C00;
}

.hja__group-encodes--miscan-results--section-issue.advisories::after {
content: "pan_tool";
color: #607D8B;
}

.hja__group-encodes--miscan-results--section-issue.errors::after {
content: "error_outline";
color: #C62828;
}

#scan-results {
margin-top: 1em;
border: 1px solid rgba(0,0,0,0.1);
border-radius: 4px;
}
		`;
			const style = document.createElement('style');
			const roboto = document.createElement('link');
			const icons = Create('link');
			roboto.rel = 'stylesheet';
			icons.rel = 'stylesheet';
			roboto.href =
				'https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,300;0,500;0,700;1,500&display=swap';
			icons.href = 'https://fonts.googleapis.com/icon?family=Material+Icons';
			style.innerHTML = css;
			document.head.appendChild(style);
			document.head.appendChild(roboto);
			document.head.appendChild(icons);
		}
	}

	function checkRecentUploads() {
		groupEncodes.show();
	}

	class MessageCentre {
		constructor() {
			this.skip = 0;
			this.script = 'admin';
			this.issueCodes = [];
		}

		branchOffice(branch, issue, data) {
			var feedback;
			var code = issue + '@' + branch;
			this.issueCodes.push(code);
			switch (branch) {
				case 'filename':
					feedback = this.filenameBranch(issue, data);
					break;
				case 'metatitle':
					feedback = this.metatitleBranch(issue, data);
					break;
				case 'x264':
					feedback = this.x264Branch(issue, data);
					break;
				case 'video':
					feedback = this.videoBranch(issue, data);
					break;
				case 'audio':
					feedback = this.audioBranch(issue, data);
					break;
				case 'subtitles':
					feedback = this.subtitlesBranch(issue, data);
					break;
				case 'chapters':
					feedback = this.chaptersBranch(issue, data);
					break;
				case 'misc':
					feedback = this.miscBranch(issue, data);
					break;
				default:
					feedback = { error: `Couldn't find branch for "${branch}"` };
			}
			feedback.code = code;
			return feedback;
		}

		// use ##source##, ##res## or ##speclink## in link.section for replacement

		filenameBranch(issue, data) {
			var severity,
				message,
				link,
				branch = 'filename';

			switch (issue) {
				case 'none':
					severity = 'errors';
					message = 'No filename found';
					break;
				case 'errorParsing':
					severity = 'errors';
					message = 'There was an error processing the filename';
					break;
				case 'extensionAbsent':
					severity = 'errors';
					message = "Couldn't find an '.mkv' filename extension";
					break;
				case 'extensionDupe':
					severity = 'errors';
					message = 'Filename extension was duplicated';
					break;
				case 'groupTagAbsent':
					severity = 'errors';
					message = "Couldn't find properly formatted group tag (.x264-HANDJOB)";
					break;
				case 'groupTagIncorrect':
					severity = 'errors';
					message = "Group tag wasn't formatted properly as '.x264-HANDJOB'";
					break;
				case 'invalidCharacters':
					severity = 'errors';
					message = `Invalid characters detected in filename. Please advise to fix the following: <div class="filename-error">${data}</div>`;
					break;
				case 'noRes':
					severity = 'errors';
					message = `No valid resolution tag found. Should include ${data}`;
					break;
				case 'noYear':
					severity = 'errors';
					message = 'No year tag was found';
					break;
				case 'reservedFilename':
					severity = 'errors';
					message = `Some operating systems won't allow filenames beginning with '${capFirst(
						data
					)}'. Advise an alternative`;
					break;
				case 'shutdown':
					severity = 'errors';
					message = 'Multiple errors found in filename';
					break;
				case 'wrongEnding':
					severity = 'errors';
					message = "Filename should always end '.x264-HANDJOB.mkv'";
					break;
				// Warnings
				case 'codecMismatch':
					severity = 'warnings';
					message = `Codec in filename (${data.filenameCodec}) doesn't match actual audio codec (${data.codec})`;
					break;
				case 'noP':
					severity = 'warnings';
					message = "Missing 'p' from resolution";
					break;
				case 'resDVD':
					severity = 'warnings';
					message = 'Included resolution tag in a DVDRip';
					break;
				case 'resFormat':
					severity = 'warnings';
					message = `Improperly formatted resolution tag. "${data.match}" should be written as "${data.res}"`;
					break;
				case 'resMismatch':
					severity = 'warnings';
					message = `Resolution tag in the filename (${data.f}) doesn't match detected resolution (${data.d})`;
					break;
				case 'resolutionAfterSource':
					severity = 'warnings';
					message = `The resolution tag should come before the source tag`;
					break;
				case 'SDTagHDSource':
					severity = 'warnings';
					message = `Encode appears to be from a Blu-Ray source, but has ${data} tag in filename`;
					break;
				case 'yearAfterResolution':
					severity = 'warnings';
					message = 'The year tag should come before the resolution one';
					break;
				case 'yearBeforeDVDRip':
					severity = 'warnings';
					message = 'The year tag should come before DVDRip';
					break;
				case 'yearFormatting':
					severity = 'warnings';
					message = "The year isn't formatted properly";
					break;
				// Advisories
				case 'ac3Included':
					severity = 'advisories';
					message = 'Audio codec should only be included for non-AC3 formats';
					break;
				case 'akaFormatting':
					severity = 'advisories';
					message = 'AKA should be written in uppercase';
					break;
				case 'akaSpelling':
					severity = 'advisories';
					message = 'Potential typo in AKA';
					break;
				case 'blurayFormatting':
					severity = 'advisories';
					message = `BluRay source tag has been incorrectly written ${data}`;
					break;
				case 'codecNotIncluded':
					severity = 'advisories';
					message = `Audio codec not present in filename, i.e. '.${data}.x264-HANDJOB.mkv'`;
					break;
				case 'extensionCase':
					severity = 'advisories';
					message = "Filename extension should be written in lowercase as '.mkv'";
					break;
			}

			return { branch, severity, message, link };
		}

		metatitleBranch(issue, data) {
			var severity,
				message,
				link,
				branch = 'metatitle';

			switch (issue) {
				case 'none':
					severity = 'errors';
					message = 'No metatitle found';
					break;
				case 'filenameMetatitle':
					severity = 'errors';
					message = 'Filename used as metatitle';
					break;
				case 'filenameSyntax':
					severity = 'errors';
					message = 'Filename syntax used';
					break;
				case 'noResolutionTag':
					severity = 'errors';
					message = 'No resolution tag was found';
					break;
				case 'noSourceTag':
					severity = 'errors';
					message = `No source tag (${data}) found`;
					break;
				case 'noYear':
					severity = 'errors';
					message = 'No year detected';
					break;
				case 'shutdown':
					severity = 'errors';
					message = 'Multiple errors found in metatitle';
					break;
				// Warnings
				case 'endHJ':
					severity = 'warnings';
					message = "No 'HJ' group tag detected";
					break;
				case 'generalError':
					severity = 'warnings';
					message = 'Error detected in metatitle formatting';
					break;
				case 'HJFormatting':
					severity = 'warnings';
					message = "' - HJ' tag is formatted incorrectly";
					break;
				case 'noP':
					severity = 'warnings';
					message = "Missing 'p' from resolution";
					break;
				case 'resDVD':
					severity = 'warnings';
					message = `Included a resolution tag (${data}) in a DVDRip`;
					break;
				case 'resFormat':
					severity = 'warnings';
					message = `Improperly formatted resolution tag. "${data.match}" should be written as "${data.res}"`;
					break;
				case 'resMismatch':
					severity = 'warnings';
					message = `Resolution tag in the metatitle (${data.m}) doesn't match detected resolution (${data.d})`;
					break;
				case 'SDTagHDSource':
					severity = 'warnings';
					message = `Encode appears to be from a Blu-Ray source, but has ${data} tag in filename`;
					break;
				case 'sourceSpelling':
					severity = 'warnings';
					message = "Source tag is mispelled. Should be 'BluRay'";
					break;
				case 'usedHANDJOB':
					severity = 'warnings';
					message = 'HJ should be used as the group tag';
					break;
				// Advisories
				case 'afterYearFormatting':
					severity = 'advisories';
					message = `Contains invalid characters after the year tag`;
					break;
				case 'audioCodecPresent':
					severity = 'advisories';
					message = "Audio codec shouldn't be included in metatitle";
					break;
				case 'noSquareBrackets':
					severity = 'advisories';
					message = 'Year should be placed inside square brackets';
					break;
				case 'sourceResOrder':
					severity = 'advisories';
					message = 'Source and resolution tags are in the wrong order';
					break;
				case 'x264Found':
					severity = 'advisories';
					message = "x264 shouldn't be included in metatitle";
					break;
				case 'yearMismatch':
					severity = 'advisories';
					message = "Year in metatitle doesn't match the year in filename";
					break;
			}
			return { branch, severity, message, link };
		}

		x264Branch(issue, data) {
			var severity,
				message,
				link,
				branch = 'x264';
			switch (issue) {
				case '2passUsed':
					severity = 'errors';
					message = 'CRF must be used for rate control, not 2-pass';
					break;
				case 'codecNotX264':
					severity = 'errors';
					message = 'Codec is not x264';
					break;
				case 'dxvaIncompatible':
					severity = 'errors';
					message = `Encode is not DXVA compatible (${data.join(' / ')})`;
					break;
				case 'minimumNotMet':
					severity = 'errors';
					message = `Minimum settings were not met (${data.join(' / ')})`;
					break;
				case 'qcompVeryLow':
					severity = 'errors';
					message = 'Q-Comp setting is too low';
					break;
				case 'qcompVeryHigh':
					severity = 'errors';
					message = 'Q-Comp setting is too high';
					break;
				// Warnings
				case 'aqUnusual':
					severity = 'warnings';
					message = `Aq-strength (${data}) outside of recommended range (0.5 - 1.2)`;
					break;
				case 'psyOff':
					severity = 'warnings';
					message = 'Psy Rate-Distortion Optimisation is turned off';
					break;
				case 'psyTrellisHigh':
					severity = 'warnings';
					message = `Psy-trellis is set unusually high at ${data[0]}:<strong>${data[1]}</strong>`;
					break;
				case 'psyUnusual':
					severity = 'warnings';
					message = `Unusual setting for Psy-RD (<strong>${data[0]}</strong>:${data[1]})`;
					break;
				case 'qcompLow':
					severity = 'warnings';
					message = 'Q-Comp should not generally be set below 0.5';
					break;
				case 'qcompHigh':
					severity = 'warnings';
					message = 'Q-Comp should not generally be set above 0.8';
					break;
				// Advisories
				case 'mbtreeOn':
					severity = 'advisories';
					message = 'MB-Tree is on. Not advised except for digital animation';
					break;
			}
			return { branch, severity, message, link };
		}

		videoBranch(issue, data) {
			var severity,
				message,
				link,
				branch = 'video';
			switch (issue) {
				case 'noVideoSection':
					severity = 'errors';
					message = 'No video section found in log';
					break;
				case '2160pEncode':
					severity = 'errors';
					message = '2160p encodes are not allowed';
					break;
				case 'bitrateUnderSeverely':
					severity = 'errors';
					message = `Video bitrate (${data.bitrate} Kbps) is well below the target range of ${data.text} for this resolution`;
					break;
				case 'bitrateOverSeverely':
					severity = 'errors';
					message = `Video bitrate (${data.bitrate} Kbps) is well above the target range of ${data.text} for this resolution`;
					break;
				case 'blurayAnamorphic':
					severity = 'errors';
					message = 'Blu-Ray encodes must not use anamorphic display';
					break;
				case 'crfVeryLow':
					severity = 'warnings';
					message = 'CRF is very low and encode is likely bloated';
					break;
				case 'crfVeryHigh':
					severity = 'warnings';
					message = 'CRF is very high. Extreme detail loss is likely';
					break;
				case 'dvdNonAnamorphic':
					severity = 'errors';
					message = 'DVDRip encodes must use anamorphic display';
					break;
				case 'resizedDVDRip':
					severity = 'errors';
					message = `This ${data} DVDRip appears to have been resized`;
					break;
				case 'resProgressive':
					severity = 'errors';
					message = 'Encode does not use progressive scan';
					break;
				case 'resWidthOver':
					severity = 'errors';
					message = `Width (${this.data.width}px) is over the maximum for this resolution (${data.width} x ${data.height})`;
					break;
				case 'resHeightOver':
					severity = 'errors';
					message = `Height (${this.data.height}px) is over the maximum for this resolution (${data.width} x ${data.height})`;
					break;
				case 'resNeitherEqual':
					severity = 'errors';
					message = `Neither width (${this.data.width}px) nor height (${this.data.height}px) equal the maximum for this resolution (${data.width} x ${data.height})`;
					break;
				case 'tvAnamorphic':
					severity = 'errors';
					message = 'TVRip / VHSRip encodes must not use anamorphic display';
					break;
				case 'variableFramerate':
					severity = 'errors';
					message = 'Variable frame-rate has been used';
					break;
				// Warnings
				case 'bitrateMissing':
					severity = 'warnings';
					message = 'Video bitrate missing from log';
					break;
				case 'bitrateUnder':
					severity = 'warnings';
					message = `Video bitrate (${data.bitrate} Kbps) is below the target range of ${data.text} for this resolution`;
					break;
				case 'bitrateOver':
					severity = 'warnings';
					message = `Video bitrate (${data.bitrate} Kbps) is above the target range of ${data.text} for this resolution`;
					break;
				case 'crfLow':
					severity = 'warnings';
					message = 'CRF is set below 14. This is usually pointless';
					break;
				case 'crfHigh':
					severity = 'warnings';
					message = 'CRF is set above 23. Advise settings for dealing with grain if necessary';
					break;
				// Advisories
				case 'bitrateUnderSlightly':
					severity = 'advisories';
					message = `Video bitrate (${data.bitrate} Kbps) is slightly below the target range of ${data.text} for this resolution`;
					break;
				case 'bitrateOverSlightly':
					severity = 'advisories';
					message = `Video bitrate (${data.bitrate} Kbps) is slightly above the target range of ${data.text} for this resolution`;
					break;
				case 'dupeFrames':
					severity = 'advisories';
					message = 'Encode is 29.970 fps, potential dupe frames';
					break;
				case 'fpsUnusual':
					severity = 'advisories';
					message = `Encode's frame-rate is ${this.data.fps} fps. Is this correct?`;
					break;
			}
			return { branch, severity, message, link };
		}

		audioBranch(issue, data) {
			var severity,
				message,
				link,
				branch = 'audio';
			switch (issue) {
				case 'audioLengthMismatch':
					severity = 'errors'; // duration and label
					message = `${data.label} length (${data.duration} mins) doesn't seem to match video's (${this.data.duration} mins)`;
					break;
				case 'bitDepthHigh':
					severity = 'errors';
					message = `${data.text} bit depth is ${data.bits}. Is lossless audio available for bit depth reduction?`;
					break;
				case 'dtsHDMA':
					severity = 'errors';
					message = `${data}: DTS-HD MA audio. Should be converted to FLAC or encoded as AC3/AAC`;
					break;
				case 'dupeEnglish':
					severity = 'errors';
					message = `Detected ${data} English main audio tracks. Is there redundant audio?`;
					break;
				case 'lpcmAudio':
					severity = 'errors';
					message = `${data}: (L)PCM audio must be converted to FLAC or encoded as AC3/AAC`;
					break;
				case 'missingBitrate':
					severity = 'errors';
					message = `The bitrate for ${data} is missing`;
					break;
				case 'noAudio':
					severity = 'errors';
					message = 'No main audio track present';
					break;
				case 'noAudioSection':
					severity = 'errors';
					message = 'No audio section found in log';
					break;
				case 'sampleRateHigh':
					severity = 'errors';
					message = `${data.text} sample rate is ${data.sample} kHz. Is lossless audio available for downsampling?`;
					break;
				// Warnings
				case 'aacCommentary':
					severity = 'warnings';
					message = 'AAC should be used for ' + data;
					break;
				case 'aacDVD':
					severity = 'warnings';
					message = 'AAC used for DVDRip. Potentially transcoded audio';
					break;
				case 'aacSurround':
					severity = 'warnings';
					message = data + ' is encoded with AAC, this should only be used for mono and stereo audio tracks';
					break;
				case 'ac3Bitrate':
					severity = 'warnings';
					message = `${data.text} is encoded at ${data.actual} Kbps. Should be ${data.spec} Kbps if lossless source available`;
					break;
				case 'defaultCommentary':
					severity = 'warnings';
					message = capFirst(data) + ' should not be set as default';
					break;
				case 'engDefault':
					severity = 'warnings';
					message = 'Original language audio should be default in dual audio encodes';
					break;
				case 'genericCommentary':
					severity = 'warnings';
					message = 'Commentary tracks generally need to be encoded as AAC @ 96 Kbps or less';
					break;
				case 'highCommentary':
					severity = 'warnings';
					message = 'If using CBR, bitrate should generally be 96 Kbps or lower for ' + data;
					break;
				case 'multipleAudio':
					severity = 'warnings';
					message = 'More than two main audio tracks detected. Any redundant?';
					break;
				case 'noLanguageTag':
					severity = 'warnings';
					message = `No language tag was found for ${data}`;
					break;
				case 'noParticipants':
					severity = 'warnings';
					message = 'Commentary track name should include participants';
					break;
				case 'noTrackName':
					severity = 'warnings';
					message = 'No title detected for ' + data;
					break;
				case 'useCommentary':
					severity = 'warnings';
					message = `If applicable, track title '${data}' should start 'Commentary'`;
					break;
				// Advisories
				case 'ac3Stereo':
					severity = 'advisories';
					message = data + ' should be encoded with AAC if the source has lossless audio available';
					break;
				case 'engDubName':
					severity = 'advisories';
					message = "English dubs should ideally be labelled 'English Dub'";
					break;
				case 'mainTitle':
					severity = 'advisories';
					message = `Main audio title (${data}) should be blank`;
					break;
				case 'mpegAudio':
					severity = 'advisories';
					message = data + ' seems to use MPEG audio. Any others available?';
					break;
				case 'titleBlank':
					severity = 'advisories';
					message = `Track name for ${data} should be blank`;
					break;
				case 'usedAudioCommentary':
					severity = 'advisories';
					message = `Used 'Audio Commentary' rather than 'Commentary' for track title`;
					break;
			}
			return { branch, severity, message, link };
		}

		subtitlesBranch(issue, data) {
			var severity,
				message,
				link,
				branch = 'subtitles';
			switch (issue) {
				// Warnings
				case 'defaultYes':
					severity = 'warnings';
					message = `Track '${data.title || data.lang || 'ID: ' + data.id}' is set to 'Default: Yes'`;
					break;
				case 'engDefault':
					severity = 'warnings';
					message = 'English subtitles should be set to default for non-English films';
					break;
				case 'forcedFlagNotDefault':
					severity = 'warnings';
					message = `Subtitle track '${
						data.title || data.lang || 'ID: ' + data.id
					}' seems to be for non-English scenes, which should generally be set as 'Default: Yes'`;
					break;
				case 'forcedYes':
					severity = 'warnings';
					message = `Subtitle track '${data.title || data.lang || 'ID: ' + data.id}' is set to forced`;
					break;
				case 'noDefault':
					severity = 'warnings';
					message = 'English subtitles not set as default for dual audio encode';
					break;
				case 'noLanguageTag':
					severity = 'warnings';
					message = `Track '${data.title || 'ID: ' + data.id}' has no language tag`;
					break;
				case 'nonEngDefault':
					severity = 'warnings';
					message = `Track ${data} set as default`;
					break;
				// Advisories
				case 'nameForced':
					severity = 'advisories';
					message = `Track title 'English [non-English scenes]' is prefered over '${data}'`;
					break;
				case 'noEngSubs':
					severity = 'advisories';
					message = 'No English subtitles found. Any available?';
					break;
				case 'noLinguisticLabelling':
					severity = 'advisories';
					message =
						'For films whose audio has no linguistic content, default subtitles are best labelled with [On-Screen Text]';
					break;
			}
			return { branch, severity, message, link };
		}

		chaptersBranch(issue, data) {
			var severity,
				message,
				link,
				branch = 'chapters';
			switch (issue) {
				case 'chapterTimecodes':
					severity = 'advisories';
					message = `Chapter titles ${data} seem to be timecodes, advise to use generics or named ones`;
					break;
				case 'noTitles':
					severity = 'advisories';
					message = `Chapter titles ${data} seem to be missing, advise to use generics or named ones`;
					break;
				case 'missingTitles':
					severity = 'advisories';
					message = `Some chapter titles are missing`;
					break;
			}
			return { branch, severity, message, link };
		}

		miscBranch(issue, data) {
			let severity,
				message,
				link,
				branch = 'misc';
			switch (issue) {
				case 'attachments':
					severity = 'errors';
					message = `Attachments detected in this .mkv file. These should be removed before uploading`;
					break;
			}
			return { branch, severity, message, link };
		}
	}

	// Last updated 2020-04-27
	class MediainfoScanner extends MessageCentre {
		constructor(log) {
			super();
			this.log = log;
			this.feedback = {
				filename: { errors: [], warnings: [], advisories: [] },
				metatitle: { errors: [], warnings: [], advisories: [] },
				video: { errors: [], warnings: [], advisories: [] },
				x264: { errors: [], warnings: [], advisories: [] },
				audio: { errors: [], warnings: [], advisories: [] },
				subtitles: { errors: [], warnings: [], advisories: [] },
				chapters: { errors: [], warnings: [], advisories: [] },
				misc: { errors: [], warnings: [], advisories: [] },
				guides: [],
			};
			this.exceptions = [];
			this.breakdown();
			let valid = this.valid();
			if (!valid) return;
			this.filename();
			this.resolution();
			this.metatitle();
			this.checkDxva();
			this.checkMinimum();
			this.x264();
			this.checkAnamorphic();
			this.checkRes();
			this.checkFramerate();
			this.checkBitrate();
			this.sortAudioTracks();
			this.checkMainAudio();
			this.checkSecondaryAudio();
			this.checkChapters();
			this.condenseErrors();
		}

		valid() {
			if (!this.data) return false;
			else if (!this.data.filename) return false;
			else if (!this.data.width) return false;
			else if (!this.data.height) return false;
			else return true;
		}

		getFeedback() {
			return this.feedback;
		}

		generateLink(link) {
			// Insertion of contextual data into link
			for (let key in link) {
				link[key] = link[key].replace(/##source##/g, this.data.source);
				link[key] = link[key].replace(/##res##/g, this.data.res);
				link[key] = link[key].replace(/##speclink##/g, this.data.speclink);
				link[key] = link[key].replace(/##spectext##/g, this.data.spectext);
			}

			if (!link.subsection) link.subsection = '';

			return ` <button type="button" data-guide="${link.section}" data-subsection="${link.subsection}" class="expand-guide">
                    <div>${link.text}</div>
                </button>`;
		}

		miParse({ match, log = this.log, singleLine = true, crushOutput = false }) {
			if (singleLine) match = new RegExp('(?:^' + match + '\\s*:\\s*)(.*$)', 'im');
			else if (match === 'General') match = /^General[\w\W]*?(?:\n\n|$)/gi;
			else if (match === 'Menu') match = /(Menu(\s#\d+)?\n)((\s|\S)*?)(\n\n|$)/gi;
			else if (match !== 'Video') match = new RegExp('(^' + match + '[\\s\\S]*?^Forced[^\\n]*)', 'gim');
			else match = new RegExp('(?:\\n)(' + match + '[\\s\\S]*?)(\\n\\n|$)', 'gi');
			let a = log.match(match);
			try {
				if (singleLine) a = a[1];
			} catch (e) {
				a = '';
			}
			if (crushOutput && a && singleLine) return a.replace(/\s/g, '');
			else return a;
		}

		err(branch, issue, data = '') {
			// messageObject returns object containing: {branch, severity, message, link: {section, subsection, message}}
			// messageObject.error if problem with retrieving message
			var output,
				messageObject,
				link = '';
			if (branch && issue) messageObject = this.branchOffice(branch, issue, data);
			else console.log('Not Enough data to generate message');

			if (this.script === 'member' && messageObject.link) link = this.generateLink(messageObject.link);

			if (/##button##/.test(messageObject.message)) output = messageObject.message.replace('##button##', link);
			else output = messageObject.message + link;
			if (!messageObject.message) console.log(`Error retrieving message for ${issue}@${branch}`);
			else this.feedback[branch][messageObject.severity].push({ code: messageObject.code, output, data });
		}

		checkSkip() {
			if (this.skip > 2) return true;
			else return false;
		}

		condenseErrors() {
			var skip = this.checkSkip();
			if (skip) return false;

			var count = 0;
			this.issueCodes.map((x) => {
				// If more than 2 metatitle issues, replace with one error
				if (x.split('@')[1].search(/metatitle/i) >= 0) count++;
			});
			if (count > 2) {
				this.feedback.metatitle = this.wipe();
				this.err('metatitle', 'shutdown');
			}

			var count = 0;
			this.issueCodes.map((x) => {
				// If more than 2 filename issues, replace with one error
				if (x.split('@')[1].search(/filename/i) >= 0) count++;
			});
			if (count > 2) {
				this.feedback.filename = this.wipe();
				this.err('filename', 'shutdown');
			}

			if (this.data.source === 'dvd') this.filterErrors(['noResolutionTag@metatitle', 'noSourceTag@metatitle']);

			this.filterErrors(['resMismatch@filename', 'noRes@filename']);
			this.filterErrors(['multipleAudio@audio', 'dupeEnglish@audio']);
			this.filterErrors(['noResolutionTag@metatitle', 'noP@metatitle']);
			this.filterErrors(['resMismatch@metatitle', 'noResolutionTag@metatitle']);
			this.filterErrors(['aacCommentary@audio', 'highCommentary@audio'], true, ['audio', 'genericCommentary']);
		}

		wipe() {
			return { errors: [], warnings: [], advisories: [] };
		}

		filterErrors(codes, all, cb) {
			let check = this.checkCombination(codes);
			if (check && all) this.removeIssue(codes);
			else if (check) this.removeIssue([codes[0]]);
			if (check && cb) this.err(cb[0], cb[1]);
		}

		checkCombination(issues) {
			let count = 0;
			this.issueCodes.map((x) => {
				for (let issue of issues) {
					if (x === issue) count++;
				}
			});
			if (count >= issues.length) return true;
			else return false;
		}

		removeIssue(issues) {
			let sections = ['errors', 'warnings', 'advisories'];
			let data = [];

			for (let i = 0; i < issues.length; i++) {
				let branch = issues[i].split('@')[1];
				for (let x of sections) {
					this.feedback[branch][x] = this.feedback[branch][x].filter((f) => {
						if (f.code !== issues[i]) return true;
						else data.push(f.data);
					});
				}
			}
			return data;
		}

		arrayCount(array, value) {
			let reg = new RegExp(value, 'i');
			return array.reduce((n, x) => n + (x.search(reg) >= 0), 0);
		}

		escapeRegExp(string) {
			return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
		}

		calculateDuration(string) {
			let hours = string.match(/(\d{1,})(?:h)/i);
			hours = hours && hours.length > 0 ? parseInt(hours[1], 10) : 0;
			let mins = string.match(/(\d{1,})(?:mn)/i);
			mins = mins && mins.length > 0 ? parseInt(mins[1], 10) : 0;
			let secs = string.match(/(\d{1,})(?:s)/i);
			secs = secs && secs.length > 0 ? parseInt(secs[1], 10) : 0;
			if (secs > 30) mins++;
			return hours * 60 + mins;
		}

		// Run these in the constructor and log with this.err("filename","none",data)

		breakdown() {
			var data = {};
			data.filename = this.miParse({ match: 'Complete name' });
			data.filename = data.filename.replace(/\\/g, '/').split('/').pop();
			data.metatitle = this.miParse({ match: 'Movie name' });

			data.primaries = this.miParse({ match: 'Color primaries' }).replace(/\W/g, '').toLowerCase();
			if (data.primaries.search(/601ntsc/i) >= 0 || /470systemm/i.test(data.primaries) === true) {
				data.source = 'dvd';
				data.region = 'ntsc';
			} else if (data.primaries.search(/601pal/i) >= 0 || /systemi/i.test(data.primaries) === true) {
				data.source = 'dvd';
				data.region = 'pal';
			} else data.source = 'bluray';

			try {
				let general = this.miParse({ match: 'General', singleLine: false })[0].trim();
				data.general = {
					uniqueID: this.miParse({ match: 'Unique ID', log: general }),
					format: this.miParse({ match: 'Format', log: general }),
					application: this.miParse({ match: 'Writing application', log: general }),
					library: this.miParse({ match: 'Writing library', log: general }),
					attachments: this.miParse({ match: 'Attachments', log: general }) || '',
				};
			} catch (e) {
				console.log(e);
			}

			try {
				data.video = this.miParse({ match: 'Video', singleLine: false })[0];
			} catch (e) {
				this.err('video', 'noVideoSection');
				this.exceptions.push({ stack: e.stack, message: 'Error finding valid video section' });
				return false;
			}

			if (data.general && data.general.attachments != '') this.err('misc', 'attachments');

			data.bitrate = this.miParse({ match: 'Bit rate', log: data.video, crushOutput: true });
			data.bitrate = data.bitrate.search(/mb/i) >= 0 ? parseFloat(data.bitrate) * 1000 : parseFloat(data.bitrate);
			data.scan = this.miParse({ match: 'Scan type', log: data.video });
			data.duration = (() => {
				let string = this.miParse({ match: 'Duration', log: data.video });
				return this.calculateDuration(string);
			})();
			data.width = parseInt(this.miParse({ match: 'Width', log: data.video, crushOutput: true }), 10);
			data.height = parseInt(this.miParse({ match: 'Height', log: data.video, crushOutput: true }), 10);
			data.aspect = this.miParse({ match: 'Display aspect ratio', log: data.video, crushOutput: true }).split(
				':'
			);
			if (!data.aspect[1]) data.aspect[1] = 1;
			data.frm = this.miParse({ match: 'Frame rate mode', log: data.video }).toLowerCase();
			data.library = this.miParse({ match: 'Writing library', log: data.video });
			data.fps = parseFloat(this.miParse({ match: 'Frame rate', log: data.video, crushOutput: true }));
			data.settings = this.miParse({ match: 'Encoding settings', log: data.video }).split('/');
			try {
				data.audio = this.miParse({ match: 'Audio', singleLine: false }) || [];
			} catch (e) {
				this.err('audio', 'noAudioSection');
			}
			data.subtitles = this.miParse({ match: 'Text', singleLine: false }) || [];
			data.chapters = this.miParse({ match: 'Menu', singleLine: false }) || [];

			for (let i = 0; i < data.audio.length; i++) {
				let audio = data.audio[i],
					obj = {};
				obj.id = parseInt(this.miParse({ match: 'ID', log: audio }), 10);
				obj.title = this.miParse({ match: 'Title', log: audio });
				obj.format = obj.fullFormat = this.miParse({ match: 'Format', log: audio, crushOutput: true });
				obj.format = obj.format.replace(/\W/, '');
				obj.comp = this.miParse({ match: 'Compression mode', log: audio });
				obj.bitrate = this.miParse({ match: 'Bit rate', log: audio });
				obj.bitrate = obj.bitrate.search(/kb/i) >= 0 ? parseFloat(obj.bitrate.replace(/[^0-9.]/g, '')) : null;
				obj.bits = parseInt(this.miParse({ match: 'Bit depth', log: audio }), 10) || 16;
				obj.channels = parseInt(this.miParse({ match: 'Channel\\(s\\)', log: audio, crushOutput: true }), 10);
				obj.duration = (() => {
					let string = this.miParse({ match: 'Duration', log: audio });
					return this.calculateDuration(string);
				})();
				obj.sample = this.miParse({ match: 'Sampling rate', log: audio, crushOutput: true });
				obj.sample = obj.sample.search(/k/i) < 0 ? parseFloat(obj.sample) / 1000 : parseFloat(obj.sample);
				obj.lang = this.miParse({ match: 'Language', log: audio });
				obj.default = this.miParse({ match: 'Default', log: audio });
				obj.default = obj.default.search(/yes/i) >= 0 ? true : false;
				data.audio[i] = obj;
			}

			for (let i = 0; i < data.subtitles.length; i++) {
				let subs = data.subtitles[i];
				let obj = {};
				obj.id = parseInt(this.miParse({ match: 'ID', log: subs }), 10);
				obj.title = this.miParse({ match: 'Title', log: subs });
				obj.format = this.miParse({ match: 'Format', log: subs });
				obj.lang = this.miParse({ match: 'Language', log: subs });
				obj.default = this.miParse({ match: 'Default', log: subs });
				obj.default = obj.default.search(/yes/i) >= 0 ? true : false;
				obj.forced = this.miParse({ match: 'Forced', log: subs });
				obj.forced = obj.forced.search(/yes/i) >= 0 ? true : false;
				data.subtitles[i] = obj;
			}

			for (let i = 0; i < data.chapters.length; i++) {
				let chap = data.chapters[i];
				chap = chap.replace(/Menu(\s#\d+)?\n/i, '').trim();
				chap = chap.split('\n');
				chap = chap.map((x) => {
					let a = x.split(/\s+:(?:[^0-9]*?:)?/);
					if (!a[1]) a[1] = '';
					if (a[0]) return { time: a[0], title: a[1] };
				});
				data.chapters[i] = chap;
			}

			var settingsObj = {};
			for (let i = 0; i < data.settings.length; i++) {
				try {
					let setting = data.settings[i].split('=');
					let key = setting[0].replace(/[^a-zA-Z0-9]/g, '');
					let value = setting[1].trim();
					if (key.search(/(aq|crf(?!max)|psyrd|qcomp|ipratio|pbratio)/i) >= 0)
						value = value.replace(/,/g, '.');
					settingsObj[key] = value;
				} catch (e) {
					continue;
				}
			}
			data.settings = settingsObj;

			this.data = data;
			if (this.data.library.search(/x264/i) < 0) {
				this.err('x264', 'codecNotX264');
				this.skip += 5;
			}
		}

		filename() {
			var skip = this.checkSkip();
			if (skip) return false;
			let f = this.data.filename;
			if (!f) {
				this.err('filename', 'none');
				return false;
			}
			var illegal = ['aux', 'com', 'lpt', 'con', 'nul', 'prn'];
			for (let i = 0; i < illegal.length; i++) {
				var regex;
				if (illegal[i] === 'com' || illegal[i] === 'lpt') {
					for (let a = 1; a < 10; a++) {
						regex = new RegExp('^' + illegal[i] + a + '\\.', 'i');
						if (regex.test(f)) this.err('filename', 'reservedFilename', illegal[i] + a);
					}
				} else {
					regex = new RegExp('^' + illegal[i] + '\\.', 'i');
					if (regex.test(f)) this.err('filename', 'reservedFilename', illegal[i]);
				}
			}
			if (f.search(/remux/i) >= 0) this.skip += 5;
			try {
				var a = f.match(/[^a-zA-z\d.-]/gi);
				if (a) {
					a = f.replace(/([^a-zA-Z0-9-.])/g, "<span class='filename-typo'>$1</span>");
					this.err('filename', 'invalidCharacters', a);
					a = '';
				}

				this.data.filenameYear = f.match(/^.*(?=19|20)(\d{4}).*$/i);
				this.data.filenameYear =
					this.data.filenameYear && this.data.filenameYear.length > 0 ? this.data.filenameYear.pop() : '';
				if (this.data.filenameYear < 0) this.err('filename', 'noYear');
				else if (f.search(/\.(?=19|20)(\d{4})\./i) < 0) this.err('filename', 'yearFormatting');
				if (f.search(/\.aka\./i) >= 0 && f.search(/\.AKA\./) < 0) this.err('filename', 'akaFormatting');
				if (f.search(/\.a(?![kK])[a-zA-Z]a\./i) >= 0) this.err('filename', 'akaSpelling');

				a = f.match(/dvdrip.\d{4}/gi);
				if (a) this.err('filename', 'yearBeforeDVDRip');

				let yearPos = f.search(/\.(?=19|20)(\d{4})\./i);
				let sourcePos = f.search(/blu(.{0,3})ray/i);
				let resolutionPos = f.search(/\d{3,4}p/i);
				if (sourcePos >= 0 && resolutionPos > sourcePos) this.err('filename', 'resolutionAfterSource');
				if (resolutionPos >= 0 && yearPos > resolutionPos) this.err('filename', 'yearAfterResolution');

				if (f.search(/ac3/i) >= 0) this.err('filename', 'ac3Included');
				a = f.match(/blu(.*?)ray/gi);
				if (a) a = `as '${a[0]}'`;
				if (f.replace(/[^0-9a-zA-Z]/g, '').search(/blu(.*?)ray/i) >= 0 && f.search(/BluRay/) < 0) {
					this.err('filename', 'blurayFormatting', a);
					a = '';
				}
				if (f.search(/\.mkv$/i) >= 0 && f.search(/\.mkv$/) < 0) this.err('filename', 'extensionCase');
				else if (f.search(/\.mkv$/) < 0) this.err('filename', 'extensionAbsent');
				else if (f.match(/mkv/gi).length >= 2) this.err('filename', 'extensionDupe');
				else if (f.replace(/[^a-zA-Z0-9]/g, '').search(/x264handjob/i) >= 0 && f.search(/x264-HANDJOB/) < 0)
					this.err('filename', 'groupTagIncorrect');
				else if (f.search(/x264-HANDJOB/) < 0) this.err('filename', 'groupTagAbsent');
				else if (f.search(/x264-HANDJOB\.mkv$/gi) < 0) this.err('filename', 'wrongEnding');
			} catch (e) {
				this.err('filename', 'errorParsing');
				this.exceptions.push({ stack: e.stack, message: 'Error during parsing of filename section' });
				console.log(e);
			}
		}

		metatitle() {
			var skip = this.checkSkip();
			let failed = false,
				general;
			if (skip) return false;
			try {
				let m = this.data.metatitle;
				if (!m) {
					this.err('metatitle', 'none');
					return true;
				} else if (
					m == this.data.filename ||
					m.search(this.data.filename) >= 0 ||
					this.data.filename.replace(/\.mkv/i, '') === m
				) {
					this.err('metatitle', 'filenameMetatitle');
					return true;
				} else if (m.search(/\s/) < 0) {
					this.err('metatitle', 'filenameSyntax');
					return true;
				}

				general = /\[\d{4}\](\s[A-Z].*[a-z])?(\s\d{3,4}p\s[a-zA-Z]{5,7}|\s\d{3,4}p\s[A-Z]{2}-[A-Z]{3}|\s[a-zA-Z]{5,7})\s-\sHJ$/g.test(
					m
				);

				this.data.metatitleYear = m.match(/^.*(?=19|20)(\d{4}).*$/i);
				this.data.metatitleYear =
					this.data.metatitleYear && this.data.metatitleYear.length > 0 ? this.data.metatitleYear.pop() : '';
				if (
					this.data.metatitleYear &&
					this.data.filenameYear &&
					this.data.metatitleYear !== this.data.filenameYear
				)
					this.err('metatitle', 'yearMismatch');
				else if (this.data.metatitleYear < 0) this.err('metatitle', 'noYear');
				else if (m.search(/\[\d{4}\]/) < 0) this.err('metatitle', 'noSquareBrackets');

				let ex = m.match(/\[\d{4}\]\s([^a-zA-Z0-9])/);
				if (ex && ex.length > 0) this.err('metatitle', 'afterYearFormatting', ex[1]);

				if (m.search(/(?:\[|\]\(\))(?:.*?)(flac|dts|aac|ac3|mp3|mp2|mpeg|eac3)/gi) >= 0)
					this.err('metatitle', 'audioCodecPresent');
				if (
					this.data.source === 'bluray' &&
					m.replace(/[^a-zA-Z0-9]/g, '').search(/bluray/i) < 0 &&
					m.replace(/[^a-zA-Z0-9]/g, '').search(/hdtv/i) < 0 &&
					m.replace(/[^a-zA-Z0-9]/g, '').search(/hddvd/i) < 0
				) {
					this.err('metatitle', 'noSourceTag', 'BluRay');
				} else if (m.replace(/[^a-zA-Z0-9]/g, '').search(/blueray/i) > 0)
					this.err('metatitle', 'sourceSpelling', 'BluRay');
				else if (
					this.data.source === 'dvd' &&
					this.data.filename.search(/(tvrip|vhsrip)/i) < 0 &&
					m.search(/dvdrip/i) < 0
				) {
					this.err('metatitle', 'noSourceTag', 'DVDRip');
				} else if (
					this.data.source === 'dvd' &&
					this.data.filename.search(/(tvrip|vhsrip)/i) > 0 &&
					m.search(/(tvrip|vhsrip)/i) < 0
				) {
					this.err('metatitle', 'noSourceTag', 'TVRip / VHSRip');
				}
				if (this.data.source === 'bluray' && m.search(/\d{3,4}p/i) < 0)
					this.err('metatitle', 'noResolutionTag');
				if (this.data.source === 'bluray' && m.search(/ray[\W]*\d{3,4}/i) >= 0) {
					let match = m.match(/blu[\W\w]*ray[\W]*\d{3,4}p/gi);
					this.err('metatitle', 'sourceResOrder', match);
				}
				if (m.search(/x264/i) >= 0) this.err('metatitle', 'x264Found');
				if (m.search(/HANDJOB$/i) >= 0) this.err('metatitle', 'usedHANDJOB');
				else if (m.search(/\s*-\s*hj\s*$/i) >= 0 && m.search(/ - HJ$/) < 0)
					this.err('metatitle', 'HJFormatting');
				else if (m.search(/\s*-\s*hj$/i) < 0) this.err('metatitle', 'endHJ');
			} catch (e) {
				failed = true;
			}

			if (failed === true) this.err('metatitle', 'generalError');
			if (
				general === false &&
				this.feedback.metatitle.errors.length == 0 &&
				this.feedback.metatitle.warnings.length == 0 &&
				this.feedback.metatitle.advisories.length == 0
			)
				this.err('metatitle', 'generalError');
		}

		checkDxva() {
			var skip = this.checkSkip();
			if (skip) return false;
			if (!this.data.settings.ref) return false;
			var pass = true,
				failings = [];
			let ref = parseInt(this.data.settings.ref, 10);
			let vbv = parseFloat(this.data.settings.vbvmaxrate);
			var maxRef = Math.floor(
				8388608 / ([Math.ceil(this.data.width / 16) * 16] * [Math.ceil(this.data.height / 16) * 16])
			);
			if (this.data.settings.analyse != '0x3:0x133' && this.data.settings.analyse != '0x3:0x113') {
				pass = false;
				failings.push(`Analyse not 0x3:0x133 or 0x3:0x113`);
			}
			if (ref > maxRef) {
				pass = false;
				failings.push(
					`Ref: ${this.data.settings.ref}. Maximum reference frames for this resolution is ${maxRef}`
				);
			}
			if (vbv > 62500) {
				pass = false;
				failings.push(`Vbv_Maxrate: ${vbv.toLocaleString()} Kbps. Maximum is 50,000 Kbps`);
			}

			if (!pass) {
				this.err('x264', 'dxvaIncompatible', failings);
			}
		}

		checkMinimum() {
			var skip = this.checkSkip();
			if (skip) return false;
			try {
				var pass = true,
					issues = [];
				if (parseInt(this.data.settings.cabac, 10) != 1) {
					pass = false;
					issues.push('CABAC not 1');
				}
				if (this.data.settings.analyse != '0x3:0x133' && this.data.settings.analyse != '0x3:0x113') {
					pass = false;
					issues.push('Analyse not 0x3:0x133 or 0x3:0x113');
				}
				if (parseInt(this.data.settings.subme, 10) < 7) {
					pass = false;
					issues.push('Subme less than 7');
				}
				if (this.data.settings.me.search(/umh|tesa|esa/) < 0) {
					pass = false;
					issues.push('Me is not UMH, ESA, or TESA');
				}
				if (parseInt(this.data.settings.trellis, 10) != 2) {
					pass = false;
					issues.push('Trellis not 2');
				}
				let deblock = this.data.settings.deblock.split(':');
				deblock.map((x) => parseInt(x, 10));
				if (deblock[0] != 1 || deblock[1] >= 0 || deblock[2] >= 0) {
					pass = false;
					issues.push('Deblock values not less than zero');
				}
				if (this.data.settings.rc.search(/2pass|crf/) < 0) {
					pass = false;
					issues.push('Rate control not 2-pass or CRF');
				}

				if (
					this.data.source == 'dvd' ||
					this.data.source === 'tv' ||
					this.data.source === 'vhs' ||
					this.data.res == '480p' ||
					this.data.res == '576p'
				) {
					if (parseInt(this.data.settings.bframes, 10) < 5) {
						pass = false;
						issues.push('Used fewer than 5 bframes');
					}
					if (parseInt(this.data.settings.ref, 10) < 9) {
						pass = false;
						issues.push('Used fewer than 9 reference frames');
					}
					if (parseInt(this.data.settings.merange, 10) < 16) {
						pass = false;
						issues.push('Me-range less than 16');
					} else if (parseInt(this.data.settings.merange, 10) < 24 && this.data.settings.me != 'tesa') {
						pass = false;
						issues.push('Me-range less than 24 without me:TESA');
					}
				} else if (this.data.res == '720p') {
					if (parseInt(this.data.settings.merange, 10) < 16) {
						pass = false;
						issues.push('Me-range less than 16');
					}
					if (parseInt(this.data.settings.ref, 10) < 8) {
						pass = false;
						issues.push('Used fewer than 8 reference frames');
					}
					if (parseInt(this.data.settings.bframes, 10) < 5) {
						pass = false;
						issues.push('Used fewer than 5 bframes');
					}
				} else if (this.data.res == '1080p') {
					if (parseInt(this.data.settings.merange, 10) < 16) {
						pass = false;
						issues.push('Me-range less than 16');
					}
					if (parseInt(this.data.settings.ref, 10) < 3) {
						pass = false;
						issues.push('Used fewer than 3 reference frames');
					}
					if (parseInt(this.data.settings.bframes, 10) < 3) {
						pass = false;
						issues.push('Used fewer than 3 bframes');
					}
				} else {
					console.log('Error checking minimum settings.');
				}

				if (!pass) {
					this.err('x264', 'minimumNotMet', issues);
				}
				return true;
			} catch (e) {
				this.err('x264', 'codecNotX264');
				return false;
			}
		}

		x264() {
			var skip = this.checkSkip();
			if (skip) return false;
			if (parseFloat(this.data.settings.qcomp) <= 0.3) this.err('x264', 'qcompVeryLow');
			else if (parseFloat(this.data.settings.qcomp) <= 0.5) this.err('x264', 'qcompLow');
			else if (parseFloat(this.data.settings.qcomp) > 0.95) this.err('x264', 'qcompVeryHigh');
			else if (parseFloat(this.data.settings.qcomp) > 0.8) this.err('x264', 'qcompHigh');
			if (this.data.settings.mbtree != '0') this.err('x264', 'mbtreeOn');
			if (this.data.settings.rc.search(/2pass/) >= 0) this.err('x264', '2passUsed');
			if (parseFloat(this.data.settings.crf) < 12) this.err('video', 'crfVeryLow');
			else if (parseFloat(this.data.settings.crf) < 14) this.err('video', 'crfLow');
			else if (parseFloat(this.data.settings.crf) > 26) this.err('video', 'crfVeryHigh');
			else if (parseFloat(this.data.settings.crf) > 23) this.err('video', 'crfHigh');

			if (this.data.settings.psy != '1') this.err('x264', 'psyOff');
			var psy = this.data.settings.psyrd.split(':');
			if (parseFloat(psy[0]) < 0.8 || parseFloat(psy[0]) > 1.2) {
				this.err('x264', 'psyUnusual', psy);
			}
			if (parseFloat(psy[1]) > 0.25) this.err('x264', 'psyTrellisHigh', psy);
			let aq = parseFloat(this.data.settings.aq.split(':')[1]);
			if (aq < 0.5 || aq > 1.2) this.err('x264', 'aqUnusual', aq);
			if (this.data.frm.search(/variable/i) >= 0) this.err('video', 'variableFramerate');
		}

		checkAnamorphic() {
			var skip = this.checkSkip();
			if (skip) return false;
			var anamorphic = false;
			var anaWidth = Math.ceil(
				(this.data.height / parseFloat(this.data.aspect[1])) * parseFloat(this.data.aspect[0])
			);
			var margin = (this.data.width / 100) * 3;
			if (anaWidth < this.data.width - margin || anaWidth > this.data.width + margin) anamorphic = true;
			this.data.anamorphic = anamorphic;

			var fname = this.data.filename.replace(/[^a-zA-Z0-9.]/g, '');
			if (fname.search(/(tvrip|vhsrip)/i) >= 0 && anamorphic) this.err('video', 'tvAnamorphic');
			else if (this.data.source == 'dvd' && fname.search(/(tvrip|vhsrip)/i) < 0 && !anamorphic)
				this.err('video', 'dvdNonAnamorphic');
			else if (this.data.source == 'bluray' && anamorphic) this.err('video', 'blurayAnamorphic');
		}

		resolution() {
			var skip = this.checkSkip();
			if (skip) return false;

			// Check width and height against this.data.res: over, under, neither equal
			// Check progressive scan

			var resolutions = ['DVDRip', 'VHSRip', 'TVRip', '480p', '576p', '720p', '1080p'];
			var resString = () => {
				let temp = this.data.source === 'bluray' ? resolutions.splice(3) : resolutions.splice(0, 3);
				let x = temp.pop();
				let y = temp.join(', ');
				return y + ' or ' + x;
			};

			// ## SECTION FOR FILENAME ##

			var regex,
				regex2,
				filenameRes = {},
				metatitleRes = {},
				detectedRes;
			switch (this.data.source) {
				case 'dvd':
					// Iterate through resolutions
					resolutions.map((x) => {
						// Ensure that numbered resolution isn't included in a DVDRip.
						regex = new RegExp(parseInt(x, 10), 'i');
						if (parseInt(x, 10) && this.data.filename.search(regex) >= 0) this.err('filename', 'resDVD');

						// Capture filename resolution
						if (!isNaN(x.charAt(0))) return true; // Skip the following for Blu-Ray resolutions

						regex = new RegExp(x.replace(/[^a-zA-Z0-9]/g, ''), 'i');
						if (this.data.filename.match(regex)) {
							filenameRes.match = this.data.filename.match(regex)[0]; // Match and save filename res
							filenameRes.actual = x;
						}
						// Test if filename resolution is formatted properly
						if (this.data.filename.search(x) < 0 && this.data.filename.search(regex) >= 0)
							this.err('filename', 'resFormat', { res: x, match: filenameRes.match });
					});
					break;
				case 'bluray':
					resolutions.map((x) => {
						if (isNaN(x.charAt(0))) {
							// If the iterated resolution starts with a letter...
							regex = new RegExp(x.replace(/[^a-zA-Z0-9]/g, ''), 'i');
							// Check that an SD tag hasn't been included
							if (this.data.filename.search(regex) >= 0 && this.data.filename.search(/hdtv/i) < 0)
								this.err('filename', 'SDTagHDSource', x);
							return true;
						} else {
							regex = new RegExp(x.replace(/[^a-zA-Z0-9]/g, ''), 'i');
							regex2 = new RegExp(x.replace(/[^0-9]/g, ''));
							if (this.data.filename.match(regex)) {
								filenameRes.match = this.data.filename.match(regex)[0]; // Match and save filename res
								filenameRes.actual = x;
								if (filenameRes.match !== x)
									this.err('filename', 'resFormat', { res: x, match: filenameRes.match });
							} else if (this.data.filename.match(regex2)) {
								filenameRes.match = this.data.filename.match(regex2)[0]; // Match and save filename res
								filenameRes.actual = x;
								if (filenameRes.match !== x) this.err('filename', 'noP', x);
							}
						}
					});
					break;
				default:
			}
			if (!filenameRes.match) this.err('filename', 'noRes', resString());

			// ## SECTION FOR METATITLE ##

			switch (this.data.source) {
				case 'dvd':
					resolutions.map((x) => {
						// Ensure that numbered resolution isn't included in a DVDRip.
						regex = new RegExp(parseInt(x, 10), 'i');
						if (parseInt(x, 10) && this.data.metatitle.search(regex) >= 0)
							this.err('metatitle', 'resDVD', x);
						if (!isNaN(x.charAt(0))) return true;

						regex = new RegExp(x.replace(/[^a-zA-Z0-9]/g, ''), 'i');
						if (regex.test(this.data.metatitle)) {
							metatitleRes.match = this.data.metatitle.match(regex)[0]; // Match and save metatitle res
							metatitleRes.actual = x;
						}
						// Test if filename resolution is formatted properly
						if (
							this.data.metatitle.search(x) < 0 &&
							this.data.metatitle.search(regex) >= 0 &&
							filenameRes.actual === metatitleRes.actual
						) {
							this.err('metatitle', 'resFormat', { res: x, match: metatitleRes.match });
						}
					});
					break;
				case 'bluray':
					resolutions.map((x) => {
						if (isNaN(x.charAt(0))) {
							// If the iterated resolution starts with a letter...
							regex = new RegExp(x.replace(/[^a-zA-Z0-9]/g, ''), 'i');
							// Check that an SD tag hasn't been included
							if (this.data.metatitle.search(regex) >= 0 && this.data.metatitle.search(/hdtv/i) < 0)
								this.err('metatitle', 'SDTagHDSource', x);
							return true;
						}
						// If the iterated resolution starts with a number
						else {
							regex = new RegExp(x.replace(/[^a-zA-Z0-9]/g, ''), 'i');
							regex2 = new RegExp(x.replace(/[^0-9]/g, ''));
							if (regex.test(this.data.metatitle)) {
								metatitleRes.match = this.data.metatitle.match(regex)[0]; // Match and save metatitle res
								metatitleRes.actual = x;
								if (metatitleRes.match !== x)
									this.err('metatitle', 'resFormat', { res: x, match: metatitleRes.match });
							} else if (regex2.test(this.data.metatitle)) {
								metatitleRes.match = this.data.metatitle.match(regex2)[0]; // Match and save metatitle res
								metatitleRes.actual = x;
								if (metatitleRes.match !== x) this.err('metatitle', 'noP', x);
							}
						}
					});
					break;
				default:
			}

			if (this.data.metatitle && !metatitleRes.match) this.err('metatitle', 'noResolutionTag', resString());

			this.data.res = filenameRes.actual ? filenameRes.actual : metatitleRes.actual;
			if (this.data.res) this.data.res = !isNaN(this.data.res.charAt(0)) ? this.data.res : '';
			if (this.data.scan.search(/progressive/i) < 0) this.err('video', 'resProgressive');

			// ## Detected Resolution Section ##

			var w = this.data.width,
				h = this.data.height;
			if (this.data.source === 'bluray') {
				if (w === 854 || h === 480) detectedRes = '480p';
				else if (w === 1024 || h === 576) detectedRes = '576p';
				else if (w === 1280 || h === 720) detectedRes = '720p';
				else if (w === 1920 || h === 1080) detectedRes = '1080p';
				else if (w === 3840 || h === 2160) detectedRes = '2160p';
				else if (w <= 939 && h <= 528) detectedRes = '480p';
				else if (w <= 1152 && h <= 648) detectedRes = '576p';
				else if (w <= 1600 && h <= 900) detectedRes = '720p';
				else if (w <= 2880 && h <= 1620) detectedRes = '1080p';
				else detectedRes = '2160p';

				if (detectedRes === '2160p') this.err('video', '2160pEncode');
			} else {
				if (this.data.region === 'ntsc') {
					if (w > 720 || h > 480) this.err('video', 'resizedDVDRip', 'NTSC');
				} else if (this.data.region === 'pal') {
					if (w > 720 || h > 576) this.err('video', 'resizedDVDRip', 'PAL');
				}
			}

			if (detectedRes && detectedRes !== filenameRes.actual)
				this.err('filename', 'resMismatch', { f: filenameRes.match, d: detectedRes });
			if (detectedRes && this.data.metatitle && detectedRes !== metatitleRes.actual)
				this.err('metatitle', 'resMismatch', { m: metatitleRes.match, d: detectedRes });
			this.data.res = detectedRes || '';

			// After this.data.res is set...
			if (this.data.filename.search(/(vhsrip|[^hd]tvrip)/i) >= 0) {
				this.data.speclink = 'vhstv';
				this.data.spectext = 'VHSRip / TVRip';
			} else if (this.data.region) {
				this.data.speclink = this.data.region + 'dvd';
				this.data.spectext = this.data.region.toUpperCase() + ' DVD';
			} else {
				this.data.speclink = this.data.res;
				this.data.spectext = this.data.res + ' Blu-Ray';
			}
		}

		// This function verifies the width and height settings of the encode. Comes later // 96px - 144px - 360px
		checkRes() {
			var skip = this.checkSkip();
			if (skip) return false;
			let wd = this.data.width,
				ht = this.data.height;
			let specs = [
				{ type: '480p', width: 854, height: 480 },
				{ type: '576p', width: 1024, height: 576 },
				{ type: '720p', width: 1280, height: 720 },
				{ type: '1080p', width: 1920, height: 1080 },
			];
			if (this.data.source == 'bluray') {
				for (var x of specs) {
					if (this.data.res == x.type) {
						if (wd > x.width) this.err('video', 'resWidthOver', x);
						if (ht > x.height) this.err('video', 'resHeightOver', x);
						if (wd < x.width && ht < x.height && x.type != '1080p') this.err('video', 'resNeitherEqual', x);
					}
				}
			}
		}

		checkFramerate() {
			var skip = this.checkSkip();
			if (skip) return false;
			if (this.data.fps == 29.97) this.err('video', 'dupeFrames');
			else if (
				this.data.fps != 25 &&
				this.data.fps != 24 &&
				this.data.fps != 23.976 &&
				this.data.frm.search(/constant/i) >= 0
			) {
				this.err('video', 'fpsUnusual');
			}
		}

		checkBitrate() {
			var skip = this.checkSkip();
			if (skip) return false;
			var min, max;
			var bitrate = this.data.bitrate;
			var insert, buttins;
			if (!bitrate) {
				this.err('video', 'bitrateMissing');
				return true;
			}

			var specs = [
				{ t: '576p', min: 2000, max: 4000 },
				{ t: '720p', min: 5000, max: 7000 },
				{ t: '1080p', min: 8000, max: 12000 },
			];
			if (this.data.source == 'dvd' || this.data.source == 'vhs' || this.data.res == '480p') {
				min = 1500;
				max = 2500;
			} else {
				for (let x of specs) {
					if (this.data.res == x.t) {
						min = x.min;
						max = x.max;
					}
				}
			}
			var text = min.toLocaleString() + ' Kbps - ' + max.toLocaleString() + ' Kbps';

			var buffer = (20 / 100) * bitrate;
			var smbuffer = (5 / 100) * bitrate;
			var sendObj = { bitrate: bitrate.toLocaleString(), text };

			if (bitrate < min && bitrate > min - smbuffer) this.err('video', 'bitrateUnderSlightly', sendObj);
			else if (bitrate < min && bitrate > min - buffer) this.err('video', 'bitrateUnder', sendObj);
			else if (bitrate < min - buffer) this.err('video', 'bitrateUnderSeverely', sendObj);
			else if (bitrate > max && bitrate < max + smbuffer) this.err('video', 'bitrateOverSlightly', sendObj);
			else if (bitrate > max && bitrate < max + buffer) this.err('video', 'bitrateOver', sendObj);
			else if (bitrate > max + buffer) this.err('video', 'bitrateOverSeverely', sendObj);
		}

		sortAudioTracks() {
			var skip = this.checkSkip();
			if (skip) return false;
			var audio = { main: [], secondary: [], score: [] };
			for (var x of this.data.audio) {
				if (this.data.audio.length === 1) x.text = 'Main audio track';
				else if (!x.title && x.default && !x.lang) x.text = 'Main audio track';
				else if (x.title) x.text = `Audio track '${x.title}'`;
				else if (x.lang) x.text = `Audio track '${x.lang}'`;
				else x.text = `Audio track 'ID: ${x.id}'`;

				if (x.duration < this.data.duration - 2 || x.duration > this.data.duration + 2)
					this.err('audio', 'audioLengthMismatch', { duration: x.duration, label: x.text });
				if (!x.lang && x.title.search(/(score|isolated)/i) < 0) this.err('audio', 'noLanguageTag', x.text);
				if (x.bits > 16) this.err('audio', 'bitDepthHigh', { text: x.text, bits: x.bits });
				if (x.sample > 48) this.err('audio', 'sampleRateHigh', { text: x.text, sample: x.sample.toFixed(1) });
				if (x.format.search(/dts/i) >= 0 && x.comp.search(/lossless/i) >= 0)
					this.err('audio', 'dtsHDMA', x.text);
				if (x.format.search(/pcm/i) >= 0) this.err('audio', 'lpcmAudio', x.text);
				if (x.title.search(/comment/i) >= 0) audio.secondary.push(x);
				else if (x.title.search(/(score|isolated)/i) >= 0) audio.score.push(x);
				else if (!x.default && x.bitrate <= 100) audio.secondary.push(x);
				else audio.main.push(x);
			}
			this.data.audio = audio;
		}

		checkAudioBitrate(input) {
			// check if bitrate is detectable... if not return an error
			if (!input.bitrate) {
				this.err('audio', 'missingBitrate', decapFirst(input.text));
				return true;
			}

			if (input.default) {
				let match,
					codecs = ['aac', 'dts', 'flac', 'pcm'],
					d = false;
				// Iterate through each of the non-AC3 codecs
				for (let x of codecs) {
					// Create a case-insentive regex with the codec
					let reg = new RegExp(x, 'i');
					// If the filename contains the codec and the audio track format doesn't contain the codec, run this block
					if (this.data.filename.search(reg) >= 0 && input.format.search(reg) < 0) {
						this.err('filename', 'codecMismatch', {
							filenameCodec: x.toUpperCase(),
							codec: input.fullFormat,
						});
						d = true;
					}
				}

				try {
					match = input.format.match(/aac|dts|flac|pcm/i);
					if (match && match.length > 0) {
						let reg = new RegExp(match[0], 'i');
						if (this.data.filename.search(reg) < 0 && !d)
							this.err('filename', 'codecNotIncluded', match[0].toUpperCase());
					}
				} catch (e) {
					console.log(e);
				}
			}

			if (this.data.source == 'dvd' && input.format.search(/aac/i) >= 0) this.err('audio', 'aacDVD');
			else if (input.format.search(/mpeg/i) >= 0) this.err('audio', 'mpegAudio', input.text);
			else if (this.data.source == 'bluray' && input.channels <= 2 && input.format.search(/ac3/i) >= 0)
				this.err('audio', 'ac3Stereo', input.text);
			else if (this.data.source == 'bluray' && input.channels > 2 && input.format.search(/aac/i) >= 0)
				this.err('audio', 'aacSurround', input.text);

			// Bitrate check for AC3 audio
			if (input.format.search(/ac3/i) >= 0 && this.data.source == 'bluray' && input.channels >= 4) {
				var spec = 0;
				if (this.data.res == '480p' || this.data.res == '576p') spec = 448;
				else if (this.data.res == '720p' || this.data.res == '1080p') spec = 640;

				if (input.bitrate < spec || input.bitrate > spec)
					this.err('audio', 'ac3Bitrate', { text: input.text, actual: input.bitrate, spec });
			}
		}

		checkMainAudio() {
			var skip = this.checkSkip();
			if (skip) return false;
			var audio = this.data.audio;
			this.data.subtitles.map((x) => {
				if (x.forced) this.err('subtitles', 'forcedYes', { lang: x.lang, title: x.title, id: x.id });
				if (!x.lang) this.err('subtitles', 'noLanguageTag', { title: x.title, id: x.id });
				if (
					x.title.search(/forced/i) >= 0 ||
					x.title.replace(/[^a-zA-Z]/, '').search(/nonenglishscene/i) >= 0
				) {
					if (x.default === false)
						this.err('subtitles', 'forcedFlagNotDefault', { lang: x.lang, title: x.title, id: x.id });
				}
			});

			switch (true) {
				case audio.main.length === 0:
					this.err('audio', 'noAudio');
					break;
				case audio.main.length === 1:
					let m = audio.main[0];
					if (m.title.search(/(stereo|mono|surround)/i) >= 0) this.err('audio', 'mainTitle', m.title);
					// Section for single audio, English language films
					if (m.lang.search(/english/i) >= 0) {
						this.data.subtitles.map((x) => {
							if (x.default && x.title.search(/non(.*?)eng|forced/i) < 0)
								this.err('subtitles', 'defaultYes', { lang: x.lang, title: x.title, id: x.id });
							if (x.title.search(/forced/i) >= 0 && x.lang.search(/english/i) >= 0)
								this.err('subtitles', 'nameForced', x.title);
						});
					}
					// Section for single audio films with no linguistic content
					else if (m.lang.search(/zxx/i) >= 0) {
						this.data.subtitles.map((x) => {
							if (x.default && x.title.search(/screen/i) < 0)
								this.err('subtitles', 'noLinguisticLabelling');
						});
					}
					// Section for single audio, non-English language films
					else {
						var english = false,
							def = false;
						this.data.subtitles.map((x) => {
							if (x.default && x.lang.search(/english/i) < 0)
								this.err('subtitles', 'nonEngDefault', x.lang);
							else if (x.default && x.lang.search(/english/i) >= 0) {
								english = true;
								def = true;
							} else if (!x.default && x.lang.search(/english/i) >= 0) english = true;
						});
						if (!def && english) this.err('subtitles', 'engDefault');
						else if (!english && m.lang) this.err('subtitles', 'noEngSubs');
					}
					this.checkAudioBitrate(m);
					break;
				case audio.main.length > 2:
					this.err('audio', 'multipleAudio');
				// No break here, needs to filter through to next condition for 2+ audio tracks
				default:
					var langs = [],
						defSub = false,
						engsub = false,
						dualAudio = false;
					audio.main.map((x) => {
						langs.push(x.lang);
					});
					var engTracks = this.arrayCount(langs, 'english');
					if (langs.length > engTracks) dualAudio = true;
					if (engTracks > 1) this.err('audio', 'dupeEnglish', engTracks);

					audio.main.map((x) => {
						this.checkAudioBitrate(x);
						if (x.default && x.lang && x.lang.search(/english/i) >= 0 && dualAudio)
							this.err('audio', 'engDefault');
						if (x.lang.search(/english/i) >= 0 && x.title.search(/dub/i) < 0 && dualAudio)
							this.err('audio', 'engDubName');
						if (
							x.lang.search(/english/i) < 0 &&
							x.default &&
							x.title.search(/(stereo|mono|surround)/i) >= 0
						)
							this.err('audio', 'titleBlank', x.title);
					});

					for (let i = 0; i < this.data.subtitles.length; i++) {
						if (this.data.subtitles[i].default) defSub = true;
						if (this.data.subtitles[i].lang.search(/English/i) >= 0) engsub = true;
						if (
							this.data.subtitles[i].default &&
							this.data.subtitles[i].lang &&
							this.data.subtitles[i].lang.search(/English/i) < 0
						) {
							this.err('subtitles', 'nonEngDefault', this.data.subtitles[i].lang);
						}
					}
					if (!engsub && dualAudio) this.err('subtitles', 'noEngSubs');
					else if (!defSub && dualAudio) this.err('subtitles', 'noDefault');
					break;
			}
		}

		checkSecondaryAudio() {
			var skip = this.checkSkip();
			if (skip) return false;
			var audio = this.data.audio,
				temp;
			for (var s of audio.secondary) {
				var tg = audio.secondary.length > 1 ? 'commentary track (ID: ' + s.id + ')' : 'commentary track';
				if (!s.title) {
					tg = 'secondary audio track (ID: ' + s.id + ')';
					this.err('audio', 'noTrackName', tg);
				}
				if (s.format.search(/aac/i) < 0) this.err('audio', 'aacCommentary', tg);
				if (s.bitrate >= 100) this.err('audio', 'highCommentary', tg);
				if (s.title.search(/comment/i) < 0) this.err('audio', 'useCommentary', tg);
				temp = s.title.replace(/[^0-9a-zA-z]/g, '').toLowerCase();
				if (temp == 'commentary' || temp == 'audiocommentary') this.err('audio', 'noParticipants', tg);
				else if (temp.search(/audiocommentary/i) >= 0) this.err('audio', 'usedAudioCommentary', tg);
				if (s.default) this.err('audio', 'defaultCommentary', tg);
			}
		}

		checkChapters() {
			var skip = this.checkSkip();
			if (skip) return false;
			for (let i = 0; i < this.data.chapters.length; i++) {
				var tg = this.data.chapters.length > 1 ? '(chapter set #' + [i + 1] + ')' : '';
				var tc = 0,
					bk = 0;
				var cSet = this.data.chapters[i];
				cSet.map((x) => {
					if (x.time && x.title.search(/[a-zA-Z]/g) < 0 && x.title.search(/[0-9]/g) >= 0) tc++;
					else if (x.time.search(/\d{2}:\d{2}/i) >= 0 && x.title.search(/[a-zA-Z0-9]/g) < 0) bk++;
				});

				if (tc == cSet.length) this.err('chapters', 'chapterTimecodes', tg);
				else if (bk == cSet.length) this.err('chapters', 'noTitles', tg);
				else if (bk > 0) this.err('chapters', 'missingTitles', tg);
			}
		}
	}

	var urls = { guide: `${PTP}/wiki.php?action=article&id=263` };
	var timers = GM_getValue('HJA_timers') || { guide: 0 };
	var settings = { cache: 1440, script: 'admin' };
	var imgs = {
		sourceInfo: 'https://ptpimg.me/30bg01.png',
		handjobBadge: 'https://ptpimg.me/sb54rt.png',
		settingsIcon: 'https://ptpimg.me/20hlgj.png',
		miScan: 'https://ptpimg.me/bw61y4.png',
		appearanceIcon: 'https://ptpimg.me/j7p346.png',
	};
	var envs = {
		hash: location.hash.replace('#', ''),
		path: window.location.pathname,
	};
	envs.page = currentPage();
	var css = {};

	var cssDarkStyle = {
		text: '#c5c5c5',
		text2: '#c5c5c5',
		background: '#34495e',
		header: '#3d678c', //"#4682b4",
		passed: '#90ee90',
		error: '#cd5c5c',
		warning: '#e08f2b',
		advisory: '#e8e66c',
		pageBackground: '#050505',
		pageBackground2: '#555555',
		offset: '#f3f3f3',
	};

	var cssLightStyle = {
		text: '#313131', // For mediainfo scan boxes
		text2: '#313131', // For everything else
		background: '#cfe0e8',
		header: '#87bdd8',
		passed: '#2cbb39',
		error: '#cd5c5c',
		warning: '#e08f2b',
		advisory: '#5f9ea0',
		pageBackground: '#fefefe',
		pageBackground2: '#f5f5dc',
		offset: '#030303',
	};

	var entityMap = {
		'&': '&amp;',
		'<': '&lt;',
		'>': '&gt;',
		'"': '&quot;',
		"'": '&#39;',
		'/': '&#x2F;',
		'`': '&#x60;',
		'=': '&#x3D;',
	};

	darkLight();
	injectStylesheet();

	switch (envs.page) {
		case 'upload':
			settings.fn = settingsModule();
			settings.fn.update('miscan');
			break;

		case 'torrent':
			torrentPageScan();
			break;

		default:
			injectHJAElements();
			activateListeners();
			replaceUserLinks();
			showEncodeCount();
			activateAltLinks();
			sourceInfoScan();
			loadHandjobGuide();
			imageDimensions();
			groupEncodes = new GroupEncodes();
			settings.fn.update('miscan');
			settings.fn.update('borders');
			scrollToAnchor(envs.hash);
			break;
	}

	function injectHJAElements() {
		const lightboxNode = Create('div#admin-lightbox');
		lightboxNode.innerHTML = `<div id="admin-lightbox-container">
                                    <div id="admin-lightbox-content">
                                        <div id="admin-lightbox-exit">X</div>
                                    </div>
                                    <div id="admin-lightbox-alert"></div>
                                </div>`;
		document.body.appendChild(lightboxNode);

		const groupEncodesNode = Create('div#group-encodes');
		groupEncodesNode.style.transform = 'scale(0)';
		groupEncodesNode.style.opacity = 0;
		document.body.appendChild(groupEncodesNode);

		const toolbarIcon = Create('div#handjob-bbcode-icon.bbcode-toolbar__button{title}Expand HANDJOB Toolbar');
		const toolbarNode = Create('div#handjob-bbcode-select');

		toolbarNode.innerHTML = `
            <div id="handjob-bbcode-settings" class="bbcode-toolbar__button" title="Toolbar Settings"></div>
            <div id="handjob-settings-window">
                <div class="settings-option" title="Automatically scan mediainfo logs posted into the HANDJOB forum"><input type="checkbox" id="switch1" name="switch1" class="switch" data-option="miscan" /><label for="switch1">MI Autoscan</label></div>
                <div class="settings-option" title="Change the username links on forum posts from profile links to uploaded HANDJOBs"><input type="checkbox" id="switch2" name="switch2" class="switch" data-option="encodelinks" /><label for="switch2">Encode Links</label></div>
                <div class="settings-option" title="Display HANDJOB encode count when hovering over username in forum post"><input type="checkbox" id="switch3" name="switch3" class="switch" data-option="encodecount" /><label for="switch3">Encode Count</label></div>
                <div class="settings-option" title="When enabled, clicking an avatar in the forum will insert an @username link in the quickpost input box"><input type="checkbox" id="switch4" name="switch4" class="switch" data-option="avatarat" /><label for="switch4">Avatar @</label></div>
                <div class="settings-option" title="Borders will be added to any detected screenshots posted. Click the eye to edit border style"><input type="checkbox" id="switch5" name="switch5" class="switch" data-option="borders" /><label for="switch5">Img Borders</label><div class="appearance-setting" title="Change Border Appearance"><img src="${imgs.appearanceIcon}"></div>
                <div class="appearance-extension-outer"><div class="appearance-extension-inner">
                    <div id="appearance-extension-border-width-container">
                        <div>Border Width:</div>
                        <div>
                            <select id="appearance-extension-border-width" class="borderStyle">
                            <option>1</option>
                            <option>2</option>
                            <option>3</option>
                            <option>4</option>
                            <option>5</option>
                            </select>
                        </div>
                    </div>
                    <div id="appearance-extension-border-colour-container">
                        <div>Colour:</div>
                        <div><input type="color" class="borderStyle" id="appearance-extension-border-colour" value="#ffffff"></div>
                    </div>
                </div></div>
                </div>
                <div class="settings-option" title="Clicking the 'Reply to Approval Request' button will generate a quote rather than an @username link in the quickpost message box"><input type="checkbox" id="switch6" name="switch6" class="switch" data-option="quotereplies" /><label for="switch6">Quote Replies</label></div>
            </div>
        </div>`;

		const target = document.querySelector('#Bbcode_Toolbar');
		const targetBefore = target.querySelector('div[style*="clear: both"]');
		target.insertBefore(toolbarIcon, targetBefore);
		target.insertBefore(toolbarNode, targetBefore);

		const linkboxNode = document.querySelector('div#content div.thin > div.linkbox:first-of-type');
		const recentEncodesNode = Create('a#checkUploadsLink.linkbox__link{href}null');
		recentEncodesNode.innerHTML = '[Check Recent HANDJOB Encodes]';

		linkboxNode.appendChild(recentEncodesNode);

		settings.fn = settingsModule();
	}

	function darkLight() {
		let bk = document.getElementsByClassName('forum_post');
		if (bk.length === 0) bk = document.getElementsByClassName('panel');
		bk = window
			.getComputedStyle(bk[0], null)
			.getPropertyValue('background-color')
			.replace(/[^0-9,]/gi, '')
			.split(',');
		var rgb = { r: parseInt(bk[0]), g: parseInt(bk[1]), b: parseInt(bk[2]) };
		var RsRGB, GsRGB, BsRGB, R, G, B;
		RsRGB = rgb.r / 255;
		GsRGB = rgb.g / 255;
		BsRGB = rgb.b / 255;

		if (RsRGB <= 0.03928) {
			R = RsRGB / 12.92;
		} else {
			R = Math.pow((RsRGB + 0.055) / 1.055, 2.4);
		}
		if (GsRGB <= 0.03928) {
			G = GsRGB / 12.92;
		} else {
			G = Math.pow((GsRGB + 0.055) / 1.055, 2.4);
		}
		if (BsRGB <= 0.03928) {
			B = BsRGB / 12.92;
		} else {
			B = Math.pow((BsRGB + 0.055) / 1.055, 2.4);
		}
		var luma = 0.2126 * R + 0.7152 * G + 0.0722 * B;
		if (luma >= 0.5) css = cssLightStyle;
		else css = cssDarkStyle;
	}

	function activateListeners() {
		// Set listener for the Admin Toolkit settings menu
		document.querySelector('#handjob-bbcode-settings').addEventListener('click', function () {
			$('#handjob-settings-window').fadeToggle('fast');
			$('.appearance-extension-outer').slideUp('fast');
		});

		// Set listener for the Admin Toolkit toolbar
		$('#handjob-bbcode-icon').on('click', function () {
			toggleToolbar(this);
		});

		// Set listener for border settings
		document.querySelector('.appearance-setting').addEventListener('click', function () {
			$('.appearance-extension-outer').slideToggle('fast');
		});

		// Set listener for the Admin Toolkit 'Check Recent HANDJOB encodes' display
		document.querySelector('#checkUploadsLink').addEventListener('click', function () {
			checkRecentUploads();
		});
	}

	function toggleToolbar(element) {
		var reset = false,
			toggleWidth,
			newWidth = 220;

		if ($(element).prop('title') === 'Expand HANDJOB Toolbar') {
			$(element).prop('title', 'Close HANDJOB Toolbar');
		} else {
			$(element).prop('title', 'Expand HANDJOB Toolbar');
			$('#handjob-settings-window').fadeOut('fast');
			$('.appearance-extension-outer').slideUp('fast');
			reset = true;
		}

		if ($('#handjob-bbcode-contest-start').length >= 1) newWidth += 25;
		if ($('#handjob-bbcode-settings').length >= 1) newWidth += 25;

		toggleWidth = $('#handjob-bbcode-select').width() === 0 ? newWidth : '0px';
		$('#handjob-bbcode-select').animate({ width: toggleWidth }, function () {
			if (reset) resetElements();
		});
	}

	// ## End Section for functions for 'Check Recent HANDJOB Encodes' function ##

	function torrentPageScan() {
		let html = ` | <a class="linkbox__link torrents_scan_mediainfo" href="#">Scan Mediainfo log(s)</a>`;
		$('tr.torrent_info_row div.linkbox').append(html);

		$('.torrents_scan_mediainfo').on('click', function (e) {
			e.preventDefault();
			var el = $(this).closest('div.linkbox').siblings('div.movie-page__torrent__panel');
			$(el)
				.find('table.mediainfo')
				.each(function () {
					var element = $(this);
					populateMiScan(element);
				});
		});
	}

	function scrollToAnchor(aid) {
		if (!aid) return true;
		else if (aid === 'quickpost') return true;

		var timer;
		var errors = 0;

		var checker = function () {
			let ele = $('#HJM_loading');
			let status = $('#HJM_loading').val();
			if (ele.length === 0 && errors < 5) {
				errors++;
			} else if (status != 'active') {
				dropAnchor();
				clearInterval(timer);
			}
		};

		function dropAnchor() {
			var aTag = $("div[id='" + aid + "']");
			$('html,body').animate({ scrollTop: aTag.offset().top }, 'slow');
		}

		timer = setInterval(checker, 100);
	}

	function lightboxBorder() {
		var style = GM_getValue('borderStyle');
		$('#lightbox').find('img').css('border', `${style.wid}px solid ${style.colour}`).css('margin', '2px');
	}

	function addBorders(bdStatus) {
		var style = GM_getValue('borderStyle');

		$('blockquote').each(function () {
			if ($(this).find('img').not('img[src*=static]').not('.view_post').length >= 3) {
				$(this)
					.find('img')
					.not('.view_post')
					.each(function () {
						if (bdStatus) {
							$(this).removeAttr('style');
							$(this).css('border', `${style.wid}px solid ${style.colour}`).css('margin', '5px 0px');
							$(this).on('click', function () {
								lightboxBorder();
							});
						} else {
							$(this).removeAttr('style').off('click');
						}
					});
			}
		});
	}

	// Adds arrow enabling a quick reply to approval requests with a mediainfo log pasted
	function addMediainfoClick(element) {
		if (envs.page || $(element).closest('#quickpostform').length > 0) return false;
		var elApp = $(element).find('tbody tr td:nth-child(3)');
		$(elApp).css('position', 'relative');
		$(elApp).prepend('<span class="replyApp" title="Reply to Approval Request">⤵</span>');
		$(elApp)
			.find('span.replyApp')
			.on('click', function () {
				var insert;
				var quotereplies = GM_getValue('quotereplies');
				var filename = $(element).prev('a').text();
				var heading = $(element).closest('div.forum_post').find('div.forum-post__heading');
				var username = $(heading).find('a.username').data('username');
				var postId = $(heading).find('a.forum-post__id').text().replace('#', '');
				var threadId = $(heading)
					.find('a.forum-post__id')
					.attr('href')
					.match(/(?:threadid=)(\d{1,})/i)[1];
				if (quotereplies === true) {
					insert = `[quote=${username}:f${threadId}:${postId}]Approval request for ${filename}[/quote]\n`;
				} else {
					insert = '@' + username + ' – ' + filename + ' ';
				}
				insertQuickpostText(insert);
			});
	}

	function displayMiScan() {
		function setObserver(element) {
			var elementNode = document.getElementById(element.replace(/[^a-zA-Z0-9]/i, ''));
			var observer = new MutationObserver(function (mutations) {
				disconnectObs();
				if (mutations[0].attributeName === 'class' && !$(element).hasClass('hidden')) {
					$(element + ' table.mediainfo').each(function () {
						if (
							$(this)
								.prev('a')
								.text()
								.search(/handjob/i) >= 0 &&
							$(this).siblings('div.mediainfo-feedback').length === 0
						) {
							populateMiScan(this);
						}
					});
				}
			});

			var observerConfig = {
				attributes: true,
				attributeOldValue: true,
			};
			observer.observe(elementNode, observerConfig);
			function disconnectObs() {
				observer.disconnect();
			}
		}

		$('#post_preview').on('click', function () {
			setObserver('#quickreplypreview');
		});

		$('#preview_button').on('click', function () {
			setObserver('#release_desc_preview');
		});

		$('.forum-post__bodyguard')
			.children('table.mediainfo')
			.each(function () {
				var feedback = populateMiScan(this);
			});

		$('.forum-post__bodyguard').each(function () {
			var replyArr = [];
			$(this)
				.find('span.replyApp')
				.each(function () {
					replyArr.push($(this).closest('table').prev('a').text());
				});
			if (replyArr.length > 1) {
				var ibox = '';
				var parent = $(this).closest('div.forum_post').find('div.forum-post__heading');
				addIconSpacer($(this).closest('.forum-post__body').find('.forum-post__bodyguard').get(0));
				var threadId = $(parent)
					.find('a.forum-post__id')
					.attr('href')
					.match(/(?:threadid=)(\d{1,})/i)[1];

				var username = $(parent).find('a.username').data('username');
				var postId = $(parent).find('a.forum-post__id').text().replace('#', '');
				if ($(this).closest('.forum-post__body').find('.source-info').length > 0) ibox = ' ibox';
				var insert = `<div data-username="${username}" data-filenames="${replyArr.join(
					'##'
				)}" data-threadid="${threadId}" data-postid="${postId}" class="mi-reply-all${ibox}" title="Reply to All">⤵</div>`;

				if (envs.page !== 'upload') $(this).after(insert);
			}
		});

		$('div.mi-reply-all').on('click', function () {
			var insert;
			var quotereplies = GM_getValue('quotereplies');
			let username = $(this).data('username');
			let postId = $(this).data('postid');
			let threadId = $(this).data('threadid');
			let filenames = $(this).data('filenames').split('##');
			filenames = filenames.filter(function (item, pos) {
				return filenames.indexOf(item) == pos;
			});
			// filenames = filenames.map((x) => "[b]" + x + "[/b] — ");
			// filenames = filenames.join('\n[*] ');
			if (quotereplies === true) {
				insert = `[quote=${username}:f${threadId}:${postId}]Approval requests for:\n[indent]${filenames.join(
					'\n'
				)}[/indent][/quote]`;
				insert += filenames.map((x) => `[i]${x}[/i]\n[*] `).join('\n');
			} else {
				insert = '@' + username + '\n[*] ' + filenames.map((x) => '[i]' + x + '[/i] — ').join('\n[*] ') + ' ';
			}

			insertQuickpostText(insert);
		});

		showAllMediainfoScans();
	}

	function showAllMediainfoScans() {
		$('div.mediainfo-feedback').addClass('log-visible');
	}

	function populateMiScan(element, inline = false) {
		var miScan = new MediainfoScanner($(element).next().text());
		if (inline) return miScan;
		else renderMiScanResults(miScan, element);
	}

	function renderMiScanResults(miScan, el) {
		if (miScan.skip >= 2) return false;
		var sections = ['filename', 'metatitle', 'x264', 'video', 'audio', 'subtitles', 'chapters', 'misc'];
		var html = "<div class='mediainfo-feedback-table'>";
		var fill = false,
			insert = '';
		if (envs.page === 'upload') insert = ' upload-mediainfo';

		for (let x = 0; x < sections.length; x++) {
			var m =
				sections[x] === 'metatitle' && miScan.data && miScan.data.metatitle
					? " (<span class='lowercase'>" + miScan.data.metatitle + '</span>)'
					: '';
			if (
				miScan.feedback[sections[x]].errors.length > 0 ||
				miScan.feedback[sections[x]].warnings.length > 0 ||
				miScan.feedback[sections[x]].advisories.length > 0
			) {
				html += `<div class="mediainfo-feedback-header">${capFirst(sections[x])}${m}</div>`;
				fill = true;
			}

			if (miScan.feedback[sections[x]].errors.length > 0) {
				miScan.feedback[sections[x]].errors.map((f) => {
					html += `<div class="mediainfo-feedback-issue mediainfo-feedback-error">${f.output}</div>`;
				});
			}
			if (miScan.feedback[sections[x]].warnings.length > 0) {
				miScan.feedback[sections[x]].warnings.map((f) => {
					html += `<div class="mediainfo-feedback-issue mediainfo-feedback-warning">${f.output}</div>`;
				});
			}
			if (miScan.feedback[sections[x]].advisories.length > 0) {
				miScan.feedback[sections[x]].advisories.map((f) => {
					html += `<div class="mediainfo-feedback-issue mediainfo-feedback-advisory">${f.output}</div>`;
				});
			}
		}

		html += '</div>';
		if (!fill) {
			html = '';
			$(el).prev('a').addClass('forum-scan-passed');
		} else {
			$(el).next('blockquote').after(`<div class="mediainfo-feedback${insert}">${html}</div>`);
		}
		addMediainfoClick(el);
		if (envs.page === 'upload') $('.upload-mediainfo').slideDown('slow');
	}

	function resetMiScan() {
		$('.forum-scan-passed').removeClass('forum-scan-passed');
		$('.mediainfo-feedback').slideUp();
	}

	function imageDimensions() {
		$('img.bbcode__image')
			.one('load', function () {
				let wd = this.naturalWidth || this.width;
				let ht = this.naturalHeight || this.height;
				$(this).attr('title', wd + ' x ' + ht);
			})
			.each(function () {
				if (this.complete) {
					$(this).trigger('load');
				}
			});
	}

	function checkTimer(last, interval) {
		interval = interval * 60 * 1000;
		var current = new Date().getTime();
		return current > last + interval ? true : false;
	}

	function loadHandjobGuide() {
		var updateCache = checkTimer(timers.guide, settings.cache);
		var cache = GM_getValue('HJA_cache_guide');

		if (updateCache || !cache) {
			ADMIN_ajaxRequest({ url: urls.guide, method: 'GET' }).then((res) => {
				timers.guide = new Date().getTime();
				GM_setValue('HJA_cache_guide', res);
				GM_setValue('HJA_timers', timers);
				populateHandjobGuide(res);
			});
		} else {
			populateHandjobGuide(GM_getValue('HJA_cache_guide'));
		}
	}

	function populateHandjobGuide(raw) {
		Array.prototype.clean = function () {
			return this.filter(((set) => (f) => !set.has(f.link) && set.add(f.link))(new Set()));
		};

		var opt_out = '</optgroup>';
		var tp = function (x) {
			return '<optgroup label="#">'.replace('#', x);
		};
		var select_in = '<select><option value="" disabled selected>-- The HANDJOB Guide --</option>',
			select_out = '</select>';
		var spec_in = tp('Quick Reference');
		var sbs_in = tp('Step-by-Step Guides');
		var faq_in = tp('Frequently Asked Questions');
		var faq_video_in = tp('FAQ -> Video');
		var faq_audio_in = tp('FAQ -> Audio');
		var faq_subtitles_in = tp('FAQ -> Subtitles');
		var faq_other_in = tp('FAQ -> Other');
		var faq_approval_in = tp('FAQ -> Approval');
		var spec = [],
			sbs = [],
			faq = {};
		faq.video = [];
		faq.audio = [];
		faq.subtitles = [];
		faq.other = [];
		faq.approval = [];

		$('a', raw).each(function () {
			let href = $(this).attr('href');
			href = href.split('#')[1] || null;
			if (href) {
				var title = $(this).text().trim().replace(/\:$/, '');
				var link = urls.guide + '#' + href;
				if (href.match(/^spec/)) {
					spec.push({ link: link, title: title });
				} else if (href.match(/^sbs/)) {
					sbs.push({ link: link, title: title });
				} else if (href.match(/^faq/)) {
					var subsec = $(this)
						.closest('div.bbcode_indent')
						.prev('span')
						.find('strong')
						.text()
						.trim()
						.toLowerCase();
					faq[subsec].push({ link: link, title: title });
				}
			}
		});

		var base = 'The HANDJOB Guide -> ';
		var ov = function (arr, txt) {
			return arr.clean().map(function (x) {
				return `<option value="[url=${x.link}]${base}${txt} --> ${x.title}[/url]">${x.title}</option>`;
			});
		};
		spec = ov(spec, 'Quick Reference');
		sbs = ov(sbs, 'Step-by-Step Guides');
		faq.video = ov(faq.video, 'FAQ -> Video');
		faq.audio = ov(faq.audio, 'FAQ -> Audio');
		faq.subtitles = ov(faq.subtitles, 'FAQ -> Subtitles');
		faq.other = ov(faq.other, 'FAQ -> Other');
		faq.approval = ov(faq.approval, 'FAQ -> Approval');

		var element = select_in.concat(
			spec_in,
			spec.join('\n'),
			opt_out,
			sbs_in,
			sbs.join('\n'),
			opt_out,
			faq_video_in,
			faq.video.join('\n'),
			opt_out,
			faq_audio_in,
			faq.audio.join('\n'),
			opt_out,
			faq_subtitles_in,
			faq.subtitles.join('\n'),
			opt_out,
			faq_other_in,
			faq.other.join('\n'),
			opt_out,
			faq_approval_in,
			faq.approval.join('\n'),
			opt_out,
			select_out
		);
		$('#handjob-bbcode-select').prepend(element);
		handjobGuideEventListener();
	}

	function handjobGuideEventListener() {
		$('#handjob-bbcode-select > select').on('change', function () {
			var txtToAdd = $(this).val();
			insertQuickpostText(txtToAdd);
		});
	}

	function resetElements() {
		$('#handjob-bbcode-select > select').prop('selectedIndex', 0);
	}

	function replaceUserLinks() {
		var cb = function (child) {
			let userid = child.getAttribute('href').split('=').pop();
			let username = child.firstChild.nodeValue;
			child.dataset.username = username;
			child.dataset.userid = userid;
		};
		domIterate('.forum-post__heading', '.username', cb);
		settings.fn.update('encodelinks');
	}

	function settingsModule() {
		var option, optionValue;
		var settingsDefaults = {
			quotereplies: false,
			encodecount: true,
			borders: true,
			miscan: true,
			encodelinks: false,
			avatarat: true,
			borderStyle: { wid: 1, colour: '#ffffff' },
		};

		if (!envs.page) {
			$('.switch').each(function () {
				option = $(this).data('option');
				var optionValue = GM_getValue(option);
				if (typeof optionValue !== 'boolean') {
					optionValue = settingsDefaults[option];
					GM_setValue(option, optionValue);
				}
				if (optionValue === true) $(this).prop('checked', true);
				if (optionValue === true && option === 'borders') $('.appearance-setting').fadeIn('fast');
			});
			$('.switch').on('change', function () {
				option = $(this).data('option');
				optionValue = GM_getValue(option);
				if ($(this).is(':checked')) {
					GM_setValue(option, true);
					updateSetting(option);
					if (option === 'borders') $('.appearance-setting').fadeIn('fast');
				} else {
					GM_setValue(option, false);
					updateSetting(option);
					if (option === 'borders') $('.appearance-setting').fadeOut('fast');
				}
			});

			$('.borderStyle').on('change', function () {
				var wid = $('#appearance-extension-border-width').val();
				var colour = $('#appearance-extension-border-colour').val();
				GM_setValue('borderStyle', { wid, colour });
			});

			var bs = GM_getValue('borderStyle');
			if (!GM_getValue('borderStyle')) {
				bs = {};
				bs.wid = settingsDefaults.borderStyle.wid;
				bs.colour = settingsDefaults.borderStyle.colour;
				GM_setValue('borderStyle', bs);
			}
			$('#appearance-extension-border-colour').val(bs.colour);
			$('#appearance-extension-border-width').val(bs.wid);
		}

		function updateSetting(option) {
			switch (option) {
				case 'quotereplies':
					if (GM_getValue(option) === true) {
					} else {
					}
					break;
				case 'encodecount':
					if (GM_getValue(option) === true) {
						$('.newtooltip_text').addClass('show');
					} else {
						$('.newtooltip_text').removeClass('show');
					}
					break;
				case 'borders':
					var borders = GM_getValue(option);
					addBorders(borders);
					break;
				case 'bordersStyle':
					break;
				case 'avatarat':
					if (GM_getValue(option) === true) $('.forum-post__avatar img').attr('title', 'Reply to User');
					else $('.forum-post__avatar img').attr('title', null);
					break;
				case 'encodelinks':
					var cb;
					var activate = function () {
						cb = function (child) {
							let userid = child.dataset.userid;
							let newlink = '/torrents.php?type=uploaded&filelist=handjob&userid=' + userid;
							child.setAttribute('href', newlink);
							return true;
						};
						domIterate('.forum-post__heading', '.username', cb);
					};

					var deactivate = function () {
						cb = function (child) {
							let userid = child.dataset.userid;
							let newlink = '/user.php?id=' + userid;
							child.setAttribute('href', newlink);
							return true;
						};
						domIterate('.forum-post__heading', '.username', cb);
					};
					if (GM_getValue(option) === true) activate();
					else deactivate();
					break;
				case 'miscan':
					if (GM_getValue(option) === true) displayMiScan();
					else resetMiScan();
					break;
			}
		}

		return { update: updateSetting };
	}

	function showEncodeCount() {
		$('.forum-post__heading a.username').append(
			'<div class="newtooltip_text"><div class="loading"><div class="loading-bar"></div><div class="loading-bar"></div><div class="loading-bar"></div><div class="loading-bar"></div></div></div>'
		);

		settings.fn.update('encodecount');
		var timer;
		var delay = 200;

		$('.forum-post__heading a.username').hover(function () {
			if (GM_getValue('encodecount') === true) {
				let el = $(this);
				let userid = $(this).attr('data-userid');
				let href = '/torrents.php?type=uploaded&filelist=handjob&userid=' + userid;
				if (!$(this).hasClass('newtooltip')) {
					$(this).addClass('newtooltip');
					var options = { method: 'GET', url: href };
					ADMIN_ajaxRequest(options)
						.then((raw) => {
							var paranoid;
							if ($('h2.page__title', raw).text() === 'Error 403') paranoid = 'I am paranoid!';
							var username = $(el).text().trim();
							var count = $('span.search-form__footer__results', raw)
								.text()
								.replace('Results', '')
								.trim();
							if (!count) {
								count = 0;
							}
							$('a.username:contains(' + username + ')').each(function () {
								var tiptext;
								if (paranoid) tiptext = paranoid;
								else tiptext = count + ' HANDJOB encodes';
								if (count == '1') tiptext = tiptext.slice(0, -1);
								$(this)
									.find('.newtooltip_text .loading')
									.fadeOut('fast', 'linear', function () {
										$(this).closest('.newtooltip_text').html(tiptext);
									});
							});
						})
						.catch(function (err) {
							console.log(err);
						});
				}
			}
		});
	}

	function activateAltLinks() {
		settings.fn.update('avatarat');

		$('.forum-post__avatar img').on('click', function () {
			if (GM_getValue('avatarat') === true) {
				let link = $(this).closest('.forum_post').find('.forum-post__heading a.username').attr('data-username');
				insertQuickpostText('@' + link + ' – ');
			}
		});
	}

	function sourceInfoScan() {
		var sources = ['DVD5', 'DVD9', 'BD25', 'BD50', 'Remux'];
		var count = 1;

		for (let i = 0; i < sources.length; i++) {
			$('a[href*="torrents.php"]:contains(' + sources[i] + ')').each(function () {
				if ($(this).closest('blockquote').length === 0) {
					var ptpLink = $(this).attr('href');
					ptpLink = '/' + ptpLink.replace(/^\//, '');
					let temp = $(this).closest('.forum-post__body');
					if (temp.attr('data-sourcelink'))
						temp.attr('data-sourcelink', temp.attr('data-sourcelink') + '~~' + ptpLink);
					else {
						$(this)
							.closest('.forum-post__body')
							.append(
								"<div title='Show info on scanned sources' class='source-info' id='source-info-" +
									count +
									"'><img src='" +
									imgs.sourceInfo +
									"' style='width:100%;height:100%;'></div><div class='source-info-box' id='source-info-box-" +
									count +
									"'></div>"
							);
						$('#source-info-' + count).attr('data-sourceinfo', ptpLink);
						$(this)
							.closest('.forum-post__body')
							.attr('data-sourcelink', ptpLink)
							.css({ position: 'relative' });
						addIconSpacer($(this).closest('.forum-post__body').find('.forum-post__bodyguard').get(0));
					}
					count++;
				}
			});
		}
		activateSourceInfo();
	}

	function addIconSpacer(el) {
		if (el.getElementsByClassName('icon-spacer').length === 0) $(el).prepend('<div class="icon-spacer"> </div>');
		else $(el).find('.icon-spacer').css('width', '42px');
	}

	function activateSourceInfo() {
		$('.source-info').on('click', function () {
			if (!$(this).next('.source-info-box').hasClass('loaded')) {
				$('.lightbox').addClass('handjobSourceImage');
				$(this).addClass('spinning-info');
				var thisId = $(this).attr('id');
				thisId = '#source-info-box-' + thisId.replace(/\D/g, '');
				let ptpLinks = $(this).closest('.forum-post__body').attr('data-sourcelink');
				ptpLinks = ptpLinks.split('~~');
				var unique_links = [];
				$.each(ptpLinks, function (i, el) {
					if ($.inArray(el, unique_links) === -1) unique_links.push(el);
				});
				gatherSourceInfo(thisId, unique_links);
			} else {
				$(this).next('.source-info-box').slideToggle();
				$('.lightbox').toggleClass('handjobSourceImage');
			}
		});
		$(document).click(function (event) {
			if (
				!$(event.target).closest('.source-info-box').length &&
				!$(event.target).closest('.source-info').length &&
				!$(event.target).closest('#lightbox__shroud').length &&
				!$(event.target).closest('img').length
			) {
				if ($('.source-info-box').is(':visible')) {
					$('.source-info-box').slideUp();
					$('.lightbox').removeClass('handjobSourceImage');
				}
			}
			if (
				!$(event.target).closest('#handjob-settings-window').length &&
				!$(event.target).closest('#handjob-bbcode-settings').length
			) {
				if ($('#handjob-settings-window').is(':visible')) {
					$('#handjob-settings-window').fadeOut('fast');
				}
			}
		});
	}

	function gatherSourceInfo(el, links) {
		var promises = [];
		var newinput = [];
		for (var i = 0; i < links.length; i++) {
			let options = { method: 'GET', url: links[i] };
			var reqPromise = new Promise(function (resolve, reject) {
				ADMIN_ajaxRequest(options)
					.then((raw) => {
						var res = {},
							imdbId;
						try {
							imdbId = $('#imdb-title-link', raw).attr('href').split('/title/')[1].replace(/\//g, '');
						} catch (e) {
							imdbId = null;
						}
						try {
							res.co = $('div#movieinfo.panel .panel__body', raw)
								.find('strong:contains("Country:")')
								.parent()
								.text()
								.split(':')[1];
						} catch (e) {
							res.co = null;
						}
						try {
							res.lang = $('div#movieinfo.panel .panel__body', raw)
								.find('strong:contains("Language:")')
								.parent()
								.text()
								.split(':')[1];
						} catch (e) {
							res.lang = null;
						}
						res.fileinfo = $('#PermaLinkedTorrentToggler', raw).text();
						var torrentRow = $('#PermaLinkedTorrentToggler', raw).closest('tr').next('tr.torrent_info_row');
						res.runtime = $('div#movieinfo.panel .panel__body', raw)
							.find('strong:contains("Runtime:")')
							.parent()
							.text()
							.split(':')[1];
						try {
							$(torrentRow)
								.find('a[onclick^="BBCode.MediaInfoToggleShow"]')
								.each(function () {
									if ($(this).text().search(/.ifo/i) >= 0) return true;
									res.audio = $(this)
										.next('table')
										.find('.mediainfo__section__caption:contains("Audio")')
										.next('tbody')
										.text()
										.split(/#\d+:/g);
								});
						} catch (e) {
							res.audio = null;
						}
						res.images = [];
						$(torrentRow)
							.find('.bbcode__image')
							.each(function () {
								res.images.push($(this).attr('src'));
							});
						res.year = $('h2.page__title', raw).text().split('[')[1].split(']')[0];
						res.title = $('h2.page__title', raw).text().split('[')[0];
						if (!imdbId) {
							var addhtml = populateSourceInfoBox(res);
							newinput.push(addhtml);
							return resolve();
						}
						var imdbReq = {
							url: 'https://www.imdb.com/title/' + imdbId + '/technical?ref_=tt_dt_spec',
							method: 'GET',
						};
						ADMIN_ajaxRequest(imdbReq)
							.then((imdbRes) => {
								res.aspect = $('td.label:contains("Aspect Ratio")', imdbRes)
									.next('td')
									.text()
									.trim()
									.replace(/\)/g, ')<br>');
								var addhtml = populateSourceInfoBox(res);
								newinput.push(addhtml);
								resolve();
							})
							.catch((err) => console.log(err));
					})
					.catch((err) => console.log(err));
			});
			promises.push(reqPromise);
		}

		Promise.all(promises).then(function () {
			$(el).html(newinput.join('')).addClass('loaded').slideDown();
			$('.source-info').removeClass('spinning-info');
		});
	}

	function populateSourceInfoBox(data) {
		let skip = false;
		let html = `<div class="source-info-header">${data.title} [${data.year}] – ${data.fileinfo}</div>
                <div class="source-info-section"><div class="source-info-section-content">`;
		if (!data.co && !data.lang && !data.runtime && !data.aspect) {
			skip = true;
			html += "<div style='width:100%;text-align:center'><em>No information on this source available</em></div>";
		} else html += `<table>`;
		html += data.co ? '<tr><td>Country: </td><td>' + data.co + '</td></tr>' : '';
		html += data.lang ? '<tr><td>Language: </td><td>' + data.lang + '</td></tr>' : '';
		html += data.runtime ? '<tr><td>Runtime: </td><td>' + data.runtime + '</td></tr>' : '';
		html += data.aspect
			? "<tr style='vertical-align:top'><td>Aspect Ratio: </td><td>" + data.aspect + '</td></tr>'
			: '';
		if (!skip) html += '</table>';
		html += '</div></div>';

		if (data.images.length > 0) {
			html += `<div class="source-info-section"><div class="source-info-section-header">Source Screens</div>
                <div class="source-info-section-content" style="text-align: center;">`;
			for (let i = 0; i < data.images.length; i++) {
				if (i > 5) break;
				html += `<img class="bbcode__image handjobSourceImage" onclick="lightbox.init(this,500)" alt="${
					data.images[i]
				}" src="${data.images[i]}" title="Source Image ${
					i + 1
				}" style="max-height:50px;border:1px solid white">`;
			}
			html += '</div></div>';
		}

		if (data.audio.length > 0) {
			html += `<div class="source-info-section"><div class="source-info-section-header">Audio Tracks</div>`;
			html += `<div class="source-info-section-content">`;
			for (let i = 0; i < data.audio.length; i++) {
				html += `<div class="source-info-section-content-item">${data.audio[i]}</div>`;
			}
			html += '</div></div>';
		}
		return html;
	}

	function insertQuickpostText(insert) {
		let el;
		if ($('textarea[id^="editbox"]').length == 1) {
			el = $('textarea[id^="editbox"]').get(0).id;
		} else el = 'quickpost';
		var caretPos = document.getElementById(el).selectionStart;
		var caretEnd = document.getElementById(el).selectionEnd;
		var textAreaTxt = $('#' + el).val();
		$('#' + el).val(textAreaTxt.substring(0, caretPos) + insert + textAreaTxt.substring(caretEnd));

		$('#' + el).focus();
		document.getElementById(el).selectionStart = caretPos + insert.length;
		document.getElementById(el).selectionEnd = caretPos + insert.length;
	}

	function ADMIN_ajaxRequest(options) {
		return new Promise((resolve, reject) => {
			let local = new Date();
			console.log('ADMIN_ajaxRequest() to ' + options.url + ' @ ' + local.toUTCString());

			GM_xmlhttpRequest({
				enctype: options.enc,
				headers: options.headers,
				method: options.method,
				data: options.data,
				url: options.url,
				onload: function (res) {
					// Callback for a JSON request, used for the HANDJOB-API
					if (options.headers && options.headers.Accept === 'application/json') {
						// If something goes wrong, try once more to login
						if (res.status !== 200) {
							console.log(res);
							return reject('Failed login');
						} else {
							const json = JSON.parse(res.responseText);
							if (json.accessToken && json.loggedOut !== true) {
								_access_token = json.accessToken;
								GM_setValue('accessToken', json.accessToken);
							} else if (json.loggedOut === true) {
								GM_deleteValue('accessToken');
							}
							resolve(json);
						}
					}
					// All other types of requests used by the script
					else resolve(res.responseText);
				},
				onerror: function (e) {
					console.log(e);
					reject(e);
				},
			});
		});
	}

	function currentPage() {
		if (envs.path.search(/upload/i) >= 0) return 'upload';
		else if (envs.path.search(/torrents.php/i) >= 0) return 'torrent';
		else return '';
	}

	function injectStylesheet() {
		var inject = `<style>

    div#Bbcode_Toolbar {
        position: relative;
    }

    #admin-lightbox-title {
        position: absolute;
        width: 100%;
        height: 40px;
        display: flex;
        align-items: center;
        justify-content: center;
        top: 0;
        font-weight: bold;
        font-size: 1.1rem;
    }

     #handjob-bbcode-icon {
        background-color: #ddd;
        background-image: url(${imgs.handjobBadge});
        background-repeat: no-repeat;
        background-position: center;
        background-size: cover;
        cursor: pointer;
    }

    #handjob-bbcode-select > select {
        width: 200px;
        margin: 0 10px;
    }

    .loading {position: relative;}

    .loading-bar {
        display: inline-block;
        width: 4px;
        height: 0.8em;
        border-radius: 3px;
        animation: loading 1s ease-in-out infinite;
    }

    .loading-bar:nth-child(1) {
        background-color: #035B99;
        animation-delay: 0;
    }

    .loading-bar:nth-child(2) {
        background-color: #1091EC;
        animation-delay: 0.09s;
    }

    .loading-bar:nth-child(3) {
        background-color: #3BA3ED;
        animation-delay: .18s;
    }

    .loading-bar:nth-child(4) {
        background-color: #66B6EE;
        animation-delay: .27s;
    }

    @keyframes loading{
        0%{transform: scale(1);}
        20% {transform:scale(1, 2.2);}
        40% {transform: scale(1);}
    }

    #handjob-bbcode-select{
        background:#ddd;
        width:0px;
        height:24px;
        float:left;
        overflow-x:hidden;
        display:flex;
        align-items:center;
    }

    input.switch:empty{margin-left: -9999px;}
    input.switch:empty ~ label{
        position: relative;
        float: left;
        line-height: 19px;
        text-indent: 48px;
        margin: 9px 0;
        cursor: pointer;
        -webkit-user-select: none;
        -moz-user-select: none;
        -ms-user-select: none;
        user-select: none;
    }

    input.switch:empty ~ label:before, input.switch:empty ~ label:after{
        position: absolute;
        display: block;
        top: 0;
        bottom: 0;
        left: 0;
        content: " ";
        width: 43px;
        background-color: #c33;
        border-radius: 4px;
        box-shadow: inset 0 3px 0 rgba(0,0,0,0.3);
        -webkit-transition: all 150ms ease-in;
        transition: all 150ms ease-in;
    }

    input.switch:empty ~ label:after {
        width: 17px;
        top: 1px;
        bottom: 1px;
        margin-left: 1px;
        background-color: #fff;
        border-radius: 2px;
        box-shadow: inset 0 -2px 0 rgba(0,0,0,0.2);
    }

    input.switch:checked ~ label:before {background-color: #393;}
    input.switch:checked ~ label:after {margin-left: 25px;}

    #handjob-settings-window{
        font-size: 12px;
        font-family:Helvetica;
        font-weight:bold;
        color:#111;
        text-transform:capitalize;
        padding:10px;
        display:none;
        position: absolute;
        width: 160px;
        height: 240px;
        background: #ddd;
        border: 2px solid black;
        right: 35px;
        top: -230px;
    }

    #handjob-bbcode-settings {
        background-color:#ddd;
        background-image:url(${imgs.settingsIcon});
        background-repeat:no-repeat;
        background-position:center;
        background-size:cover;
        cursor:pointer
    }

    .mediainfo-feedback-table {
        width:100%;
    }

    span.lowercase {
        text-transform: none;
    }

    .feedback-advisories {
        background:#d9edf7;
        border:1px solid #bce8f1;
        color:#31708f;
    }

    .feedback-warnings {
        background:#fcf8e3;
        border:1px solid #faebcc;
        color:#8a6d3b;
    }

    .feedback-errors {
        background:#f2dede;
        border:1px solid #ebccd1;
        color:#a94442;
    }

    .mediainfo-feedback-issue, .inline-mi-scan-issue {
        color: ${css.text};
        margin-left: 20px;
        padding: 10px 10px 10px 20px;
    }

    .mediainfo-feedback-issue {
        position: relative;
    }

    .mediainfo-feedback-issue::after {
        position: absolute;
        right: 10px;
        top: 50%;
        opacity: 0;
        transform: translateY(-50%);
        transition: opacity 150ms ease-in-out;
    }

    .mediainfo-feedback-issue:hover::after {
        opacity: 0.5;
    }

    .mediainfo-feedback-error:after {
        color: ${css.error};
        content: "Error";
    }

     .mediainfo-feedback-warning:after {
        color: ${css.warning};
        content: "Warning";
    }

     .mediainfo-feedback-advisory:after {
        color: ${css.advisory};
        content: "Advisory";
    }

    .mediainfo-feedback-advisory, .inline-advisory {
       border-left: 8px solid ${css.advisory};
    }

    .mediainfo-feedback-warning, .inline-warning {
       border-left: 8px solid ${css.warning};
    }

    .mediainfo-feedback-error, .inline-error {
       border-left: 8px solid ${css.error};
    }

    .mediainfo-feedback-header, .inline-mi-header, .source-info-header {
        color: ${css.text};
        text-align:center;
        padding:7px 0px;
        font-weight:bold;
        background: ${css.header};
        text-transform: uppercase;
    }

    .source-info-box {
        top: 25px;
        z-index: 1000;
        min-width: 500px;
        background-color: ${css.background};
        border: 4px solid ${css.header};
        color: #fff;
        display: none;
        right: -1px;
        position: absolute;
        box-shadow: 0 0 10px 0 #333;
    }

    .source-info-section-content {
        padding: 10px;
        color: ${css.text};
    }

    .source-info-section-content td {
        padding: 2px;
    }

    .source-info-section-content td:first-child {
        padding-right: 10px;
        min-width: 100px;
    }

    .source-info-section-header {
        text-align: left;
        color: ${css.text};
        background: ${css.header};
        padding: 1px 20px;
    }

    .inline-mi-header span, .source-info-header {
        text-transform: none !important;
    }

    .inline-mi-newfile {
        background: ${css.header};
        padding: 7px 0px;
        display: flex;
        align-items: center;
        color: ${css.text};
        margin-left: -4px;
        width: calc(100% + 8px);
        border-style: dotted;
        border-color: ${css.text};
        border-width: 0 0 1px 0;
    }

    .inline-mi-newfile img {
        height: 1rem;
        width: auto;
        margin: 0 5px;
    }

    .mediainfo-feedback {
        background: ${css.background};
        margin-top: 5px;
        padding: 0;
        font-weight: bold;
        font-family: "helvetica neue", helvetica, tahoma, sans-serif;
        transition: max-height 0.5s cubic-bezier(0, 1, 0, 1);
    }

    .upload-mediainfo {
        display: none;
    }

    .mediainfo-feedback.log-visible {
        visibility: visible;
    }

    .lightbox.handjobSourceImage img {
        border:1px solid white;
    }

    .tooltip-loader {
        margin:0 auto;
        border: 6px solid #f3f3f3;
        border-top: 6px solid #3498db;
        border-radius: 50%;
        width: 50px;
        height: 50px;
        animation: spin 2s linear infinite;
    }

    .spinning-info img {
        animation: spin 2s linear infinite;
    }

    @keyframes spin {
        0%{transform:rotate(0deg);}
        100%{transform:rotate(360deg);}
    }

    .newtooltip .newtooltip_text {
        min-height:2em;
        top: -5px;
        left: calc(100% + 10px);
    }

    .newtooltip .newtooltip_text:not(.inline-mi-tooltip)::after {
        z-index:9999;
        content: " ";
        position: absolute;
        top: 50%;
        right: 100%;
        margin-top: -13px;
        border-width: 13px;
        border-style: solid;
        border-color: transparent ${css.header} transparent transparent;
    }

    .newtooltip {position:relative}
    .newtooltip_text {
        pointer-events: none;
        opacity : 0;
        transition:opacity 150ms ,min-height 150ms;
        position:absolute;
        z-index:9999;
        width: 200px;
        background-color: ${css.background};
        border:2px solid ${css.header};
        color: ${css.text};
        text-align: center;
        padding: 5px 0;
    }

    .newtooltip_text.inline-mi-tooltip {
        text-align: left;
        width: 500px;
        top: 1rem;
        left: -1px;
        background: ${css.background};
        padding: 0;
        border: 4px solid ${css.header};
        border-radius: 0;
    }

    .newtooltip_text.inline-mi-tooltip > .inline-mi-header:first-child,
    .source-info-box > .source-info-header:first-child {
        padding-top: 3px;
    }

    .newtooltip_text.inline-mi-tooltip.reverse-position {
        bottom: 1rem !important;
        top: unset !important;
    }

    .newtooltip:hover .newtooltip_text.show {
        opacity:1.0;
        min-height:2.1em;
    }

    .scan-passed {
        color: ${css.passed} !important;
        font-weight: bold;
    }

    .forum-scan-passed {
        color: ${css.passed} !important;
    }

    .scan-errors, .inline-mi-scan-errors {
        color: ${css.error} !important;
        font-weight: bold;
    }

    .scan-warnings, .inline-mi-scan-warnings {
        color: ${css.warning} !important;
        font-weight: bold;
    }

    .scan-advisories, .inline-mi-scan-advisories {
        color: ${css.advisory} !important;
        font-weight: bold;
    }

    span.replyApp {
        cursor:pointer;
        border-radius: 0px 0px 0px 10px;
        color: white;
        background: ${css.header};
        padding: 0 10px;
        position: absolute;
        right: -1px;
        top: -1px;
        transition: filter 150ms ease-in-out;
    }

    .icon-spacer {
        height: 16px;
        width: 16px;
        position: relative;
        float: right;
    }

    div.source-info {
        background: ${css.header};
        cursor: pointer;
        width:26px;
        height:26px;
        position:absolute;
        top: -1px;
        right: -1px;
        transition: filter 150ms ease-in-out;
        padding: 4px;
        z-index: 1001;
    }

    .replyApp + table.mediainfo__section caption {
        min-width: 100px;
    }

    #return-message {
        display: none;
        width: 100%;
        text-align: center;
        padding: 1rem 0;
        font-style: italic;
        color: ${css.header};
        font-weight: bold;
    }

    div.mi-reply-all {
        cursor: pointer;
        position: absolute;
        top: -1px;
        right: -1px;
        width: 26px;
        height: 26px;
        background: ${css.header};
        font-size: 1.2rem;
        color: white;
        text-align: center;
        line-height: 30px;
        transition: filter 150ms ease-in-out;
    }

    .appearance-setting img {
        transition: opacity 150ms ease-in-out;
        opacity: 1;
    }

    .appearance-setting:hover img {
        opacity: 0.5;
    }

    div.source-info:hover, div.mi-reply-all:hover, a.handjob-add-member:hover, span.replyApp:hover {filter: brightness(0.6);}

    div.mi-reply-all.ibox {right: 25px;}

    div.source-audio-box {
        display: flex;
        flex-flow: column;
        padding: 2px 0px;
        margin-top: 10px;
    }

    div.source-audio-box-title {
        font-weight: bold;
        padding: 2px;
    }

    div.source-audio-box-item {
        font-style: italic;
    }

    span.superscript {
        font-size: 0.7em;
        vertical-align: super;
    }

    #admin-lightbox {
            display: none;
            position: fixed;
            width: 100%;
            height: 100%;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            z-index: 9999;
            font-size: 11px;
        }

        #admin-lightbox-container {
            display: flex;
            align-items: center;
            justify-content: center;
            width: 100%;
            height: 100%;
            background: rgba(0,0,0,0.8);
        }

        #admin-lightbox-content {
            position: relative;
            width: 100%;
            height:100%;
            overflow-y: auto;
            background-color: ${css.pageBackground};
            padding-top: 35px;
        }

        #admin-lightbox-exit, #admin-alert-exit {
            position: absolute;
            top: 10px;
            right: 10px;
            width: 30px;
            height: 30px;
            background: #555;
            text-align: center;
            font-size: 1.5rem;
            font-weight: bold;
            color: #fff;
            cursor: pointer;
            transition: color 150ms ease-in;
            z-index: 1;
        }

        #admin-alert-exit {
            font-size: 1rem;
            height: 20px;
            width: 20px;
            top: 5px;
            right: 5px;
            background: #000;
        }

        #admin-alert-exit:hover, #admin-lightbox-exit:hover {
            color: #ccc;
        }

        #admin-lightbox-alert {
            display: none;
            position: absolute;
            background-color: ${css.pageBackground2};
            width: 85%;
            padding: 30px 20px 10px 20px;
            border: 2px solid #888;
            max-height: 100%;
            overflow-y: auto;
        }

        .admin-lightbox-table {
            width: 100%;
        }

        .admin-lightbox-table thead {
            background-color: ${css.header};
            text-transform: uppercase;
            font-weight: bold;
            color: ${css.text};
        }

        .admin-lightbox-table thead td, .admin-lightbox-table-item td {
            padding: 5px 10px;
            vertical-align: top;
        }

        .admin-lightbox-table-item:nth-child(even) {
            background-color: ${css.background};
        }

        .admin-lightbox-table-tab {
            width: 100%;
        }

        #admin-lightbox-table-tab2 {
            display: none;
        }

        .admin-lightbox-table-header {
            width: 100%;
            display: flex;
            flex-flow: row;
            justify-content: center;
        }

        .admin-lightbox-table-header div {
            padding: 5px 20px;
            background: ${css.background};
            font-weight: bold;
            color: ${css.offset};
            cursor: pointer;
        }

        .admin-lightbox-table-header div.active-tab {
            background: ${css.header};
        }

        .admin-lightbox-table-header div:nth-child(1) {
            border-radius: 10px 0 0 0;
        }

        .admin-lightbox-table-header div:nth-child(2) {
            border-radius: 0 10px 0 0;
        }

        .auth-loader {
            position: relative;
        }

        .auth-loader:before {
            position: absolute;
            content: "";
            top: -25px;
            left: 10px;
            margin:0 auto;
            border: 5px solid ${css.offset};
            border-top: 5px solid #3498db;
            border-radius: 50%;
            width: 25px;
            height: 25px;
            animation: spin 2s linear infinite;
        }

        .admin-lightbox-table-search-header {
            position: absolute;
            top: 0;
            left: 50%;
            transform: translateX(-50%);
            font-weight: bold;
            font-size: 0.8rem;
            margin-top: 5px;
            white-space: nowrap;
        }

        span.filename-typo {
          position: relative;
        }

        .settings-option {
            height: 37px;
            position: relative;
        }

        span.filename-typo::before {
                z-index:9999;
                content: " ";
                position: absolute;
                top: -0.3rem;
                right: -0.1rem;
                border-width: 5px;
                border-style: solid;
                border-color: ${css.error} transparent transparent transparent;
        }

        .appearance-setting {
            width: 20px;
            height: 14px;
            position: absolute;
            right: -5px;
            top: 50%;
            transform: translateY(-50%);
            display: none;
            cursor: pointer;
        }

        .appearance-setting img {
            max-width: 100%;
            height: auto;
        }

        .appearance-extension-inner {
            display: flex;
            justify-content: space-between;
            font-size: 0.8em;
            width: 100%;
            height: 100%;
        }

        .appearance-extension-outer {
            position: absolute;
            width: calc(100% + 20px);
            padding: 4px;
            z-index: 999999;
            background: linear-gradient(to bottom, #ddd, #aaa);
            top: 100%;
            display: none;
            margin-left: -10px;
            padding: 4px 10px;
        }

        div.filename-error {
            width: 100%;
            text-align: center;
            margin-top: 0.5rem;
            font-family: monospace, monospace;
            line-height: 1.4rem;
        }

    </style>`;
		$('head').append(inject);
	}

	Date.prototype.getMonthName = function () {
		var months = [
			'January',
			'February',
			'March',
			'April',
			'May',
			'June',
			'July',
			'August',
			'September',
			'October',
			'November',
			'December',
		];
		return months[this.getMonth()];
	};

	function decapFirst(string) {
		return string.charAt(0).toLowerCase() + string.slice(1);
	}

	function capFirst(string) {
		return string.charAt(0).toUpperCase() + string.slice(1);
	}

	function domIterate(parents, child, cb) {
		var childNode;
		if (parents.charAt(0) === '.') {
			parents = document.getElementsByClassName(parents.replace('.', ''));
		} else if (parents.charAt(0) === '#') {
			parents = document.getElementById(parents.replace('#', ''));
		}
		for (let i = 0; i < parents.length; i++) {
			if (child.charAt(0) === '.') {
				childNode = parents[i].getElementsByClassName(child.replace('.', ''));
			} else if (child.charAt(0) === '#') {
				childNode = parents[i].getElementById(child.replace('#', ''));
			}
			if (childNode.length === 0) continue;
			cb(childNode[0]);
		}
	}

	function Create(element) {
		// Creates a DOM element from string (accepts "type#id.class{data}active:true-id:1")
		let type = element.match(/^\w+[^#.{]?/)[0];

		let id = element.match(/#([^.{]*)/) || null;
		if (id && id.length > 0) id = id[1];

		let classes = element.match(/\.([^{#]*)/) || [];
		if (classes && classes.length > 0) classes = classes[1].split('.');

		let data = element.match(/{[0-9a-zA-Z_-]*}[^.{#]*/gi) || [];
		data = data.map((x) => {
			let sp = x.replace('{', '').split('}');
			return { att: sp[0], val: sp[1] };
		});

		let el = document.createElement(type);
		if (id) el.id = id;
		for (let i = 0; i < classes.length; i++) {
			el.classList.add(classes[i]);
		}
		for (let i = 0; i < data.length; i++) {
			data[i].val = data[i].val.replace('null', '#');
			el.setAttribute(data[i].att, data[i].val);
		}

		return el;
	}

	function parseHTMLtoDOM(raw) {
		const parser = new DOMParser();
		return parser.parseFromString(raw, 'text/html');
	}

	/**
	 * Abbreviate supplied text string to chosen length
	 * @param {string} str - The string to truncate
	 * @param {number} maxLength - The maximum length allowed before truncating
	 * @param {boolean} split - Place ellipses in the middle of the string
	 * @returns {string}
	 */
	function truncate({ str = '', maxLength = 30, split = false } = {}) {
		if (typeof str !== 'string' || typeof maxLength !== 'number' || typeof split !== 'boolean') {
			return '';
		}
		// Replace incoming special characters for length check
		let y = str.replace(/&[^\s;]*;/g, 'a');
		// If string is shorter than maxLength, return unchanged
		if (y.length <= maxLength) return str;
		// Return string with an ellipsis break
		else if (split === true)
			return (
				str
					.substr(0, maxLength / 2)
					.trim()
					.replace(/\.$/, '') +
				' &hellip; ' +
				str
					.substr(str.length - maxLength / 2)
					.trim()
					.replace(/^\./, '')
			);
		// Return string with an ellipsis at the end
		else return str.substr(0, maxLength - 1).trim() + '&hellip;';
	}

	function debounce(func, wait, immediate) {
		let timeout;
		return function () {
			const context = this,
				args = arguments;
			const later = function () {
				timeout = null;
				if (!immediate) func.apply(context, args);
			};
			const callNow = immediate && !timeout;
			clearTimeout(timeout);
			timeout = setTimeout(later, wait);
			if (callNow) func.apply(context, args);
		};
	}
})();