*arr default sort & filter

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

// ==UserScript==
// @name        *arr default sort & filter
// @namespace   https://github.com/Millio345/arr-default-sort-and-filter
/* 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 */
// @include http://*
// @include https://*
// @require     https://openuserjs.org/src/libs/sizzle/GM_config.js
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_registerMenuCommand
// @version     1.2
// @author      Millio
// @description Sets default sort and filter options for Radarr/Sonarr/...'s interactive search
// @license      MIT
// ==/UserScript==

/* This script allows you to define default sorting and filter for your interactive searches for the arr websites.
As of version 1.1 you no longer need to make any changes to this file.
USAGE:
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

Problems? Suggestions? Improvements? GitHub at https://github.com/Millio345/arr-default-sort-and-filter

CHANGELOG:
1.2 - Add support for Lidarr, Sonarr, Readarr & Whisparr
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)
1.0 - Initial release, basic sort & filter functionality
*/
(function() {
    'use strict';

    /* FUNCTIONS */
    // Sort the table by sortColumn and click "Filter" button if filterName is set
    function clickColumnAndFilter(tableDiv) {
        if(!(sortColumn === null || sortColumn === ""))
        {
            // Find all table headers
            let thElements = tableDiv.getElementsByTagName('th');
            let correctHeaderFound = false
            let headerList = [];
            // Loop through all table headers
            for (let i = 0; i < thElements.length; i++) {
                // Check if this is the table header we want to sort by
                if (thElements[i].textContent.trim().toLowerCase() === sortColumn.toLowerCase()) {
                    correctHeaderFound = true;
                }
                // Not it, let's check if it has a span with the correct title for icon columns
                else if(thElements[i].querySelector('span') && thElements[i].querySelector('span').getAttribute('title').toLowerCase() === sortColumn.toLowerCase()) {
                    correctHeaderFound = true;
                }
                else {
                    if(thElements[i].textContent!==""){
                        headerList.push(thElements[i].textContent);
                    }
                    else if(thElements[i].querySelector('span')){
                        headerList.push(thElements[i].querySelector('span').getAttribute('title'));
                    }
                }

                if(correctHeaderFound){
                    // Click the <th> element
                    thElements[i].click();

                    // If we don't want ascending order, click again
                    if(!sortAscending)
                        thElements[i].click();

                    break; // Stop the loop since we found the target column
                }
            }
            if(!correctHeaderFound){
                console.warn("Sort column '"+sortColumn+"' not found in table headers.")
                console.log("Valid columns: "+headerList.join(','))
            }
        }
        //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
        if(filterName === null || filterName === "" || filterButtonClicked)
            return

        // "Filter" button is first child of tableDiv parent
        let divs = tableDiv.parentElement.getElementsByTagName('div')
        for(let i =0; i < divs.length;i++){
            if(divs[i].className.startsWith('FilterMenu-filterMenu-'))
            {
                let filter = divs[i]
                let button = filter.querySelector('button')
                button.click()
                return;
            }
        }
        console.warn("Filter button not found. Unable to apply filter.");
    }

    // Click the filterName as soon as the filter menu is opened
    function clickChosenFilter(filterListNode) {

        // First make sure filterName is set
        if(filterName === null || filterName === ""){
            return
        }

        // 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
        if(filterButtonClicked){
            return
        }

        // Get all filters
        let filters = filterListNode.getElementsByTagName('button')

        // Create a filterList for debug logging in case filter is not found
        let filterList = [];

        for(let i = 0; i < filters.length; i++) {
            let div = filters[i].querySelector('div');
            if(div !== null){
                if(div.textContent.trim()===filterName){
                    // Got the right filter, click it
                    filters[i].click();

                    // Set filterButtonClicked to true
                    filterButtonClicked = true
                    return;
                }
                else
                    filterList.push(div.textContent.trim());
            }
        }
        console.warn("Filter '"+filterName+"' not found in filter list.")
        console.log("Valid filters:"+filterList.join(','));
    }

    // Observe the body element so we know when modal is opened / closed
    function bodyClassChanged(mutationsList, _observer){
        mutationsList.forEach(mutation => {
            if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
                // Modal was closed; Set filterButtonClicked to false so we can reclick it next time the modal is opened
                if(!mutation.target.className.includes("Modal-modalOpen")) {
                    filterButtonClicked = false;
                }
            }
        });
    }
    // This function is called by the mutationObserver when additional page content is added. Check if it contains the search table or the filter 'scroller'
    function handleMutations(mutationsList, _observer) {
        for (let mutation of mutationsList) {
            if (mutation.type === 'childList') {
                // Check if the added nodes include the target element
                if (mutation.addedNodes) {
                    for (let node of mutation.addedNodes) {
                        // Check if the added node is an element (type 1) which has classes
                        if (node.nodeType === 1 && typeof node.className === 'string')
                        {
                            // Was a tableContainer in a modal added?
                            if(node.className.includes('Table-tableContainer-')&&node.closest("[class^='ModalBody']")) {
                                clickColumnAndFilter(node);
                                return;
                            }
                            // Was a menu scroller added (This is the opened filter list)? Click the chosen filter
                            else if(node.className.includes('MenuContent-scroller-')) {
                                clickChosenFilter(node)
                            }
                        }
                    }
                }
            }
        }
    }

    // Check current page url to see if we're on page with interactive search button or not
    function onPageWithInteractiveSearchButton(arr_type) {
        let pattern;

        // Depending on which arr site we're on we check the url
        if(arr_type==="Lidarr") {
            // artist/albums links end in /(artist|album)/artist-or-album-name
            pattern = /\/(artist|album)\/[\w-]+$/;
        }
        else if(arr_type==="Radarr") {
            // movie links end in /movie/NUMBER
            pattern = /\/movie\/\d+$/;
        }
        else if(arr_type==="Readarr") {

            // author links end in /author/NUMBER
            // 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.
            pattern = /\/author\/\d+$/;
        }
        else if(arr_type==="Sonarr") {

            // series links end in /series/series-name
            pattern = /\/series\/[\w-]+$/;
        }
        else if(arr_type==="Whisparr") {
            // site links end in /site/sitename
            pattern = /\/site\/[\w-]+$/;
        }

        // Return true / false depending on whether we're on a media page
        return pattern.test(window.location.href);
    }


    // When a movie page is loaded, start mutation observers and set filter clicked to false
    function interactiveSearchPageLoaded(){
        filterButtonClicked = false
        observer.observe(document.documentElement, { childList: true, subtree: true });
        body_observer.observe(document.querySelector('body'), { attributes: true, attributeFilter: ['class'], attributeOldValue: true });
    }

    // To prevent unintended consequences on other tables/buttons, stop observer when another page is loaded
    function nonInteractiveSearchPageLoaded(){
        observer.disconnect()
        body_observer.disconnect()
    }
    function loadScript(arr_type)
    {
        // Set Variable prefix to the arr type (f.e. SonarrInteractiveSearchFilterName
        let prefix = arr_type;

        // To prevent breaking changes to previous installations when this script only supported Radarr, Radarr variables don't have a prefix.
        if(prefix==="Radarr"){
            prefix="";
        }
        // Setup GM Config, which provides a nice UI to set the options
        gmc = new GM_config(
            {
                // We need unique id for each site, otherwise all non-specified values will be cleared when saving.
                'id': prefix+'ArrDefaultSortAndFilter',
                "css": `#`+prefix+`ArrDefaultSortAndFilter {background: #2a2a2a;}
                #`+prefix+`ArrDefaultSortAndFilter .field_label {color: #fff;}
                #`+prefix+`ArrDefaultSortAndFilter .config_header {color: #fff; padding-bottom: 10px;}
                #`+prefix+`ArrDefaultSortAndFilter .reset {color: #f00; text-align: center;}
                #`+prefix+`ArrDefaultSortAndFilter .section_header {color: #fff; text-align: left; margin-top: 15px;margin-bottom: 5px; padding:5px;}
                #`+prefix+`ArrDefaultSortAndFilter .section_desc {background: #2a2a2a; color: #fff; text-align: left; border:none;}
                #`+prefix+`ArrDefaultSortAndFilter .config_var {text-align: left;}
                #`+prefix+`ArrDefaultSortAndFilter input { display: block; margin-top:5px; margin-bottom: 20px}
                #`+prefix+`ArrDefaultSortAndFilter .reset_holder {display: none;}`,
                'title': arr_type+' Default Sort and Filter', // Panel Title
                'fields': // Fields object
                    {
                        [prefix+'InteractiveSearchFilterName']: // This is the id of the field
                            {
                                'label': 'Name of default filter to apply (leave empty to disable)', // Appears next to field
                                'section': [GM_config.create('Automatic Filter'), 'Automatically applies a filter when loading interactive search table'],
                                'type': 'text', // Makes this setting a text field
                                'title': 'Enter the name of the filter exactly as you see it in '+arr_type,
                                'default': '' // Default value if user doesn't change it
                            },
                        [prefix+'InteractiveSearchSortColumn']: // This is the id of the field
                            {
                                'label': 'Default interactive search column to sort by (leave empty to disable)', // Appears next to field
                                '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'],
                                'title':  'Column title exactly as you see it. For icons enter the value that appears when you move your mouse over the icon.',
                                'type': 'text', // Makes this setting a text field
                                'default': '' // Default value if user doesn't change it
                            },
                        [prefix+'InteractiveSearchSortAscending']: // This is the id of the field
                            {
                                'label': 'Sort Ascending? (Check to sort A-Z / 0 - 9)', // Appears next to field
                                'type': 'checkbox', // Makes this setting a text field
                                'default': false // Default value if user doesn't change it
                            },
                    },
                'events': {
                    'init': () => {
                        // initialization complete, load values
                        filterName = gmc.get([prefix+'InteractiveSearchFilterName']);
                        sortColumn = gmc.get([prefix+'InteractiveSearchSortColumn']);
                        sortAscending = gmc.get([prefix+'InteractiveSearchSortAscending']);
                    },
                    'save': function () { // runs after values are saved
                        // settings may have been changed, reload values
                        filterName = gmc.get([prefix+'InteractiveSearchFilterName']);
                        sortColumn = gmc.get([prefix+'InteractiveSearchSortColumn']);
                        sortAscending = gmc.get([prefix+'InteractiveSearchSortAscending']);
                    }
                }
            });

        // Add configuration menu item to extension
        GM_registerMenuCommand("Configure "+arr_type+" default sort & filter", show_config, "c");

        // Initial page load - Are we on a movie page?
        if(onPageWithInteractiveSearchButton(arr_type))
            interactiveSearchPageLoaded()

        // 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
        let oldPushState = history.pushState;
        history.pushState = function pushState() {
            let ret = oldPushState.apply(this, arguments);
            window.dispatchEvent(new Event('pushstate'));
            window.dispatchEvent(new Event('locationchange'));
            return ret;
        };

        let oldReplaceState = history.replaceState;
        history.replaceState = function replaceState() {
            let ret = oldReplaceState.apply(this, arguments);
            window.dispatchEvent(new Event('replacestate'));
            window.dispatchEvent(new Event('locationchange'));
            return ret;
        };

        window.addEventListener('popstate', () => {
            window.dispatchEvent(new Event('locationchange'));
        });

        // locationchange event is fired when url is changed thanks to code above
        window.addEventListener('locationchange', function () {
            //Are we on a movie page? Call appropriate function
            if(onPageWithInteractiveSearchButton(arr_type))
                interactiveSearchPageLoaded()
            // When on another page, disable the mutation observer
            else
                nonInteractiveSearchPageLoaded()
        });

    }

    function show_config()
    {
        gmc.open();
    }

    /* END FUNCTIONS */
    // Following code is ran on every single tab so should be kept as light as possible
    // Support all sites listed under Media Automation on https://wiki.servarr.com/
    let supportedSites = ["Lidarr","Radarr","Readarr","Sonarr","Whisparr"];

    // Check if we're on a supported site (site with metadata description 'Radarr'/'Sonarr'/...)
    let description = document.querySelector('meta[name="description"]');

    if(description && supportedSites.includes(description.content)){
        let arr_type=description.content
        console.log(arr_type+" installation found. Watching for interactive search table.")
        // Define some variables for global use through the script
        // Create a new MutationObserver since *arr sites dynamically add elements after pageload
        var observer = new MutationObserver(handleMutations);

        // Observe the body element for changes to the class (= modal opened / closed)
        var body_observer = new MutationObserver(bodyClassChanged);

        // *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)
        var filterButtonClicked = false;

        // Variable to store gm config
        var gmc;

        // filter and sort options. Variables automatically get populated by the GMC config menu on init
        var filterName;
        var sortColumn;
        var sortAscending;

        // Load the script functionality
        loadScript(arr_type);
    }
})();