NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name PTP Radarr Integration
// @namespace https://openuserjs.org/users/SB100
// @description The PTP <-> Radarr Integration we always wanted
// @updateURL https://openuserjs.org/meta/SB100/PTP_Radarr_Integration.meta.js
// @version 1.6.3
// @author SB100
// @copyright 2022, SB100 (https://openuserjs.org/users/SB100)
// @license MIT
// @include https://passthepopcorn.me/*
// @grant GM.xmlHttpRequest
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.registerMenuCommand
// ==/UserScript==
// ==OpenUserJS==
// @author SB100
// ==/OpenUserJS==
/* jshint esversion: 8 */
/**
* =============================
* ADVANCED OPTIONS
* =============================
*/
// show debug logs in the browser console
const SETTING_DEBUG = false;
// how long to cache radarr existing movies for
// default = 1000 * 60 * 10 = 10 minutes
const SETTING_CACHE_TIME = 1000 * 60 * 10;
/**
* =============================
* END ADVANCED OPTIONS
* DO NOT MODIFY BELOW THIS LINE
* =============================
*/
// ================================= Config
/**
* Get a config value from the GM cache
*/
async function getConfig(key, fallback = '') {
return GM.getValue(key, fallback);
}
/**
* Set a config value into the GM cache
*/
async function setConfig(key, value) {
await GM.setValue(key, value);
}
/**
* Get all settings stored in localStorage for this script
*/
function getSettings() {
const settings = window.localStorage.getItem('radarrIntegrationSettings');
// eslint-disable-next-line no-use-before-define
return JsonParseWithDefault(settings || {}, {});
}
/**
* Set a setting into localStorage for this script
*/
function setSetting(name, value) {
const json = getSettings();
json[name] = value;
window.localStorage.setItem(
'radarrIntegrationSettings',
JSON.stringify(json)
);
}
/**
* Set a radarr bar setting into the settings localStorage object
* @param type
* @param value
*/
function setRadarrBarSetting(type, value) {
const existingSettings = getSettings().radarrBar || {};
const newSettings = {
...existingSettings,
[type]: value
};
setSetting('radarrBar', newSettings);
}
// ================================= Helpers
/**
* Try parsing a string into JSON, otherwise fallback
*/
function JsonParseWithDefault(s, fallback = null) {
try {
return JSON.parse(s);
}
catch (e) {
return fallback;
}
}
/**
* Print a debug message, if enabled
*/
function debug(strOrStrArray) {
if (!SETTING_DEBUG) return;
// eslint-disable-next-line no-console
console.log(
`[PTP Radarr Integration] ${
Array.isArray(strOrStrArray) ? strOrStrArray.join(' - ') : strOrStrArray
}`
);
}
/**
* Wait some time
*/
// eslint-disable-next-line no-promise-executor-return
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Chunk an array into pieces
*/
function chunk(arr, chunkSize) {
if (chunkSize <= 0) {
throw new Error('Invalid chunk size');
}
const r = [];
for (let i = 0, len = arr.length; i < len; i += chunkSize) {
r.push(arr.slice(i, i + chunkSize));
}
return r;
}
/**
* Check if we're using the old setting format for storing imdbIds
* @param imdbIds
* @returns {arg is any[]}
*/
function isImdbIdsSettingOutdated(imdbIds) {
return Array.isArray(imdbIds);
}
// ================================= Query
/**
* Query the radarr api
*/
async function query(path, method = 'get', params = {}) {
let resolver;
let rejecter;
const p = new Promise((resolveFn, rejectFn) => {
resolver = resolveFn;
rejecter = rejectFn;
});
const radarrUrl = (await getConfig('host')).replace(/\/$/, '');
const apiKey = await getConfig('key');
const url = new URL(`${radarrUrl}/api/v3${path}`);
const obj = {
method,
timeout: 60000,
onloadstart: () => {},
onload: (response) => resolver(response),
onerror: (response) => rejecter(response),
ontimeout: (response) => rejecter(response),
};
if (method === 'post') {
const headers = {
'Content-Type': 'application/json',
};
url.search = new URLSearchParams({
apikey: apiKey
}).toString();
const final = Object.assign(obj, {
url: url.toString(),
headers,
data: JSON.stringify(params),
});
GM.xmlHttpRequest(final);
}
else {
const newParams = {
...params,
apikey: apiKey
};
url.search = new URLSearchParams(newParams).toString();
const final = Object.assign(obj, {
url: url.toString(),
});
GM.xmlHttpRequest(final);
}
return p;
}
/**
* Get request to Radarr API. Parse results
*/
function radarrGet(path, params = {}) {
return query(path, 'get', params).then((response) =>
JSON.parse(response.responseText)
);
}
/**
* Post request to Radarr API. Parse results
*/
function radarrPost(path, params = {}) {
return query(path, 'post', params).then((response) =>
JSON.parse(response.responseText)
);
}
/**
* Get the data needed to populate the radarr bar
*/
async function getDataForRadarrBar() {
try {
const [qualityProfiles, rootFolders, tags] = await Promise.all([
radarrGet('/qualityprofile'),
radarrGet('/rootfolder'),
radarrGet('/tag'),
]);
return {
qualityProfiles,
rootFolders,
tags,
};
}
catch (e) {
debug([`Couldn't connect to Radarr`, e.message]);
return {
error: true,
};
}
}
/**
* Get and cache existing imdbIds from radarr
*/
async function getExistingRadarrImdbIds() {
try {
const {
imdbIds,
lastUpdated
} = getSettings();
if (
!lastUpdated ||
new Date().getTime() > lastUpdated + SETTING_CACHE_TIME ||
isImdbIdsSettingOutdated(imdbIds)
) {
const hasSuccessfulConnection = await getConfig(
'hasSuccessfulConnection',
false
);
if (!hasSuccessfulConnection) {
return [];
}
const allMovies = await radarrGet('/movie');
const radarrImdbIds = allMovies.reduce((result, movie) => {
// eslint-disable-next-line no-param-reassign
result[movie.imdbId] = movie.titleSlug;
return result;
}, {});
setSetting('lastUpdated', new Date().getTime());
setSetting('imdbIds', radarrImdbIds);
return radarrImdbIds;
}
return imdbIds;
}
catch (e) {
await setConfig('hasSuccessfulConnection', false);
const radarrBar = document.querySelector('.ptp-radarr__bar');
radarrBar.dataset.isLoaded = '0';
debug([`Couldn't get existing IMDb IDs from Radarr`, e.message]);
return [];
}
}
// ================================= UI Helpers
/**
* Tries to get the theme the user is using
*/
function getTheme() {
const linkTags = Array.from(
document.querySelectorAll('link[rel="stylesheet"]')
);
for (let i = 0, len = linkTags.length; i < len; i += 1) {
const tag = linkTags[i];
if (tag.href.includes('blue-night')) {
return 'blue-night';
}
if (tag.href.includes('marcel')) {
return 'marcel';
}
}
return 'default';
}
/**
* Get necessary selectors depending on the view mode
* @returns {{item: string, img: string, imdb: string, shouldObserveForMutations: boolean}|{}}
* - item = single movie containing img and imdb
* - img = img / a element to attach checkbox to
* - imdb = element to get the IMDb ID from
* - shouldObserveForMutations = whether MutationObserver should watch for changes to reload checkboxes
*/
function getImgAndImdbSelectors() {
const {
href
} = window.location;
/* eslint-disable no-nested-ternary */
const viewMode = href.includes('torrents.php?id=') ?
'Custom-Movie' :
href.includes('torrents.php') ?
localStorage.getItem('BrowseViewMode') :
href.includes('collages.php') ?
localStorage.getItem('CollectionViewMode') || 'Cover' :
href.includes('top10.php') ?
localStorage.getItem('Top10MoviesViewMode') || 'Cover' :
href.includes('requests.php') ?
'Custom-Requests' :
null;
/* eslint-enable no-nested-ternary */
switch (viewMode) {
case 'Cover':
return {
item: '.cover-movie-list__movie',
img: '.cover-movie-list__movie__cover-link',
imdb: '.cover-movie-list__movie__rating-and-tags a',
shouldObserveForMutations: true,
};
case 'Huge':
return {
item: '.huge-movie-list__movie',
img: '.huge-movie-list__movie__cover__link',
imdb: '.huge-movie-list__movie__ratings__icon-column a',
shouldObserveForMutations: true,
};
// don't support CompactList
case 'CompactList':
case 'SmallCover': // only on collage
return {};
// single movie page: torrents.php?id=xxx
case 'Custom-Movie':
return {
item: '#torrents #content',
img: '.sidebar-cover-image',
imdb: '#movie-ratings-table td a',
};
// single request page: requests.php?action=view&id=xxx
case 'Custom-Requests':
return {
item: '#requests #content',
img: '.sidebar-cover-image',
imdb: '#request-table td a',
};
case 'List':
default:
return {
item: '.basic-movie-list__details-row',
img: '.basic-movie-list__movie__cover-link',
imdb: '.basic-movie-list__movie__rating__title a',
shouldObserveForMutations: true,
};
}
}
/**
* Show processing / selected text in the radarr bar
* @param {'selected'|'processed'|'added'} type
*/
function toggleRadarrBarStatusText(type) {
const selectedCont = document.querySelector(
'.ptp-radarr__bar-label-text--selected'
);
const processedCont = document.querySelector(
'.ptp-radarr__bar-label-text--processed'
);
const addedCont = document.querySelector(
'.ptp-radarr__bar-label-text--added'
);
if (type === 'selected') {
selectedCont.style.display = 'block';
processedCont.style.display = 'none';
addedCont.style.display = 'none';
}
else if (type === 'processed') {
selectedCont.style.display = 'none';
processedCont.style.display = 'block';
addedCont.style.display = 'none';
}
else {
selectedCont.style.display = 'none';
processedCont.style.display = 'none';
addedCont.style.display = 'block';
}
}
// ================================= Event Handlers
/**
* Stop navigation away from the page if a checkbox is selected
*/
const beforeUnloadListener = (event) => {
event.preventDefault();
// eslint-disable-next-line no-param-reassign
event.returnValue = 'Are you sure you want to exit?';
return event;
};
/**
* Force all checkboxes to show if one is checked
* Force Radarr Bar to show if one is selected
*/
function handleCheckboxChange() {
const radarrBar = document.querySelector('.ptp-radarr__bar');
const allCheckboxes = Array.from(
document.querySelectorAll('.ptp-radarr__checkbox')
);
const allRibbons = Array.from(
document.querySelectorAll('.ptp-radarr__ribbon')
);
if (allCheckboxes.some((checkbox) => checkbox.checked)) {
allCheckboxes.forEach((checkbox) => {
checkbox.classList.add('ptp-radarr__checkbox--forced');
});
allRibbons.forEach((ribbon) => {
ribbon.classList.add('ptp-radarr__ribbon--forced');
});
if (!radarrBar.classList.contains('ptp-radarr__bar--showing')) {
radarrBar.classList.add('ptp-radarr__bar--showing');
window.addEventListener('beforeunload', beforeUnloadListener, {
capture: true,
});
}
// if it hasn't been already, add dropdowns to the radarr bar
// eslint-disable-next-line no-use-before-define
populateRadarrBar(radarrBar);
// update movie selected counter
const numMoviesElems = Array.from(
document.querySelectorAll('.ptp-radarr__bar-num-movies--selected')
);
if (numMoviesElems.length) {
numMoviesElems.forEach((numMoviesElem) => {
// eslint-disable-next-line no-param-reassign
numMoviesElem.innerText = Array.from(
document.querySelectorAll('.ptp-radarr__checkbox:checked')
).length;
});
}
}
else {
allCheckboxes.forEach((checkbox) => {
checkbox.classList.remove('ptp-radarr__checkbox--forced');
});
allRibbons.forEach((ribbon) => {
ribbon.classList.remove('ptp-radarr__ribbon--forced');
});
if (radarrBar.classList.contains('ptp-radarr__bar--showing')) {
radarrBar.classList.remove('ptp-radarr__bar--showing');
window.removeEventListener('beforeunload', beforeUnloadListener, {
capture: true,
});
}
}
}
/**
* When the select all / none button is clicked, do the action, and trigger handler event
*/
function handleRadarrBarClick(event) {
const selectAllElem = document.querySelector('#ptp-radarr__bar-select-all');
const selectNoneElem = document.querySelector('#ptp-radarr__bar-select-none');
const multiSelectOptions = document.querySelector('.multi-select__options');
// select all / none
if ([selectAllElem, selectNoneElem].includes(event.target)) {
const shouldSelectAll = event.target === selectAllElem;
Array.from(document.querySelectorAll('.ptp-radarr__checkbox')).forEach(
(checkbox) => {
// eslint-disable-next-line no-param-reassign
checkbox.checked = shouldSelectAll;
}
);
handleCheckboxChange();
}
// if options is opened, and we click outside the multi-select, close options
if (
multiSelectOptions &&
multiSelectOptions.classList.contains('multi-select__options--opened')
) {
const targetSelector = '.multi-select';
let {
target
} = event;
while (target && target.matches('.ptp-radarr__bar') === false) {
if (target.matches(targetSelector)) {
return;
}
target = target.parentNode;
}
multiSelectOptions.classList.remove('multi-select__options--opened');
}
}
/**
* When form is submitted, add the selected movies to Radarr
*/
async function handleRadarrBarSubmit(event) {
event.preventDefault();
const addMoviesButton = document.getElementById('ptp-radarr__bar-submit');
// if errored, and form is submitted, reset the form
if (event.target.dataset.reset === '1') {
// eslint-disable-next-line no-param-reassign
event.target.dataset.reset = '0';
addMoviesButton.value = 'Add Movie(s)';
toggleRadarrBarStatusText('selected');
return;
}
// get elements to update
const selectedElem = document.querySelector(
'.ptp-radarr__bar-num-movies--selected'
);
const processedElem = document.querySelector(
'.ptp-radarr__bar-num-movies--processed'
);
const addedElem = document.querySelector(
'.ptp-radarr__bar-num-movies--added'
);
const erroredElem = document.querySelector(
'.ptp-radarr__bar-num-movies--errored'
);
const existingElem = document.querySelector(
'.ptp-radarr__bar-num-movies--existing'
);
// disable the button
addMoviesButton.value = 'Processing …';
addMoviesButton.disabled = true;
// eslint-disable-next-line no-param-reassign
event.target.dataset.reset = '1';
// show processing text
toggleRadarrBarStatusText('processed');
// get the form data
const radarrBarFormData = Object.fromEntries(
Array.from(new FormData(event.target))
);
const tagIds = Array.from(
document.querySelectorAll('.multi-select__option-checkbox:checked')
).map((c) => parseInt(c.value, 10));
try {
// get existing movies
const existingImdbIdObj = await getExistingRadarrImdbIds();
const existingImdbIds = Object.keys(existingImdbIdObj);
// get selected imdbs
const imdbIds = Array.from(
document.querySelectorAll('.ptp-radarr__checkbox:checked')
).map((checkbox) => checkbox.value);
// calculate missing imdbs
const missingImdbIds = imdbIds.filter(
(id) => !existingImdbIds.includes(id)
);
// and chunk them
const imdbChunks = chunk(missingImdbIds, 5);
// process in chunks - get all the movie info, and push to an array
const toImport = [];
for (let j = 0, jLen = imdbChunks.length; j < jLen; j += 1) {
const promises = imdbChunks[j].map((imdbId) =>
radarrGet('/movie/lookup/imdb', {
imdbId
})
);
const movieInfos = await Promise.all(promises);
const addBodies = movieInfos.map((movieInfo) => ({
...movieInfo,
qualityProfileId: parseInt(radarrBarFormData.qualityProfileId, 10),
monitored: radarrBarFormData.monitor === true ||
radarrBarFormData.monitor === 'true',
minimumAvailability: radarrBarFormData.minimumAvailability,
rootFolderPath: radarrBarFormData.rootFolderPath,
tags: tagIds,
addOptions: {
searchForMovie: radarrBarFormData.searchForMovie !== undefined ||
radarrBarFormData.searchForMovie === 'on',
},
}));
toImport.push(...addBodies);
processedElem.innerHTML = toImport.length;
await delay(5);
}
// add to radarr
if (toImport.length) {
addMoviesButton.value = 'Adding …';
// chunk imports
const toImportChunks = chunk(toImport, 10);
const promises = toImportChunks.map((ic) =>
radarrPost('/movie/import', ic)
);
// await all promises
const importResults = await Promise.all(promises);
// turn into a flat array
const importResult = importResults.flat(5);
// check results
const {
added,
errored,
missingObj
} = importResult.reduce(
(result, curr) => {
if (curr.errorCode) {
// eslint-disable-next-line no-param-reassign
result.errored += 1;
debug([`Error importing movie`, curr.errorMessage]);
}
else {
// eslint-disable-next-line no-param-reassign
result.added += 1;
// eslint-disable-next-line no-param-reassign
result.missingObj[curr.imdbId] = curr.titleSlug;
}
return result;
}, {
added: 0,
errored: 0,
missingObj: {},
}
);
// update stats to show user
addedElem.innerHTML = `${added}`;
erroredElem.innerHTML = `${errored}`;
existingElem.innerHTML = `${imdbIds.length - missingImdbIds.length}`;
// update cache, or wipe if issue
if (errored > 0) {
setSetting('lastUpdated', 0);
setSetting('imdbIds', {});
}
else {
setSetting('imdbIds', {
...existingImdbIdObj,
...missingObj
});
}
// refresh the checkboxes to show the new ribbons
// eslint-disable-next-line no-use-before-define
await addCheckboxesToMovies();
selectedElem.innerHTML = '0';
}
else {
addedElem.innerHTML = '0';
erroredElem.innerHTML = '0';
existingElem.innerHTML = `${imdbIds.length - missingImdbIds.length}`;
}
}
catch (e) {
await setConfig('hasSuccessfulConnection', false);
const radarrBar = document.querySelector('.ptp-radarr__bar');
radarrBar.dataset.isLoaded = '0';
radarrBar.innerHTML = `<div class="loading-icon__cont">Error adding to Radarr. <a href="#radarr">Recheck your connection</a> or check your browser console for more info</div>`;
debug([`Couldn't import movie(s)`, e.message]);
return;
}
// show number that was added and reset button
toggleRadarrBarStatusText('added');
addMoviesButton.disabled = false;
addMoviesButton.value = 'Reset';
}
// ================================= UI RadarrBar
/**
* Create a loading icon
*/
function createLoadingIcon() {
const loader = document.createElement('div');
loader.className = 'loading-icon';
const container = document.createElement('div');
container.className = 'loading-icon__cont';
container.appendChild(loader);
return container;
}
/**
* Add checkboxes to all the movies so we can trigger radarr later
*/
async function addCheckboxesToMovies() {
const {
item: itemSelector,
img: imgSelector,
imdb: imdbSelector,
} = getImgAndImdbSelectors();
if (!itemSelector || !imgSelector || !imdbSelector) {
return false;
}
// remove existing checkboxes and ribbons
Array.from(
document.querySelectorAll('.ptp-radarr__checkbox, .ptp-radarr__ribbon')
).forEach((elem) => elem.remove());
// get existing imdbIds
const existingImdbIdObj = await getExistingRadarrImdbIds();
const existingImdbIds = Object.keys(existingImdbIdObj);
// get setting on whether we always want to show ribbons or not
const ribbons = await getConfig('ribbons', false);
// get radarr url
const radarrUrl = (await getConfig('host')).replace(/\/$/, '');
// add checkboxes and ribbons
let somethingAdded = false;
Array.from(document.querySelectorAll(itemSelector)).forEach((movieElem) => {
const imgElem = movieElem.querySelector(imgSelector);
if (!imgElem) {
return;
}
const imdbElems = Array.from(movieElem.querySelectorAll(imdbSelector));
const imdbId = imdbElems
.find((imdbElem) => imdbElem?.href?.match(/tt\d+/)?.[0])
?.href?.match(/tt\d+/)?.[0];
const parent = imgElem.parentNode;
parent.style.position = 'relative';
// no imdb id: show a ribbon to the user so they're not confused as to why no checkbox shows up
if (!imdbId) {
const ribbon = document.createElement('div');
ribbon.className = `ptp-radarr__ribbon ${
ribbons ? 'ptp-radarr__ribbon--always' : ''
}`;
ribbon.innerHTML = `<span>No IMDb</span>`;
parent.appendChild(ribbon);
return;
}
// already added to radarr, ignore!
if (existingImdbIds.includes(imdbId)) {
const ribbon = document.createElement('div');
ribbon.className = `ptp-radarr__ribbon ptp-radarr__ribbon--existing ${
ribbons ? 'ptp-radarr__ribbon--always' : ''
}`;
if (radarrUrl) {
ribbon.innerHTML = `<a href="${radarrUrl}/movie/${existingImdbIdObj[imdbId]}" rel="noreferrer noopener" target="_blank">In Radarr</a>`;
}
else {
ribbon.innerHTML = `<span>In Radarr</span>`;
}
parent.appendChild(ribbon);
return;
}
somethingAdded = true;
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.name = 'ptp-radarr__checkbox';
checkbox.className = 'ptp-radarr__checkbox';
checkbox.value = imdbId;
checkbox.onchange = () => handleCheckboxChange();
parent.appendChild(checkbox);
});
return somethingAdded;
}
/**
* Add dropdowns to the radarr bar, based on your radarr configuration
*/
async function populateRadarrBar(radarrBar) {
// make sure we've successfully connected before
const hasSuccessfulConnection = await getConfig(
'hasSuccessfulConnection',
false
);
if (!hasSuccessfulConnection) {
// eslint-disable-next-line no-param-reassign
radarrBar.innerHTML = `<div class="loading-icon__cont"><a href="#radarr">Configure and Test</a> your Radarr Connection first</div>`;
return;
}
// no need to query radarr again if we've fully loaded the options before
if (radarrBar.dataset.isLoaded === '1') {
return;
}
// query radarr for data
const {
qualityProfiles,
rootFolders,
tags,
error
} =
await getDataForRadarrBar();
if (error) {
await setConfig('hasSuccessfulConnection', false);
// eslint-disable-next-line no-param-reassign
radarrBar.dataset.isLoaded = '0';
// eslint-disable-next-line no-param-reassign
radarrBar.innerHTML = `<div class="loading-icon__cont">Error loading Radarr settings. <a href="#radarr">Recheck your connection</a> or check the browser console for more info</div>`;
return;
}
// get saved settings for the radarr bar
const settings = getSettings().radarrBar || {};
// build radarr bar inners
const qualityProfileOptions = qualityProfiles
.map(
(qp) =>
`<option value="${qp.id}" ${
settings.qualityProfileId === qp.id ? 'selected' : ''
}>${qp.name}</option>`
)
.join('');
const rootFolderOptions = rootFolders
.map(
(r) =>
`<option value="${r.path}" ${
settings.rootFolderPath === r.path ? 'selected' : ''
}>${r.path}</option>`
)
.join('');
let tagsSelected = 0;
const tagOptions = tags
.map((tag) => {
let checked = '';
if ((settings.tags || []).includes(tag.id)) {
checked = 'checked';
tagsSelected += 1;
}
return `<label for="multi-select__option-${tag.id}" class="multi-select__option">
<input type="checkbox" class="multi-select__option-checkbox" id="multi-select__option-${tag.id}" value="${tag.id}" ${checked}>
<span class="multi-select__option-text">${tag.label}</span>
</label>`;
})
.join('');
const numMovies = Array.from(
document.querySelectorAll('.ptp-radarr__checkbox:checked')
).length;
// eslint-disable-next-line no-param-reassign
radarrBar.innerHTML = `
<label class="ptp-radarr__bar-label ptp-radarr__bar-label--right-border" for="ptp-radarr__bar-select-all-none">
<span class="ptp-radarr__bar-label-text">Select:</span>
<input type="button" class="ptp-radarr__bar-button" id="ptp-radarr__bar-select-all" value="All">
<input type="button" class="ptp-radarr__bar-button" id="ptp-radarr__bar-select-none" value="None">
</label>
<label class="ptp-radarr__bar-label" for="ptp-radarr__bar-monitored">
<span class="ptp-radarr__bar-label-text">Monitor Movie(s)</span>
<select class="ptp-radarr__bar-select" id="ptp-radarr__bar-monitored" name="monitor">
<option value="true" ${
settings.monitor === 'true' ? 'selected' : ''
}>Monitored</option>
<option value="false" ${
settings.monitor === 'false' ? 'selected' : ''
}>Unmonitored</option>
</select>
</label>
<label class="ptp-radarr__bar-label" for="ptp-radarr__bar-qp">
<span class="ptp-radarr__bar-label-text">Quality Profile</span>
<select class="ptp-radarr__bar-select" id="ptp-radarr__bar-qp" name="qualityProfileId">
${qualityProfileOptions}
</select>
</label>
<label class="ptp-radarr__bar-label" for="ptp-radarr__bar-availability">
<span class="ptp-radarr__bar-label-text">Minimum Availability</span>
<select class="ptp-radarr__bar-select" id="ptp-radarr__bar-availability" name="minimumAvailability">
<option value="announced" ${
settings.minimumAvailability === 'announced' ? 'selected' : ''
}>Announced</option>
<option value="inCinemas" ${
settings.minimumAvailability === 'inCinemas' ? 'selected' : ''
}>In Cinemas</option>
<option value="released" ${
settings.minimumAvailability === 'released' ? 'selected' : ''
}>Released</option>
</select>
</label>
<label class="ptp-radarr__bar-label" for="ptp-radarr__bar-root-folder">
<span class="ptp-radarr__bar-label-text">Root Folder</span>
<select class="ptp-radarr__bar-select" id="ptp-radarr__bar-root-folder" name="rootFolderPath">
${rootFolderOptions}
</select>
</label>
<label class="ptp-radarr__bar-label" for="ptp-radarr__bar-tags">
<span class="ptp-radarr__bar-label-text">Tags</span>
<div class="multi-select">
<div class="multi-select__options">
${tagOptions}
</div>
<button class="multi-select__button">
<span class="multi-select__button-icon">☰</span>
<span class="multi-select__button-text">${tagsSelected} Tag(s)</span>
</button>
</div>
</label>
<label class="ptp-radarr__bar-label" for="ptp-radarr__bar-search-on-add">
<span class="ptp-radarr__bar-label-text">Search on Add</span>
<input type="checkbox" id="ptp-radarr__bar-search-on-add" name="searchForMovie" ${
settings.searchForMovie === true ? 'checked' : ''
}>
</label>
<label class="ptp-radarr__bar-label ptp-radarr__bar-label--right" for="ptp-radarr__bar-submit">
<span class="ptp-radarr__bar-label-text ptp-radarr__bar-label-text--selected">
<span class="ptp-radarr__bar-num-movies--selected">${numMovies}</span> Movie(s) Selected
</span>
<span class="ptp-radarr__bar-label-text ptp-radarr__bar-label-text--processed">
<span class="ptp-radarr__bar-num-movies--processed">0</span> / <span class="ptp-radarr__bar-num-movies--selected">${numMovies}</span> Processed
</span>
<span class="ptp-radarr__bar-label-text ptp-radarr__bar-label-text--added">
<span title="Added"><span class="ptp-radarr__bar-num-movies--added">0</span> 🟢</span>
| <span title="Existing"><span class="ptp-radarr__bar-num-movies--existing">0</span> 🟡️</span>
| <span title="Errors"><span class="ptp-radarr__bar-num-movies--errored">0</span> 🔴</span>
</span>
<input type="submit" class="ptp-radarr__bar-button" id="ptp-radarr__bar-submit" value="Add Movie(s)" />
</label>
`;
// multi select button
// open and close menu
document
.querySelector('.multi-select__button')
.addEventListener('click', (event) => {
event.preventDefault();
const target = event.target.matches('.multi-select__button') ?
event.target :
event.target.parentNode;
const options = target.previousElementSibling;
if (options.classList.contains('multi-select__options--opened')) {
options.classList.remove('multi-select__options--opened');
}
else {
options.classList.add('multi-select__options--opened');
}
});
// multi select checkboxes changed
Array.from(
document.querySelectorAll('.multi-select__option-checkbox')
).forEach((checkbox) => {
checkbox.addEventListener('change', () => {
const selected = Array.from(
document.querySelectorAll('.multi-select__option-checkbox:checked')
);
// updated how many tags have been selected
document.querySelector(
'.multi-select__button-text'
).innerHTML = `${selected.length} Tag(s)`;
// save new settings
setRadarrBarSetting(
'tags',
selected.map((s) => parseInt(s.value, 10))
);
});
});
// on select change, save the setting to use for next time
Array.from(document.querySelectorAll('.ptp-radarr__bar-select')).forEach(
(select) => {
select.addEventListener('change', (event) => {
const parsed = parseInt(event.target.value, 10);
setRadarrBarSetting(
select.getAttribute('name'),
Number.isNaN(parsed) ? event.target.value : parsed
);
});
}
);
// search on add change
document
.getElementById('ptp-radarr__bar-search-on-add')
.addEventListener('change', (event) => {
setRadarrBarSetting('searchForMovie', event.target.checked);
});
// eslint-disable-next-line no-param-reassign
radarrBar.dataset.isLoaded = '1';
}
/**
* Add the radarr bar to the page
*/
async function addRadarrBar() {
const alwaysShow = await getConfig('alwaysShow', false);
const radarrBar = document.createElement('form');
radarrBar.className = `ptp-radarr__bar search-bar`;
radarrBar.method = 'post';
radarrBar.addEventListener('submit', handleRadarrBarSubmit);
radarrBar.addEventListener('click', handleRadarrBarClick);
radarrBar.appendChild(createLoadingIcon());
const wrapper = document.getElementById('wrapper');
wrapper.appendChild(radarrBar);
if (alwaysShow) {
radarrBar.classList.add('ptp-radarr__bar--showing');
populateRadarrBar(radarrBar);
}
// weird hack to apply a black background color on the default styling
// don't want to override other stylesheets colors
const comp = window.getComputedStyle(radarrBar);
if (comp.getPropertyValue('background-color') === 'rgba(0, 0, 0, 0)') {
radarrBar.style.backgroundColor = '#000';
}
}
// ================================= UI Config
/**
* Close the radarr settings page
*/
function closeRadarrConfig(overlayElem) {
if (!overlayElem) {
return;
}
overlayElem.remove();
document.body.style.overflow = 'inherit';
window.location.hash = '#close';
}
/**
* Test a connection to Radarr
*/
async function testConnection() {
const textarea = document.getElementById(
'ptp-radarr__config-testbox-textarea'
);
const host = await getConfig('host');
const key = await getConfig('key');
textarea.value = '';
if (!host || !key) {
textarea.value += 'Error: Please fill in your host and api key';
return;
}
textarea.value += 'Testing connection ...\n\n';
try {
const healthCheck = await radarrGet('/health');
await setConfig('hasSuccessfulConnection', true);
if (healthCheck?.length === 0) {
textarea.value += 'Success! Everything looks good to go\n\n';
}
else {
textarea.value +=
'Success! Connected to radarr, but there are health issues!\n\n';
healthCheck.forEach((h) => {
textarea.value += ` – ${h.message}\n\n`;
});
}
// reloads the radarr bar if needed
handleCheckboxChange();
// reload checkboxes
addCheckboxesToMovies();
}
catch (error) {
textarea.value += `Error: Couldn't connect to radarr. Ensure your host and api key are correct\n\n`;
}
}
/**
* Create config container to allow user to configure settings
*/
async function createRadarrConfig() {
const host = await getConfig('host');
const key = await getConfig('key');
const ribbons = await getConfig('ribbons', false);
const alwaysShow = await getConfig('alwaysShow', false);
const {
body
} = document;
const container = document.createElement('div');
container.className = 'page__main-content ptp-radarr__config-container';
container.innerHTML = `<div class="search-bar">
<h3 class="ptp-radarr__config-header">PTP Radarr Configuration</h3>
</div>
<div class="ptp-radarr__config-container-content">
<div class="ptp-radarr__config-desc">
The Radarr API Key is attached to the request as a <strong>query string param</strong>. Ensure your Radarr
instance is accessible through any firewalls / auth processes you have in place for this.<br /><br />
Once you click "Test Connection", a new tab should open asking for permission to connect to your Radarr domain.
Click <strong>"Always Allow Domain"</strong>.<br /><br />
Please also ensure your <a href="https://passthepopcorn.me/forums.php?action=viewthread&threadid=40322" target="_blank" rel="noreferrer noopener">Radarr instance is secured</a>
as per Staff recommendations.
</div>
<div class="ptp-radarr__config-testbox">
<label for="ptp-radarr__config-testbox-textarea" class="ptp-radarr__config-label">
Test Output
<textarea id="ptp-radarr__config-testbox-textarea" readonly></textarea>
</label>
<input type="button" class="ptp-radarr__config-button--test" value="Test Connection" />
</div>
<label for="ptp-radarr__config-host" class="ptp-radarr__config-label">
Radarr Host: <span class="ptp-radarr__config-label-aside">(inc. port, if applicable)</span>
<input type="text" class="ptp-radarr__config-input" id="ptp-radarr__config-host" data-config-key="host" placeholder="e.g. https://my.radarr.com or 127.0.0.1:7878" value="${host}" />
</label>
<label for="ptp-radarr__config-apikey" class="ptp-radarr__config-label">
Radarr API Key: <span class="ptp-radarr__config-label-aside">(Settings -> General -> API Key)</span>
<span class="ptp-radarr__config-input--with-icon">
<input type="password" class="ptp-radarr__config-input" id="ptp-radarr__config-apikey" data-config-key="key" placeholder="e.g. 5b1008a9ce6f35b4cfa8d5b1e0062401" value="${key}" />
<span class="ptp-radarr__config-input-icon">🔒</span>
</span>
</label>
<label for="ptp-radarr__config-ribbons" class="ptp-radarr__config-label">
Always show "In Radarr" and "No IMDb" ribbons:
<input type="checkbox" class="ptp-radarr__config-checkbox" id="ptp-radarr__config-ribbons" data-config-key="ribbons" ${
ribbons ? 'checked' : ''
} />
</label>
<label for="ptp-radarr__config-always-show" class="ptp-radarr__config-label">
Always show Radarr Bar on page load:
<input type="checkbox" class="ptp-radarr__config-checkbox" id="ptp-radarr__config-always-show" data-config-key="alwaysShow" ${
alwaysShow ? 'checked' : ''
} />
</label>
<input type="button" class="ptp-radarr__config-button ptp-radarr__config-button--close" value="Close" />
</div>
`;
const overlay = document.createElement('div');
overlay.className = 'ptp-radarr__config-overlay';
overlay.onclick = (e) => {
if (e.target !== overlay) {
return;
}
closeRadarrConfig(overlay);
};
overlay.appendChild(container);
body.style.overflow = 'hidden';
body.appendChild(overlay);
// vars for event listeners
const textarea = document.getElementById(
'ptp-radarr__config-testbox-textarea'
);
// event listeners
// host and api key
Array.from(document.querySelectorAll('.ptp-radarr__config-input')).forEach(
(inputElem) => {
const {
configKey
} = inputElem.dataset;
inputElem.addEventListener('change', async (event) => {
await setConfig(configKey, event.target.value);
await setConfig('hasSuccessfulConnection', false);
textarea.value = `Settings changed: Test Connection to continue using script`;
const radarrBar = document.querySelector('.ptp-radarr__bar');
radarrBar.dataset.isLoaded = '0';
handleCheckboxChange();
});
}
);
// checkboxes
Array.from(document.querySelectorAll('.ptp-radarr__config-checkbox')).forEach(
(inputElem) => {
inputElem.addEventListener('change', async (event) => {
const {
checked
} = event.target;
const {
configKey
} = inputElem.dataset;
await setConfig(configKey, checked);
// refresh the checkboxes
addCheckboxesToMovies();
});
}
);
// close button
document
.querySelector('.ptp-radarr__config-button--close')
?.addEventListener('click', () => closeRadarrConfig(overlay));
// test button
document
.querySelector('.ptp-radarr__config-button--test')
?.addEventListener('click', testConnection);
// lock / unlock text input
/* eslint-disable no-param-reassign */
Array.from(
document.querySelectorAll('.ptp-radarr__config-input-icon')
).forEach((iconElem) => {
iconElem.addEventListener('click', () => {
if (iconElem.innerText === '🔒') {
iconElem.innerText = '🔓';
iconElem.previousElementSibling.type = 'text';
}
else {
iconElem.innerText = '🔒';
iconElem.previousElementSibling.type = 'password';
}
});
});
/* eslint-enable no-param-reassign */
}
/**
* Open / close settings depending on url hash
*/
function checkOpenSettings() {
const overlay = document.querySelector('.ptp-radarr__config-overlay');
if (window.location.hash === '#radarr') {
if (!overlay) {
createRadarrConfig();
}
}
else if (overlay) {
closeRadarrConfig(overlay);
}
}
/**
* Create the radarr config tab, under movies
*/
function addRadarrConfigTab() {
const openSettings = () => {
window.location.hash = '#radarr';
checkOpenSettings();
};
GM.registerMenuCommand('Open Settings', openSettings, 's');
}
// ================================= Observers
/**
* Observes pages to see if we need to reload checkboxes
*/
function setupMutationObserver() {
const {
item,
shouldObserveForMutations
} = getImgAndImdbSelectors();
if (!shouldObserveForMutations) {
return;
}
const parentNode = document.querySelector(item)?.parentNode;
if (!parentNode) {
return;
}
// observer config - only interested in tree modifications
const config = {
childList: true,
subtree: true
};
// Check modified nodes and add checkboxes if needed
const callback = (mutationsList) => {
mutationsList.forEach((mutation) => {
if (mutation.target === parentNode) {
addCheckboxesToMovies();
handleCheckboxChange();
}
});
};
// create an observer instance with the callback
const observer = new MutationObserver(callback);
// Start observing the target node for mutations
observer.observe(parentNode, config);
}
// ================================= CSS
/**
* Create our custom style tag
*/
function createStyleTag(styleType) {
const css = `
/* no styling exists for disabled inputs, so add some */
input[type="submit"]:disabled {
cursor: not-allowed;
filter: brightness(0.70);
}
input[type="submit"]:disabled:hover {
animation: none;
background: inherit;
box-shadow: inherit;
}
.multi-select {
position: relative;
}
.multi-select__options {
display: none;
position: absolute;
bottom: calc(100% + 3px);
left: 0;
border: 1px solid #3a4056;
background: ${
// eslint-disable-next-line no-nested-ternary
styleType === 'blue-night'
? '#0b0d19;'
: styleType === 'marcel'
? '#232223;'
: '#fff;'
}
border-radius: ${styleType === 'blue-night' ? '5px;' : '2px;'};
color: ${
// eslint-disable-next-line no-nested-ternary
styleType === 'blue-night'
? '#949aac;'
: styleType === 'marcel'
? '#fff;'
: '#000;'
};
padding: 0.3rem;
max-width: 200px;
max-height: 150px;
overflow-y: scroll;
z-index: 101;
}
.multi-select__options--opened {
display: block;
}
.multi-select__option {
display: block;
padding: 3px;
white-space: nowrap;
overflow: hidden;
}
.multi-select__option-checkbox {
display: inline-block;
vertical-align: middle;
}
.multi-select__option-text {
display: inline-block;
vertical-align: middle;
margin-left: 4px;
}
.multi-select__button {
border: 1px solid #3a4056;
background: ${
// eslint-disable-next-line no-nested-ternary
styleType === 'blue-night'
? '#0b0d19;'
: styleType === 'marcel'
? 'rgba(0, 0, 0, 0.12);'
: '#fff;'
}
border-radius: ${styleType === 'blue-night' ? '5px;' : '2px;'};
color: ${
// eslint-disable-next-line no-nested-ternary
styleType === 'blue-night'
? '#949aac;'
: styleType === 'marcel'
? '#fff;'
: '#000;'
};
padding: ${styleType === 'marcel' ? '0.32rem' : '0.42rem'};
box-shadow: none;
min-width: 120px;
max-width: 140px;
line-height: 1.15;
font-weight: normal!important;
text-align: left;
margin: 0;
cursor: default;
}
.multi-select__button:hover {
animation: none;
cursor: default;
}
.multi-select__button:focus {
background: ${
// eslint-disable-next-line no-nested-ternary
styleType === 'blue-night'
? '#0b0d19;'
: styleType === 'marcel'
? 'rgba(0, 0, 0, 0.12);'
: '#fff;'
}
border: 1px solid #3a4056;
box-shadow: none;
}
.multi-select__button-icon {
float: right;
position: relative;
top: -1px;
}
.loading-icon__cont {
width: 100%;
height: 70px;
display: flex;
align-items: center;
justify-content: center;
}
.loading-icon__cont a {
padding: 0 5px;
}
.loading-icon {
position: relative;
width: 10px;
height: 10px;
border-radius: 5px;
background-color: #9880ff;
color: #9880ff;
animation: dot-flashing 0.75s infinite linear alternate;
animation-delay: .375s;
}
.loading-icon::before,
.loading-icon::after {
content: '';
display: inline-block;
position: absolute;
top: 0;
left: -15px;
width: 10px;
height: 10px;
border-radius: 5px;
background-color: #9880ff;
color: #9880ff;
animation: dot-flashing 0.75s infinite alternate;
animation-delay: 0s;
}
.loading-icon::after {
left: 15px;
animation-delay: 0.75s;
}
@keyframes dot-flashing {
0% {
background-color: #9880ff;
}
50%,
100% {
background-color: #ebe6ff;
}
}
.ptp-radarr__ribbon {
width: 50px;
height: 50px;
position: absolute;
left: 0;
top: 0;
overflow: hidden;
opacity: 0;
transition: opacity 0.3s!important;
}
.ptp-radarr__ribbon--always,
.ptp-radarr__ribbon--forced,
*:hover > .ptp-radarr__ribbon {
opacity: 0.75;
transition: opacity 0.3s!important;
}
.ptp-radarr__ribbon > a,
.ptp-radarr__ribbon > span {
position: absolute;
display: block;
width: 75px;
padding: 5px;
background-color: #3498db;
box-shadow: 0 5px 10px rgb(0 0 0 / 10%);
color: #fff;
text-align: center;
font-size: 6px;
right: -2px;
top: 6px;
transform: rotate(-45deg);
}
.ptp-radarr__ribbon--existing > a,
.ptp-radarr__ribbon--existing > span {
background-color: red;
}
.ptp-radarr__checkbox {
position: absolute!important;
top: 2px;
left: 2px;
opacity: 0;
transition: opacity 0.3s!important;
}
.ptp-radarr__checkbox--forced,
.ptp-radarr__checkbox:checked,
*:hover > .ptp-radarr__checkbox {
opacity: 1;
transition: opacity 0.3s!important;
}
/* this overwrites some styles in search bar */
.ptp-radarr__bar {
display: block;
position: fixed;
bottom: 0;
left: 50%;
max-width: 1140px;
width: 100%;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
font-size: 12px;
text-align: left;
padding: 0;
transform: translate(-50%, 100%);
transition: transform 0.5s;
z-index: 3; /* over the reply bar, if also using infinite scroll script */
${styleType === 'marcel' ? 'background-color: #242424f5;' : ''}
}
.ptp-radarr__bar--showing {
transform: translate(-50%, 0%);
transition: transform 0.5s;
}
.ptp-radarr__bar-label {
display: inline-block;
vertical-align: top;
padding: 10px 9px;
}
.ptp-radarr__bar-label--right-border {
border-right: 1px dashed rgba(0, 0, 0, 0.5);
}
.ptp-radarr__bar-label--right {
float: right;
text-align: center;
}
.ptp-radarr__bar-label-text {
display: block;
margin-bottom: 5px;
}
.ptp-radarr__bar-label-text--processed,
.ptp-radarr__bar-label-text--added {
display: none;
}
.ptp-radarr__bar-button {
padding: 0.3rem;
}
#ptp-radarr__bar-submit {
min-width: 120px;
}
.ptp-radarr__bar-select {
min-width: 120px;
max-width: 140px;
padding: 0.3rem;
}
#ptp-radarr__bar-search-on-add {
width: 29px;
height: 29px;
}
#ptp-radarr__bar-search-on-add:after {
width: 10px;
height: 15px;
left: 9px;
top: 3px;
}
.ptp-radarr__config-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.75);
z-index: 100;
}
.ptp-radarr__config-container {
width: 550px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -55%);
border-radius: 10px;
padding: 0;
${styleType === 'marcel' ? 'background-color: #1c1c1c;' : ''}
}
.ptp-radarr__config-container .search-bar {
width: auto;
}
.ptp-radarr__config-container-content {
padding: 15px;
}
.ptp-radarr__config-header {
margin: 5px 0;
text-align: center;
${styleType === 'marcel' ? 'padding-top: 10px;' : ''}
}
.ptp-radarr__config-desc {
margin-bottom: 10px;
filter: brightness(85%);
padding-bottom: 15px;
border-bottom: 1px dashed #999;
}
.ptp-radarr__config-testbox {
float: right;
width: 200px;
}
#ptp-radarr__config-testbox-textarea {
display: block;
margin-top: 5px;
width: 100%;
height: 205px;
font-family: monospace;
font-size: 10px;
}
.ptp-radarr__config-label {
display: block;
padding: 8px 0;
}
.ptp-radarr__config-label-aside {
color: #ccc;
font-size: 10px;
padding-left: 5px;
}
.ptp-radarr__config-input {
display: block;
margin-top: 5px;
width: 300px;
}
.ptp-radarr__config-input-icon {
position: absolute;
top: 7px;
right: 6px;
font-size: 16px;
}
.ptp-radarr__config-input--with-icon {
position: relative;
display: inline-block;
}
.ptp-radarr__config-checkbox {
margin-top: 5px!important;
display: block!important;
}
.ptp-radarr__config-button {
margin-top: 10px!important;
}`;
const style = document.createElement('style');
style.type = 'text/css';
style.appendChild(document.createTextNode(css));
document.head.appendChild(style);
}
// ================================= Main Runner
(async function run() {
createStyleTag(getTheme());
addRadarrConfigTab();
// make sure we can add a checkbox to at least one item on the page, before rendering the other things
const addedCheckboxes = await addCheckboxesToMovies();
if (addedCheckboxes) {
addRadarrBar();
setupMutationObserver();
window.addEventListener('hashchange', checkOpenSettings);
checkOpenSettings();
}
})();