NHentai Konnichiwa

A simple usercript for downloading doujinshi from NHentai and mirrors

// ==UserScript==
// @name         NHentai Konnichiwa
// @author       naiymu
// @version      1.1.11
// @license      MIT; https://raw.githubusercontent.com/naiymu/nhentai-konnichiwa/main/LICENSE
// @namespace    https://github.com/naiymu/nhentai-konnichiwa
// @homepage     https://github.com/naiymu/nhentai-konnichiwa
// @supportURL   https://github.com/naiymu/nhentai-konnichiwa/issues
// @description  A simple usercript for downloading doujinshi from NHentai and mirrors
// @match        https://nhentai.net/*
// @match        https://nhentai.xxx/*
// @match        https://nyahentai.red/*
// @match        https://nhentai.to/*
// @match        https://nhentai.website/*
// @exclude      /https:\/\/n.*hentai.(red|net|xxx|to|website)\/g\/[0-9]*\/[0-9]+\/?$/
// @connect      nhentai.xxx
// @connect      cdn.nload.xyz
// @connect      i3.nhentai.net
// @connect      cdn.nhentai.xxx
// @connect      nhentai.com
// @connect      t.dogehls.xyz
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.0/jszip.min.js
// @require      https://unpkg.com/comlink@4.3.1/dist/umd/comlink.min.js
// @grant        GM_addStyle
// @grant        GM_setClipboard
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @run-at       document-end
// @icon         https://raw.githubusercontent.com/naiymu/nhentai-konnichiwa/main/assets/icon.png
// ==/UserScript==

GM_addStyle (
`
.relative {
  position: relative !important;
}

.download-check {
  position: absolute;
  top: 0;
  left: 0;
  cursor: pointer;
  height: 20px;
  width: 20px;
  accent-color: #ed2553;
}

.download-check:focus,
.download-check:hover {
  box-shadow: 0 0 10px 0 #ed2553;
}

.red-box {
  position: relative;
  background-color: #ed2553;
  color: white;
  border: none;
  outline: none;
  font-size: 16px;
  width: 80px;
  height: 35px;
  border-radius: 5px;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  cursor: pointer;
}

.percent {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%,-50%);
}

.downloading-span,
.compressing-span {
  display: inline-block;
  position: absolute;
  top: 0;
  left: 0;
  width: 50px;
  height: 100%;
  border-radius: 5px;
}

.downloading-span {
  background-color: #03c03c;
}

.compressing-span {
  background-color:  #0047ab;
}

.red-box:hover {
  background-color: #4d4d4d;
}

.red-box>i,
.red-box>.download-check {
  margin-right: 5px;
}

.download-check-all {
  width: 20px;
  height: 20px;
  cursor: pointer;
  accent-color: #1f1f1f;
}

.red-box>.download-check:focus,
.red-box>.download-check:hover {
  box-shadow: none;
}

.red-box:disabled {
  background-color: #1f1f1f;
  cursor: default;
}

.download-div {
  position: fixed;
  bottom: 0;
  left: 0;
  display: flex;
  flex-direction: column;
  gap: 5px;
}

.div-horizontal {
  flex-direction: row !important;
}

.fa-spinner,
.fa-circle-notch {
  animation: spin 1.5s infinite linear;
}

@keyframes spin {
  100% {transform: rotate(359deg)};
}

.config-wrapper {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.8);
  z-index: 999999;
  visibility: hidden;
}

.visible {
  visibility: visible;
}

.config-div {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 100%;
  height: 100%;
  max-width: 850px;
  max-height: 550px;
  padding: 50px;
  transform: translate(-50%, -50%);
  background-color: #1f1f1f;
  border-radius: 5px;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: center;
  overflow: scroll;
}

.config-element-div {
  width: 100%;
  display: inline-grid;
  grid: auto / 200px auto;
  margin-bottom: 15px
}

.config-element-div>input {
  color: #000 !important;
}

.config-element-div>label {
  grid-column-start: 1;
}

.config-element-div>input[type='checkbox'] {
  justify-self: left;
}

.config-element-div>*:not(label) {
  grid-column-start: 2;
  border-radius: 0;
  border: none;
  background-color: #d9d9d9 !important;
}

.config-btn-div {
  display: flex;
  flex-direction: row;
  gap: 10px;
  align-items: center;
}

.config-reset:hover {
  cursor: pointer;
  color: #ed2553;
}

.heading {
  width: 100%;
  border-bottom: solid 1px #d9d9d9;
  text-align: left;
}
`
);

