Redacted YouTube Searcher

Add YouTube search links that play in an embedded player or open in a new tab.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Redacted YouTube Searcher
// @license      MIT
// @namespace    https://redacted.sh/
// @version      1.4.2
// @description  Add YouTube search links that play in an embedded player or open in a new tab.
// @author       x__a
// @match        https://*.redacted.sh/*
// @grant        GM_xmlhttpRequest
// ==/UserScript==

(function () {
  "use strict";

  if (document.getElementById("redacted-youtube")) return;

  const CONFIG = {
    STORAGE_KEY: "redacted-youtube-player-visibility",
    YOUTUBE_SEARCH_URL: "https://www.youtube.com/results?search_query=",
    YOUTUBE_EMBED_URL: "https://www.youtube-nocookie.com/embed/",
    VIDEO_ID_REGEX: /"videoId"\s*:\s*"([^"]+)"/,
  };

  let activeTrack = null;

  const utils = {
    slugify(string) {
      return string
        .toLowerCase()
        .trim()
        .replace(/[^\w\s-]/g, "")
        .replace(/[\s_-]+/g, "-")
        .replace(/^-+|-+$/g, "");
    },
  };

  const UI = {
    createLoadingSpinner() {
      const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
      svg.id = "redacted-youtube-spinner";
      svg.setAttribute("viewBox", "0 0 100 100");

      const circle = document.createElementNS(
        "http://www.w3.org/2000/svg",
        "circle",
      );
      circle.id = "redacted-youtube-spinner-circle";
      circle.setAttribute("cx", "50");
      circle.setAttribute("cy", "50");
      circle.setAttribute("r", "45");

      svg.appendChild(circle);
      return svg;
    },

    createYouTubeIcon() {
      return `<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M23.495 6.205a3.007 3.007 0 0 0-2.088-2.088c-1.87-.501-9.396-.501-9.396-.501s-7.507-.01-9.396.501A3.007 3.007 0 0 0 .527 6.205a31.247 31.247 0 0 0-.522 5.805 31.247 31.247 0 0 0 .522 5.783 3.007 3.007 0 0 0 2.088 2.088c1.868.502 9.396.502 9.396.502s7.506 0 9.396-.502a3.007 3.007 0 0 0 2.088-2.088 31.247 31.247 0 0 0 .5-5.783 31.247 31.247 0 0 0-.5-5.805zM9.609 15.601V8.408l6.264 3.602z"/></svg>`;
    },

    createExternalLinkIcon() {
      return `<svg width="12" height="12" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.25 5.5a.75.75 0 00-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 00.75-.75v-4a.75.75 0 011.5 0v4A2.25 2.25 0 0112.75 17h-8.5A2.25 2.25 0 012 14.75v-8.5A2.25 2.25 0 014.25 4h5a.75.75 0 010 1.5h-5z" clip-rule="evenodd" /><path fill-rule="evenodd" d="M6.194 12.753a.75.75 0 001.06.053L16.5 4.44v2.81a.75.75 0 001.5 0v-4.5a.75.75 0 00-.75-.75h-4.5a.75.75 0 000 1.5h2.553l-9.056 8.194a.75.75 0 00-.053 1.06z" clip-rule="evenodd" /></svg>`;
    },
  };

  const YouTubeAPI = {
    async searchVideo(query) {
      return new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
          method: "GET",
          url: `${CONFIG.YOUTUBE_SEARCH_URL}${encodeURIComponent(query)}`,
          onload: function (response) {
            if (response.readyState === 4 && response.status === 200) {
              const videoIds = response.responseText.match(
                CONFIG.VIDEO_ID_REGEX,
              );
              if (videoIds && videoIds.length > 0) {
                resolve(videoIds[1]);
              } else {
                reject(new Error("No video ID found"));
              }
            } else {
              reject(new Error(`HTTP ${response.status}`));
            }
          },
          onerror: () => reject(new Error("Network error")),
        });
      });
    },

    createEmbedPlayer(videoId) {
      const player = document.createElement("iframe");
      player.id = "redacted-youtube-player";
      player.src = `${CONFIG.YOUTUBE_EMBED_URL}${videoId}?autoplay=1`;
      return player;
    },
  };

  const PlayerManager = {
    setVisibility(state) {
      localStorage.setItem(CONFIG.STORAGE_KEY, state);
      const trackList = document.getElementById("redacted-youtube-track-list");
      if (trackList) {
        trackList.style.display = state === "hidden" ? "none" : "";
      }
    },

    toggleVisibility(event) {
      event.preventDefault();
      const currentState = localStorage.getItem(CONFIG.STORAGE_KEY);
      const newState = currentState === "hidden" ? "visible" : "hidden";
      this.setVisibility(newState);

      const showLink = event.target;
      showLink.textContent = newState === "hidden" ? "(Show)" : "(Hide)";
    },

    async playFirstResult(event) {
      event.preventDefault();

      const parent = event.target.closest("[data-query]");
      if (!parent) return;

      const query = parent.getAttribute("data-query");
      const existingPlayer = document.getElementById("redacted-youtube-player");

      if (existingPlayer && activeTrack === parent.id) {
        activeTrack = null;
        existingPlayer.remove();
        return;
      }

      const spinner = UI.createLoadingSpinner();
      parent.appendChild(spinner);

      try {
        if (existingPlayer) existingPlayer.remove();

        const videoId = await YouTubeAPI.searchVideo(query);
        const player = YouTubeAPI.createEmbedPlayer(videoId);
        parent.appendChild(player);

        spinner.remove();
        activeTrack = parent.id;
      } catch (error) {
        console.error("YouTube search failed:", error);
        spinner.remove();

        const errorMsg = document.createElement("span");
        errorMsg.textContent = "Search failed";
        errorMsg.style = "color: #ed5651; font-size: 12px; margin-left: 5px;";
        parent.appendChild(errorMsg);

        setTimeout(() => errorMsg.remove(), 3000);
      }
    },
  };

  const TrackProcessor = {
    AUDIO_EXTENSIONS: [".mp3", ".flac", ".wav", ".aac", ".opus"],
    TRACK_NUMBER_PATTERNS: [
      /^(\d{1,2})\.(\d{1,2})[.\s-]+/,
      /^(\d{1,2})-(\d{1,2})[.\s-]+/,
      /^(\d{1,2})[.\s-]+/,
    ],
    UNWANTED_PATTERNS: [
      /\([^)]*\)/g,
      /\[[^\]]*\]/g,
      /\{[^}]*\}/g,
      /\bfeat(?:\.|uring)?\b/gi,
      /\bft\.?\b/gi,
      /extended/gi,
      /radio edit/gi,
      /clean version/gi,
      /explicit/gi,
      /instrumental/gi,
      /demo/gi,
      /live/gi,
      /acoustic/gi,
      /original mix/gi,
      /club mix/gi,
      /dub mix/gi,
    ],

    isAudioFile(filename) {
      const extension = filename.slice(filename.lastIndexOf("."));
      return this.AUDIO_EXTENSIONS.includes(extension.toLowerCase());
    },

    cleanText(text) {
      let cleaned = text;
      this.UNWANTED_PATTERNS.forEach(
        (pattern) => (cleaned = cleaned.replace(pattern, "")),
      );
      return cleaned
        .replace(/^\s+|\s+$/g, "")
        .replace(/[-\s.]+$/g, "")
        .replace(/^[-\s.]+/g, "")
        .replace(/\s+/g, " ")
        .trim();
    },

    extractTrackNumber(filename) {
      for (const pattern of this.TRACK_NUMBER_PATTERNS) {
        const match = filename.match(pattern);
        if (match) {
          const trackNumber = match[1];
          const discNumber = match[2] || null;
          const remaining = filename.replace(match[0], "");
          return {
            trackNumber: parseInt(trackNumber, 10),
            discNumber: discNumber ? parseInt(discNumber, 10) : null,
            remaining: remaining.trim(),
          };
        }
      }
      return { trackNumber: null, discNumber: null, remaining: filename };
    },

    removeArtistName(trackTitle, artist) {
      if (!artist) return trackTitle;

      const artistNames = artist
        .split(/[&and]/i)
        .map((name) => name.trim())
        .filter(Boolean);

      for (const artistName of artistNames) {
        const escapedArtist = artistName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
        const patterns = [
          new RegExp(`^\\s*${escapedArtist}\\s*[-–—]\\s*`, "i"),
          new RegExp(`^\\s*${escapedArtist}\\s*:\\s*`, "i"),
          new RegExp(`^\\s*${escapedArtist}\\s+`, "i"),
        ];

        for (const pattern of patterns) {
          if (pattern.test(trackTitle)) {
            return trackTitle.replace(pattern, "").trim();
          }
        }
      }
      return trackTitle;
    },

    extractTrackInfo(fileText, artist) {
      if (!this.isAudioFile(fileText)) return null;

      try {
        const lastDotIndex = fileText.lastIndexOf(".");
        let trackTitle =
          lastDotIndex > 0 ? fileText.slice(0, lastDotIndex) : fileText;

        const numberInfo = this.extractTrackNumber(trackTitle);
        trackTitle = numberInfo.remaining;

        if (artist) trackTitle = this.removeArtistName(trackTitle, artist);
        trackTitle = this.cleanText(trackTitle);

        if (!trackTitle)
          trackTitle =
            lastDotIndex > 0 ? fileText.slice(0, lastDotIndex) : fileText;

        const artistInTrack =
          artist && trackTitle.toLowerCase().includes(artist.toLowerCase());
        const artistAndTrack = artistInTrack
          ? trackTitle
          : `${artist} - ${trackTitle}`;

        return {
          trackName: trackTitle,
          artistAndTrack,
          trackId: utils.slugify(trackTitle),
          trackNumber: numberInfo.trackNumber,
          discNumber: numberInfo.discNumber,
        };
      } catch (error) {
        console.error("Error processing track:", fileText, error);
        return null;
      }
    },

    createTrackElement(trackInfo) {
      const trackLinkElement = document.createElement("tr");
      const trackLinkTableData = document.createElement("td");
      trackLinkTableData.id = trackInfo.trackId;
      trackLinkTableData.setAttribute("data-query", trackInfo.artistAndTrack);

      const trackLinkAnchor = document.createElement("a");
      trackLinkAnchor.href = "#";
      trackLinkAnchor.innerHTML = trackInfo.trackName;
      trackLinkAnchor.addEventListener("click", (e) =>
        PlayerManager.playFirstResult(e),
      );

      const trackSearchAnchor = document.createElement("a");
      trackSearchAnchor.href = `${CONFIG.YOUTUBE_SEARCH_URL}${encodeURIComponent(trackInfo.artistAndTrack)}`;
      trackSearchAnchor.title = "Open YouTube";
      trackSearchAnchor.rel = "noopener";
      trackSearchAnchor.target = "_blank";
      trackSearchAnchor.innerHTML = UI.createExternalLinkIcon();
      trackSearchAnchor.style = "margin-left: 4px";

      trackLinkTableData.appendChild(trackLinkAnchor);
      trackLinkTableData.appendChild(trackSearchAnchor);
      trackLinkElement.appendChild(trackLinkTableData);

      return trackLinkElement;
    },
  };

  const App = {
    injectStyles() {
      const head = document.head || document.getElementsByTagName("head")[0];
      const style = document.createElement("style");
      style.id = "redacted-youtube";
      style.innerHTML = `.redacted-youtube-link{transition:all 0.15s ease!important;line-height:0!important;color:#c4302b!important}.redacted-youtube-link:hover{color:#ed5651!important}.redacted-youtube-link>svg{width:12px!important;height:12px!important}.redacted-youtube-svg{width:12px!important;height:12px!important}#redacted-youtube-player{display:block;border:none;border-radius:0.5rem;margin-top:0.5rem;aspect-ratio:16/9;width:100%}#redacted-youtube-spinner{animation:2s linear infinite svg-animation;max-width:10px;margin-left:5px}@keyframes svg-animation{0%{transform:rotateZ(0deg)}100%{transform:rotateZ(360deg)}}#redacted-youtube-spinner-circle{animation:1.4s ease-in-out infinite both circle-animation;display:block;fill:transparent;stroke:#ed5651;stroke-linecap:round;stroke-dasharray:283;stroke-dashoffset:280;stroke-width:10px;transform-origin:50% 50%}@keyframes circle-animation{0%,25%{stroke-dashoffset:280;transform:rotate(0)}50%,75%{stroke-dashoffset:75;transform:rotate(45deg)}100%{stroke-dashoffset:280;transform:rotate(360deg)}}`;
      head.appendChild(style);
    },

    addTorrentLinks() {
      const urlParams = new URLSearchParams(window.location.search);

      document
        .querySelectorAll("table.torrent_table > tbody > tr")
        .forEach((torrent) => {
          if (torrent.querySelector(".redacted-youtube-link")) return;

          const artistLink = torrent.querySelector('a[href*="artist.php?id"]');
          const releaseLink = torrent.querySelector(
            'a[href*="torrents.php?id"]',
          );

          if (!artistLink && !releaseLink) return;

          let artist = artistLink ? artistLink.textContent : null;

          if (
            /\/artist.php/.test(window.location.pathname) &&
            urlParams.has("id")
          ) {
            artist =
              document.querySelector(".header > h2")?.textContent || artist;
          }

          const release = releaseLink.textContent;
          const query = encodeURIComponent(
            artist ? `${artist} - ${release}` : release,
          );
          const actionButtons = torrent.querySelector(
            "span.torrent_action_buttons",
          );
          const addBookmarkButton = torrent.querySelector("span.add_bookmark");

          const youtubeLink = `| <a href="${CONFIG.YOUTUBE_SEARCH_URL}${query}" class="tooltip redacted-youtube-link" rel="noopener" target="_blank" title="Search YouTube">${UI.createYouTubeIcon()}</a>`;

          if (actionButtons) {
            actionButtons.insertAdjacentHTML("beforeend", youtubeLink);
          } else if (addBookmarkButton) {
            addBookmarkButton.insertAdjacentHTML(
              "beforebegin",
              `<span title="Search YouTube" class="tooltip" style="margin-left: 4px"><a href="${CONFIG.YOUTUBE_SEARCH_URL}${query}" class="redacted-youtube-link" rel="noopener" target="_blank">${UI.createYouTubeIcon()}</a></span>`,
            );
          }
        });
    },

    observeDynamicContent() {
      const observer = new MutationObserver((mutations) => {
        let shouldUpdate = false;

        mutations.forEach((mutation) => {
          if (mutation.type === "childList") {
            mutation.addedNodes.forEach((node) => {
              if (node.nodeType === Node.ELEMENT_NODE) {
                if (
                  node.matches &&
                  (node.matches("table.torrent_table") ||
                    node.matches("table.torrent_table *"))
                ) {
                  shouldUpdate = true;
                }
                if (
                  node.querySelector &&
                  node.querySelector("table.torrent_table")
                ) {
                  shouldUpdate = true;
                }
              }
            });
          }
        });

        if (shouldUpdate) {
          setTimeout(() => this.addTorrentLinks(), 100);
        }
      });

      observer.observe(document.body, {
        childList: true,
        subtree: true,
      });
    },

    createTrackList() {
      const urlParams = new URLSearchParams(window.location.search);

      if (
        !/\/torrents.php/.test(window.location.pathname) ||
        !urlParams.has("id")
      )
        return;

      const artist = Array.from(
        document.querySelectorAll('h2 a[href*="artist.php"]'),
      )
        .map((link) => link.textContent)
        .join(" & ");

      const fileTables = Array.from(
        document.querySelectorAll("table.filelist_table"),
      ).filter((table) => {
        const releaseRow =
          table.closest("tr.releases_1")?.previousElementSibling;
        const firstTdText = releaseRow?.querySelector("td")?.textContent || "";
        return !/\/\s*Scene/i.test(firstTdText);
      });

      const fileRows = fileTables.flatMap((table) =>
        Array.from(
          table.querySelectorAll(
            "tbody > tr:not(.colhead_dark) > td:not(.number_column)",
          ),
        ),
      );

      // const fileRows = document.querySelectorAll(
      //   "table.filelist_table > tbody > tr:not(.colhead_dark) > td:not(.number_column)",
      // );

      const trackLinks = [];

      fileRows.forEach((item) => {
        const file = item.textContent;
        const trackInfo = TrackProcessor.extractTrackInfo(file, artist);

        if (!trackInfo) return;

        const trackElement = TrackProcessor.createTrackElement(trackInfo);

        const normalizedTrackName = trackInfo.trackName.toLowerCase().trim();
        const isDuplicate = trackLinks.some(
          (trackLink) =>
            trackLink.id === trackInfo.trackId ||
            trackLink.artistAndTrack === trackInfo.artistAndTrack ||
            trackLink.normalizedName === normalizedTrackName,
        );

        if (!isDuplicate) {
          trackLinks.push({
            id: trackInfo.trackId,
            element: trackElement,
            artistAndTrack: trackInfo.artistAndTrack,
            normalizedName: normalizedTrackName,
            trackNumber: trackInfo.trackNumber,
            discNumber: trackInfo.discNumber,
          });
        }
      });

      if (trackLinks.length === 0) return;

      const table = document.createElement("table");
      table.id = "redacted-youtube-tracks-table";
      table.className = "collage_table";

      const thead = document.createElement("thead");
      const headerRow = document.createElement("tr");
      headerRow.className = "colhead";
      const headerCell = document.createElement("td");

      const upLink = document.createElement("a");
      upLink.href = "#";
      upLink.textContent = "↑";
      const trackSearchText = document.createTextNode(" YouTube Track Search ");
      const showLink = document.createElement("a");
      showLink.href = "#";
      showLink.textContent =
        localStorage.getItem(CONFIG.STORAGE_KEY) === "hidden"
          ? "(Show)"
          : "(Hide)";
      showLink.onclick = (e) => PlayerManager.toggleVisibility(e);

      headerCell.appendChild(upLink);
      headerCell.appendChild(trackSearchText);
      headerCell.appendChild(showLink);
      headerRow.appendChild(headerCell);
      thead.appendChild(headerRow);

      const tbody = document.createElement("tbody");
      tbody.id = "redacted-youtube-track-list";
      tbody.style =
        localStorage.getItem(CONFIG.STORAGE_KEY) === "hidden"
          ? "display: none"
          : "";

      table.appendChild(thead);
      table.appendChild(tbody);

      trackLinks.sort((a, b) => {
        if (a.discNumber !== b.discNumber)
          return (a.discNumber || 0) - (b.discNumber || 0);
        return (a.trackNumber || 0) - (b.trackNumber || 0);
      });

      const descriptionBox = document.querySelector(
        "div.box.torrent_description",
      );
      if (descriptionBox) {
        descriptionBox.insertAdjacentElement("beforebegin", table);
        trackLinks.forEach((track) => tbody.appendChild(track.element));
      }
    },

    init() {
      this.injectStyles();
      this.addTorrentLinks();
      this.createTrackList();
      this.observeDynamicContent();
    },
  };

  App.init();
})();