*arr default sort & filter

Sets default sort and filter options for Radarr/Sonarr/...'s interactive search

  1. // ==UserScript==
  2. // @name *arr default sort & filter
  3. // @namespace https://github.com/Millio345/arr-default-sort-and-filter
  4. /* Since *arr's installation url is different for every user we need to match all links; Script checks for a 'Radarr' metadata tag before actually doing anything */
  5. // @include http://*
  6. // @include https://*
  7. // @require https://openuserjs.org/src/libs/sizzle/GM_config.js
  8. // @grant GM_getValue
  9. // @grant GM_setValue
  10. // @grant GM_registerMenuCommand
  11. // @version 1.2
  12. // @author Millio
  13. // @description Sets default sort and filter options for Radarr/Sonarr/...'s interactive search
  14. // @license MIT
  15. // ==/UserScript==
  16.  
  17. /* This script allows you to define default sorting and filter for your interactive searches for the arr websites.
  18. As of version 1.1 you no longer need to make any changes to this file.
  19. USAGE:
  20. After installing the extension, visit your Radarr/Sonarr/... installation and click the 'Configure *arr default sort & filter' button which should show up in your Userscript extension's menu on your browser
  21.  
  22. Problems? Suggestions? Improvements? GitHub at https://github.com/Millio345/arr-default-sort-and-filter
  23.  
  24. CHANGELOG:
  25. 1.2 - Add support for Lidarr, Sonarr, Readarr & Whisparr
  26. 1.1 - Move all configuration to UI to prevent users having to override this file with every update. Allow sorting by icon columns by entering the label text you see when moving your mouse over the element (span title)
  27. 1.0 - Initial release, basic sort & filter functionality
  28. */
  29. (function() {
  30. 'use strict';
  31.  
  32. /* FUNCTIONS */
  33. // Sort the table by sortColumn and click "Filter" button if filterName is set
  34. function clickColumnAndFilter(tableDiv) {
  35. if(!(sortColumn === null || sortColumn === ""))
  36. {
  37. // Find all table headers
  38. let thElements = tableDiv.getElementsByTagName('th');
  39. let correctHeaderFound = false
  40. let headerList = [];
  41. // Loop through all table headers
  42. for (let i = 0; i < thElements.length; i++) {
  43. // Check if this is the table header we want to sort by
  44. if (thElements[i].textContent.trim().toLowerCase() === sortColumn.toLowerCase()) {
  45. correctHeaderFound = true;
  46. }
  47. // Not it, let's check if it has a span with the correct title for icon columns
  48. else if(thElements[i].querySelector('span') && thElements[i].querySelector('span').getAttribute('title').toLowerCase() === sortColumn.toLowerCase()) {
  49. correctHeaderFound = true;
  50. }
  51. else {
  52. if(thElements[i].textContent!==""){
  53. headerList.push(thElements[i].textContent);
  54. }
  55. else if(thElements[i].querySelector('span')){
  56. headerList.push(thElements[i].querySelector('span').getAttribute('title'));
  57. }
  58. }
  59.  
  60. if(correctHeaderFound){
  61. // Click the <th> element
  62. thElements[i].click();
  63.  
  64. // If we don't want ascending order, click again
  65. if(!sortAscending)
  66. thElements[i].click();
  67.  
  68. break; // Stop the loop since we found the target column
  69. }
  70. }
  71. if(!correctHeaderFound){
  72. console.warn("Sort column '"+sortColumn+"' not found in table headers.")
  73. console.log("Valid columns: "+headerList.join(','))
  74. }
  75. }
  76. //If no filterName's set, or we've already clicked the filter button for this page load for this movie, this function is done. Otherwise, continue to clicking the filter button
  77. if(filterName === null || filterName === "" || filterButtonClicked)
  78. return
  79.  
  80. // "Filter" button is first child of tableDiv parent
  81. let divs = tableDiv.parentElement.getElementsByTagName('div')
  82. for(let i =0; i < divs.length;i++){
  83. if(divs[i].className.startsWith('FilterMenu-filterMenu-'))
  84. {
  85. let filter = divs[i]
  86. let button = filter.querySelector('button')
  87. button.click()
  88. return;
  89. }
  90. }
  91. console.warn("Filter button not found. Unable to apply filter.");
  92. }
  93.  
  94. // Click the filterName as soon as the filter menu is opened
  95. function clickChosenFilter(filterListNode) {
  96.  
  97. // First make sure filterName is set
  98. if(filterName === null || filterName === ""){
  99. return
  100. }
  101.  
  102. // Only click filter button once after opening a modal; Otherwise the user would not be able to override the filter since the script would always take over
  103. if(filterButtonClicked){
  104. return
  105. }
  106.  
  107. // Get all filters
  108. let filters = filterListNode.getElementsByTagName('button')
  109.  
  110. // Create a filterList for debug logging in case filter is not found
  111. let filterList = [];
  112.  
  113. for(let i = 0; i < filters.length; i++) {
  114. let div = filters[i].querySelector('div');
  115. if(div !== null){
  116. if(div.textContent.trim()===filterName){
  117. // Got the right filter, click it
  118. filters[i].click();
  119.  
  120. // Set filterButtonClicked to true
  121. filterButtonClicked = true
  122. return;
  123. }
  124. else
  125. filterList.push(div.textContent.trim());
  126. }
  127. }
  128. console.warn("Filter '"+filterName+"' not found in filter list.")
  129. console.log("Valid filters:"+filterList.join(','));
  130. }
  131.  
  132. // Observe the body element so we know when modal is opened / closed
  133. function bodyClassChanged(mutationsList, _observer){
  134. mutationsList.forEach(mutation => {
  135. if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
  136. // Modal was closed; Set filterButtonClicked to false so we can reclick it next time the modal is opened
  137. if(!mutation.target.className.includes("Modal-modalOpen")) {
  138. filterButtonClicked = false;
  139. }
  140. }
  141. });
  142. }
  143. // This function is called by the mutationObserver when additional page content is added. Check if it contains the search table or the filter 'scroller'
  144. function handleMutations(mutationsList, _observer) {
  145. for (let mutation of mutationsList) {
  146. if (mutation.type === 'childList') {
  147. // Check if the added nodes include the target element
  148. if (mutation.addedNodes) {
  149. for (let node of mutation.addedNodes) {
  150. // Check if the added node is an element (type 1) which has classes
  151. if (node.nodeType === 1 && typeof node.className === 'string')
  152. {
  153. // Was a tableContainer in a modal added?
  154. if(node.className.includes('Table-tableContainer-')&&node.closest("[class^='ModalBody']")) {
  155. clickColumnAndFilter(node);
  156. return;
  157. }
  158. // Was a menu scroller added (This is the opened filter list)? Click the chosen filter
  159. else if(node.className.includes('MenuContent-scroller-')) {
  160. clickChosenFilter(node)
  161. }
  162. }
  163. }
  164. }
  165. }
  166. }
  167. }
  168.  
  169. // Check current page url to see if we're on page with interactive search button or not
  170. function onPageWithInteractiveSearchButton(arr_type) {
  171. let pattern;
  172.  
  173. // Depending on which arr site we're on we check the url
  174. if(arr_type==="Lidarr") {
  175. // artist/albums links end in /(artist|album)/artist-or-album-name
  176. pattern = /\/(artist|album)\/[\w-]+$/;
  177. }
  178. else if(arr_type==="Radarr") {
  179. // movie links end in /movie/NUMBER
  180. pattern = /\/movie\/\d+$/;
  181. }
  182. else if(arr_type==="Readarr") {
  183.  
  184. // author links end in /author/NUMBER
  185. // Known limitation: The book page does not offer an interactive search modal and is currently not supported; Go to the author page and search from there.
  186. pattern = /\/author\/\d+$/;
  187. }
  188. else if(arr_type==="Sonarr") {
  189.  
  190. // series links end in /series/series-name
  191. pattern = /\/series\/[\w-]+$/;
  192. }
  193. else if(arr_type==="Whisparr") {
  194. // site links end in /site/sitename
  195. pattern = /\/site\/[\w-]+$/;
  196. }
  197.  
  198. // Return true / false depending on whether we're on a media page
  199. return pattern.test(window.location.href);
  200. }
  201.  
  202.  
  203. // When a movie page is loaded, start mutation observers and set filter clicked to false
  204. function interactiveSearchPageLoaded(){
  205. filterButtonClicked = false
  206. observer.observe(document.documentElement, { childList: true, subtree: true });
  207. body_observer.observe(document.querySelector('body'), { attributes: true, attributeFilter: ['class'], attributeOldValue: true });
  208. }
  209.  
  210. // To prevent unintended consequences on other tables/buttons, stop observer when another page is loaded
  211. function nonInteractiveSearchPageLoaded(){
  212. observer.disconnect()
  213. body_observer.disconnect()
  214. }
  215. function loadScript(arr_type)
  216. {
  217. // Set Variable prefix to the arr type (f.e. SonarrInteractiveSearchFilterName
  218. let prefix = arr_type;
  219.  
  220. // To prevent breaking changes to previous installations when this script only supported Radarr, Radarr variables don't have a prefix.
  221. if(prefix==="Radarr"){
  222. prefix="";
  223. }
  224. // Setup GM Config, which provides a nice UI to set the options
  225. gmc = new GM_config(
  226. {
  227. // We need unique id for each site, otherwise all non-specified values will be cleared when saving.
  228. 'id': prefix+'ArrDefaultSortAndFilter',
  229. "css": `#`+prefix+`ArrDefaultSortAndFilter {background: #2a2a2a;}
  230. #`+prefix+`ArrDefaultSortAndFilter .field_label {color: #fff;}
  231. #`+prefix+`ArrDefaultSortAndFilter .config_header {color: #fff; padding-bottom: 10px;}
  232. #`+prefix+`ArrDefaultSortAndFilter .reset {color: #f00; text-align: center;}
  233. #`+prefix+`ArrDefaultSortAndFilter .section_header {color: #fff; text-align: left; margin-top: 15px;margin-bottom: 5px; padding:5px;}
  234. #`+prefix+`ArrDefaultSortAndFilter .section_desc {background: #2a2a2a; color: #fff; text-align: left; border:none;}
  235. #`+prefix+`ArrDefaultSortAndFilter .config_var {text-align: left;}
  236. #`+prefix+`ArrDefaultSortAndFilter input { display: block; margin-top:5px; margin-bottom: 20px}
  237. #`+prefix+`ArrDefaultSortAndFilter .reset_holder {display: none;}`,
  238. 'title': arr_type+' Default Sort and Filter', // Panel Title
  239. 'fields': // Fields object
  240. {
  241. [prefix+'InteractiveSearchFilterName']: // This is the id of the field
  242. {
  243. 'label': 'Name of default filter to apply (leave empty to disable)', // Appears next to field
  244. 'section': [GM_config.create('Automatic Filter'), 'Automatically applies a filter when loading interactive search table'],
  245. 'type': 'text', // Makes this setting a text field
  246. 'title': 'Enter the name of the filter exactly as you see it in '+arr_type,
  247. 'default': '' // Default value if user doesn't change it
  248. },
  249. [prefix+'InteractiveSearchSortColumn']: // This is the id of the field
  250. {
  251. 'label': 'Default interactive search column to sort by (leave empty to disable)', // Appears next to field
  252. 'section': [GM_config.create('Automatic Sort'), 'If you want the script to automatically apply a filter when loading interactive search, enter the exact name of the filter here'],
  253. 'title': 'Column title exactly as you see it. For icons enter the value that appears when you move your mouse over the icon.',
  254. 'type': 'text', // Makes this setting a text field
  255. 'default': '' // Default value if user doesn't change it
  256. },
  257. [prefix+'InteractiveSearchSortAscending']: // This is the id of the field
  258. {
  259. 'label': 'Sort Ascending? (Check to sort A-Z / 0 - 9)', // Appears next to field
  260. 'type': 'checkbox', // Makes this setting a text field
  261. 'default': false // Default value if user doesn't change it
  262. },
  263. },
  264. 'events': {
  265. 'init': () => {
  266. // initialization complete, load values
  267. filterName = gmc.get([prefix+'InteractiveSearchFilterName']);
  268. sortColumn = gmc.get([prefix+'InteractiveSearchSortColumn']);
  269. sortAscending = gmc.get([prefix+'InteractiveSearchSortAscending']);
  270. },
  271. 'save': function () { // runs after values are saved
  272. // settings may have been changed, reload values
  273. filterName = gmc.get([prefix+'InteractiveSearchFilterName']);
  274. sortColumn = gmc.get([prefix+'InteractiveSearchSortColumn']);
  275. sortAscending = gmc.get([prefix+'InteractiveSearchSortAscending']);
  276. }
  277. }
  278. });
  279.  
  280. // Add configuration menu item to extension
  281. GM_registerMenuCommand("Configure "+arr_type+" default sort & filter", show_config, "c");
  282.  
  283. // Initial page load - Are we on a movie page?
  284. if(onPageWithInteractiveSearchButton(arr_type))
  285. interactiveSearchPageLoaded()
  286.  
  287. // Code below to detect page change without reload *arr uses - https://stackoverflow.com/questions/6390341/how-to-detect-if-url-has-changed-after-hash-in-javascript
  288. let oldPushState = history.pushState;
  289. history.pushState = function pushState() {
  290. let ret = oldPushState.apply(this, arguments);
  291. window.dispatchEvent(new Event('pushstate'));
  292. window.dispatchEvent(new Event('locationchange'));
  293. return ret;
  294. };
  295.  
  296. let oldReplaceState = history.replaceState;
  297. history.replaceState = function replaceState() {
  298. let ret = oldReplaceState.apply(this, arguments);
  299. window.dispatchEvent(new Event('replacestate'));
  300. window.dispatchEvent(new Event('locationchange'));
  301. return ret;
  302. };
  303.  
  304. window.addEventListener('popstate', () => {
  305. window.dispatchEvent(new Event('locationchange'));
  306. });
  307.  
  308. // locationchange event is fired when url is changed thanks to code above
  309. window.addEventListener('locationchange', function () {
  310. //Are we on a movie page? Call appropriate function
  311. if(onPageWithInteractiveSearchButton(arr_type))
  312. interactiveSearchPageLoaded()
  313. // When on another page, disable the mutation observer
  314. else
  315. nonInteractiveSearchPageLoaded()
  316. });
  317.  
  318. }
  319.  
  320. function show_config()
  321. {
  322. gmc.open();
  323. }
  324.  
  325. /* END FUNCTIONS */
  326. // Following code is ran on every single tab so should be kept as light as possible
  327. // Support all sites listed under Media Automation on https://wiki.servarr.com/
  328. let supportedSites = ["Lidarr","Radarr","Readarr","Sonarr","Whisparr"];
  329.  
  330. // Check if we're on a supported site (site with metadata description 'Radarr'/'Sonarr'/...)
  331. let description = document.querySelector('meta[name="description"]');
  332.  
  333. if(description && supportedSites.includes(description.content)){
  334. let arr_type=description.content
  335. console.log(arr_type+" installation found. Watching for interactive search table.")
  336. // Define some variables for global use through the script
  337. // Create a new MutationObserver since *arr sites dynamically add elements after pageload
  338. var observer = new MutationObserver(handleMutations);
  339.  
  340. // Observe the body element for changes to the class (= modal opened / closed)
  341. var body_observer = new MutationObserver(bodyClassChanged);
  342.  
  343. // *arr sites uses ajax to change pages; We only want to click the filter button once per movie (otherwise the filter button is completely blocked from user interaction)
  344. var filterButtonClicked = false;
  345.  
  346. // Variable to store gm config
  347. var gmc;
  348.  
  349. // filter and sort options. Variables automatically get populated by the GMC config menu on init
  350. var filterName;
  351. var sortColumn;
  352. var sortAscending;
  353.  
  354. // Load the script functionality
  355. loadScript(arr_type);
  356. }
  357. })();