const netMediaUrl = "https://i3.nhentai.net/galleries/";
const btnStates = {
    enabled: "<i class='fa fa-download'></i>",
    fetching: "<i id='btn-spinner' class='fa fa-spinner'></i>",
    downloading: "<i id='btn-spinner' class='fa fa-circle-notch'></i>",
    config: "<i class='fa fa-cog'></i>",
};
const titleFormats = {
    PR: "pretty",
    EN: "english",
    JP: "japanese",
    ID: "id",
};
const saveJSONModes = {
    NO: "Don't save",
    FI: "Save as JSON file",
    CB: "Copy to clipboard",
};
const fileNameSeps = {
    SP: "Space",
    HY: "Hyphen",
    US: "Underscore",
};
const btnOrientations = {
    VR: "Vertical",
    HR: "Horizontal",
};
const CONFIG = {
    simulN: {
        label: 'Download batch size',
        type: 'int',
        min: 1,
        max: 50,
        default: 10,
    },
    compressionLevel: {
        label: 'Compression level',
        type: 'int',
        min: 0,
        max: 9,
        default: 0,
    },
    titleFormat: {
        label: 'Title format',
        type: 'select',
        options: [titleFormats.PR,titleFormats.EN,titleFormats.JP,
                  titleFormats.ID],
        default: titleFormats.PR,
    },
     fileNamePrep: {
        label: 'Filename to prepend',
        type: 'text',
        size: 30,
        default: "",
    },
     fileNameSep: {
        label: 'Filename separator',
        type: 'select',
        options: [fileNameSeps.SP,fileNameSeps.HY,fileNameSeps.US],
        default: fileNameSeps.SP,
    },
    saveJSONMode: {
         label: 'Save JSON',
         type: 'select',
         options: [saveJSONModes.NO,saveJSONModes.FI,saveJSONModes.CB],
         default: saveJSONModes.NO,
     },
    includeGroups: {
        label: 'Include groups in authors',
        type: 'checkbox',
        default: false,
    },
    btnOrientation: {
        label: 'Button orientation',
        type: 'select',
        options: [btnOrientations.VR, btnOrientations.HR],
        default: btnOrientations.VR,
    },
    openInNewTab: {
        label: 'Open galleries in new tab',
        type: 'checkbox',
        default: true,
    },
    autorestart: {
        label: 'Auto restart downloads',
        type: 'checkbox',
        default: true,
    }
};

const WORKER_THREAD_NUM = ((navigator && navigator.hardwareConcurrency) || 2) - 1;

class JSZipWorkerPool {
    constructor() {
        this.pool = [];
        this.WORKER_URL = URL.createObjectURL(
            new Blob(
                [
                    `importScripts(
                         'https://unpkg.com/comlink/dist/umd/comlink.min.js',
                         'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js'
                     );
                     class JSZipWorker {
                         constructor() {
                             this.zip = new JSZip;
                         }
                         file(title, name, {data:data}) {
                             this.zip.folder(title).file(name, data);
                         }
                         async generateAsync(options, onUpdate) {
                             const data = await this.zip.generateAsync(options, onUpdate);
                             const url = URL.createObjectURL(data);
                             return Comlink.transfer({url:url});
                         }
                     }
                     Comlink.expose(JSZipWorker);`
                ],
                {type: 'text/javascript'}
            )
        );
        for(let id=0; id<WORKER_THREAD_NUM; id++) {
            this.pool.push({
                id,
                JSZip: null,
                idle: true,
            });
        }
    }
    createWorker() {
        const worker = new Worker(this.WORKER_URL);
        return Comlink.wrap(worker);
    }
    async generateAsync(files, options, onUpdate) {
        const worker = this.pool.find(({idle}) => idle);
        if(!worker) throw new Error('No available JSZip worker');
        worker.idle = false;
        if(!worker.JSZip) worker.JSZip = this.createWorker();
        const zip = await new worker.JSZip();
        for(const {title, name, data} of files) {
            await zip.file(title, name, Comlink.transfer({data}, [data]));
        }
        return zip
               .generateAsync(
                   options,
                   Comlink.proxy((data) => onUpdate({workerId: worker.id, ...data}))
               )
               .then(({url}) => {
                   worker.idle = true;
                   return url;
               });
    }
}

