khinsider mass downloader

mass downloader for downloads.khinsider.com

// ==UserScript==
// @name         khinsider mass downloader
// @namespace    https://venipa.net/
// @license      GPL-3.0
// @version      0.2.1
// @description  mass downloader for downloads.khinsider.com
// @author       Venipa <admin@venipa.net>
// @include      /^https?://(\w+).khinsider\.com/game-soundtracks/album/(*.)
// @match        https://*.khinsider.com/game-soundtracks/*
// @connect      vgmsite.com
// @require      https://cdn.jsdelivr.net/npm/jszip@3.2.2/dist/jszip.min.js
// @require      https://cdn.jsdelivr.net/npm/file-saver@2.0.2/dist/FileSaver.min.js
// @grant        GM_xmlhttpRequest
// @run-at       document-end
// ==/UserScript==

(function () {
  "use strict";
  function sanitizeFilename(input, options) {
    var illegalRe = /[\/\?<>\\:\*\|":]/g;
    var controlRe = /[\x00-\x1f\x80-\x9f]/g;
    var reservedRe = /^\.+$/;
    var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;

    function sanitize(input, replacement) {
      var sanitized = input
        .replace(illegalRe, replacement)
        .replace(controlRe, replacement)
        .replace(reservedRe, replacement)
        .replace(windowsReservedRe, replacement);
      return sanitized.split("").splice(0, 255).join("");
    }

    return (function (input, options) {
      var replacement = (options && options.replacement) || "";
      var output = sanitize(input, replacement);
      if (replacement === "") {
        return output;
      }
      return sanitize(output, "");
    })(input, options);
  }
  const downloadStatus = {
    running: false,
    skip: false,
  };
  const queue = [];
  console.log("loaded mass downloader");
  var btns = document.querySelector('p[align="left"]');
  const TEXTS = {
    /**
     *
     * @param {string} type
     */
    DOWNLOAD(type) {
      return "Download Album (" + type + ")";
    },
    LOADING: "LOADING...",
    /**
     *
     * @param {number} value
     * @param {number} max
     * @param {string} type
     */
    PREPARE(max, type) {
      return (
        "Preparing audio downloads... (Audio Files: " + max + ") (" + type + ")"
      );
    },
    /**
     *
     * @param {number} value
     * @param {number} max
     * @param {string} type
     */
    PROGRESS_ITEM(value, max, type) {
      const maxLength = max.toString().length;
      return (
        "Fetching... (" +
        value.toString().padStart(maxLength) +
        " / " +
        max +
        ") (" +
        type +
        ")"
      );
    },
    ARCHIVE_START(value, type) {
      return "Compressing... " + value + " (" + type + ")";
    },
  };
  var dlButton = function (type) {
    var el = document.createElement("button");
    "btn khinsider-massdl".split(" ").forEach((cl) => el.classList.add(cl));
    el.innerText = TEXTS.DOWNLOAD(type || "default");
    el.dataset.type = type;
    return el;
  };
  var checkFlac = () => Array.from(document.querySelectorAll("#songlist_header th>b")).findIndex(x => x.innerText.trim() === "FLAC") !== -1;
  var spacerEl = function (x, y) {
    var el = document.createElement("div");
    el.style.width = (x || 0) + "px";
    el.style.height = (y || 0) + "px";
    el.style.display = "inline-block";
    return el;
  };
  var mp3DL = dlButton("mp3");
  var flacDL = dlButton("flac");
  var hasFlac = checkFlac();
  const setDisabledState = function (state) {
    mp3DL.disabled = state;
    if (hasFlac) flacDL.disabled = state;
  };
  const getFetch = (url, responseType = "json") =>
      new Promise((resolve, reject) => {
        try {
          return fetch({ url: url, method: "GET", responseType: responseType })
            .then((response) => response.blob())
            .then(resolve);
        } catch (err) {
          reject(err);
        }
      }),
    get = (url, responseType = "json", retry = 3) =>
      new Promise((resolve, reject) => {
        try {
          GM_xmlhttpRequest({
            method: "GET",
            url,
            responseType,
            onerror: (e) => {
              if (retry === 0) reject(e);
              else {
                console.warn("Network error, retry.");
                if (e.status == 415) {
                  url = url.slice(0, url.lastIndexOf(".")) + ".mp3";
                }
                setTimeout(() => {
                  resolve(get(url, responseType, retry - 1));
                }, 1000);
              }
            },
            onload: ({ status, response }) => {
              if ([200, 206].includes(status)) resolve(response);
              else if (status === 415)
                setTimeout(() => {
                  resolve(
                    get(
                      url.slice(0, url.lastIndexOf(".")) + ".mp3",
                      responseType,
                      retry - 1
                    )
                  );
                }, 500);
              else if (retry === 0) reject(`${status} ${url}`);
              else {
                console.warn(status, url);
                setTimeout(() => {
                  resolve(get(url, responseType, retry - 1));
                }, 500);
              }
            },
          });
        } catch (error) {
          reject(error);
        }
      }),
    requestPage = (url) =>
      new Promise((resolve, reject) => {
        try {
          GM_xmlhttpRequest({
            method: "GET",
            url,
            responseType: "text",
            onerror: reject,
            onload: ({ status, response, error }) => {
              if (status === 200) resolve(response);
              reject(error);
            },
          });
        } catch (error) {
          reject(error);
        }
      });
  const startQueue = async (typeOfDL) => {
    if (!downloadStatus.running && queue.length > 0) {
      const dl = typeOfDL === "flac" ? flacDL : mp3DL;
      const zip = new JSZip();
      let i = 0;
      let l = queue.length;
      downloadStatus.running = true;
      dl.innerText = TEXTS.PREPARE(l, typeOfDL);
      do {
        const { url: meta, data } = await queue[0]();
        const { url, title } = meta;
        if (!data || data.size <= 0) {
          queue.shift();
          dl.innerText = TEXTS.PROGRESS_ITEM(i++, l, typeOfDL);
          continue;
        }
        let fname = url.split("/").reverse()[0];
        fname = fname.slice(0, fname.lastIndexOf("."));
        let fext = typeOfDL === "flac" ? "flac" : "mp3";
        if (data.type === "audio/mpeg") fext = "mp3";
        zip.file(
          sanitizeFilename(title).replace(/\.(mp3|flac)$/g, "") + "." + fext,
          data
        );
        queue.shift();
        dl.innerText = TEXTS.PROGRESS_ITEM(i++, l, typeOfDL);
      } while (queue.length > 0);
      downloadStatus.running = false;
      return await zip.generateAsync(
        { type: "blob" },
        function onUpdate(progress) {
          dl.innerText = TEXTS.ARCHIVE_START(
            progress.percent.toFixed(2) + "%",
            typeOfDL
          );
        }
      );
    }
    return null;
  };
  /**
   *
   * @param ev {{target: HTMLButtonElement}}
   */
  const onClick = function (ev) {
    ev.preventDefault();
    if (ev.target.disabled) return;
    ev.target.disabled = true;
    setDisabledState(true);
    const typeOfDL = ev.target.dataset.type;
    const typeOfExt =
      typeOfDL === "flac" ? ".flac" : typeOfDL === "mp3" ? ".mp3" : null;
    const header = Array.from(
      document.querySelectorAll("#songlist #songlist_header > th")
    );
    const hasCD = !!header.find(
        (x) => x.innerText && x.innerText.trim() === "CD"
      ),
      hasNumber = !!header.find(
        (x) => x.innerText && x.innerText.trim() === "#"
      );
    const urls = Array.from(
      document.querySelectorAll("#songlist #songlist_header ~ tr")
    )
      .filter((x) => x.querySelectorAll("td.clickable-row a").length > 0)
      .map((x) => {
        const fields = x.querySelectorAll("td");
        let title = x.querySelectorAll("td.clickable-row a")[0].innerText,
          url = x.querySelector(".playlistDownloadSong a").href,
          meta = {
            CD: hasCD ? fields[1].innerText : null,
            PIECE: hasCD
              ? fields[2].innerText
              : hasNumber
              ? fields[1].innerText
              : null,
          };
        title = title.replace(/\.(mp3|flac)$/g, "");
        return {
          title:
            (meta.CD ? meta.CD + "-" : "") +
            (meta.PIECE ? meta.PIECE.trim().match(/(\d+)/i)[0] + " " : "") +
            title +
            typeOfExt,
          url: url,
        };
      });
    if (urls.length === 0) {
      ev.target.disabled = false;
      setDisabledState(false);
      return;
    }
    const pageName = document.querySelector("#pageContent>h2").innerText;
    queue.push(
      ...urls.map((x) => {
        return async () => {
          try {
            return {
              url: x,
              data: await requestPage(x.url)
                .then((page) => {
                  const container =
                    document.implementation.createHTMLDocument()
                      .documentElement;
                  container.style.display = "none";
                  container.innerHTML = page;
                  const fileUrl =
                    container.querySelector(".songDownloadLink").parentElement
                      .href;
                  return get(fileUrl, "blob", 2);
                })
                .catch((err) => {
                  console.error(err);
                  return null;
                }),
            };
          } catch (ex) {
            console.error(ex);
            return { url: x, data: null };
          }
        };
      })
    );
    startQueue(typeOfDL).then((data) => {
      ev.target.disabled = false;
      setDisabledState(false);
      if (data) {
        saveAs(data, pageName + ".zip");
        ev.target.innerText = TEXTS.DOWNLOAD(typeOfDL);
      }
    });
  };
  mp3DL.onclick = onClick;
  if (hasFlac) flacDL.onclick = onClick;
  if (btns) {
    btns.appendChild(mp3DL);
    if (hasFlac) {
      btns.appendChild(spacerEl(8, 0));
      btns.appendChild(flacDL);
    }
  }
})();