Mofo

Add links (in torrent description) to external sources for more information.

// ==UserScript==
// @name           Mofo
// @description    Add links (in torrent description) to external sources for more information.
// @author         _
// @version        0.40
// @grant          GM.xmlHttpRequest
// @connect        www.junodownload.com
// @connect        www.allmusic.com
// @connect        listen.tidal.com
// @connect        api.deezer.com
// @connect        api.discogs.com
// @connect        itunes.apple.com
// @connect        bandcamp.com
// @connect        beatport.com
// @connect        bleep.com
// @connect        qobuz.com
// @connect        bing.com
// @connect        www.bing.com
// @connect        musicbrainz.org
// @match          https://redacted.ch/torrents.php?action=editgroup&groupid=*
// @match          https://redacted.ch/torrents.php?id=*
// @match          https://redacted.ch/user.php?action=edit&*
// @match          https://orpheus.network/torrents.php?action=editgroup&groupid=*
// @match          https://orpheus.network/torrents.php?id=*
// @match          https://orpheus.network/user.php?action=edit&*
// @run-at         document-end
// @namespace      _
// ==/UserScript==

(() => {
  "use strict";

  const myUID = document.querySelector("#nav_userinfo > a.username").href.split("=")[1];
  const MofoConfig = JSON.parse(localStorage.getItem("mofo_config") || '{"mofo_summary": "Mofo", "discogs_token": "", "allmusic_review": false, "before_links": false}');

  if (location.href.includes(`action=edit&userid=${myUID}`)) {
    const updateMofoConfig = (e) => {
      const config = {};
      config.before_links = document.getElementById("before_links").checked;
      config.allmusic_review = document.getElementById("allmusic_review").checked;
      config.discogs_token = document.getElementById("discogs_token").value;
      config.mofo_summary = document.getElementById("mofo_summary").value;
      localStorage.setItem("mofo_config", JSON.stringify(config));
    };
    // ref: pootie's External Stylesheet Switcher
    const torGrouping = $("#tor_group_tr");
    const MofoSummaryRow = $(`
      <tr>
        <td class="label tooltip">
          <strong>Mofo Summary Text:</strong>
        </td>
      </tr>
    `);
    const ReviewPosition = $(`
      <li>
        <input type="checkbox" name="before_links" id="before_links">
        <label for="showtfilter">Append AllMusic Review Before Mofo Links</label>
      </li>
    `);
    const AllMusicRow = $(`
      <tr>
        <td class="label tooltip">
          <strong>Mofo AllMusic:</strong>
        </td>
      </tr>
    `);
    const AllMusicCol = $(`<td></td>`);
    const AllMusicUl = $(`<ul class="options_list nobullet"></ul>`);
    const AllMusicReviews = $(`
      <li>
        <input type="checkbox" name="allmusic_review" id="allmusic_review">
        <label for="showtfilter">Include review in torrent description</label>
      </li>
    `);
    AllMusicUl.append([AllMusicReviews,ReviewPosition]);
    AllMusicCol.append(AllMusicUl);
    AllMusicRow.append(AllMusicCol);
    const MofoSummaryCol = $(`<td></td>`);
    const MofoSummaryTxt = $(`<input type="text" size="40" name="mofo_summary" id="mofo_summary" value="${MofoConfig.mofo_summary}">`)
    const DiscogsTokenRow= $(`
      <tr>
        <td class="label tooltip">
          <strong>Mofo Discogs Token:</strong>
        </td>
      </tr>
    `);
    const DiscogsTokenCol = $(`<td></td>`);
    const DiscogsTokenTxt = $(`<input type="text" size="40" name="discogs_token" id="discogs_token" value="${MofoConfig.discogs_token}">`)
    MofoSummaryCol.append(MofoSummaryTxt);
    MofoSummaryRow.append(MofoSummaryCol);
    torGrouping.after(MofoSummaryRow);
    DiscogsTokenCol.append(DiscogsTokenTxt);
    DiscogsTokenRow.append(DiscogsTokenCol);
    torGrouping.after(DiscogsTokenRow);
    torGrouping.after(AllMusicRow);
    document.getElementById("before_links").checked = MofoConfig.before_links;
    document.getElementById("allmusic_review").checked = MofoConfig.allmusic_review;
    document.getElementById("userform").addEventListener("submit", updateMofoConfig);
    return;
  }

  const groupid = new URL(window.location).searchParams.get("groupid");
  if (!groupid) {
    // we are on a torrent page
    const id = new URL(window.location).searchParams.get("id");
    const editDescr = document.querySelector("div.header > .linkbox").firstElementChild;
    editDescr.insertAdjacentHTML("afterend", ` <a href=torrents.php?action=editgroup&groupid=${id}&mofo=true class="brackets">Mofo</a>`);
    return;
  }

  const promises = [];
  const previewBtn = document.querySelector('.button_preview_0');

  $('head').append('<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">');
  previewBtn.insertAdjacentHTML(
    "afterend",
    ` <i style="display: none; padding: 10px;" id="spinner" class="fa fa-spinner fa-spin"></i><input type="button" value="Mofo" id="button_mofo" title="More Info">`
  );

  const decodeHTML = orig => {
    const txt = document.createElement("textarea");
    txt.innerHTML = orig;
    return txt.value;
  };

  const sanitize = text => {
    return text
      .replace(/\s+\(?EP\)?$/i, "")
      .replace(/\s+\(cover\)$/i, "")
      .toLowerCase()
      .trim();
  };

  const gazelleAPI = async groupid => {
    try {
      const res = await fetch(`/ajax.php?action=torrentgroup&id=${groupid}`);
      return res.json();
    } catch (error) {
      console.log(error);
    }
  };

  const corsFetch = url => {
    const headers = {};
    if (url.includes("tidal")) {
      headers['x-tidal-token'] = "CzET4vdadNUFQ5JU";
    }
    return new Promise((resolve, reject) => {
      GM.xmlHttpRequest({
        method: "GET",
        url: encodeURI(url),
        headers: headers,
        onload: res => resolve(res.responseText),
        onerror: res => reject(res)
      });
    });
  };

  const fetchJSON = async url => {
    const responseText = await corsFetch(url);
    return JSON.parse(responseText);
  };

  const fetchDOM = async url => {
    const responseText = await corsFetch(url);
    const parser = new DOMParser();
    return parser.parseFromString(responseText, "text/html");
  };

  const appleSearch = async (artist, album, upc) => {
    try {
      let response = await fetchJSON(`https://itunes.apple.com/lookup?upc=${upc}&entity=album`);
      let itunes_id;
      if (response.results.length > 0) {
        itunes_id = response.results[0].collectionId;
      }
      if (itunes_id) {
        return {source: "Apple", url: `https://music.apple.com/album/${itunes_id}`}
      }
      console.log("Apple: UPC search failed; trying direct artist - title query.")
      response = await fetchJSON(`https://itunes.apple.com/search?term=${artist} - ${album}&entity=album`);
      if (response.results.length > 0) {
        const collection = response.results.find(release => sanitize(artist).includes(sanitize(release.artistName)) && sanitize(album).includes(sanitize(release.collectionName)));
        itunes_id = collection.collectionId;
      }
      if (itunes_id) {
        return {source: "Apple", url: `https://music.apple.com/album/${itunes_id}`}
      }
    } catch (e) {
      console.error(
        `Oops! Apple search failed (probably, release not found)...${e && e.message && `\n(${e.message})`}`
      );
    }
  };

  const searchDiscogs = async (artist, album) => {
    try {
      const response = await fetchJSON(`https://api.discogs.com/database/search?release_title=${album}&artist=${artist}&token=${MofoConfig.discogs_token}`);
      if (response.results.length == 0) console.log('Discogs: no match.')
      const master = response.results.find(release => sanitize(release.title).includes(sanitize(artist)) && sanitize(release.title).includes(sanitize(album)));
      if (master) {
        if (master.master_id) return {source: "Discogs", url: `https://www.discogs.com/master/${master.master_id}`};
        return {source: "Discogs", url: `https://www.discogs.com${master.uri}`};
      }
    } catch (e) {
      console.error(
        `Oops! Discogs search failed (probably, release not found)...${e && e.message && `\n(${e.message})`}`
      );
    }
  };

  const deezSearch = async (artist, album, upc) => {
    try {
      let response = await fetchJSON(`https://api.deezer.com/album/upc:${upc}`);
      let deezer_id = response.hasOwnProperty("error") ? false : response.id;
      if (deezer_id) {
        return {source: "Deezer", url: `https://www.deezer.com/en/album/${deezer_id}`}
      }
      console.log("Deezer: UPC search failed; querying API directly for artist - title.")
      response = await fetchJSON(
        `https://api.deezer.com/search?q=artist:"${sanitize(artist)}" album:"${sanitize(album)}"`
      );
      const found_album = response.data.find(release => sanitize(artist).includes(sanitize(release.artist.name)) && sanitize(album).includes(sanitize(release.album.title)));
      if (found_album) {
        return {source: "Deezer", url: `https://www.deezer.com/en/album/${found_album.album.id}`}
      }
    } catch (e) {
      console.error(
        `Oops! Deezer search failed (probably, release not found)...${e && e.message && `\n(${e.message})`}`
      );
    }
  };

  const qobuzSearch = async (artist, album) => {
    try {
      const query = `${artist} - ${album}`;
      const doc = await fetchDOM(
        `https://qobuz.com/nz-en/search?q=${query}`
      );
      const releases = [...doc.querySelectorAll('#main-column > div.search-results > div > div.detail')];
      const found_album = releases.find(release => {
        if (sanitize(release.querySelector('.artist-name').innerText).includes(sanitize(artist))) {
          return sanitize(release.querySelector('.album-title').innerText).includes(sanitize(album));
        }
      });
      const purchaseLink = found_album.querySelector('div.price-box > div.action > ul > li:nth-child(1) > a').href;
      if (purchaseLink) {
        return {source: "Qobuz", url: purchaseLink.replace(location.origin, "https://www.qobuz.com")};
      }
    } catch (e) {
      console.error(`Oops! Qobuz search failed (probably, release not found)...${e && e.message && `\n(${e.message})`}`
      );
    }
  };

  // bleep's internal search is cumbersome; better luck using bing
  const bleepSearch = async (artist, album) => {
    try {
      const doc = await fetchDOM(
        `https://bing.com/search?q=${artist} ${album} site:bleep.com`
      );
      const nodes = [...doc.querySelectorAll('li.b_algo h2 > a')];
      const found_album = nodes.find(
        item =>
          sanitize(item.innerText).includes(sanitize(album)) &&
          sanitize(item.innerText).includes(sanitize(artist)));
      const bingObfuscate = await corsFetch(found_album.href);
      const bleepString = bingObfuscate.match(/bleep\.com\/release\/([^";]+)/);
      if (bleepString && bleepString.length == 2) {
        return {source: "Bleep", url: `https://bleep.com/release/${bleepString[1]}`};
      }
    } catch (e) {
      console.error(
        `Oops! Bleep search failed (probably, release not found)...${e && e.message && `\n(${e.message})`}`
      );
    }
  };

  // spotify requires account/api key, so using bing
  const spotifySearch = async (artist, album) => {
    try {
      const doc = await fetchDOM(
        `https://bing.com/search?q=${artist} ${album} site:open.spotify.com/album`
      );
      const nodes = [...doc.querySelectorAll('li.b_algo h2 > a')];
      const found_album = nodes.find(
        item =>
          sanitize(item.innerText).includes(sanitize(album)) &&
          sanitize(item.innerText).includes(sanitize(artist)));
      const bingObfuscate = await corsFetch(found_album.href);
      const spotifyString = bingObfuscate.match(/album\/([a-zA-Z0-9]+)/);
      if (spotifyString && spotifyString.length == 2) {
        return {source: "Spotify", url: `https://open.spotify.com/album/${spotifyString[1]}`};
      }
    } catch (e) {
      console.error(
        `Oops! Spotify search failed (probably, release not found)...${e && e.message && `\n(${e.message})`}`
      );
    }
  };

  // bandcamp's internal search is cumbersome; try using bing first
  const bandSearch = async (artist, album) => {
    try {
      let doc = await fetchDOM(
        `https://bing.com/search?q=site:bandcamp.com/album ${artist} ${album}`
      );
      const nodes = [...doc.querySelectorAll('li.b_algo h2 > a')];
      const found_album = nodes.find(
        item =>
          sanitize(item.innerText).includes(sanitize(album)) &&
          sanitize(item.innerText).includes(sanitize(artist)));
      const bingObfuscate = await corsFetch(found_album.href);
      const bandcampLink = bingObfuscate.match(/"(https:\/\/.+bandcamp\.com.+)"/);
      if (bandcampLink && bandcampLink.length == 2) {
        return {source: "Bandcamp", url: `${bandcampLink[1]}`};
      } else {
        // fall back to BC search
        console.log("Bandcamp: Bing failed; attempting direct search.")
        doc = await fetchDOM(`https://bandcamp.com/search?q=${artist} - ${album}`);
        for (const result of doc.querySelectorAll('.searchresult.album')) {
          let albumTitle = result.querySelector('div.result-info > .heading').innerText.trim();
          let albumArtist = result.querySelector('div.result-info > .subhead').innerText.trim().replace("by ", "");
          if (sanitize(albumTitle).includes(sanitize(album)) && sanitize(albumArtist).includes(sanitize(artist))) {
            let directLink = result.querySelector('div.result-info > .heading > a').href.split('?')[0]
            return {source: "Bandcamp", url: directLink};
          }
        }
      }
      const bclink = found_album.href;
      if (bclink && bclink.includes("bandcamp")) {
        return {source: "Bandcamp", url: bclink.split('?')[0]};
      }
    } catch (e) {
      console.error(
        `Oops! Bandcamp search failed (probably, release not found)...${e && e.message && `\n(${e.message})`}`
      );
    }
  };

  const beatSearch = async (artist, album) => {
    try {
      const query = `${artist} - ${album}`;
      const doc = await fetchDOM(
        `https://www.beatport.com/search?q=${query}`
      );
      const releases = [...doc.querySelectorAll('ul.bucket-items > li.bucket-item.ec-item.release')];
      const found_album = releases.find(release => {
        if (sanitize(release.querySelector('p.release-artists').innerText).includes(sanitize(artist))) {
          return sanitize(release.querySelector('p.release-title').innerText).includes(sanitize(album));
        }
      });
      const purchaseLink = found_album.querySelector('p.release-title > a').href;
      if (purchaseLink) {
        return {source: "Beatport", url: purchaseLink.replace(location.origin, "https://beatport.com")};
      }
    } catch (e) {
      console.error(`Oops! Beatport search failed (probably, release not found)...${e && e.message && `\n(${e.message})`}`
      );
    }
  };

  const junoSearch = async (artist, album) => {
    try {
      const query = `${artist} - ${album}`;
      const doc = await fetchDOM(
        `https://www.junodownload.com/search/?solrorder=relevancy&q[all][]=${query}&track_sale_format=flac`
      );
      const artists = [...doc.querySelectorAll(".juno-artist")];
      const found_artists = artists.filter(node =>
        sanitize(node.innerText).includes(sanitize(artist))
      );
      const found_album = found_artists.find(
        artist =>
          sanitize(artist.parentNode.nextElementSibling.querySelector(".juno-title").innerText) === sanitize(album)
      );
      const parentNode = found_album.parentNode.parentNode;
      const purchaseLink = "https://" + parentNode
        .querySelector(".juno-title")
        .href.replace(`${location.origin}`, "www.junodownload.com");
      if (purchaseLink) {
        return {source: "Juno", url: purchaseLink};
      }
    } catch (e) {
      console.error(`Oops! Juno search failed (probably, release not found)...${e && e.message && `\n(${e.message})`}`
      );
    }
  };

  const allMusicSearch = async (artist, album) => {
    try {
      let review = false;
      const query = `${artist} ${album}`;
      let doc = await fetchDOM(`https://www.allmusic.com/search/all/${query}`);
      const albums = [...doc.querySelectorAll("div.results li.album")];
      const found_album = albums.find(
        captured => sanitize(captured.querySelector('.title').innerText).includes(sanitize(album)) && sanitize(captured.querySelector('.artist').innerText).includes(sanitize(artist))
      );
      const allMusicLink = found_album.querySelector('div.title > a')
        .href.replace(`${location.origin}`, "www.allmusic.com");
      if (MofoConfig.allmusic_review) {
        doc = await fetchDOM(allMusicLink);
        const authorElem = doc.querySelector('div.review-headline-container > p > span');
        const reviewElem = [...doc.querySelectorAll('section.read-more > div.text > p')];
        if (authorElem && reviewElem) {
          review = {author: authorElem.innerText.trim(), text: reviewElem.map((e) => e.innerText.trim()).join("\n\n")}
        }
      }
      return {source: "AllMusic", url: allMusicLink, review: review};
    } catch (e) {
      console.error(`Oops! AllMusic search failed (probably, release not found)...${e && e.message && `\n(${e.message})`}`
      );
    }
  };

  const tidalSearch = async (artist, album, upc) => {
    try {
      const query = `${artist} - ${album}`;
      const response = await fetchJSON(`https://listen.tidal.com/v1/search?query=${query}&limit=10&offset=0&types=ALBUMS&includeContributors=true&countryCode=US`);
      for (const release of response.albums.items) {
        if (upc && release['upc'].includes(upc)) {
          return {source: "Tidal", url: `https://tidal.com/browse/album/${release['url'].split("/").pop()}`};
        } else if (sanitize(release['artists'][0]['name']) == sanitize(artist) && sanitize(release['title']) == sanitize(album)) {
          return {source: "Tidal", url: `https://tidal.com/browse/album/${release['url'].split("/").pop()}`};
        }
      }
    } catch (e) {
      console.error(`Oops! Tidal search failed (probably, release not found)...${e && e.message && `\n(${e.message})`}`
      );
    }
  };

  const musicBrainzSearch = async (artist, album) => {
    try {
      const response = await fetchJSON(`https://musicbrainz.org/ws/2/release-group?query="${album}" AND artist:${artist}&fmt=json`);
      if (response['release-groups'].length == 0) console.log("MusicBrainz: no match.");
      for (const release of response['release-groups']) {
        if (!sanitize(release.title).includes(sanitize(album))) continue;
        for (const mbartist of release['artist-credit']) {
          if (sanitize(mbartist.name).includes(sanitize(artist))) {
            return {source: "MusicBrainz", url: `https://musicbrainz.org/release-group/${release.id}`};
          }
        }
      }
    } catch (e) {
      console.error(`Oops! MusicBrainz search failed (probably, release not found)...${e && e.message && `\n(${e.message})`}`
      );
    }
  };

  const searchForLinks = async (event) => {
    event.target.removeEventListener("click", searchForLinks);
    document.querySelector('#button_mofo').style.display = "none";
    document.querySelector('#spinner').style.display = "";
    gazelleAPI(groupid).then(({ response, status }) => {
      if (status !== "success") {
        console.log("API request failed; aborting.");
        return;
      } 
      const artist = decodeHTML(response.group.musicInfo.artists[0].name);
      const album = decodeHTML(response.group.name);
      const torrents = response.torrents.filter(torrent => /\b(\d{10,})\b/.test(torrent.remasterCatalogueNumber));
      let upc;
      if (torrents.length != 0) {
        upc = torrents[0].remasterCatalogueNumber.match(/\b(\d{10,})\b/);
      }
      if (upc && upc.length == 2) {
        upc = upc[1];
      }
      promises.push(deezSearch(artist, album, upc));
      promises.push(allMusicSearch(artist, album));
      promises.push(appleSearch(artist, album, upc));
      promises.push(bandSearch(artist, album));
      promises.push(bleepSearch(artist, album));
      promises.push(junoSearch(artist, album));
      promises.push(beatSearch(artist, album));
      promises.push(qobuzSearch(artist,album));
      promises.push(searchDiscogs(artist,album));
      promises.push(musicBrainzSearch(artist,album));
      promises.push(spotifySearch(artist,album));
      promises.push(tidalSearch(artist, album, upc));
      Promise.all(promises).then((results) => {
        const moreInfo = [];
        results.sort((a, b) => {
          var sourceA = a.source.toUpperCase();
          var sourceB = b.source.toUpperCase();
          if (sourceA < sourceB) {
            return -1;
          }
          if (sourceA > sourceB) {
            return 1;
          }
          return 0;
        });
        results.forEach(result => {
          if (!result) return;
            moreInfo.push(`[url=${result.url}]${result.source}[/url]`)
        });
        if (moreInfo.length > 0) {
          const allMusicReview = results.find(result => result && result.source == "AllMusic" && result.review);
          if (allMusicReview && MofoConfig.before_links) {
            groupDesc.value += `\n\n[quote=AllMusic]${allMusicReview.review.text}\n[align=right]- ${allMusicReview.review.author}[/align][/quote]`
          } else {
            groupDesc.value += "\n\n";
          }
          groupDesc.value += "[b]More info:[/b] " + moreInfo.join(" | ");
          if (allMusicReview && !MofoConfig.before_links) {
            groupDesc.value += `\n\n[quote=AllMusic]${allMusicReview.review.text}\n[align=right]- ${allMusicReview.review.author}[/align][/quote]`
          }
          document.getElementsByName('summary')[0].value = MofoConfig.mofo_summary;
        }
        groupDesc.style.height = groupDesc.scrollHeight + "px";
        if ([...document.getElementById('preview_wrap_0').classList].includes("hidden")) {
          previewBtn.click();
        } else {
          previewBtn.click();
          groupDesc.style.height = groupDesc.scrollHeight + "px";
          previewBtn.click();
        }
        document.querySelector('#spinner').style.display = "none";
      });
    });
  }
  const groupDesc = document.querySelector('#textarea_wrap_0 > textarea');
  groupDesc.style.height = groupDesc.scrollHeight + "px";
  document.getElementById("button_mofo").addEventListener("click", searchForLinks);
  const autoMofo = new URL(window.location).searchParams.get("mofo");
  if (autoMofo) {
    document.getElementById("button_mofo").click();
  }
})();