const jsZipPool = new JSZipWorkerPool();

class JSZip {
    constructor() {
        this.files = [];
    }
    file(title, name, data) {
        this.files.push({title, name, data});
    }
    generateAsync(options, onUpdate) {
        return jsZipPool.generateAsync(this.files, options, onUpdate);
    }
}

// nhentai.net API URL
const netAPI = "https://nhentai.net/api/gallery/";
// nhentai.xxx page URL
const xxxPage = "https://nhentai.xxx/g/";
// If we are on nhentai.net
const onNET = location.hostname == 'nhentai.net';
// DOM parser for later
var parser = new DOMParser();
// Saved config options
var configOptions = JSON.parse(GM_getValue('configOptions') || '{}');
// Buttons and Divs
var downloadDiv,
    downloadBtn,
    downloadPercent,
    downloadingSpan,
    compressingSpan,
    configWrapper,
    configDiv;
// Download info
var downloading = false,
    cancelled = false,
    total = 0,
    downloaded = 0,
    currentDownloads = 0,
    queue = [],
    info = JSON.parse(sessionStorage.getItem('info') || '[]');
// Final zip
var zip;

function disableButton(btnState) {
    downloadingSpan.style.width = 0;
    compressingSpan.style.width = 0;
    downloadBtn.disabled = true;
    downloadPercent.innerHTML = btnState;
}

function enableButton() {
    downloadingSpan.style.width = 0;
    compressingSpan.style.width = 0;
    downloadBtn.disabled = false;
    downloadPercent.innerHTML = btnStates.enabled;
}

function createNode(element, classes=[]) {
    var node = document.createElement(element);
    for(let cls of classes) {
        node.classList.add(cls);
    }
    return node;
}

function createLabel(id, text) {
    var label = createNode('label');
    label.innerHTML = text;
    label.setAttribute('for', id);
    return label;
}

function getExtension(type) {
    switch(type) {
        case 'j': return '.jpg';
        case 'p': return '.png';
        case 'g': return '.gif';
    }
}

function saveConfig(reset=false) {
    const oldOpenInNewTab = configOptions.openInNewTab;
    for(const [key, value] of Object.entries(CONFIG)) {
        var element = document.getElementById(`config-${key}`);
        var configValue;
        switch(value.type) {
            case 'checkbox':
                configValue = element.checked;
                break;
            case 'int':
                configValue = element.value;
                if(configValue > value.max) {
                    configValue = value.max;
                }
                if(configValue < value.min) {
                    configValue = value.min;
                }
                break;
            default:
                configValue = element.value;
        }
        configValue = reset ? value.default : configValue;
        configOptions[key] = configValue;
        element.value = configValue;
        if(value.type == 'checkbox') element.checked = configValue;
    }
    if(configOptions.btnOrientation == btnOrientations.HR) {
        downloadDiv.classList.add('div-horizontal');
    }
    else {
        downloadDiv.classList.remove('div-horizontal');
    }
    GM_setValue('configOptions', JSON.stringify(configOptions));
    if(configOptions.openInNewTab != oldOpenInNewTab) {
        var gLinks = document.querySelectorAll('.gallery > a, .gallerythumb');
        for(let a of gLinks) {
            if(configOptions.openInNewTab) {
                a.setAttribute('target', '_blank');
            }
            else {
                a.removeAttribute('target');
            }
        }
    }
}

