Mediux - Yaml Fixes

Adds fixes and functions to Mediux

// ==UserScript==
// @name          Mediux - Yaml Fixes
// @version       2.1.0
// @description   Adds fixes and functions to Mediux
// @author        Journey Over
// @license       MIT
// @match         *://mediux.pro/*
// @require       https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@c185c2777d00a6826a8bf3c43bbcdcfeba5a9566/libs/gm/gmcompat.min.js
// @require       https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@c185c2777d00a6826a8bf3c43bbcdcfeba5a9566/libs/utils/utils.min.js
// @require       https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js
// @grant         GM.xmlHttpRequest
// @grant         GM.setValue
// @grant         GM.getValue
// @run-at        document-end
// @icon          https://www.google.com/s2/favicons?sz=64&domain=mediux.pro
// @homepageURL   https://github.com/StylusThemes/Userscripts
// @namespace https://greasyfork.org/users/32214
// ==/UserScript==

(function() {
  'use strict';

  const logger = Logger('Mediux - Yaml Fixes', { debug: false });

  /**
   * MediuxFixes - Main application namespace
   *
   * This userscript enhances Mediux.pro by providing tools to:
   * - Extract and format YAML for posters and backdrops
   * - Process boxsets and their associated media
   * - Fix formatting issues in YAML for Kometa compatibility
   */
  const MediuxFixes = {
    // UI elements cache to avoid repeated DOM queries
    elements: {
      codeblock: null,
      buttons: {}
    },

    // Utility functions for common operations
    utils: {
      /**
       * Creates a promise that resolves after the specified time
       * @param {number} ms - Milliseconds to wait
       * @returns {Promise} - Promise that resolves after delay
       */
      sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
      },

      /**
       * Checks if a value is a string
       * @param {any} value - Value to check
       * @returns {boolean} - True if value is a string
       */
      isString(value) {
        return typeof value === 'string';
      },

      /**
       * Checks if a value is a non-empty object (not null, not array, has keys)
       * @param {any} obj - Object to check
       * @returns {boolean} - True if object is valid and has properties
       */
      isNonEmptyObject(obj) {
        return (
          typeof obj === 'object' && // Check if it's an object
          obj !== null && // Check that it's not null
          !Array.isArray(obj) && // Ensure it's not an array
          Object.keys(obj).length > 0 // Check if it has keys
        );
      },

      /**
       * Displays a temporary notification on the page
       * @param {string} message - Text to display
       * @param {number} duration - How long to show the notification (ms)
       */
      showNotification(message, duration = 3000) {
        // Create the notification div
        const notification = document.createElement('div');
        const myleftDiv = document.querySelector('#myleftdiv');
        const parentDiv = $(myleftDiv).parent();

        // Set the styles
        Object.assign(notification.style, {
          width: '50%',
          height: '50%',
          backgroundColor: 'rgba(200, 200, 200, 0.85)',
          color: 'black',
          padding: '20px',
          borderRadius: '5px',
          justifyContent: 'center',
          alignItems: 'center',
          zIndex: '1000',
          display: 'none'
        });

        // Set the message
        notification.innerText = message;

        $(myleftDiv).after(notification);

        // Show the notification
        notification.style.display = 'flex';

        // Hide after specified duration
        setTimeout(() => {
          notification.style.display = 'none';
          parentDiv.removeChild(notification);
        }, duration);
      },

      /**
       * Updates a button's appearance to indicate success/failure
       * @param {HTMLElement} button - Button to update
       * @param {boolean} success - Whether operation was successful
       */
      updateButtonState(button, success = true) {
        // Visual feedback for button actions
        button.classList.remove('bg-gray-500');
        button.classList.add(success ? 'bg-green-500' : 'bg-red-500');

        // After 3 seconds, change it back to default
        setTimeout(() => {
          button.classList.remove('bg-green-500', 'bg-red-500');
          button.classList.add('bg-gray-500');
        }, 3000);
      },

      /**
       * Copies text to clipboard and shows notification
       * @param {string} text - Text to copy
       * @returns {Promise<boolean>} - Whether copy was successful
       */
      copyToClipboard(text) {
        return navigator.clipboard.writeText(text)
          .then(() => {
            this.showNotification("Results copied to clipboard!");
            return true;
          })
          .catch(err => {
            logger.error('Failed to copy: ', err);
            this.showNotification("Failed to copy to clipboard", 3000);
            return false;
          });
      }
    },

    // Data retrieval functions to extract information from Mediux
    data: {
      /**
       * Extracts poster data from page scripts
       * @returns {Array} - Array of poster objects
       */
      getPosters() {
        const regexpost = /posterCheck/g;
        const scriptlist = document.querySelectorAll('script');

        // Search scripts from the end (newer scripts tend to be at the end)
        for (let i = scriptlist.length - 1; i >= 0; i--) {
          const element = scriptlist[i];
          if (regexpost.test(element.textContent)) {
            // Extract and parse the JSON data from the script
            let str = element.textContent.replace('self.__next_f.push(', '');
            str = str.substring(0, str.length - 1);
            const jsonString = JSON.parse(str)[1].split('{"set":')[1];
            const fullJson = `{"set":${jsonString}`;
            const parsedObject = JSON.parse(fullJson.substring(0, fullJson.length - 2));
            return parsedObject.set.files;
          }
        }
        return [];
      },

      /**
       * Extracts set data and creator information
       * @returns {Array} - Array of set objects
       */
      getSets() {
        const regexpost = /posterCheck/g;
        const scriptlist = document.querySelectorAll('script');

        for (let i = scriptlist.length - 1; i >= 0; i--) {
          const element = scriptlist[i];
          if (regexpost.test(element.textContent)) {
            // Extract and parse the JSON data from the script
            let str = element.textContent.replace('self.__next_f.push(', '');
            str = str.substring(0, str.length - 1);
            const jsonString = JSON.parse(str)[1].split('{"set":')[1];
            const fullJson = `{"set":${jsonString}`;
            const parsedObject = JSON.parse(fullJson.substring(0, fullJson.length - 2));
            // Store the creator's username for later use
            GMC.setValue('creator', parsedObject.set.user_created.username);
            return parsedObject.set.boxset.sets;
          }
        }
        return [];
      },

      /**
       * Fetches a specific set by ID
       * @param {string} setId - The ID of the set to fetch
       * @returns {Promise<string>} - HTML content of the set page
       */
      getSet(setId) {
        return new Promise((resolve, reject) => {
          GMC.xmlHttpRequest({
            method: 'GET',
            url: `https://mediux.pro/sets/${setId}`,
            timeout: 30000,
            onload: (response) => {
              resolve(response.responseText);
            },
            onerror: () => {
              logger.error(`An error occurred loading set ${setId}`);
              reject(new Error('Request failed'));
            },
            ontimeout: () => {
              logger.error(`It took too long to load set ${setId}`);
              reject(new Error('Request timed out'));
            }
          });
        });
      }
    },

    // YAML processing functions
    yaml: {
      /**
       * Loads and processes an entire boxset, generating YAML for all items
       * @param {HTMLElement} codeblock - Code element to update with results
       * @returns {Promise<void>}
       */
      async loadBoxset(codeblock) {
        const button = document.querySelector('#bsetbutton');
        let originalText = codeblock.textContent + '\n';
        const sets = MediuxFixes.data.getSets();
        const creator = await GMC.getValue('creator');
        const startTime = Date.now();
        let elapsedTime = 0;
        let processedMovies = [];

        // Replace codeblock text with a timer
        codeblock.innerText = "Processing... 0 seconds";

        // Setup progress display to show elapsed time and recently processed items
        const timerInterval = setInterval(() => {
          elapsedTime = Math.floor((Date.now() - startTime) / 1000);
          const latestMovies = processedMovies.slice(-3).join(', ');
          codeblock.innerText = `Processing... ${elapsedTime} seconds\nRecent processed: ${latestMovies}`;
        }, 1000);

        try {
          // Process each set in the boxset
          for (const set of sets) {
            try {
              // Fetch the set data
              const response = await MediuxFixes.data.getSet(set.id);
              const response2 = response.replaceAll('\\', ''); // Remove escape characters

              // Extract files data using regex
              const regexfiles = /"files":(\[{"id":.*?}]),"boxset":/s;
              const match = response2.match(regexfiles);

              if (match && match[1]) {
                let filesArray;
                try {
                  filesArray = JSON.parse(match[1]);
                } catch (err) {
                  logger.error('Error parsing filesArray:', err);
                  continue;
                }

                // Filter out collection posters and sort by title
                const filteredFiles = filesArray
                  .filter(file => !file.title.trim().endsWith('Collection'))
                  .sort((a, b) => a.title.localeCompare(b.title));

                // Process each file (poster or backdrop)
                for (const f of filteredFiles) {
                  if (f.movie_id !== null) {
                    // Handle movie posters
                    const posterId = f.fileType === 'poster' && f.id.length > 0 ? f.id : 'N/A';
                    const movieId = MediuxFixes.utils.isNonEmptyObject(f.movie_id) ? f.movie_id.id : 'N/A';
                    const movieTitle = MediuxFixes.utils.isString(f.title) && f.title.length > 0 ? f.title.trimEnd() : 'N/A';

                    // Build YAML entry for movie poster
                    originalText += `  ${movieId}: # ${movieTitle} Poster by ${creator} on MediUX.  https://mediux.pro/sets/${set.id}\n    url_poster: https://api.mediux.pro/assets/${posterId}\n    `;
                    processedMovies.push(movieTitle);
                    logger(`Title: ${movieTitle}\nPoster: ${posterId}`);
                  } else if (f.movie_id_backdrop !== null) {
                    // Handle movie backdrops
                    const backdropId = f.fileType === 'backdrop' && f.id.length > 0 ? f.id : 'N/A';
                    const movieId = MediuxFixes.utils.isNonEmptyObject(f.movie_id_backdrop) ? f.movie_id_backdrop.id : 'N/A';
                    originalText += `url_background: https://api.mediux.pro/assets/${backdropId}\n\n`;
                    logger(`Backdrop: ${backdropId}\nMovie id: ${movieId}`);
                  }
                }
              }
            } catch (err) {
              logger.error(`Error processing set ${set.id}:`, err);
            }
          }
        } finally {
          // Stop the timer when processing is complete
          clearInterval(timerInterval);
        }

        // Create a clickable link for copying the results
        codeblock.innerText = "Processing complete!";
        const copyLink = document.createElement('a');
        copyLink.href = "#";
        copyLink.innerText = "Click here to copy the results";
        copyLink.style.color = 'blue';
        copyLink.style.cursor = 'pointer';

        // Add click event listener to copy the results
        copyLink.addEventListener('click', async (e) => {
          e.preventDefault();
          try {
            await navigator.clipboard.writeText(originalText);
            codeblock.innerText = originalText;
            MediuxFixes.utils.updateButtonState(button);
            MediuxFixes.utils.showNotification("Results copied to clipboard!");
          } catch (err) {
            logger.error('Failed to copy: ', err);
          }
        });

        // Append the link to the codeblock
        codeblock.appendChild(copyLink);
        const totalTime = Math.floor((Date.now() - startTime) / 1000);
        logger(`Total time taken: ${totalTime} seconds`);
      },

      /**
       * Fixes missing season posters in YAML
       * @param {HTMLElement} codeblock - Code element to update
       */
      fixPosters(codeblock) {
        const button = document.querySelector('#fpbutton');
        let yaml = codeblock.textContent;
        const posters = MediuxFixes.data.getPosters();

        // Filter for season posters
        const seasons = posters.filter(poster => poster.title.includes("Season"));

        // Add each season poster to the YAML
        for (let i in seasons) {
          const current = seasons.filter(season => season.title.includes(`Season ${i}`));
          if (current.length > 0) {
            yaml += `      ${i}:\n        url_poster: https://api.mediux.pro/assets/${current[0].id}\n`;
          }
        }

        // Update codeblock and copy to clipboard
        codeblock.innerText = yaml;
        navigator.clipboard.writeText(yaml)
          .then(() => {
            MediuxFixes.utils.showNotification("Results copied to clipboard!");
            MediuxFixes.utils.updateButtonState(button);
          });
      },

      /**
       * Fixes missing season numbers in TitleCard YAML
       * @param {HTMLElement} codeblock - Code element to update
       */
      fixCards(codeblock) {
        const button = document.querySelector('#fcbutton');
        const str = codeblock.innerText;

        // Check if the YAML needs fixing (has episodes without season numbers)
        const regextest = /(seasons:\n)(        episodes:)/g;
        const regex = /(        episodes:)/g;

        if (regextest.test(str)) {
          // Add season numbers before each episodes section
          let counter = 1;
          const modifiedStr = str.replace(regex, (match) => {
            const newLine = `      ${counter++}:\n`;
            return `${newLine}${match}`;
          });

          // Update codeblock and copy to clipboard
          codeblock.innerText = modifiedStr;
          navigator.clipboard.writeText(modifiedStr)
            .then(() => {
              MediuxFixes.utils.showNotification("Results copied to clipboard!");
              MediuxFixes.utils.updateButtonState(button);
            });
        } else {
          MediuxFixes.utils.showNotification("No card formatting needed");
        }
      },

      /**
       * Formats TV show YAML for compatibility with Kometa
       * @param {HTMLElement} codeblock - Code element to update
       */
      formatTvYml(codeblock) {
        const button = document.querySelector('#fytvbutton');
        let yaml = codeblock.textContent;

        // Extract the set ID, title, and year from the YAML content
        const regexSet = /(\d+): # TVDB id for (.*?)\. Set by (.*?) on MediUX\. (https:\/\/mediux\.pro\/sets\/\d+)/;

        // Extract title and year from the HTML page
        const htmlTitle = document.querySelector('h1').textContent;
        const yearMatch = htmlTitle.match(/\((\d{4})\)/);
        const year = yearMatch ? yearMatch[1] : 'Unknown';

        const match = yaml.match(regexSet);
        if (match) {
          const setId = match[1];
          const title = match[2];
          const url = match[4];

          // Replace the header part with formatted metadata
          yaml = yaml.replace(regexSet, `# Posters from:\n# ${url}\n\nmetadata:\n\n  ${setId}: # ${title} (${year})`);
        }

        // Clean up the formatting
        // Remove any leading spaces from the header
        yaml = yaml.replace(/^\s+# Posters from:/m, `# Posters from:`);

        // Add quotes around URLs for YAML compatibility
        yaml = yaml.replace(/(url_poster|url_background): (https:\/\/api\.mediux\.pro\/assets\/[a-z0-9\-]+)/g, '$1: "$2"');

        // Fix season indentation for proper YAML hierarchy
        yaml = yaml.replace(/(\d+):\n\s+url_poster: (https:\/\/api\.mediux\.pro\/assets\/[a-z0-9\-]+)\n/g,
          (match, season, url) => `      ${season}:\n        url_poster: "${url}"\n`);

        // Update the code block and copy to clipboard
        codeblock.innerText = yaml;
        navigator.clipboard.writeText(yaml)
          .then(() => {
            MediuxFixes.utils.showNotification("YAML transformed and copied to clipboard!");
            MediuxFixes.utils.updateButtonState(button);
          });
      },

      /**
       * Formats Movie YAML for compatibility with Kometa
       * @param {HTMLElement} codeblock - Code element to update
       */
      formatMovieYml(codeblock) {
        const button = document.querySelector('#fymoviebutton');
        let yaml = codeblock.textContent;

        // Extract set URL from the YAML content
        const regexSet = /https:\/\/mediux\.pro\/sets\/\d+/;
        const urlMatch = yaml.match(regexSet);
        const url = urlMatch ? urlMatch[0] : null;

        if (url) {
          // Clean up individual movie entries while preserving ID, title and year
          yaml = yaml.replace(
            /(\d+):\s*#\s*(.*?)\s*\((\d{4})\).*?(https:\/\/mediux\.pro\/sets\/\d+)/g,
            (match, id, title, year) => `${id}: # ${title.trim()} (${year})`
          );

          // Add a standardized header with the set URL
          const header = `# Posters from:\n# ${url}\n\nmetadata:\n\n`;
          yaml = yaml.replace(/(^|\n)metadata:\n/g, '');
          yaml = header + yaml;

          // Format URLs with quotes and clean up whitespace
          yaml = yaml
            .replace(/(url_poster|url_background): (https:\/\/api\.mediux\.pro\/assets\/\S+)/g, '$1: "$2"')
            .replace(/(\n\n)(\s+\n)/g, '\n\n')
            .replace(/\n{3,}/g, '\n\n');
        }

        // Update the code block and copy to clipboard
        codeblock.innerText = yaml;
        navigator.clipboard.writeText(yaml)
          .then(() => {
            MediuxFixes.utils.showNotification("YAML transformed and copied to clipboard!");
            MediuxFixes.utils.updateButtonState(button);
          });
      }
    },

    // UI initialization and management
    ui: {
      /**
       * Creates the user interface elements and attaches event handlers
       */
      createInterface() {
        // Get the DOM elements
        const codeblock = document.querySelector('code.whitespace-pre-wrap');
        MediuxFixes.elements.codeblock = codeblock;

        // Restructure the page to make room for our custom UI
        const myDiv = document.querySelector('.flex.flex-col.space-y-1\\.5.text-center.sm\\:text-left');
        $(myDiv).children('h2, p').wrapAll('<div class="flex flex-row" style="align-items: center"><div id="myleftdiv" style="width: 25%; align: left"></div></div>');

        const myleftdiv = document.querySelector('#myleftdiv');

        // Define button configurations
        const buttons = [{
            id: 'fcbutton',
            title: 'Fix missing season numbers in TitleCard YAML',
            icon: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-puzzle w-5 h-5"><path d="M7 2h10"></path><path d="M5 6h14"></path><rect width="18" height="12" x="3" y="10" rx="2"></rect></svg>',
            action: () => MediuxFixes.yaml.fixCards(codeblock)
          },
          {
            id: 'fpbutton',
            title: 'Fix missing season posters YAML',
            icon: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-image w-5 h-5"><path d="M2 7v10"></path><path d="M6 5v14"></path><rect width="12" height="18" x="10" y="3" rx="2"></rect></svg>',
            action: () => MediuxFixes.yaml.fixPosters(codeblock)
          },
          {
            id: 'bsetbutton',
            title: 'Generate YAML for associated boxset',
            icon: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-box w-5 h-5"><rect width="18" height="18" x="3" y="3" rx="2"></rect><path d="M7 7v10"></path><path d="M11 7v10"></path><path d="m15 7 2 10"></path></svg>',
            action: () => MediuxFixes.yaml.loadBoxset(codeblock)
          },
          {
            id: 'fytvbutton',
            title: 'Format TV show YAML for Kometa',
            icon: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-tv w-5 h-5"><rect x="2" y="7" width="20" height="15" rx="2" ry="2"></rect><polyline points="17 2 12 7 7 2"></polyline></svg>',
            action: () => MediuxFixes.yaml.formatTvYml(codeblock)
          },
          {
            id: 'fymoviebutton',
            title: 'Format Movie YAML for Kometa',
            icon: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-film-reel w-5 h-5"><circle cx="12" cy="12" r="8" stroke="currentColor" stroke-width="2"></circle><line x1="12" y1="4" x2="12" y2="20"></line><line x1="4" y1="12" x2="20" y2="12"></line><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2"></circle></svg>',
            action: () => MediuxFixes.yaml.formatMovieYml(codeblock)
          }
        ];

        // Create a container for the buttons
        const buttonContainer = $('<div id="extbuttons" class="flex flex-row" style="margin-top: 10px"></div>');

        // Create each button and add it to the container
        buttons.forEach((button, index) => {
          const $button = $(`<button id="${button.id}" title="${button.title}" class="duration-500 py-1 px-2 text-xs bg-gray-500 text-white rounded flex items-center justify-center focus:outline-none"${index > 0 ? ' style="margin-left:10px"' : ''}>${button.icon}</button>`);
          $button.on('click', button.action);
          buttonContainer.append($button);
          MediuxFixes.elements.buttons[button.id] = $button[0];
        });

        // Add the buttons to the page
        $(myleftdiv).append(buttonContainer);
        $(myleftdiv).parent().append('<div style="width: 25%;"></div>');
      }
    },

    /**
     * Initialize the application
     * Waits for the code element to be present before setting up the UI
     */
    init() {
      waitForKeyElements("code.whitespace-pre-wrap", () => {
        this.ui.createInterface();
        logger('Initialized');
      });
    }
  };

  // Start the application
  MediuxFixes.init();

  /**
   * waitForKeyElements - A utility function for Greasemonkey scripts that
   * detects and handles AJAXed content.
   *
   * @param {string} selectorTxt - The jQuery selector for target elements
   * @param {Function} actionFunction - Function to run when elements are found
   * @param {boolean} bWaitOnce - If false, continue looking for new elements
   * @param {string} iframeSelector - Optional selector for iframe to search in
   */
  function waitForKeyElements(
    selectorTxt,
    /* Required: The jQuery selector string that
                        specifies the desired element(s).
                    */
    actionFunction,
    /* Required: The code to run when elements are
                             found. It is passed a jNode to the matched
                             element.
                         */
    bWaitOnce,
    /* Optional: If false, will continue to scan for
                    new elements even after the first match is
                    found.
                */
    iframeSelector
    /* Optional: If set, identifies the iframe to
                        search.
                    */
  ) {
    var targetNodes, btargetsFound;

    if (typeof iframeSelector == "undefined")
      targetNodes = jQuery(selectorTxt);
    else
      targetNodes = jQuery(iframeSelector).contents()
      .find(selectorTxt);

    if (targetNodes && targetNodes.length > 0) {
      btargetsFound = true;
      /*--- Found target node(s). Go through each and act if they
          are new.
      */
      targetNodes.each(function() {
        var jThis = jQuery(this);
        var alreadyFound = jThis.data('alreadyFound') || false;

        if (!alreadyFound) {
          //--- Call the payload function.
          var cancelFound = actionFunction(jThis);
          if (cancelFound)
            btargetsFound = false;
          else
            jThis.data('alreadyFound', true);
        }
      });
    } else {
      btargetsFound = false;
    }

    //--- Get the timer-control variable for this selector.
    var controlObj = waitForKeyElements.controlObj || {};
    var controlKey = selectorTxt.replace(/[^\w]/g, "_");
    var timeControl = controlObj[controlKey];

    //--- Now set or clear the timer as appropriate.
    if (btargetsFound && bWaitOnce && timeControl) {
      //--- The only condition where we need to clear the timer.
      clearInterval(timeControl);
      delete controlObj[controlKey]
    } else {
      //--- Set a timer, if needed.
      if (!timeControl) {
        timeControl = setInterval(function() {
            waitForKeyElements(selectorTxt,
              actionFunction,
              bWaitOnce,
              iframeSelector
            );
          },
          300
        );
        controlObj[controlKey] = timeControl;
      }
    }
    waitForKeyElements.controlObj = controlObj;
  }

})();