NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name BTN Sonarr Integration
// @namespace https://openuserjs.org/users/SB100
// @description The BTN <-> Sonarr Integration we always wanted
// @updateURL https://openuserjs.org/meta/SB100/BTN_Sonarr_Integration.meta.js
// @version 1.0.1
// @author SB100
// @copyright 2025, SB100 (https://openuserjs.org/users/SB100)
// @license MIT
// @match https://broadcasthe.net/series.php?id=*
// @grant GM.xmlHttpRequest
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.registerMenuCommand
// @connect thetvdb.com
// ==/UserScript==
// ==OpenUserJS==
// @author SB100
// ==/OpenUserJS==
/* jshint esversion: 11 */
/**
* =============================
* ADVANCED OPTIONS
* =============================
*/
// show debug logs in the browser console
const SETTING_DEBUG = false;
// how long to cache sonarr existing series for
// default = 1000 * 60 * 10 = 10 minutes
const SETTING_CACHE_TIME = 1000 * 60 * 10;
/**
* =============================
* END ADVANCED OPTIONS
* DO NOT MODIFY BELOW THIS LINE
* =============================
*/
// ================================= Basics
/**
* 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(
`[BTN Sonarr Integration] ${
Array.isArray(strOrStrArray) ? strOrStrArray.join(' - ') : strOrStrArray
}`
);
}
// ================================= 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('sonarrIntegrationSettings');
// 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(
'sonarrIntegrationSettings',
JSON.stringify(json)
);
}
/**
* Set a sonarr bar setting into the settings localStorage object
*/
function setSonarrBarSetting(type, value) {
const existingSettings = getSettings().sonarrBar || {};
const newSettings = {
...existingSettings,
[type]: value
};
setSetting('sonarrBar', newSettings);
}
// ================================= Query
/**
* Query the sonarr api
*/
async function query(url, method = 'get', params = {}, sonarrApiKey = null) {
let resolver;
let rejecter;
const p = new Promise((resolveFn, rejectFn) => {
resolver = resolveFn;
rejecter = rejectFn;
});
const clonedUrl = new URL(url);
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',
};
if (sonarrApiKey) {
clonedUrl.search = new URLSearchParams({
apikey: sonarrApiKey,
}).toString();
}
const final = Object.assign(obj, {
url: clonedUrl.toString(),
headers,
data: JSON.stringify(params),
});
GM.xmlHttpRequest(final);
}
else {
const newParams = sonarrApiKey ?
{
...params,
apikey: sonarrApiKey
} :
params;
clonedUrl.search = new URLSearchParams(newParams).toString();
const final = Object.assign(obj, {
url: clonedUrl.toString(),
});
GM.xmlHttpRequest(final);
}
return p;
}
/**
* Get request to Sonarr API. Parse results
*/
async function sonarrGet(path, params = {}) {
const sonarrUrl = (await getConfig('host')).replace(/\/$/, '');
const apiKey = await getConfig('key');
const url = `${sonarrUrl}/api/v3`;
return query(new URL(`${url}${path}`), 'get', params, apiKey).then(
(response) => JSON.parse(response.responseText)
);
}
/**
* Post request to Sonarr API. Parse results
*/
async function sonarrPost(path, params = {}) {
const sonarrUrl = (await getConfig('host')).replace(/\/$/, '');
const apiKey = await getConfig('key');
const url = `${sonarrUrl}/api/v3`;
return query(new URL(`${url}${path}`), 'post', params, apiKey).then(
(response) => JSON.parse(response.responseText)
);
}
/**
* Either parse the tvdb id from the url, or query the page for it
*/
async function tvdbGetIdFromUrl(tvdbUrl) {
const url = new URL(tvdbUrl);
// for format: https://thetvdb.com/?tab=series&id=
if (url.searchParams.get('id') !== null) {
return url.searchParams.get('id');
}
// for format: https://www.thetvdb.com/series
return query(url).then((response) => {
const parser = new DOMParser();
const html = parser.parseFromString(response.responseText, 'text/html');
return (
html.querySelector('#series_basic_info li span')?.textContent ?? null
);
});
}
/**
* Get existing tvdb ids from sonarr, and cache for the appropriate time
*/
async function getExistingSonarrTvdbIds() {
try {
const {
sonarrTvdbIds,
lastUpdated
} = getSettings();
if (
!lastUpdated ||
new Date().getTime() > lastUpdated + SETTING_CACHE_TIME
) {
const hasSuccessfulConnection = await getConfig(
'hasSuccessfulConnection',
false
);
if (!hasSuccessfulConnection) {
return [];
}
const allSeries = await sonarrGet('/series');
const tvdbIds = allSeries.reduce((result, series) => {
// eslint-disable-next-line no-param-reassign
result[series.tvdbId] = series.titleSlug;
return result;
}, {});
setSetting('lastUpdated', new Date().getTime());
setSetting('sonarrTvdbIds', tvdbIds);
return tvdbIds;
}
return sonarrTvdbIds;
}
catch (e) {
await setConfig('hasSuccessfulConnection', false);
// TODO update for sonarr
// const sonarrBar = document.querySelector('.btn-sonarr__bar');
// sonarrBar.dataset.isLoaded = '0';
// debug([`Couldn't get existing IMDb IDs from Radarr`, e.message]);
return [];
}
}
/**
* Get the data needed to populate the sonarr bar
*/
async function getDataForSonarrBar() {
try {
const [rootFolders, qualityProfiles, tags] = await Promise.all([
sonarrGet('/rootfolder'),
sonarrGet('/qualityprofile'),
sonarrGet('/tag'),
]);
return {
rootFolders,
qualityProfiles,
tags,
};
}
catch (e) {
debug([`Couldn't connect to Sonarr`, e.message]);
return {
error: true,
};
}
}
// ================================= 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('btn-future.css')) {
return 'btn-future';
}
}
return 'default';
}
/**
* Get the BTN series id from the url
*/
function getBtnSeriesIdFromUrl() {
if (window.location.pathname !== '/series.php') {
debug(
`Could not find BTN series ID from URL: "${window.location.toString()}"`
);
return null;
}
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('id') ?? null;
}
/**
* Get a tvdb id from the BTN id that has been saved in settings
*/
function getTvdbIdFromBtnId(btnId) {
const {
btnToTvdbMap = {}
} = getSettings();
return btnToTvdbMap[btnId];
}
/**
* Save a BTN -> tvdb Id mapping
*/
function setTvdbIdForBtnId(btnId, tvdbId) {
const {
btnToTvdbMap = {}
} = getSettings();
btnToTvdbMap[btnId] = tvdbId;
setSetting('btnToTvdbMap', btnToTvdbMap);
}
/**
* Get the tvdb url from the BTN series page
*/
function getTvdbUrlFromBtnSeriesPage() {
if (window.location.pathname !== '/series.php') {
debug(`Could not find tvdb URL from URL: "${window.location.toString()}"`);
return null;
}
const a = document.querySelector(
'[href*="thetvdb.com/series"], [href*="thetvdb.com/?tab=series&id="]'
);
return a?.href ?? null;
}
/**
* All encompassing function to find the tvdb id from a BTN series page
*/
async function getTvdbId() {
const btnId = getBtnSeriesIdFromUrl();
const maybeLocalTvdbId = getTvdbIdFromBtnId(btnId);
if (maybeLocalTvdbId) {
debug(`tvdb ID exists locally [BTN: ${btnId}] [tvdb: ${maybeLocalTvdbId}]`);
return maybeLocalTvdbId;
}
const tvdbUrl = getTvdbUrlFromBtnSeriesPage();
if (!tvdbUrl) {
debug(
`Could not obtain tvdb URL from BTN series page: "${window.location.toString()}"`
);
return null;
}
const maybeRemoteTvdbId = await tvdbGetIdFromUrl(tvdbUrl);
const asInt = Number.parseInt(maybeRemoteTvdbId, 10);
if (Number.isNaN(asInt)) {
debug(
`Could not obtain tvdb ID from remote for URL: "${window.location.toString()}"`
);
return null;
}
debug(`tvdb ID obtained remotely [BTN: ${btnId}] [tvdb: ${asInt}]`);
setTvdbIdForBtnId(btnId, asInt);
return asInt;
}
// ================================= Event Handlers
/**
* Force Sonarr Bar to show if the state of a checkbox is changed
*/
function handleCheckboxChange() {
const sonarrBar = document.querySelector('.btn-sonarr__bar');
const allCheckboxes = Array.from(
document.querySelectorAll('.btn-sonarr__checkbox')
);
if (allCheckboxes.some((checkbox) => checkbox.checked)) {
if (!sonarrBar.classList.contains('btn-sonarr__bar--showing')) {
sonarrBar.classList.add('btn-sonarr__bar--showing');
}
// if it hasn't been already, add dropdowns to the sonarr bar
// eslint-disable-next-line no-use-before-define
populateSonarrBar(sonarrBar);
}
else if (sonarrBar.classList.contains('btn-sonarr__bar--showing')) {
sonarrBar.classList.remove('btn-sonarr__bar--showing');
}
}
/**
* Close the Multiselect options if we have it open, and we click outside of it on the sonarr bar
*/
function handleSonarrBarClick(event) {
const multiSelectOptions = document.querySelector('.multi-select__options');
// 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('.btn-sonarr__bar') === false) {
if (target.matches(targetSelector)) {
return;
}
target = target.parentNode;
}
multiSelectOptions.classList.remove('multi-select__options--opened');
}
}
/**
* Add the series to sonarr with the selected options
*/
async function handleSonarrBarSubmit(event) {
event.preventDefault();
const addSeriesButton = document.getElementById('btn-sonarr__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';
addSeriesButton.value = 'Add Series';
return;
}
// disable the button
addSeriesButton.value = 'Processing …';
addSeriesButton.disabled = true;
// eslint-disable-next-line no-param-reassign
event.target.dataset.reset = '1';
// get the form data
const sonarrBarFormData = Object.fromEntries(
Array.from(new FormData(event.target))
);
try {
const tvdbId = parseInt(
document.querySelector('.btn-sonarr__checkbox:checked')?.value,
10
);
if (Number.isNaN(tvdbId)) {
addSeriesButton.disabled = false;
addSeriesButton.value = 'Nothing selected';
return;
}
const tagIds = Array.from(
document.querySelectorAll('.multi-select__option-checkbox:checked')
).map((c) => parseInt(c.value, 10));
const seriesInfos = await sonarrGet('/series/lookup', {
term: `tvdb:${tvdbId}`,
});
if (!Array.isArray(seriesInfos) || seriesInfos.length !== 1) {
addSeriesButton.disabled = false;
addSeriesButton.value = 'Invalid TVDb info';
return;
}
const addBody = {
...seriesInfos[0],
alternateTitles: [],
addOptions: {
ignoreEpisodesWithFiles: false,
ignoreEpisodesWithoutFiles: false,
searchForCutoffUnmetEpisodes: sonarrBarFormData.searchCutoffUnmetEpisodes === 'on',
searchForMissingEpisodes: sonarrBarFormData.searchMissingEpisodes === 'on',
monitor: sonarrBarFormData.monitor,
},
monitored: sonarrBarFormData.monitor !== 'none',
tags: tagIds,
rootFolderPath: sonarrBarFormData.rootFolderPath,
qualityProfileId: parseInt(sonarrBarFormData.qualityProfileId, 10),
seasonFolder: sonarrBarFormData.seasonFolder === 'on',
path: `${sonarrBarFormData.rootFolderPath}/${seriesInfos[0].folder}`,
added: new Date().toISOString(),
};
delete addBody.folder;
delete addBody.remotePoster;
addSeriesButton.value = 'Adding …';
const addResult = await sonarrPost('/series', addBody);
if (addResult.errors) {
addSeriesButton.value = 'Error adding series';
debug(['Error importing series', addResult.errors.$]);
setSetting('lastUpdated', 0);
setSetting('sonarrTvdbIds', {});
return;
}
addSeriesButton.value = 'Added!';
// update cache
const {
sonarrTvdbIds
} = getSettings();
setSetting('sonarrTvdbIds', {
...sonarrTvdbIds,
[addResult.tvdbId]: addResult.titleSlug,
});
// refresh the checkboxes to show the new ribbons
// eslint-disable-next-line no-use-before-define
await addCheckboxesToSeries();
}
catch (e) {
await setConfig('hasSuccessfulConnection', false);
const sonarrBar = document.querySelector('.btn-sonarr__bar');
sonarrBar.dataset.isLoaded = '0';
sonarrBar.innerHTML = `<div class="loading-icon__cont">Error adding to Sonarr. <a href="#sonarr">Recheck your connection</a> or check your browser console for more info</div>`;
debug([`Couldn't import series`, e.message]);
return;
}
addSeriesButton.disabled = false;
}
// ================================= UI Sonarr Bar
/**
* 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;
}
/**
* Grab all contents needed to populate the sonarr bar, and render it
*/
async function populateSonarrBar(sonarrBar) {
// make sure we've successfully connected before
const hasSuccessfulConnection = await getConfig(
'hasSuccessfulConnection',
false
);
if (!hasSuccessfulConnection) {
// eslint-disable-next-line no-param-reassign
sonarrBar.innerHTML = `<div class="loading-icon__cont"><a href="#sonarr">Configure and Test</a> your Sonarr Connection first</div>`;
return;
}
// no need to query sonarr again if we've fully loaded the options before
if (sonarrBar.dataset.isLoaded === '1') {
return;
}
// query sonarr for data
const {
qualityProfiles,
rootFolders,
tags,
error
} =
await getDataForSonarrBar();
if (error) {
await setConfig('hasSuccessfulConnection', false);
// eslint-disable-next-line no-param-reassign
sonarrBar.dataset.isLoaded = '0';
// eslint-disable-next-line no-param-reassign
sonarrBar.innerHTML = `<div class="loading-icon__cont">Error loading Sonarr settings. <a href="#sonarr">Recheck your connection</a> or check the browser console for more info</div>`;
return;
}
// get saved settings for the sonarr bar
const settings = getSettings().sonarrBar || {};
// build sonarr 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('');
// eslint-disable-next-line no-param-reassign
sonarrBar.innerHTML = `
<label class="btn-sonarr__bar-label" for="btn-sonarr__bar-root-folder">
<span class="btn-sonarr__bar-label-text">Root Folder</span>
<select class="btn-sonarr__bar-select" id="btn-sonarr__bar-root-folder" name="rootFolderPath">
${rootFolderOptions}
</select>
</label>
<label class="btn-sonarr__bar-label" for="btn-sonarr__bar-monitor">
<span class="btn-sonarr__bar-label-text">Monitor</span>
<select class="btn-sonarr__bar-select" id="btn-sonarr__bar-monitor" name="monitor">
<option value="all" ${
settings.monitor === 'all' ? 'selected' : ''
}>All Episodes</option>
<option value="future" ${
settings.monitor === 'future' ? 'selected' : ''
}>Future Episodes</option>
<option value="missing" ${
settings.monitor === 'missing' ? 'selected' : ''
}>Missing Episodes</option>
<option value="existing" ${
settings.monitor === 'existing' ? 'selected' : ''
}>Existing Episodes</option>
<option value="recent" ${
settings.monitor === 'recent' ? 'selected' : ''
}>Recent Episodes</option>
<option value="pilot" ${
settings.monitor === 'pilot' ? 'selected' : ''
}>Pilot Episodes</option>
<option value="firstSeason" ${
settings.monitor === 'firstSeason' ? 'selected' : ''
}>First Season</option>
<option value="lastSeason" ${
settings.monitor === 'lastSeason' ? 'selected' : ''
}>Last Season</option>
<option value="monitorSpecials" ${
settings.monitor === 'monitorSpecials' ? 'selected' : ''
}>Monitor Specials</option>
<option value="unmonitorSpecials" ${
settings.monitor === 'unmonitorSpecials' ? 'selected' : ''
}>Unmonitor Specials</option>
<option value="none" ${
settings.monitor === 'none' ? 'selected' : ''
}>None</option>
</select>
</label>
<label class="btn-sonarr__bar-label" for="btn-sonarr__bar-qp">
<span class="btn-sonarr__bar-label-text">Quality Profile</span>
<select class="btn-sonarr__bar-select" id="btn-sonarr__bar-qp" name="qualityProfileId">
${qualityProfileOptions}
</select>
</label>
<label class="btn-sonarr__bar-label" for="btn-sonarr__bar-series-type">
<span class="btn-sonarr__bar-label-text">Series Type</span>
<select class="btn-sonarr__bar-select" id="btn-sonarr__bar-series-type" name="seriesType">
<option value="standard" ${
settings.seriesType === 'standard' ? 'selected' : ''
}>Standard</option>
<option value="daily" ${
settings.seriesType === 'daily' ? 'selected' : ''
}>Daily / Date</option>
<option value="anime" ${
settings.seriesType === 'anime' ? 'selected' : ''
}>Anime / Absolute</option>
</select>
</label>
<label class="btn-sonarr__bar-label" for="btn-sonarr__bar-tags">
<span class="btn-sonarr__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>
<span class="btn-sonarr__bar-label">
<span class="btn-sonarr__bar-label-text">Create</span>
<span class="btn-sonarr__bar-multi-label">
<label for="btn-sonarr__bar-season-folder">
<input type="checkbox" class="btn-sonarr__bar-checkbox" id="btn-sonarr__bar-season-folder" name="seasonFolder" ${
settings.seasonFolder === true ? 'checked' : ''
}>
Season folder
</label>
</span>
</span>
<span class="btn-sonarr__bar-label">
<span class="btn-sonarr__bar-label-text">Search for</span>
<span class="btn-sonarr__bar-multi-label">
<label for="btn-sonarr__bar-search-missing-episodes">
<input type="checkbox" class="btn-sonarr__bar-checkbox" id="btn-sonarr__bar-search-missing-episodes" name="searchMissingEpisodes" ${
settings.searchMissingEpisodes === true ? 'checked' : ''
}>
Missing
</label>
<label for="btn-sonarr__bar-search-cutoff-unmet-episodes">
<input type="checkbox" class="btn-sonarr__bar-checkbox" id="btn-sonarr__bar-search-cutoff-unmet-episodes" name="searchCutoffUnmetEpisodes" ${
settings.searchCutoffUnmetEpisodes === true ? 'checked' : ''
}>
Cutoff unmet
</label>
</span>
</span>
<label class="btn-sonarr__bar-label" for="btn-sonarr__bar-submit">
<span class="btn-sonarr__bar-label-text">Add Series</span>
<input type="submit" class="btn-sonarr__bar-button" id="btn-sonarr__bar-submit" value="Add Series" />
</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
setSonarrBarSetting(
'tags',
selected.map((s) => parseInt(s.value, 10))
);
});
});
// on select change, save the setting to use for next time
Array.from(document.querySelectorAll('.btn-sonarr__bar-select')).forEach(
(select) => {
select.addEventListener('change', (event) => {
const parsed = parseInt(event.target.value, 10);
setSonarrBarSetting(
select.getAttribute('name'),
Number.isNaN(parsed) ? event.target.value : parsed
);
});
}
);
// on checkbox change, save the setting to use for next time
Array.from(document.querySelectorAll('.btn-sonarr__bar-checkbox')).forEach(
(checkbox) => {
checkbox.addEventListener('change', (event) => {
const {
checked
} = event.target;
setSonarrBarSetting(checkbox.getAttribute('name'), checked);
});
}
);
// eslint-disable-next-line no-param-reassign
sonarrBar.dataset.isLoaded = '1';
}
/**
* Add the sonarr bar to the page
*/
async function addSonarrBar() {
const alwaysShow = await getConfig('alwaysShow', false);
const sonarrBar = document.createElement('form');
sonarrBar.className = `btn-sonarr__bar`;
sonarrBar.method = 'post';
sonarrBar.addEventListener('submit', handleSonarrBarSubmit);
sonarrBar.addEventListener('click', handleSonarrBarClick);
sonarrBar.appendChild(createLoadingIcon());
const wrapper = document.getElementById('wrapper');
wrapper.appendChild(sonarrBar);
if (alwaysShow) {
sonarrBar.classList.add('btn-sonarr__bar--showing');
populateSonarrBar(sonarrBar);
}
}
// ================================= UI Checkboxes
/**
* Add checkboxes to the series posters
*/
async function addCheckboxesToSeries() {
// remove existing checkboxes and ribbons
Array.from(
document.querySelectorAll('.btn-sonarr__checkbox, .btn-sonarr__ribbon')
).forEach((elem) => elem.remove());
// get setting on whether we always want to show ribbons or not
const ribbons = await getConfig('ribbons', false);
// get sonarr url
const sonarrUrl = (await getConfig('host')).replace(/\/$/, '');
// find img poster
const imgElem = document.querySelector('.sidebar img[onload]');
if (!imgElem) {
debug(`Could not find cover poster for "${window.location.toString()}"`);
return false;
}
const parent = imgElem.parentNode;
parent.style.position = 'relative';
// add a loader whilst we do the api calls
const loader = createLoadingIcon();
loader.style.position = 'absolute';
loader.style.inset = 0;
parent.appendChild(loader);
// get current tvdbId, and all existing ones in sonarr
const [tvdbId, existingTvdbIdObj] = await Promise.all([
getTvdbId(),
getExistingSonarrTvdbIds(),
]);
const existingTvdbIds = Object.keys(existingTvdbIdObj);
// remove the loader
loader.remove();
// add checkboxes / ribbons
// couldn't parse tvdb id from somewhere
if (!tvdbId) {
const ribbon = document.createElement('div');
ribbon.className = `btn-sonarr__ribbon ${
ribbons ? 'btn-sonarr__ribbon--always' : ''
}`;
ribbon.innerHTML = `<span>No tvdb</span>`;
parent.appendChild(ribbon);
return false;
}
// already added to sonarr, ignore!
if (existingTvdbIds.includes(tvdbId.toString())) {
const ribbon = document.createElement('div');
ribbon.className = `btn-sonarr__ribbon btn-sonarr__ribbon--existing ${
ribbons ? 'btn-sonarr__ribbon--always' : ''
}`;
if (sonarrUrl) {
ribbon.innerHTML = `<a href="${sonarrUrl}/series/${existingTvdbIdObj[tvdbId]}" rel="noreferrer noopener" target="_blank">In Sonarr</a>`;
}
else {
ribbon.innerHTML = `<span>In Sonarr</span>`;
}
parent.appendChild(ribbon);
return false;
}
const alwaysShow = await getConfig('alwaysShow', false);
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.name = 'btn-sonarr__checkbox';
checkbox.className = 'btn-sonarr__checkbox';
checkbox.value = tvdbId;
checkbox.checked = alwaysShow;
checkbox.onchange = () => handleCheckboxChange();
parent.appendChild(checkbox);
return true;
}
// ================================= UI Config
/**
* Close the sonarr settings page
*/
function closeSonarrConfig(overlayElem) {
if (!overlayElem) {
return;
}
overlayElem.remove();
document.body.style.overflow = 'inherit';
window.location.hash = '#close';
}
/**
* Test a connection to Sonarr
*/
async function testConnection() {
const textarea = document.getElementById(
'btn-sonarr__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 sonarrGet('/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 sonarr, but there are health issues!\n\n';
healthCheck.forEach((h) => {
textarea.value += ` – ${h.message}\n\n`;
});
}
// TODO: reloads the sonarr bar if needed
handleCheckboxChange();
// TODO: reload checkboxes
addCheckboxesToSeries();
}
catch (error) {
textarea.value += `Error: Couldn't connect to sonarr. Ensure your host and api key are correct\n\n`;
}
}
/**
* Create config container to allow user to configure settings
*/
async function createSonarrConfig() {
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 = 'btn-sonarr__config-container';
container.innerHTML = `<div class="btn-sonarr__config-header">
<h3>BTN Sonarr Configuration</h3>
</div>
<div class="btn-sonarr__config-container-content">
<div class="btn-sonarr__config-desc">
The Sonarr API Key is attached to the request as a <strong>query string param</strong>. Ensure your Sonarr
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 Sonarr domain.
Click <strong>"Always Allow Domain"</strong>.
</div>
<div class="btn-sonarr__config-testbox">
<label for="btn-sonarr__config-testbox-textarea" class="btn-sonarr__config-label">
Test Output
<textarea id="btn-sonarr__config-testbox-textarea" readonly></textarea>
</label>
<input type="button" class="btn-sonarr__config-button--test" value="Test Connection" />
</div>
<label for="btn-sonarr__config-host" class="btn-sonarr__config-label">
Sonarr Host: <span class="btn-sonarr__config-label-aside">(inc. port, if applicable)</span>
<input type="text" class="btn-sonarr__config-input" id="btn-sonarr__config-host" data-config-key="host" placeholder="e.g. https://my.sonarr.com or 127.0.0.1:8989" value="${host}" />
</label>
<label for="btn-sonarr__config-apikey" class="btn-sonarr__config-label">
Sonarr API Key: <span class="btn-sonarr__config-label-aside">(Settings -> General -> API Key)</span>
<span class="btn-sonarr__config-input--with-icon">
<input type="password" class="btn-sonarr__config-input" id="btn-sonarr__config-apikey" data-config-key="key" placeholder="e.g. 5b1008a9ce6f35b4cfa8d5b1e0062401" value="${key}" />
<span class="btn-sonarr__config-input-icon">🔒</span>
</span>
</label>
<label for="btn-sonarr__config-ribbons" class="btn-sonarr__config-label">
Always show "In Sonarr" and "No tvdb" ribbons:
<input type="checkbox" class="btn-sonarr__config-checkbox" id="btn-sonarr__config-ribbons" data-config-key="ribbons" ${
ribbons ? 'checked' : ''
} />
</label>
<label for="btn-sonarr__config-always-show" class="btn-sonarr__config-label">
Always show Sonarr Bar on page load:
<input type="checkbox" class="btn-sonarr__config-checkbox" id="btn-sonarr__config-always-show" data-config-key="alwaysShow" ${
alwaysShow ? 'checked' : ''
} />
</label>
<input type="button" class="btn-sonarr__config-button btn-sonarr__config-button--close" value="Close" />
</div>
`;
const overlay = document.createElement('div');
overlay.className = 'btn-sonarr__config-overlay';
overlay.onclick = (e) => {
if (e.target !== overlay) {
return;
}
closeSonarrConfig(overlay);
};
overlay.appendChild(container);
body.style.overflow = 'hidden';
body.appendChild(overlay);
// vars for event listeners
const textarea = document.getElementById(
'btn-sonarr__config-testbox-textarea'
);
// event listeners
// host and api key
Array.from(document.querySelectorAll('.btn-sonarr__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 sonarrBar = document.querySelector('.btn-sonarr__bar');
sonarrBar.dataset.isLoaded = '0';
handleCheckboxChange();
});
}
);
// checkboxes
Array.from(document.querySelectorAll('.btn-sonarr__config-checkbox')).forEach(
(inputElem) => {
inputElem.addEventListener('change', async (event) => {
const {
checked
} = event.target;
const {
configKey
} = inputElem.dataset;
await setConfig(configKey, checked);
addCheckboxesToSeries();
});
}
);
// close button
document
.querySelector('.btn-sonarr__config-button--close')
?.addEventListener('click', () => closeSonarrConfig(overlay));
// test button
document
.querySelector('.btn-sonarr__config-button--test')
?.addEventListener('click', testConnection);
// lock / unlock text input
/* eslint-disable no-param-reassign */
Array.from(
document.querySelectorAll('.btn-sonarr__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('.btn-sonarr__config-overlay');
if (window.location.hash === '#sonarr') {
if (!overlay) {
createSonarrConfig();
}
}
else if (overlay) {
closeSonarrConfig(overlay);
}
}
/**
* Create the sonarr config tab
*/
function addSonarrConfigTab() {
const openSettings = () => {
window.location.hash = '#sonarr';
checkOpenSettings();
};
GM.registerMenuCommand('Open Settings', openSettings, 's');
}
// ================================= CSS
/**
* Create our custom style tag
*/
function createStyleTag(theme) {
const css = `
.btn-sonarr__config-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.75);
z-index: 100;
}
.btn-sonarr__config-container {
width: 700px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -55%);
border-radius: 10px;
padding: 0;
background-color: #262340;
}
.btn-sonarr__config-container-content {
padding: 15px;
}
.btn-sonarr__config-header {
padding: 1px;
text-align: center;
font-size: 18px;
background-color: #2f2b4c;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
}
.btn-sonarr__config-desc {
margin-bottom: 10px;
filter: brightness(85%);
padding-bottom: 15px;
border-bottom: 1px dashed #999;
}
.btn-sonarr__config-testbox {
float: right;
width: 300px;
}
#btn-sonarr__config-testbox-textarea {
display: block;
margin-top: 5px;
width: 100%;
height: 235px;
font-family: monospace;
font-size: 10px;
}
.btn-sonarr__config-label {
display: block;
padding: 8px 0;
}
.btn-sonarr__config-label-aside {
color: #ccc;
font-size: 10px;
padding-left: 5px;
}
.btn-sonarr__config-input {
display: block;
margin-top: 5px;
width: 340px;
}
.btn-sonarr__config-input-icon {
position: absolute;
top: 10px;
right: 6px;
font-size: 16px;
}
.btn-sonarr__config-input--with-icon {
position: relative;
display: inline-block;
}
.btn-sonarr__config-checkbox {
margin-top: 5px!important;
display: block!important;
}
.btn-sonarr__config-button {
margin-top: 10px!important;
}
.multi-select {
position: relative;
}
.multi-select__options {
display: none;
position: absolute;
bottom: calc(100% + 3px);
left: 0;
border: 1px solid #3a4056;
background: #2f2b4c;
border-radius: 2px;
color: #c0bdda;
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 ${theme === 'default' ? '#666' : '#373257'};
background: ${theme === 'default' ? '#333' : '#2f2b4c'};
border-radius: ${theme === 'default' ? '0px' : '5px'};
color: ${theme === 'default' ? '#fff' : '#c0bdda'};
padding: 0.42rem;
box-shadow: none;
min-width: 120px;
max-width: 140px;
line-height: ${theme === 'default' ? '1' : '1.15'};
font-weight: normal!important;
text-align: left;
margin: 0;
cursor: default;
${theme === 'default' ? 'font-size: 11px' : ''}
}
.multi-select__button:hover {
background: ${theme === 'default' ? '#333' : '#2f2b4c'};
animation: none;
cursor: default;
${theme === 'default' ? 'border: 1px solid #aaa;' : ''}
}
.multi-select__button:focus {
background: ${theme === 'default' ? '#333' : '#2f2b4c'};
border: 1px solid ${theme === 'default' ? '#aaa' : '#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;
}
}
.btn-sonarr__ribbon {
width: 60px;
height: 60px;
position: absolute;
left: 10px;
top: 10px;
overflow: hidden;
opacity: 0;
transition: opacity 0.3s!important;
}
.btn-sonarr__ribbon--always,
.btn-sonarr__ribbon--forced,
*:hover > .btn-sonarr__ribbon {
opacity: 0.75;
transition: opacity 0.3s!important;
}
.btn-sonarr__ribbon > a,
.btn-sonarr__ribbon > span {
position: absolute;
display: block;
width: 85px;
padding: 5px;
background-color: #3498db;
box-shadow: 0 5px 10px rgb(0 0 0 / 10%);
color: #fff;
text-align: center;
font-size: 8px;
right: -1px;
top: 3px;
transform: rotate(-45deg);
}
.btn-sonarr__ribbon--existing > a,
.btn-sonarr__ribbon--existing > span {
background-color: red;
}
.btn-sonarr__checkbox {
position: absolute!important;
top: 2px;
left: 2px;
opacity: 0;
transition: opacity 0.3s!important;
}
.btn-sonarr__checkbox--forced,
.btn-sonarr__checkbox:checked,
*:hover > .btn-sonarr__checkbox {
opacity: 1;
transition: opacity 0.3s!important;
}
.btn-sonarr__bar {
display: block;
position: fixed;
bottom: 0;
left: 50%;
max-width: 1170px;
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;
background-color: #1e1c33;
border: 2px solid #443f73;
border-bottom: transparent;
z-index: 10;
}
.btn-sonarr__bar--showing {
transform: translate(-50%, 0%);
transition: transform 0.5s;
}
.btn-sonarr__bar-label {
display: inline-block;
vertical-align: top;
padding: 10px 9px;
}
.btn-sonarr__bar-label-text {
display: block;
margin-bottom: 5px;
}
.btn-sonarr__bar-multi-label {
display: flex;
flex-direction: column;
}
.btn-sonarr__bar-multi-label label {
display: flex;
gap: 5px;
}
.btn-sonarr__bar-button {
padding: 0.3rem;
}
.btn-sonarr__bar-select {
min-width: 120px;
max-width: 140px;
padding: 0.3rem;
}
#btn-sonarr__bar-submit {
min-width: 120px;
}
`;
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());
addSonarrConfigTab();
// make sure we can add a checkbox to at least one item on the page, before rendering the other things
const addedCheckboxes = await addCheckboxesToSeries();
if (addedCheckboxes) {
addSonarrBar();
window.addEventListener('hashchange', checkOpenSettings);
checkOpenSettings();
}
})();