function addConfigMenu() {
    var saveConfigBtn, closeConfigBtn, cancelAnchor, resetConfigAnchor;

    var heading = createNode('h3', ['heading']);
    heading.innerHTML = "nhentai-konnichiwa";
    configDiv.appendChild(heading);

    for(const [key, value] of Object.entries(CONFIG)) {
        var div = createNode('div', ['config-element-div']);
        var element, id, label;
        switch(value.type) {
            case 'select':
                element = createNode('select');
                for(let i=0; i<value.options.length; i++) {
                    var optionElement = createNode('option');
                    var option = value.options[i];
                    optionElement.text = option;
                    optionElement.value = option;
                    element.appendChild(optionElement);
                }
                break;
            case 'int':
                element = createNode('input');
                element.type = 'number';
                if(value.min != undefined) {
                    element.min = value.min;
                }
                if(value.max != undefined) {
                    element.max = value.max;
                }
                break;
            case 'checkbox':
                element = createNode('input');
                element.type = 'checkbox';
                break;
            case 'text':
                element = createNode('input');
                element.type = 'text';
                element.maxLength = value.size;
                element.defaultValue = value.default;
                break;
        }
        id = `config-${key}`;
        element.id = id;
        var configValue = (configOptions.hasOwnProperty(key))
                          ? configOptions[key]
                          : value.default;
        if(value.type == 'checkbox') {
            element.checked = configValue;
        }
        else {
            element.value = configValue;
        }
        // If key does not exist in configOptions, add it
        if(!configOptions[key]) {
            configOptions[key] = configValue;
        }
        label = createLabel(id, value.label);

        div.appendChild(label);
        div.appendChild(element);

        configDiv.appendChild(div);
    }

    var btnDiv = createNode('div', ['config-btn-div']);
    saveConfigBtn = createNode('button', ['red-box']);
    saveConfigBtn.innerHTML = "Save";
    saveConfigBtn.addEventListener('click', () => {
        saveConfig();
    });

    closeConfigBtn = createNode('button', ['red-box']);
    closeConfigBtn.innerHTML = "Close";
    closeConfigBtn.addEventListener('click', () => {
        configWrapper.classList.remove('visible');
    });

    cancelAnchor = createNode('a', ['config-reset']);
    cancelAnchor.innerHTML = "Cancel downloads";
    cancelAnchor.addEventListener('click', () => {
        cancelDownload();
    });

    resetConfigAnchor = createNode('a', ['config-reset']);
    resetConfigAnchor.innerHTML = 'Reset to default';
    resetConfigAnchor.addEventListener('click', () => {
        saveConfig(true);
    });

    btnDiv.appendChild(saveConfigBtn);
    btnDiv.appendChild(closeConfigBtn);
    btnDiv.appendChild(cancelAnchor);
    btnDiv.appendChild(resetConfigAnchor);

    configDiv.appendChild(btnDiv);
}

