Mediux - Yaml Fixes

Adds fixes and functions to Mediux

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name          Mediux - Yaml Fixes
// @version       2.2.2
// @description   Adds fixes and functions to Mediux
// @author        Journey Over
// @license       MIT
// @match         *://mediux.pro/*
// @require       https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@0171b6b6f24caea737beafbc2a8dacd220b729d8/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 });

  const MediuxFixes = {
    elements: {
      codeblock: null,
      buttons: {}
    },

    utils: {
      sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
      },

      isString(input) {
        return typeof input === 'string';
      },

      // Check for valid objects with properties (excludes null, arrays, empty objects)
      isNonEmptyObject(object) {
        return (
          typeof object === 'object' &&
          object !== null &&
          !Array.isArray(object) &&
          Object.keys(object).length > 0
        );
      },

      // Extract year from page elements, trying multiple selectors for reliability
      getYear() {
        const selectors = [
          'h1',
          'a[href*="/sets/"]',
          'a[href*="/shows/"]'
        ];

        for (const selector of selectors) {
          const element = document.querySelector(selector);
          if (element) {
            const match = element.textContent.match(/\((\d{4})\)/);
            if (match) return match[1];
          }
        }
        return 'Unknown';
      },

      showNotification(message, duration = 3000) {
        const notification = document.createElement('div');
        const myleftDiv = document.querySelector('#myleftdiv');

        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: 'flex'
        });

        notification.innerText = message;
        $(myleftDiv).after(notification);

        setTimeout(() => {
          $(notification).remove();
        }, duration);
      },

      updateButtonState(button, success = true) {
        button.classList.remove('bg-gray-500');
        button.classList.add(success ? 'bg-green-500' : 'bg-red-500');

        setTimeout(() => {
          button.classList.remove('bg-green-500', 'bg-red-500');
          button.classList.add('bg-gray-500');
        }, 3000);
      },

      copyToClipboard(text) {
        return navigator.clipboard.writeText(text)
          .then(() => {
            this.showNotification('Results copied to clipboard!');
            return true;
          })
          .catch(error => {
            logger.error('Failed to copy: ', error);
            this.showNotification('Failed to copy to clipboard', 3000);
            return false;
          });
      }
    },

    data: {
      // Extract poster data from Next.js script tags by searching from end (newer scripts tend to be last)
      getPosters() {
        const regexPosterCheck = /posterCheck/g;
        const scriptElements = document.querySelectorAll('script');

        for (let index = scriptElements.length - 1; index >= 0; index--) {
          const element = scriptElements[index];
          if (regexPosterCheck.test(element.textContent)) {
            let scriptContent = element.textContent.replace('self.__next_f.push(', '');
            scriptContent = scriptContent.substring(0, scriptContent.length - 1);
            const jsonData = JSON.parse(scriptContent)[1].split('{"set":')[1];
            const fullJsonString = `{"set":${jsonData}`;
            const parsedData = JSON.parse(fullJsonString.substring(0, fullJsonString.length - 2));
            return parsedData.set.files;
          }
        }
        return [];
      },

      // Extract set data and store creator info for later use
      getSets() {
        const regexPosterCheck = /posterCheck/g;
        const scriptElements = document.querySelectorAll('script');

        for (let index = scriptElements.length - 1; index >= 0; index--) {
          const element = scriptElements[index];
          if (regexPosterCheck.test(element.textContent)) {
            let scriptContent = element.textContent.replace('self.__next_f.push(', '');
            scriptContent = scriptContent.substring(0, scriptContent.length - 1);
            const jsonData = JSON.parse(scriptContent)[1].split('{"set":')[1];
            const fullJsonString = `{"set":${jsonData}`;
            const parsedData = JSON.parse(fullJsonString.substring(0, fullJsonString.length - 2));
            GM_setValue('creator', parsedData.set.user_created.username);
            return parsedData.set.boxset.sets;
          }
        }
        return [];
      },

      getSet(setId) {
        return new Promise((resolve, reject) => {
          GM_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: {
      // Process entire boxset by fetching each set and generating YAML for all items
      async loadBoxset(codeblock) {
        const button = document.querySelector('#bsetbutton');
        let yamlOutput = codeblock.textContent + '\n';
        const sets = MediuxFixes.data.getSets();
        const creator = GM_getValue('creator');
        const startTime = Date.now();
        let elapsedTime = 0;
        let processedMovieTitles = [];

        codeblock.innerText = 'Processing... 0 seconds';

        const timerInterval = setInterval(() => {
          elapsedTime = Math.floor((Date.now() - startTime) / 1000);
          const latestMovies = processedMovieTitles.slice(-3).join(', ');
          codeblock.innerText = `Processing... ${elapsedTime} seconds\nRecent processed: ${latestMovies}`;
        }, 1000);

        try {
          for (const set of sets) {
            try {
              const response = await MediuxFixes.data.getSet(set.id);
              const responseWithoutEscapes = response.replaceAll('\\', '');

              const regexFiles = /"files":(\[{"id":.*?}]),"boxset":/s;
              const fileMatch = responseWithoutEscapes.match(regexFiles);

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

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

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

                    yamlOutput += `  ${movieId}: # ${movieTitle} Poster by ${creator} on MediUX.  https://mediux.pro/sets/${set.id}\n    url_poster: https://api.mediux.pro/assets/${posterId}\n    `;
                    processedMovieTitles.push(movieTitle);
                    logger(`Title: ${movieTitle}\nPoster: ${posterId}`);
                  } else if (file.movie_id_backdrop !== null) {
                    const backdropId = file.fileType === 'backdrop' && file.id.length > 0 ? file.id : 'N/A';
                    const movieId = MediuxFixes.utils.isNonEmptyObject(file.movie_id_backdrop) ? file.movie_id_backdrop.id : 'N/A';
                    yamlOutput += `url_background: https://api.mediux.pro/assets/${backdropId}\n\n`;
                    logger(`Backdrop: ${backdropId}\nMovie id: ${movieId}`);
                  }
                }
              }
            } catch (error) {
              logger.error(`Error processing set ${set.id}:`, error);
            }
          }
        } finally {
          clearInterval(timerInterval);
        }

        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';

        copyLink.addEventListener('click', async (event_) => {
          event_.preventDefault();
          try {
            await navigator.clipboard.writeText(yamlOutput);
            codeblock.innerText = yamlOutput;
            MediuxFixes.utils.updateButtonState(button);
            MediuxFixes.utils.showNotification('Results copied to clipboard!');
          } catch (error) {
            logger.error('Failed to copy: ', error);
          }
        });

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

      // Add missing season posters to YAML (seasons without explicit poster entries)
      fixPosters(codeblock) {
        const button = document.querySelector('#fpbutton');
        let yamlContent = codeblock.textContent;
        const posters = MediuxFixes.data.getPosters();

        const seasonPosters = posters.filter(poster => poster.title.includes('Season'));

        for (let seasonIndex in seasonPosters) {
          const matchingSeasonPosters = seasonPosters.filter(season => season.title.includes(`Season ${seasonIndex}`));
          if (matchingSeasonPosters.length > 0) {
            yamlContent += `      ${seasonIndex}:\n        url_poster: https://api.mediux.pro/assets/${matchingSeasonPosters[0].id}\n`;
          }
        }

        codeblock.innerText = yamlContent;
        navigator.clipboard.writeText(yamlContent)
          .then(() => {
            MediuxFixes.utils.showNotification('Results copied to clipboard!');
            MediuxFixes.utils.updateButtonState(button);
          });
      },

      // Fix Kometa TitleCard YAML by adding missing season numbers before episodes
      fixCards(codeblock) {
        const button = document.querySelector('#fcbutton');
        const yamlContent = codeblock.innerText;

        const regexSeasonsEpisodes = /(seasons:\n)(        episodes:)/g;
        const regexEpisodes = /(        episodes:)/g;

        if (regexSeasonsEpisodes.test(yamlContent)) {
          let seasonCounter = 1;
          const modifiedYaml = yamlContent.replace(regexEpisodes, (match) => {
            const newLine = `      ${seasonCounter++}:\n`;
            return `${newLine}${match}`;
          });

          codeblock.innerText = modifiedYaml;
          navigator.clipboard.writeText(modifiedYaml)
            .then(() => {
              MediuxFixes.utils.showNotification('Results copied to clipboard!');
              MediuxFixes.utils.updateButtonState(button);
            });
        } else {
          MediuxFixes.utils.showNotification('No card formatting needed');
        }
      },

      // Transform TV show YAML to Kometa-compatible format with proper metadata structure
      formatTvYml(codeblock) {
        const button = document.querySelector('#fytvbutton');
        let yamlContent = codeblock.textContent;

        const regexSetInfo = /(\d+): # TVDB id for (.*?)\. Set by (.*?) on MediUX\. (https:\/\/mediux\.pro\/sets\/\d+)/;

        const year = MediuxFixes.utils.getYear();

        const setMatch = yamlContent.match(regexSetInfo);
        if (setMatch) {
          const tvdbId = setMatch[1];
          const showTitle = setMatch[2];
          const setUrl = setMatch[4];

          yamlContent = yamlContent.replace(regexSetInfo, `# Posters from:\n# ${setUrl}\n\nmetadata:\n\n  ${tvdbId}: # ${showTitle} (${year})`);
        }

        yamlContent = yamlContent.replace(/^\s+# Posters from:/m, `# Posters from:`);
        yamlContent = yamlContent.replace(/(url_poster|url_background): (https:\/\/api\.mediux\.pro\/assets\/[a-z0-9\-]+)/g, '$1: "$2"');
        yamlContent = yamlContent.replace(/(\d+):\n\s+url_poster: (https:\/\/api\.mediux\.pro\/assets\/[a-z0-9\-]+)\n/g,
          (match, season, posterUrl) => `      ${season}:\n        url_poster: "${posterUrl}"\n`);

        codeblock.innerText = yamlContent;
        navigator.clipboard.writeText(yamlContent)
          .then(() => {
            MediuxFixes.utils.showNotification('YAML transformed and copied to clipboard!');
            MediuxFixes.utils.updateButtonState(button);
          });
      },

      // Transform movie YAML to Kometa format with standardized headers and URL quoting
      formatMovieYml(codeblock) {
        const button = document.querySelector('#fymoviebutton');
        let yamlContent = codeblock.textContent;

        const regexSetUrl = /https:\/\/mediux\.pro\/sets\/\d+/;
        const urlMatch = yamlContent.match(regexSetUrl);
        const setUrl = urlMatch ? urlMatch[0] : null;

        if (setUrl) {
          yamlContent = yamlContent.replace(
            /(\d+):\s*#\s*(.*?)\s*\((\d{4})\).*?(https:\/\/mediux\.pro\/sets\/\d+)/g,
            (match, movieId, movieTitle, releaseYear) => `${movieId}: # ${movieTitle.trim()} (${releaseYear})`
          );

          const yamlHeader = `# Posters from:\n# ${setUrl}\n\nmetadata:\n\n`;
          yamlContent = yamlContent.replace(/(^|\n)metadata:\n/g, '');
          yamlContent = yamlHeader + yamlContent;

          yamlContent = yamlContent
            .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');
        }

        codeblock.innerText = yamlContent;
        navigator.clipboard.writeText(yamlContent)
          .then(() => {
            MediuxFixes.utils.showNotification('YAML transformed and copied to clipboard!');
            MediuxFixes.utils.updateButtonState(button);
          });
      }
    },

    ui: {
      createInterface() {
        const codeblock = document.querySelector('code.whitespace-pre-wrap');
        MediuxFixes.elements.codeblock = codeblock;

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

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

        const buttonConfigs = [{
            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)
          }
        ];

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

        for (const [index, config] of buttonConfigs.entries()) {
          const $buttonElement = $(`<button id="${config.id}" title="${config.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"' : ''}>${config.icon}</button>`);
          $buttonElement.on('click', config.action);
          buttonContainer.append($buttonElement);
          MediuxFixes.elements.buttons[config.id] = $buttonElement[0];
        }

        $(leftContainerDiv).append(buttonContainer);
        $(leftContainerDiv).parent().append('<div style="width: 25%;"></div>');
      }
    },

    init() {
      waitForKeyElements('code.whitespace-pre-wrap', () => {
        this.ui.createInterface();
        logger('Initialized');
      });
    }
  };

  MediuxFixes.init();

  // Utility function to wait for dynamically loaded elements in Next.js SPA
  function waitForKeyElements(
    selectorTxt,
    actionFunction,
    bWaitOnce,
    iframeSelector
  ) {
    var targetElements, targetsFound;

    targetElements = typeof iframeSelector == 'undefined' ? jQuery(selectorTxt) : jQuery(iframeSelector).contents()
      .find(selectorTxt);

    if (targetElements && targetElements.length > 0) {
      targetsFound = true;
      targetElements.each(function() {
        var $currentElement = jQuery(this);
        var alreadyProcessed = $currentElement.data('alreadyFound') || false;

        if (!alreadyProcessed) {
          var shouldCancel = actionFunction($currentElement);
          if (shouldCancel)
            targetsFound = false;
          else
            $currentElement.data('alreadyFound', true);
        }
      });
    } else {
      targetsFound = false;
    }

    var controlObject = waitForKeyElements.controlObj || {};
    var controlKey = selectorTxt.replace(/[^\w]/g, '_');
    var intervalId = controlObject[controlKey];

    if (targetsFound && bWaitOnce && intervalId) {
      clearInterval(intervalId);
      delete controlObject[controlKey]
    } else {
      if (!intervalId) {
        intervalId = setInterval(function() {
            waitForKeyElements(selectorTxt,
              actionFunction,
              bWaitOnce,
              iframeSelector
            );
          },
          300
        );
        controlObject[controlKey] = intervalId;
      }
    }
    waitForKeyElements.controlObj = controlObject;
  }

})();