Mediux - Yaml Fixes

Adds fixes and functions to Mediux

// ==UserScript==
// @name          Mediux - Yaml Fixes
// @version       2.0.1
// @description   Adds fixes and functions to Mediux
// @author        Journey Over
// @license       MIT
// @match         *://mediux.pro/*
// @require       https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js
// @require       https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@5f2cbff53b0158ca07c86917994df0ed349eb96c/libs/gm/gmcompat.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==

/**
 * 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 => {
          console.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: () => {
            console.log(`[Mediux Fixes] An error occurred loading set ${setId}`);
            reject(new Error('Request failed'));
          },
          ontimeout: () => {
            console.log(`[Mediux Fixes] 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 (error) {
                console.error('Error parsing filesArray:', error);
                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);
                  console.log(`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`;
                  console.log(`Backdrop: ${backdropId}\nMovie id: ${movieId}`);
                }
              }
            }
          } catch (error) {
            console.error(`Error processing set ${set.id}:`, error);
          }
        }
      } 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) {
          console.error('Failed to copy: ', err);
        }
      });

      // Append the link to the codeblock
      codeblock.appendChild(copyLink);
      const totalTime = Math.floor((Date.now() - startTime) / 1000);
      console.log(`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();
      console.log('[Mediux YAML Fixes] 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;
}