(async function() {
    'use strict';

    window.addEventListener('beforeunload', (e) => {
        if(downloading) {
            e.preventDefault();
            return '';
        }
    });

    var aList, configBtn, checkAllDiv, checkAll;

    configWrapper = createNode('div', ['config-wrapper']);
    configDiv = createNode('div', ['config-div']);
    configWrapper.addEventListener('click', () => {
        if(event.target != configWrapper) {
            return;
        }
        configWrapper.classList.remove('visible');
    });
    configWrapper.appendChild(configDiv);
    addConfigMenu();

    aList = document.querySelectorAll(".gallery > a, #cover > a");
    for(let a of aList) {
        if(configOptions.openInNewTab) a.setAttribute('target', '_blank');
        var ref = a.href;
        var parent = a.parentElement;
        var code;
        code = ref.split("/g/")[1];
        if(code.endsWith("/")) {
            code = code.split("/")[0];
        }
        var check = createNode('input', ['download-check']);
        check.type = 'checkbox';
        check.value = code;
        parent.classList.add("relative");
        parent.appendChild(check);
    }
    if(configOptions.openInNewTab) {
        aList = document.getElementsByClassName("gallerythumb");
        for(let a of aList) {
            a.setAttribute('target', '_blank');
        }
    }
    let classes = ['download-div'];
    if(configOptions.btnOrientation == btnOrientations.HR) {
        classes.push('div-horizontal');
    }
    downloadDiv = createNode('div', classes);

    downloadBtn = createNode('button', ['red-box']);
    downloadPercent = createNode('span', ['percent']);
    downloadingSpan = createNode('span', ['downloading-span']);
    compressingSpan = createNode('span', ['compressing-span']);
    enableButton();
    downloadBtn.appendChild(downloadingSpan);
    downloadBtn.appendChild(compressingSpan);
    downloadBtn.appendChild(downloadPercent);

    configBtn = createNode('button', ['red-box']);
    configBtn.innerHTML = btnStates.config;
    configBtn.addEventListener("click", (event) => {
        configWrapper.classList.add('visible');
    });

    checkAllDiv = createNode('div', ['red-box', 'relative']);
    checkAll = createNode('input', ['download-check-all']);
    checkAll.type = 'checkbox';
    checkAll.addEventListener('change', () => {
        let toCheck = checkAll.checked;
        let boxes = document.querySelectorAll('.download-check');
        for(let box of boxes) {
            if(box === checkAll) {
                continue;
            }
            box.checked = toCheck;
        }
    });
    checkAllDiv.appendChild(checkAll);

    downloadBtn.addEventListener("click", async () => {
        var checked = document.querySelectorAll(".download-check:checked");
        if(checked.length > 0) {
            disableButton(btnStates.fetching);
        }
        else {
            return;
        }
        zip = await new JSZip();
        switch(configOptions.fileNameSep) {
            case fileNameSeps.SP: configOptions.fileNameSep = " "; break;
            case fileNameSeps.HY: configOptions.fileNameSep = "-"; break;
            case fileNameSeps.US: configOptions.fileNameSep = "_"; break;
        }
        for(let c of checked) {
            c.checked = false;
        }
        for(const c of checked) {
            var code = c.getAttribute("value");
            await addInfo(code);
            sessionStorage.setItem('info', JSON.stringify(info));
        }
        startDownload();
    });

    downloadDiv.appendChild(downloadBtn);
    downloadDiv.appendChild(configBtn);
    downloadDiv.appendChild(checkAllDiv);

    document.body.appendChild(downloadDiv);
    document.body.appendChild(configWrapper);

    if(info.length > 0) {
        if(configOptions.autorestart) {
            queue = [];
            startDownload();
        }
        else {
            info = [];
            sessionStorage.removeItem('info');
        }
    }
})();

function startDownload() {
    downloading = true;
    cancelled = false;
    currentDownloads = 0;
    downloaded = 0;
    zip = new JSZip();
    populateQueue();
    total = queue.length;
    disableButton(btnStates.downloading);
    downloadQueue();
}

function cancelDownload() {
    info = [];
    queue = [];
    sessionStorage.removeItem('info');
    currentDownloads = 0;
    downloading = false;
    cancelled = true;
    enableButton();
}

function cleanString(string) {
    string = string.replace(/(\.+$)|(^\.+)|(\|)/g, '');
    string = string.replace(/\\\/\:\;/g, configOptions.fileNameSep);
    string = string.replace(/\s\s+/, ' ');
    string = string.trim();
    return string;
}

async function makeGetRequest(url, code = null) {
    return new Promise((resolve, reject) => {
        if(onNET) {
            fetch(url, {
                method: 'GET',
                mode: 'same-origin',
                credentials: 'same-origin',
                headers: {
                    'Content-Type': 'application/json'
                },
                referrerPolicy: 'same-origin',
            })
            .then(response => resolve(response.json()));
        }
        else {
            GM_xmlhttpRequest({
                method: "GET",
                url: url,
                onload: (response) => {
                    resolve(parseXXXResponse(response, code));
                },
                onerror: (error) => {
                    reject(error);
                }
            });
        }
    });
}

