// ==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 // @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 // @downloadURL none // ==/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); } } })();