GGn No-Intro Helper

A GGn user script to help with No-Intro uploads/trumps

2022-09-04 يوللانغان نەشرى. ئەڭ يېڭى نەشرىنى كۆرۈش.

// ==UserScript==
// @name          GGn No-Intro Helper
// @description   A GGn user script to help with No-Intro uploads/trumps
// @namespace     http://tampermonkey.net/
// @version       2.0.1
// @author        BestGrapeLeaves
// @license       MIT
// @match         *://gazellegames.net/upload.php?groupid=*
// @match         *://gazellegames.net/torrents.php?id=*
// @grant         unsafeWindow
// @grant         GM_xmlhttpRequest
// @grant         GM_listValues
// @grant         GM_deleteValue
// @grant         GM_setValue
// @grant         GM_getValue
// @connect       datomatic.no-intro.org
// @icon          https://i.imgur.com/UFOk0Iu.png
// ==/UserScript==


/******/ (() => { // webpackBootstrap
/******/ 	"use strict";
var __webpack_exports__ = {};

;// CONCATENATED MODULE: ./src/inserts/checkForTrumpsButton.ts
function checkForTrumpsButton() {
    const existing = $("#check-for-no-intro-trumps-button");
    const button = existing.length > 0 ? existing : $(`<input id="check-for-no-intro-trumps-button" type="button" value="Check for No-Intro Trumps" style="background: hotpink; color: black; font-weight: bold; margin-left: 10px;"/>`);
    const progress = (text)=>{
        button.val(text);
    };
    const disable = ()=>{
        button.prop("disabled", true);
        button.css("background-color", "pink");
        button.css("color", "darkslategray");
        button.css("box-shadow", "none");
    };
    const insert = ()=>{
        button.detach();
        $(".torrent_table > tbody > tr:first-child > td:first-child").first().append(button);
    };
    return {
        disable,
        progress,
        insert,
        button
    };
}

;// CONCATENATED MODULE: ./src/utils/dom/extractNoIntroLinkFromDescription.ts
function extractNoIntroLinkFromDescription(torrentId) {
    const links = $(`#torrent_${torrentId} #description a`);
    return links.map(function() {
        return $(this).attr("href");
    }).get().map((link)=>{
        const url = new URL(link);
        url.protocol = "https:"; // Rarely descriptions have the http protocol
        return url.toString();
    }).find((link)=>link.startsWith("https://datomatic.no-intro.org/"));
}

;// CONCATENATED MODULE: ./src/utils/dom/getNoIntroTorrentsOnPage.ts

function notFalse(x) {
    return x !== false;
}
function getNoIntroTorrentsOnPage() {
    return $('a[title="Permalink"]').map(function() {
        const torrentId = new URLSearchParams($(this).attr("href").replace("torrents.php", "")).get("torrentid");
        const noIntroLink = extractNoIntroLinkFromDescription(torrentId);
        if (!noIntroLink) {
            return false;
        }
        return {
            torrentId,
            a: $(this),
            noIntroLink
        };
    }).get().filter(notFalse);
}

;// CONCATENATED MODULE: ./src/inserts/insertAddCopyHelpers.ts

function insertAddCopyHelpers() {
    getNoIntroTorrentsOnPage().forEach((param)=>{
        let { torrentId , a , noIntroLink  } = param;
        // Extract edition information
        const editionInfo = a.parents(".group_torrent").parent().prev().find(".group_torrent > td > strong").text();
        const [editionYear, ...rest] = editionInfo.split(" - ");
        const editionName = rest.join(" - ");
        const formatedEditionInfo = `${editionName} (${editionYear})`;
        // GroupId
        const groupId = new URLSearchParams(window.location.search).get("id");
        // Create params
        const params = new URLSearchParams();
        params.set("groupid", groupId);
        params.set("edition", formatedEditionInfo);
        params.set("no-intro", noIntroLink);
        // Insert button
        const addCopyButton = $(`<a href="upload.php?${params.toString()}" title="Add Copy" id="ac_${torrentId}">AC</a>`);
        $([
            " | ",
            addCopyButton
        ]).insertAfter(a);
    });
}

;// CONCATENATED MODULE: ./src/constants.ts
// REGEXES
const PARENS_TAGS_REGEX = /\(.*?\)/g;
const NO_INTRO_TAGS_REGEX = /\((Unl|Proto|Sample|Aftermarket|Homebrew)\)|\(Rev \d+\)|\(v[\d\.]+\)|\(Beta(?: \d+)?\)/;
// LISTS
const GGN_REGIONS = [
    "USA",
    "Europe",
    "Japan",
    "Asia",
    "Australia",
    "France",
    "Germany",
    "Spain",
    "Italy",
    "UK",
    "Netherlands",
    "Sweden",
    "Russia",
    "China",
    "Korea",
    "Hong Kong",
    "Taiwan",
    "Brazil",
    "Canada",
    "Japan, USA",
    "Japan, Europe",
    "USA, Europe",
    "Europe, Australia",
    "Japan, Asia",
    "UK, Australia",
    "World",
    "Region-Free",
    "Other",
];
// TABLES
const REGION_TO_LANGUAGE = {
    USA: "English",
    Europe: "English",
    Japan: "Japanese",
    World: "English",
    "USA, Europe": "English",
    Other: "English",
    Korea: "Korean",
    Taiwan: "Chinese"
};
const TWO_LETTER_REGION_CODE_TO_NAME = {
    en: "English",
    de: "German",
    fr: "French",
    cz: "Czech",
    zh: "Chinese",
    it: "Italian",
    ja: "Japanese",
    ko: "Korean",
    pl: "Polish",
    pt: "Portuguese",
    ru: "Russian",
    es: "Spanish"
};

;// CONCATENATED MODULE: ./src/utils/GMCache.ts
class GMCache {
    getKeyName(key) {
        return `cache${this.name}.${key}`;
    }
    get(key) {
        const res = GM_getValue(this.getKeyName(key));
        if (res === undefined) {
            return undefined;
        }
        const { value , expires  } = res;
        if (expires && expires < Date.now()) {
            this.delete(key);
            return undefined;
        }
        return value;
    }
    set(key, value, ttl) {
        const expires = Date.now() + ttl;
        GM_setValue(this.getKeyName(key), {
            value,
            expires
        });
    }
    delete(key) {
        GM_deleteValue(this.getKeyName(key));
    }
    cleanUp() {
        const keys = GM_listValues();
        keys.forEach((key)=>{
            if (key.startsWith(this.getKeyName(""))) {
                const { expires  } = GM_getValue(key);
                if (expires < Date.now()) {
                    GM_deleteValue(key);
                }
            }
        });
    }
    constructor(name){
        this.name = name;
    }
}

;// CONCATENATED MODULE: ./src/utils/noIntroToGGnLanguage.ts

function noIntroToGGnLanguage(region, possiblyLanguages) {
    if (possiblyLanguages === undefined) {
        // @ts-expect-error
        return REGION_TO_LANGUAGE[region] || "Other";
    }
    const twoLetterCodes = possiblyLanguages.split(",").map((l)=>l.trim().toLowerCase());
    const isLanguages = twoLetterCodes.every((l)=>l.length === 2);
    if (!isLanguages || twoLetterCodes.length === 0) {
        // @ts-expect-error
        return REGION_TO_LANGUAGE[region] || "Other";
    }
    if (twoLetterCodes.length > 1) {
        return "Multi-Language";
    }
    return TWO_LETTER_REGION_CODE_TO_NAME[twoLetterCodes[0]] || "Other";
}

;// CONCATENATED MODULE: ./src/utils/fetchNoIntro.ts



const cache = new GMCache("no-intro");
function fetchNoIntro(url) {
    return new Promise((resolve, reject)=>{
        const cached = cache.get(url);
        if (cached) {
            resolve({
                ...cached,
                cached: true
            });
            return;
        }
        GM_xmlhttpRequest({
            method: "GET",
            url,
            timeout: 5000,
            onload: (param)=>{
                let { responseText  } = param;
                try {
                    const parser = new DOMParser();
                    const scraped = parser.parseFromString(responseText, "text/html");
                    // HTML is great
                    const dumpsTitle = [
                        ...scraped.querySelectorAll("td.TableTitle"),
                    ].find((td)=>td.innerText.trim() === "Dump(s)");
                    if (!dumpsTitle) {
                        // @ts-expect-error
                        unsafeWindow.GMPARSER = scraped;
                        console.error("GGn No-Intro Helper: dumps title not found, set parser as global: GMPARSER", responseText);
                        throw new Error("No dump's title found");
                    }
                    const filename = dumpsTitle.parentElement.parentElement.parentElement.nextElementSibling.querySelector("table > tbody > tr:nth-child(2) > td:last-child").innerText.trim();
                    const title = scraped.querySelector("tr.romname_section > td").innerText.trim();
                    // Region/Lang
                    const [region, possiblyLanguages] = title.match(/\(.+?\)/g).map((p)=>p.slice(1, -1));
                    const matchedGGnRegion = GGN_REGIONS.find((r)=>r === region) || "Other";
                    const matchedGGnLanguage = noIntroToGGnLanguage(matchedGGnRegion, possiblyLanguages);
                    // One hour seems appropriate
                    const info = {
                        filename,
                        title,
                        language: matchedGGnLanguage,
                        region: matchedGGnRegion,
                        cached: false
                    };
                    cache.set(url, info, 1000 * 60 * 60);
                    resolve(info);
                } catch (err) {
                    console.error("zibzab helper failed to parse no-intro:", err);
                    reject(new Error("Failed to parse no-intro :/\nPlease report to BestGrapeLeaves,\nincluding the error that was logged to the browser console"));
                }
            },
            ontimeout: ()=>{
                reject(new Error("Request to No-Intro timed out after 5 seconds"));
            }
        });
    });
}

;// CONCATENATED MODULE: ./src/utils/dom/fetchTorrentFilelist.ts
// We are fetching files for checking,
// might as well reduce load on servers and save to dom (like the button does)
function fetchTorrentFilelist(torrentId) {
    const parseFromDom = ()=>$(`#files_${torrentId} > table > tbody > tr:not(.colhead_dark) > td:first-child`).map(function() {
            return $(this).text();
        }).get();
    return new Promise((resolve)=>{
        // @ts-expect-error
        if ($("#files_" + torrentId).raw().innerHTML === "") {
            // $('#files_' + torrentId).gshow().raw().innerHTML = '<h4>Loading...</h4>';
            ajax.get("torrents.php?action=torrentfilelist&torrentid=" + torrentId, function(response) {
                // @ts-expect-error
                $("#files_" + torrentId).ghide();
                // @ts-expect-error
                $("#files_" + torrentId).raw().innerHTML = response;
                resolve(parseFromDom());
            });
        } else {
            resolve(parseFromDom());
        }
    });
}

;// CONCATENATED MODULE: ./src/utils/dom/checkIfTrumpable.ts



async function checkIfTrumpable(torrentId) {
    const url = extractNoIntroLinkFromDescription(torrentId);
    if (!url) {
        return {
            trumpable: false,
            cached: false
        };
    }
    try {
        const { filename , cached  } = await fetchNoIntro(url);
        const desiredFilename = filename.split(".").slice(0, -1).join(".") + ".zip";
        const files = await fetchTorrentFilelist(torrentId);
        if (files.length !== 1) {
            return {
                trumpable: true,
                desiredFilename,
                cached,
                inditermint: "Couldn't determine if the torrent is trumpable -\nMultiple/No zip files found in torrent"
            };
        }
        const actualFilename = files[0];
        return {
            trumpable: desiredFilename !== actualFilename,
            desiredFilename,
            actualFilename,
            cached
        };
    } catch (err) {
        console.error("GGn No-Intro Helper: Error checking trumpability", err);
        return {
            trumpable: true,
            cached: true,
            inditermint: "Couldn't determine if the torrent is trumpable -\nFailed fetching No-Intro:\n" + err.message
        };
    }
}

;// CONCATENATED MODULE: ./src/inserts/smallPre.ts
function smallPre(text, bgColor) {
    return `<pre style="
    padding: 0px;
    margin: 0;
    background-color: ${bgColor};
    color: black;
    font-weight: bold;
    font-size: 12px;
    padding-left: 3px;
    padding-right: 3px;
    width: fit-content;
  ">${text}</pre>`;
}

;// CONCATENATED MODULE: ./src/inserts/insertTrumpNotice.ts

function insertTrumpNotice(info) {
    const { inditermint , actualFilename , desiredFilename , torrentId  } = info;
    // Settings
    const color = inditermint ? "pink" : "hotpink";
    const title = inditermint ? "Couldn't determine if the torrent is trumpable:" : "This torrent is trumpable!";
    const details = inditermint ?? `The filename in the torrent is: ${smallPre(actualFilename, "lightcoral")} but the desired filename, based on <i>No-Intro</i> is: ${smallPre(desiredFilename, "lightgreen")}`;
    // Elements
    const detailsDiv = $(`<div style="font-weight: normal; color: white;">${details}</div>`).hide();
    const titleSpan = $(`
    <span style="color: ${color}; font-size: 14px; font-weight: bold;">${title}</span>`);
    const actionsDiv = $(`<div id="trump-notice-links-${torrentId}" style="font-weight: normal; font-size: 11px; display: inline; margin: 5px; user-select: none;"></div>`);
    // Toggle Details
    const toggleDetailsActionSpan = $(`<span style="cursor: pointer;">[Expand]</span>`);
    toggleDetailsActionSpan.click(()=>{
        const collapsed = toggleDetailsActionSpan.text() === "[Expand]";
        if (collapsed) {
            toggleDetailsActionSpan.text("[Collapse]");
            detailsDiv.show();
        } else {
            toggleDetailsActionSpan.text("[Expand]");
            detailsDiv.hide();
        }
    });
    // Tree
    const wrapper = $(`<div></div>`);
    actionsDiv.append(toggleDetailsActionSpan);
    titleSpan.append(actionsDiv);
    wrapper.append(titleSpan);
    wrapper.append(detailsDiv);
    // Place
    let currentlyAdaptedToSmallScreen;
    function placeTrumpNotice() {
        console.log("adapting", window.innerWidth);
        if (window.innerWidth <= 800) {
            if (currentlyAdaptedToSmallScreen) {
                return;
            }
            currentlyAdaptedToSmallScreen = true;
            $(`#torrent${torrentId}`).css("border-bottom", "none");
            wrapper.css("margin-left", "25px");
            wrapper.detach();
            wrapper.insertAfter(`#torrent${torrentId}`);
        } else {
            if (currentlyAdaptedToSmallScreen === false) {
                return;
            }
            currentlyAdaptedToSmallScreen = false;
            $(`#torrent${torrentId}`).css("border-bottom", "");
            wrapper.css("margin-left", "0px");
            wrapper.detach();
            wrapper.appendTo(`#torrent${torrentId} > td:first-child`);
        }
    }
    placeTrumpNotice();
    $(window).resize(placeTrumpNotice);
    // Call global hook (for other scripts)
    // @ts-expect-error
    if (typeof unsafeWindow.GM_GGN_NOINTRO_HELPER_ADDED_LINKS === "function") {
        // @ts-expect-error
        unsafeWindow.GM_GGN_NOINTRO_HELPER_ADDED_LINKS({
            ...info,
            links: actionsDiv
        });
    }
}

;// CONCATENATED MODULE: ./src/inserts/insertTrumpSuggestions.ts



async function insertTrumpSuggestions(torrents) {
    const { disable , progress  } = checkForTrumpsButton();
    disable();
    let trumps = 0;
    let prevCached = false;
    for(let i = 0; i < torrents.length; i++){
        const torrent = torrents[i];
        progress(`Checking For Trumps ${i + 1}/${torrents.length}...`);
        // timeout to avoid rate limiting
        if (!prevCached) {
            await new Promise((resolve)=>setTimeout(resolve, 500));
        }
        // Check trump
        const TrumpCheckResult = await checkIfTrumpable(torrent.torrentId);
        const { trumpable , cached  } = TrumpCheckResult;
        if (!trumpable) {
            continue;
        }
        // Follow up
        insertTrumpNotice({
            ...TrumpCheckResult,
            ...torrent
        });
        trumps++;
        prevCached = cached;
    }
    if (trumps === 0) {
        progress("No Trumps Found");
    } else if (trumps === 1) {
        progress("1 Trump Found");
    } else {
        progress(`${trumps} Trumps Found`);
    }
}

;// CONCATENATED MODULE: ./src/pages/torrents.ts




function trumpSuggestions() {
    const torrents = getNoIntroTorrentsOnPage();
    if (torrents.length === 0) {
        return;
    }
    const { button , insert  } = checkForTrumpsButton();
    insert();
    if (torrents.length <= 4) {
        insertTrumpSuggestions(torrents);
    }
    button.click((e)=>{
        e.stopImmediatePropagation();
        insertTrumpSuggestions(torrents);
    });
}
function torrentsPageMain() {
    insertAddCopyHelpers();
    trumpSuggestions();
}

;// CONCATENATED MODULE: ./src/inserts/uploadLinkParserUI.ts
function uploadNoIntroLinkParserUI() {
    // elements
    const container = $(`<tr id="no-intro-url" name="no-intro-url">
      <td class="label">No-Intro Link</td>
    </tr>`);
    const input = $('<input type="text" id="no-intro-url-input" name="no-intro-url-input" size="70%" class="input_tog" value="">');
    const error = $('<p id="no-intro-url-error" name="no-intro-url-error" style="color: red; white-space:pre-line;"></p>').hide();
    const loading = $('<p id="no-intro-url-loading" name="no-intro-url-loading" style="color: green;">Loading...</p>').hide();
    // structure
    const td = $("<td></td>");
    td.append(input);
    td.append(error);
    td.append(loading);
    container.append(td);
    // utils
    const setError = (msg)=>{
        error.text(msg);
        error.show();
    };
    const setLoading = (isLoading)=>{
        if (isLoading) {
            loading.show();
        } else {
            loading.hide();
        }
    };
    return {
        loading,
        error,
        container,
        input,
        setError,
        setLoading
    };
}

;// CONCATENATED MODULE: ./src/utils/dom/setUploadEdition.ts
function setUploadEdition(edition) {
    try {
        $("#groupremasters").val(edition).change();
        GroupRemaster();
    } catch  {
    // group remaster always throws (regardless of the userscript)
    }
}

;// CONCATENATED MODULE: ./src/utils/generateTorrentDescription.ts
const generateTorrentDescription = function() {
    let url = arguments.length > 0 && arguments[0] !== void 0 ? arguments[0] : "xxx", filename = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : "xxx";
    return `[align=center]${filename} matches [url=${url}]No-Intro checksum[/url]
Compressed with [url=https://sourceforge.net/projects/trrntzip/]torrentzip.[/url][/align]
`;
};

;// CONCATENATED MODULE: ./src/pages/upload.ts





function linkParser() {
    // UI
    const { error , container , input , setError , setLoading  } = uploadNoIntroLinkParserUI();
    // watch link input
    let justChecked = "";
    input.on("paste", (e)=>{
        e.preventDefault();
        const text = e.originalEvent.clipboardData.getData("text/plain");
        input.val(text);
        submit();
    });
    input.change(submit);
    // React to release type change, and insert input
    $("select#miscellaneous").change(function() {
        const selected = $("select#miscellaneous option:selected").text();
        if (selected === "ROM") {
            container.insertBefore("#regionrow");
            $("textarea#release_desc").val(generateTorrentDescription()); /// xxx temporary
        } else {
            container.detach();
        }
    });
    // handle submit
    async function submit() {
        // Prechecks
        error.hide();
        const url = input.val();
        if (justChecked === url) {
            return;
        }
        if (!url.startsWith("https://datomatic.no-intro.org/")) {
            setError("Invalid URL");
            return;
        }
        // Go
        justChecked = url;
        setLoading(true);
        try {
            const { filename , language , region  } = await fetchNoIntro(url);
            $("textarea#release_desc").val(generateTorrentDescription(url, filename));
            $("select#region").val(region);
            $("select#language").val(language);
        } catch (err) {
            setError(err.message || err || "An unexpected error has occurred");
        } finally{
            setLoading(false);
        }
    }
}
function magicNoIntroPress() {
    const filename = $("#file").val();
    const tags = filename ? filename.match(PARENS_TAGS_REGEX).filter((p)=>NO_INTRO_TAGS_REGEX.test(p)).join(" ") : "";
    // Release type = ROM
    $("select#miscellaneous").val("ROM").change();
    // It is a special edition
    if (!$("input#remaster").prop("checked")) {
        $("input#remaster").prop("checked", true);
        Remaster();
    }
    // Not a scene release
    $("#ripsrc_home").prop("checked", true);
    // @ts-expect-error Update title
    updateReleaseTitle($("#title").raw().value + " " + tags);
    // Get url params
    const params = new URLSearchParams(window.location.search);
    // Set correct edition (fallback to guessing)
    const editionInfo = params.get("edition");
    $("#groupremasters > option").each(function() {
        const title = $(this).text().toLowerCase();
        console.log("checking", title);
        if (editionInfo && title === editionInfo.toLowerCase()) {
            setUploadEdition($(this).val());
            return false; // This breaks out of the jquery loop
        } else {
            if (title.includes("no-intro") || title.includes("nointro")) {
                setUploadEdition($(this).val());
            }
        }
    });
    // Trigger no-intro link scraper
    const noIntroLink = params.get("no-intro");
    if (noIntroLink) {
        $("#no-intro-url-input").val(noIntroLink).change();
    }
}
function uploadPageMain() {
    // Insert No Intro magic button
    const noIntroMagicButton = $('<input type="button" value="No-Intro"></input>');
    noIntroMagicButton.click(()=>magicNoIntroPress());
    noIntroMagicButton.insertAfter("#file");
    linkParser();
}

;// CONCATENATED MODULE: ./src/index.ts


async function main() {
    console.log("GGn No-Intro Helper: Starting...");
    if (window.location.pathname === "/torrents.php") {
        torrentsPageMain();
    } else if (window.location.pathname === "/upload.php") {
        uploadPageMain();
    }
}
main().catch((e)=>{
    console.log(e);
});

/******/ })()
;