function addInfoNET(obj, code) {
    var title;
    if(configOptions.titleFormat == 'id') {
        title = `${obj.id}`;
    }
    else {
        title = obj.title[configOptions.titleFormat];
        title = cleanString(title);
    }
    var pages = obj.num_pages;
    var tagList = obj.tags;
    var artists = [];
    var tags = [];
    for(let i=0; i<tagList.length; i++) {
        let tagItem = tagList[i];
        if(tagItem.type == "artist"
            || (configOptions.includeGroups && tagItem.type == "group")) {
            artists.push(tagItem.name);
        }
        if(tagItem.type == "tag") {
            tags.push(tagItem.name);
        }
    }
    var mediaUrl = `${netMediaUrl}${obj.media_id}/`;
    const constTitleExists = info.some((el) => el.title === title);
    if(constTitleExists) {
        title += " - "+code;
    }
    var fileNamePrep = configOptions.fileNamePrep;
    fileNamePrep = cleanString(fileNamePrep);
    var namePrep = "";
    if(fileNamePrep != "") {
        namePrep = fileNamePrep + configOptions.fileNameSep;
    }
    var coverExtension = getExtension(obj.images.pages[0].t);
    info.push({
        code: code,
        title: title,
        artists: artists,
        tags: tags,
        pages: pages,
        mediaUrl: mediaUrl,
        namePrep: namePrep,
        coverExtension: coverExtension,
        pagesInfo: obj.images.pages,
    });
}

async function addInfo(code) {
    var apiUrl, obj;
    if(onNET) {
        apiUrl = netAPI + code;
        obj = await makeGetRequest(apiUrl);
        addInfoNET(obj, code);
    }
    else {
        apiUrl = xxxPage + code;
        obj = await makeGetRequest(apiUrl, code);
        info.push(obj);
    }
}

function parseXXXResponse(response, code) {
    var htmlDoc = parser.parseFromString(response.responseText,
                                         'text/html');
    var title, artists = [], tags = [], pages, mediaUrl, pagesInfo = [], coverExtension;
    var titleTemplate = string => {
        return `${string}.title > span`;
    }
    const cleanRegex = /(\[[^\]]*\])|(\([^)]*\))|(\{[^}]*\})|([\.\|\~]*)/g;
    switch(configOptions.titleFormat) {
        case 'english':
            title = htmlDoc.querySelector(titleTemplate('h1'));
            title = title.textContent;
            break;
        case 'japanese':
            title = htmlDoc.querySelector(titleTemplate('h2'));
            title = title.textContent;
            break;
        case 'pretty':
        default:
            title = htmlDoc.querySelector(titleTemplate('h1'));
            title = title.textContent;
            title = title.replace(cleanRegex, '');
            title = title.replace(/\s\s+/g, ' ');
            title = title.trim();
            title = title.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
    }
    var tagTemplate = string => {
        return `a[href*='${string}/'] > span.name`;
    }
    var artistSpans = htmlDoc.querySelectorAll(tagTemplate('artist'));
    for(var artist of artistSpans) {
        artists.push(artist.textContent);
    }
    if(configOptions.includeGroups) {
        var groupSpans = htmlDoc.querySelectorAll(tagTemplate('group'));
        for(var group of groupSpans) {
            artists.push(group.textContent);
        }
    }
    var tagSpans = htmlDoc.querySelectorAll(tagTemplate('tag'));
    for(var tag of tagSpans) {
        tags.push(tag.textContent);
    }
    pages = htmlDoc.querySelector(".tag[href*='#'] > span.name");
    pages = parseInt(pages.textContent);
    var fileNamePrep = configOptions.fileNamePrep;
    fileNamePrep = cleanString(fileNamePrep);
    var namePrep = "";
    if(fileNamePrep != "") {
        namePrep = fileNamePrep + configOptions.fileNameSep;
    }
    var thumbs = htmlDoc.querySelectorAll("a.gallerythumb > img");
    mediaUrl = thumbs[0].src;
    mediaUrl = mediaUrl.substring(0, mediaUrl.lastIndexOf("/")+1);
    for(var thumb of thumbs) {
        var extension = thumb.src.split('.').pop().charAt(0);
        pagesInfo.push({t: extension});
    }
    coverExtension = getExtension(pagesInfo[0].t);
    var obj = {
        code: code,
        title: title,
        artists: artists,
        tags: tags,
        pages: pages,
        mediaUrl: mediaUrl,
        namePrep: namePrep,
        coverExtension: coverExtension,
        pagesInfo: pagesInfo,
    }
    return obj;
}

async function addToQueue(item, mediaId=null) {
    var pages = item.pages;
    var title = item.title;
    var namePrep = item.namePrep;
    var mediaUrl = item.mediaUrl;
    for(let i=0; i<pages; i++) {
        var extension = getExtension(item.pagesInfo[i].t);
        let page = i + 1;
        var imgUrl = `${mediaUrl}${page}${extension}`;
        queue.push({
            page: page,
            url: imgUrl,
            title: title,
            namePrep: namePrep,
            extension: extension,
        });
    }
}

function populateQueue() {
    for(const item of info) {
        addToQueue(item);
    }
}

async function downloadQueue() {
    while(queue.length > 0) {
        if(currentDownloads >= configOptions.simulN) {
            await sleep(125);
            continue;
        }
        var item = queue.shift();
        download(item);
    }
}

function saveJSON(fileName) {
    var data = [];
    for(var item of info) {
        data.push({
            dirName: item.title,
            dirCover: `${item.namePrep}1${item.coverExtension}`,
            authors: item.artists,
            tags: item.tags,
        });
    }
    var fileContent = {
        'directories': data
    };
    fileContent = JSON.stringify(fileContent, null, 2);
    if(configOptions.saveJSONMode == saveJSONModes.CB) {
        GM_setClipboard(fileContent, 'text');
        return;
    }
    fileContent = new TextEncoder().encode(fileContent);
    zip.file('', fileName, fileContent.buffer);
}

function generateZip() {
    var dateName = Date.now();
    var compressionType = configOptions.compressionLevel == 0
                          ? 'STORE'
                          : 'DEFLATE';
    var zipName = `${dateName}.zip`;
    var jsonName = `${dateName}.json`;
    if(configOptions.saveJSONMode != saveJSONModes.NO) {
        saveJSON(jsonName);
    }
    zip.generateAsync(
        {
        type: 'blob',
        compression: compressionType,
        compressionOptions: {
            level: configOptions.compressionLevel,
        }},
        ({workerId, percent, currentFile}) => {
            var fraction = percent / 100;
            if(fraction == 1) {
                enableButton();
                return;
            }
            downloadPercent.innerHTML = `${percent.toFixed(2)}%`;
            compressingSpan.style.width = fraction * downloadBtn.offsetWidth + 'px';
        }
    )
    .then((url) => {
        var a = createNode('a');
        a.download = zipName;
        a.href = url;
        a.click();
    })
    .then(() => {
        info = [];
        sessionStorage.removeItem('info');
        downloading = false;
    });
}

function download(item) {
    currentDownloads++;
    const fileName = `${item.namePrep}${item.page}${item.extension}`;
    GM_xmlhttpRequest({
        method: 'GET',
        url: item.url,
        responseType: 'arraybuffer',
        onload: (response) => {
            if(cancelled) return;
            var data = response.response;
            zip.file(item.title, fileName, data);

            currentDownloads--;

            downloaded++;
            var fraction = downloaded/total;
            downloadPercent.innerHTML = `${(fraction * 100).toFixed(2)}%`;
            downloadingSpan.style.width = fraction * downloadBtn.offsetWidth + 'px';

            if(queue.length == 0 && currentDownloads <= 0) {
                disableButton(btnStates.downloading);
                generateZip();
            }
        },
        onerror: (error) => {
            currentDownloads--;
            console.warn(`Could not download '${item.title}' - page ${item.page}`);
        }
    });
}

function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}