e-hentai preload next page

preload and cache next page image for faster loading

// ==UserScript==
// @name        e-hentai preload next page
// @description preload and cache next page image for faster loading
// @author      owowed <island@owowed.moe>
// @version     0.4.5
// @namespace   e.owowed.moe
// @license     GPL-2.0-or-later
// @match       *://e-hentai.org/s/*
// @grant       GM_addStyle
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_deleteValue
// @grant       GM_listValues
// @grant       GM_info
// @grant       GM.xmlHttpRequest
// @grant       GM_download
// @connect     hath.network
// @require     https://cdn.jsdelivr.net/npm/@trim21/gm-fetch@0.1.15/dist/gm_fetch.js#sha256-Q0UpKw6Eod6c9OWELIjizD2ejwO+8/EaFxnb8IsSCN0=
// @run-at      document-end
// @copyright   Licensed under GPL-2.0-or-later. View license at https://spdx.org/licenses/GPL-2.0-or-later.html
// ==/UserScript==

;3 ;3 ;3

!async function(){
// Debug

const enableLogging = GM_getValue("debug.log", true);
const exposeDebugProperties = GM_getValue("debug.expose-debug-properties", false);

// Logging
    
function createCustomLog(fn) {
    if (enableLogging) {
        return Function.prototype.bind.call(fn, console, "%cehp%c", "background: #ff7de9; padding: 2px; border-radius: 5px; color: black; font-weight: bold;", "all: initial;");
    }
    else {
        return () => {};
    }
}

const log = createCustomLog(console.log);
const info = createCustomLog(console.info);
const debug = createCustomLog(console.debug);
const dbg = (...args) => (debug(...args), args.at(-1));

// Storage

const gmValueDefaultMap = {
    "image-caching-limit": 100,
    "concurrent-image-caching-limit": 10,
    "concurrent-image-caching-idle-limit": -1,
    "image-caching-cooldown": 3000,
    "page-cache-limit": 50,
    "persistent-cache-timeout": 2,
    "persistent-cache-limit": 800,
    "persistent-cache-storage-type": "file"
};

const gmValueCompatibilityMap = {
    "page-cache-limit": ["page-cache-store-limit"],
    "image-caching-limit": ["max-image-cache"],
    "concurrent-image-caching-idle-limit": ["max-image-cache-idle"],
};

for (const [key, oldKeys] of Object.entries(gmValueCompatibilityMap)) {
    for (const oldKey of oldKeys) {
        const value = GM_getValue(oldKey);
        if (value != undefined) {
            GM_setValue(key, value);
            info(`Migrated storage ${oldKey} -> ${key}`);
        }
        GM_deleteValue(oldKey);
    }
}

function getGmValue(settingsName) {
    return GM_getValue(settingsName, gmValueDefaultMap[settingsName]);
}

function setGmValue(settingsName, value) {
    GM_setValue(settingsName, value);
}

const getUserscriptDirectory = async () => {
    return navigator.storage.getDirectory()
        .then(dir => dir.getDirectoryHandle("preload-userscript", { create: true }));
};

const isUserscriptDirectoryAvailable = async () => {
    return getUserscriptDirectory()
        .then(d => d.getFileHandle("test-write.blob", { create: true }))
        .catch(() => false)
        || getGmValue("persistent-cache-storage-type") == "file";
};

if (!await getUserscriptDirectory().catch(() => undefined)) {
    info("File-based storage (OPFS) is not available due to an error, falling back to key-value based storage (GM_*). May have performance impact due to large image data serialization between binary data and base64.");
}

async function getPersistentCacheImage(imageName) {
    const key = encodeURIComponent(imageName);
    if (await isUserscriptDirectoryAvailable()) {
        return getUserscriptDirectory()
            .then(d => d.getFileHandle(`img-${key}.blob`))
            .then(async (handle) => {
                const file = await handle.getFile()
                const fileSliced = file.slice(0, file.size, GM_getValue(`img-mimetype-${key}`));
                return fileSliced;
            })
            .catch(() => undefined);
    }
    else {
        const dataUrl = GM_getValue(`img-${key}`);
        if (!dataUrl) return undefined;
        const blob = await convertDataUrlBase64ToBlob(dataUrl);
        return blob;
    }
}

async function setPersistentCacheImage(imageName, blob) {
    const key = encodeURIComponent(imageName);
    if (await isUserscriptDirectoryAvailable()) {
        GM_setValue(`img-mimetype-${key}`, blob.type);
        return getUserscriptDirectory()
            .then(d => d.getFileHandle(`img-${key}.blob`, { create: true }))
            .then(f => f.createWritable())
            .then(w =>  w.write(blob))
            .then(w => w.close());
    }
    else {
        const dataUrl = await convertBlobToDataUrlBase64(blob);
        GM_setValue(`img-${key}`, dataUrl);
    }
}

async function deletePersistentCacheImage(imageName) {
    const key = encodeURIComponent(imageName);

    if (await isUserscriptDirectoryAvailable()) {
        GM_deleteValue(`img-mimetype-${key}`);
        return getUserscriptDirectory()
            .then(d => d.removeEntry(`img-${key}.blob`));
    }
    else {
        GM_deleteValue(key);
    }
}

async function listPersistentCacheImages() {
    if (await isUserscriptDirectoryAvailable()) {
        return Array.fromAsync(
            getUserscriptDirectory().then(d => d.entries())
        )
            .then(arr => arr.map(f => f[0]));
    }
    else {
        return GM_listValues().filter(key => key.startsWith("img-"));
    }
}

async function updatePersistentCache(cacheStore) {
    if (window.history.state == null
        || getGmValue("persistent-cache-limit") <= -1) return;
    const cacheSaveMin = Math.min(getGmValue("page-cache-limit"), getGmValue("persistent-cache-limit"));
    const cacheStoreKeys = Object.keys(cacheStore)
        .sort((a, b) => getCacheKeyInfo(a).pageIndex - getCacheKeyInfo(b).pageIndex);

    let sliceStart = cacheStoreKeys.findIndex(key => key.match(/\d+$/)[0] == String(window.history.state.page));
    sliceStart = sliceStart == -1 ? 1 : sliceStart;
    const cacheStoreKeysSliced = cacheStoreKeys.slice(sliceStart, sliceStart + cacheSaveMin);

    if (cacheStoreKeys.length <= 0) return;

    const persistentCacheKeys = await listPersistentCacheImages();
    const persistentCacheUpdate = {};

    for (const key of cacheStoreKeysSliced) {
        if (persistentCacheKeys.includes(key)) continue;
        persistentCacheUpdate[key] = await cacheStore[key];
    }

    const persistentCacheUpdateKeys = Object.keys(persistentCacheUpdate);
    const getTotalLength = () => persistentCacheKeys.length + persistentCacheUpdateKeys.length;

    while (getTotalLength() > getGmValue("persistent-cache-limit")) {
        const deletedKey = persistentCacheKeys.shift();
        await deletePersistentCacheImage(deletedKey);
    }

    for (const [key, value] of Object.entries(persistentCacheUpdate)) {
        await setPersistentCacheImage(key, value);
    }

    log(`Sync cache, persistent cache size is ${await listPersistentCacheImages().then(keys => keys.length)}/${getGmValue("persistent-cache-limit")}`);
}

async function resetCacheStoreAll(cacheStore = {}) {
    if (await isUserscriptDirectoryAvailable()) {
        const dir = await getUserscriptDirectory();
        for (const key of await listPersistentCacheImages()) {
            dir.removeEntry(key, { recursive: true });
            log("Removed file entry " + key);
        }
    }
    else {
        for (const key of listPersistentCacheImages()) {
            GM_deleteValue(key);
        }        
    }

    for (const key of Object.keys(cacheStore)) {
        delete cacheStore[key];
    }

    log("Cache cleared");
}

if (exposeDebugProperties) {
    Object.assign(document, {
        GM_setValue,
        GM_getValue,
        GM_info,
        getPersistentCacheImage,
        setPersistentCacheImage,
        listPersistentCacheImages
    });
}

// Common HTML CSS (so it can be overloaded later)

GM_addStyle(/*css*/`
    .btn-link {
        display: inline;

        &:where(button) {
            all: unset;
        }
        
        &, * {
            cursor: pointer;
        }

        &:is(:hover, :has(:checked)) {
            color: #8F4701;
        }
    }
`)

// Settings HTML

GM_addStyle(/*css*/`
    #ehp-settings-activator {
        position: absolute;
        left: 6px;
        bottom: 1px;
    }
        
    #ehp-settings {
        position: absolute;
        
        display: flex;
        flex-flow: column;
        gap: 8px;

        background: #EDEBDF;
        border: 1px solid #5C0D12;

        padding: 12px 6px;
        margin: 4px;
        width: 550px;
        min-width: 200px;
        resize: both;

        font-size: 9pt;
        text-wrap: auto !important;

        #ehp-settings-activator:has(input:not(:checked)) + & {
            display: none;
        }

        input {
            width: 5em;
            text-align: center;
        }

        .popup-title {
            font-weight: bold;
            font-style: italic;
            font-size: 12pt;
        }
        
        .entry {
            .title {
                font-size: 12pt;
            }

            .desc {
                label:hover {
                    cursor: pointer;
                }
                label:has(input:not(:checked)) {
                    color: red;
                    
                    ~ .desc-more {
                        display: none;
                    }
                }
            }
        }

        .note {
            font-style: italic;
        }

        .credits {
            display: flex;
            justify-content: center;
            align-items: center;

            :first-child {
                margin-right: 2px;
            }
        }

        .version {
            position: absolute;
            right: 16px;
            bottom: 1px;
        }
    }
`);

function initSettingsHtml(cacheStore) {
    const settingsPage = makeElement("div", {
        id: "ehp-settings",
        innerHTML: /*html*/`
            <div class="header">
                <div class="popup-title">e-hentai preload next page</div>
                <div class="note credits">
                    <div>~ made with pleasure by</div>
                    <div id="owo-icon-pfp"></div>
                    <div>owowed ~</div>
                </div>
            </div>
            <!-- insert settings entries -->
            <div class="settings-button">
                <button id="fetch-cache">Refetch Cache</button>
                <button id="sync-persistent-cache">Save Cache</button>
                <button id="reset-persistent-cache">Clear Cache</button>
                <button id="fetch-all-cache">Fetch All</button>
            </div>
            <div class="note">you may need to refresh the page for some settings to apply</div>
            <div class="note version">v${GM_info.script.version}</div>
        `
    });

    const settingsEntries = [
        makeSettingsEntryHtml({
            key: "image-caching-limit",
            inputType: "number",
            inputUnit: "images",
            title: "Image caching limit",
            description: "Set how many images need to be cached.",
            descriptionMore: ""
        }),
        makeSettingsEntryHtml({
            key: "concurrent-image-caching-limit",
            inputType: "number",
            inputUnit: "images",
            title: "Concurrent image caching limit",
            description: "Set how many images can be downloaded and cached concurrently.",
            descriptionMore: `
                Images can be downloaded and cached concurrently, if it reaches the limit, the userscript will wait previous image caching to be done.
                Be careful to set the limit, setting too high essentially spam/DoS the e-hentai servers, and can lead to temporary site-ban.
            `
        }),
        makeSettingsEntryHtml({
            key: "concurrent-image-caching-idle-limit",
            inputType: "number",
            inputUnit: "images",
            title: "Idle concurrent image caching limit",
            description: "Set maximum concurrent image caching while the browser is idling.",
            descriptionMore: "Set -1 to disable idle concurrent image caching."
        }),
        makeSettingsEntryHtml({
            key: "image-caching-cooldown",
            inputType: "number",
            inputUnit: "milliseconds",
            title: "Image caching cooldown",
            description: "Set cooldown before the script will cache an image.",
            descriptionMore: "This can be useful to workaround site-ban. Set to -1 to disable this."
        }),
        makeSettingsEntryHtml({
            key: "page-cache-limit",
            inputType: "number",
            inputUnit: "images",
            title: "Page cache limit",
            description: "Set how many images cached in the current page that can be stored into persistent cache.",
            descriptionMore: `
                This can be useful if you don't want to fill up your persistent cache with the current gallery.
                For example: if the current gallery has 100 pages, it will only save N amount of pages specified by this limit.
            `
        }),
        makeSettingsEntryHtml({
            key: "persistent-cache-limit",
            inputType: "number",
            inputUnit: "images",
            title: "Persistent cache size limit",
            description: "Set how many images can be saved in the userscript's storage across different pages.",
            descriptionMore: `
                Persistent cache store are saved in your userscript's storage, and can be re-used later for faster loading time between tabs and page refresh.
                Set to -1 to disable persistent cache store.
            `
        }),
        makeSettingsEntryHtml({
            key: "persistent-cache-storage-type",
            inputRaw: /*html*/`
                <select key="persistent-cache-storage-type">
                    <option value="file">File-based</option>
                    <option value="key-value">Key-value based</option>
                </select>
            `,
            title: "Persistent cache storage type",
            description: "Set how persistent cache will be saved in your computer.",
            descriptionMore: `
                By default, persistent cache will be saved by file-based storage type, but some browser may not support it, and will fallback to key-value based storage type.
                File-based storage type is recommended because it directly stores the image data into a file, key-value based storage type have data serialization, which may cause performance impact.
            `
        }),
        makeSettingsEntryHtml({
            key: "persistent-cache-timeout",
            inputType: "number",
            inputUnit: "days",
            title: "Persistent cache timeout",
            description: "Set timeout (or expiry date) for when the userscript will reset the persistent cache.",
            descriptionMore: "If you set it to 3, then the persistent cache will reset every 3 day from now."
        })
    ];

    const settingsInput = settingsEntries
        .map(e => e.querySelector(":is(input, select)[key]"));

    for (const entry of settingsEntries) {
        settingsPage.querySelector(".settings-button").insertAdjacentElement("beforebegin", entry);
    }

    const getInputValue = input => {
        if (input.type == "checkbox") return input.checked;
        else if (input.type == "number") return parseInt(input.value);
        else return input.value;
    };

    const setInputValue = (input, value) => {
        if (input.type == "checkbox") input.checked = value;
        else if (input.type == "number") input.value = value;
        else input.value = value;
    };

    for (const input of settingsInput) {
        const keyName = input.getAttribute("key");
        setInputValue(input, getGmValue(keyName));
        input.addEventListener("change", () => {
            setGmValue(keyName, getInputValue(input));
        });
    }

    const settingsChannel = new BroadcastChannel("settings");

    settingsChannel.addEventListener("message", ({ data }) => {
        if (data.type == "reset-persistent-cache") {
            resetCacheStoreAll(cacheStore);
        }
    });

    settingsPage.querySelector("#fetch-cache").addEventListener("click", () => {
        loadCacheStore(cacheStore, { forceAbort: true, skipFirstCheck: true });
    });

    settingsPage.querySelector("#fetch-all-cache").addEventListener("click", () => {
        loadCacheStore(cacheStore, { forceAbort: true, skipFirstCheck: true, imageCachingLimit: getGmValue("image-caching-limit") });
    });

    settingsPage.querySelector("#reset-persistent-cache").addEventListener("click", () => {
        resetCacheStoreAll(cacheStore);

        settingsChannel.postMessage({ type: "reset-persistent-cache" });
    });

    settingsPage.querySelector("#sync-persistent-cache").addEventListener("click", () => {
        updatePersistentCache(cacheStore);
    });

    document.addEventListener("visibilitychange", () => {
        if (document.visibilityState != "visible") return;
        for (const input of settingsInput) {
            const keyName = input.getAttribute("key");
            const newValue = getGmValue(keyName);
            if (newValue == undefined
                || newValue == getInputValue(input)) continue;
            setInputValue(input, getGmValue(keyName));
        }
    });

    const activator = makeActivatorHtml(
        "ehp-settings-activator",
        "[ image preloading settings ]"
    );

    const insertionPoint = document.querySelector("#i2");

    insertionPoint.append(activator, settingsPage);
}

function makeSettingsEntryHtml(
    {
        key,
        inputType,
        inputUnit = "",
        inputRaw,
        title,
        description,
        descriptionMore,
    } = {}
) {
    const div = document.createElement("div");
    div.innerHTML = /*html*/`
        <div class="entry">
            <div class="title">${title}</div>
            <div class="desc">
                ${description}
                ${
                    descriptionMore
                    ? /*html*/`
                        <label>
                            <input type="checkbox" hidden>
                            [ more ]
                        </label>
                        <div class="desc-more">
                            ${descriptionMore}
                        </div>
                    `
                    : ""
                }
            </div>
            ${
                inputType
                ? /*html*/`<input type="${inputType}" key="${key}">`
                : inputRaw
            }${inputUnit}
        </div>
    `;

    return div.children[0];
}

// Cache Indicator HTML

GM_addStyle(/*css*/`
    #ehp-cache-indicator {
        position: absolute;
        right: 10px;
        bottom: 1px;

        font-size: 0.8rem;

        &[state=cached] {
            color: goldenrod;
        }

        &[state="not cached"] {
            color: red;
        }

        &[state="cache downloading"] {
            color: darkslategrey;
        }
    }
`);

function initCacheIndicatorHtml() {
    const cacheIndicator = document.createElement("div");
    
    cacheIndicator.id = "ehp-cache-indicator";
    
    const insertionPoint = document.querySelector("#i2");

    insertionPoint.append(cacheIndicator);
}

// Cache Viewer HTML

GM_addStyle(/*css*/`
    #ehp-cache-viewer-activator:has(input:not(:checked)) {
        margin-bottom: 70px;
    }
    #ehp-cache-viewer {
        width: 90vw;
        margin: 10px auto;
        margin-bottom: 1200px;

        #ehp-cache-viewer-activator:has(input:not(:checked)) + & {
            display: none;
        }
        
        .button-list {
            display: flex;
            justify-content: center;
            gap: 6px;
        }
        hr {
            border-top: solid 2px;
        }
        
        .entry-list {
            .header {
                margin-bottom: 10px;
            }
            .loading {
                margin: 14px 0;
                font-weight: bold;

                &:not(.active) {
                    display: none;
                }
            }
        }
        .entry-item {
            &:not(:last-child) {
                margin-bottom: 10px;
            }

            .title {
                font-size: 12pt;
                font-weight: bold;
            }

            &:has(.page-list:not(:empty), :not(.loading)) .empty-message {
                display: none;
            }
        }
        .page-list {
            display: flex;
            flex-wrap: wrap;
            justify-content: center;

            /* real nasty 😭 */
            .entry-list:has(.show-all-gallery input:not(:checked)) .entry-item:has(.show-gallery input:not(:checked)) & {
                display: none;
            }
        }
        .page-item {
            display: flex;
            flex-direction: column;
            font-size: 13px;
            margin-top: 4px;
        }
        .page-item > img {
            width: 500px;
        }
    }
`);

async function initCacheViewerHtml(cacheStore) {
    if (initCacheViewerHtml.cacheViewerPage) return;

    const cacheViewerPage = makeElement("div", {
        id: "ehp-cache-viewer",
    });
    const activator = makeActivatorHtml(
        "ehp-cache-viewer-activator",
        "[ view persistent cache ]"
    );

    cacheViewerPage.replaceChildren(await makeCacheViewerEntryList(cacheStore));

    initCacheViewerHtml.cacheViewerPage = cacheViewerPage;

    document.body.append(activator, cacheViewerPage);
}

async function makeCacheViewerEntryList(cacheStore) {
    const persistentCacheKeysInfo = await listPersistentCacheImages()
        .then(keys => keys
            .map(key => getCacheKeyInfo(getCacheKeyMatch(key)))
        );
    const pageIds = persistentCacheKeysInfo
        .map(keyInfo => keyInfo.pageId)
        .filter((key, idx, arr) => arr.indexOf(key) == idx);
    const pageCacheEntryHtml = makeElement("div", {
        className: "entry-list",
        innerHTML: /*html*/`
            <div class="header">
                <div class="persistent-cache-info">There are a total of ${pageIds.length} galleries and ${persistentCacheKeysInfo.length} individual images saved in persistent cache</div>
                <div class="button-list">
                    <!-- insert show all gallery, download all, refresh -->
                </div>
            </div>
            <div class="loading">Loading...</div>
        `
    });

    pageCacheEntryHtml.querySelector(".header > .button-list").append(
        makeActivatorHtml(
            { className: "show-all-gallery" },
            "[ show all gallery ]"
        ),
        makeElement("button", {
            className: "btn-link",
            textContent: "[ download all gallery ]",
            callback(el) {
                el.addEventListener("click", async () => {
                    downloadCache(undefined, persistentCacheKeysInfo, cacheStore);
                });
            }
        }),
        makeElement("button", {
            className: "btn-link",
            textContent: "[ refresh all ]",
            callback(el) {
                el.addEventListener("click", async () => {
                    pageCacheEntryHtml.replaceWith(await makeCacheViewerEntryList(cacheStore));
                });
            }
        }),
    );

    const loading = pageCacheEntryHtml.querySelector(".loading");
    const entryItemLoading = [];
    let entryItemLoadingDoneCounter = 0;

    loading.classList.toggle("active", true);

    for (const pageId of pageIds) {
        const { element, galleryPromise } = makePageCacheEntryItem(pageId, persistentCacheKeysInfo, cacheStore);
        galleryPromise
            .then(() => {
                entryItemLoadingDoneCounter++;
                loading.textContent = `Loading ${entryItemLoadingDoneCounter} out of ${pageIds.length}...`;
            });
        pageCacheEntryHtml.append(element);
        entryItemLoading.push(galleryPromise);
    }

    Promise.all(entryItemLoading)
        .then(() => {
            loading.classList.toggle("active", false);
        });

    return pageCacheEntryHtml;
}

function makePageCacheEntryItem(pageId, cacheKeysInfo, cacheStore) {
    let entryPageList = document.createElement("div"); // dummy
    const imageCount = makeElement("div", {
        className: "image-count",
    });
    const loading = makeElement("div", {
        className: "loading active",
        textContent: "Loading gallery..."
    });

    const galleryPromise = resetGallery(entryPageList, cacheKeysInfo).then(el => {
        entryPageList = el;
    });

    async function resetGallery(entryPageListOld, cacheKeysInfo) {
        loading.classList.toggle("active", true);
        cacheKeysInfo ??= await listPersistentCacheImages()
            .then(key => key
                .map(key => getCacheKeyInfo(getCacheKeyMatch(key)))
            );
        cacheKeysInfo = cacheKeysInfo
            .filter(info => info.pageId == pageId)
            .sort((a, b) => a.pageIndex - b.pageIndex);
        imageCount.textContent = `(${cacheKeysInfo.length} images)`;
        const entryPageList = await makeCacheViewerEntryPageList(pageId, cacheKeysInfo);
        loading.classList.toggle("active", false);
        entryPageListOld?.replaceWith(entryPageList);
        return entryPageList;
    }

    const element = makeElement("div", {
        id: `cache-viewer-entry-item-${pageId}`,
        className: "entry-item",
        children: [
            makeElement("div", {
                className: "title",
                textContent: `Gallery #${pageId}` + (getCacheKey(window.location).includes(pageId) ? " (current)" : "")
            }),
            makeElement("div", {
                className: "button-list",
                children: [
                    makeActivatorHtml(
                        { className: "show-gallery" },
                        "[ show gallery ]",
                    ),
                    makeElement("button", {
                        className: "download btn-link",
                        textContent: "[ download gallery ]",
                        callback(el) {
                            el.addEventListener("click", () => {
                                const currentPageId = pageId;
                                downloadCache(currentPageId, cacheKeysInfo, cacheStore);
                            });
                        }
                    }),
                    makeElement("button", {
                        className: "clear-gallery btn-link",
                        textContent: "[ clear gallery ]",
                        callback(el) {
                            el.addEventListener("click", async () => {
                                const currentPageId = pageId;
                                for (const { key, pageId } of cacheKeysInfo) {
                                    if (pageId != currentPageId) continue;
                                    await deletePersistentCacheImage(key);
                                }
                                info(`Gallery #${pageId} cleared`);
                                entryPageList = await resetGallery(entryPageList);
                            });
                        }
                    }),
                    makeElement("button", {
                        className: "refresh-gallery btn-link",
                        textContent: "[ refresh ]",
                        callback(el) {
                            el.addEventListener("click", async () => {
                                entryPageList = await resetGallery(entryPageList);
                            });
                        }
                    }),
                ]
            }),
            makeElement("hr"),
            imageCount,
            makeElement("div", {
                className: "empty-message",
                textContent: "~ Nothing to see here ~"
            }),
            loading,
            entryPageList
        ]
    });
 
    return { element, galleryPromise };
}

async function makeCacheViewerEntryPageList(pageId, cacheKeysInfo) {
    const pageCacheEntryHtml = makeElement("div", {
        id: `cache-viewer-page-list-${pageId}`,
        className: "page-list",
    });

    for (const cacheKeyInfo of cacheKeysInfo) {
        pageCacheEntryHtml.append(await makeCacheViewerEntryPage(cacheKeyInfo));
    }

    return pageCacheEntryHtml;
}

async function makeCacheViewerEntryPage({ key, pageId, pageIndex }) {
    const blob = await getPersistentCacheImage(key);
    const blobUrl = URL.createObjectURL(blob);

    return makeElement("div", {
        id: `cache-viewer-page-item-p${pageIndex}-${pageId}`,
        className: "page-item",
        innerHTML: /*html*/`
            p${pageIndex}-${pageId}
            <img src="${blobUrl}">
        `
    });
}

async function downloadCache(currentPageId, cacheKeysInfo, cacheStore) {
    for (const { key, pageId, pageIndex } of cacheKeysInfo) {
        if (currentPageId != undefined && pageId != currentPageId) continue;
        const value = await cacheStore[key];
        const filename = `p${pageIndex}-${pageId}.${value.type.split("/")[1]}`;
        GM_download({
            url: URL.createObjectURL(value),
            name: filename,
            saveAs: false
        });
    }
}

// Common HTML

function makeElement(type, { children, callback, ...properties } = {}) {
    const element = Object.assign(document.createElement(type), {
        ...properties
    });
    if (Array.isArray(children)) {
        element.append.apply(element, children);
    }
    callback?.(element);
    return element;
}

function makeActivatorHtml(id, title) {
    let options = id;
    let type, className;
    if (typeof options == "object" && options != null) {
        ({ type, className = "", id = "", ...options } = options);
    }
    return makeElement(type ?? "button", {
        ...options,
        id,
        className: `${className} activator btn-link`,
        role: type != "button" ? "button" : undefined,
        innerHTML: /*html*/`
            <label>
                <input class="activator-input" type="checkbox" hidden/>
                ${title}
            </label>
        `
    });
}

// HTML Insertion

GM_addStyle(/*css*/`
    #owo-icon-pfp {
        display: inline-block;
        width: 20px;
        height: 20px;
        background: url(https://files.catbox.moe/gg9tzd.jpg);
        background-size: contain;
    }

    #i2 {
        position: relative;
    }
`);

function initHtmlElements(cacheStore) {
    initCacheIndicatorHtml(cacheStore);
    initSettingsHtml(cacheStore);
    initCacheViewerHtml(cacheStore);
}

// Main

{
    const cacheStore = new Proxy({}, {
        async get(store, prop) {
            if (store[prop] == undefined) {
                const savedCache = await getPersistentCacheImage(prop);
                if (savedCache != undefined) store[prop] = savedCache;
            }
            return store[prop];
        },
        set(store, prop, val, receiver) {
            return Reflect.set(store, prop, val, receiver);
        },
    });

    if (GM_getValue("debug.expose-debug-properties")) {
        document.cacheStore = cacheStore;
    }

    initHtmlElements(cacheStore);

    // load cache from saved cache, and then use it for the page
    
    loadCacheStore(cacheStore);
    
    // load cache when page changes
    
    let loadCacheStoreAbortController = new AbortController();
    let lastPage = -1;
    observePageChange(() => {
        if (lastPage == history.state.page) return;
        
        log(`Page change from ${lastPage} to ${history.state.page}`);

        initHtmlElements(cacheStore);
        loadCacheStore(cacheStore, { abortSignal: loadCacheStoreAbortController.signal });
        
        loadCacheStoreAbortController = new AbortController();
        lastPage = history.state.page;
    });

    // caching while idling

    let idleCachingStarted = false;

    const startIdleCache = () => {
        if (getGmValue("concurrent-image-caching-idle-limit") != -1 && !idleCachingStarted) {
            const createIdleTimer = () => {
                return setTimeout(() => {
                    log("Idle cache", getGmValue("concurrent-image-caching-idle-limit"));
                    loadCacheStoreAbortController.abort();
                    loadCacheStore(cacheStore, { concurrentImageCachingLimit: getGmValue("concurrent-image-caching-idle-limit"), skipFirstCheck: true });
                }, 16 * 60 * 1000); // 16 minutes
            };
            
            let idleTimer = createIdleTimer();
            const resetIdleTimer = () => {
                clearTimeout(idleTimer);
                idleTimer = createIdleTimer();
            };

            document.addEventListener("mousemove", () => resetIdleTimer());
            document.addEventListener("keydown", () => resetIdleTimer());
            document.addEventListener("click", () => resetIdleTimer());
            document.addEventListener("visibilitychange", () => document.visibilityState == "visible" && resetIdleTimer());

            idleCachingStarted = true;
        }
    }

    startIdleCache();

    // reset persistent cache passed set days

    const persistentCacheTimeout = getGmValue("persistent-cache-timeout");    
    const fiveDayPassedMilliseconds = persistentCacheTimeout * 24 * 60 * 60 * 1000; // day * hour * minute * second * millisecond
    let persistentCacheLastTimeout = GM_getValue("persistent-cache-last-timeout", Date.now());

    function persistentCacheCheck() {
        const currentDate = Date.now();
        const timeDifference = currentDate - persistentCacheLastTimeout;

        if (timeDifference >= fiveDayPassedMilliseconds) {
            persistentCacheLastTimeout = currentDate;
            setGmValue("persistent-cache-last-timeout", persistentCacheLastTimeout);
            resetCacheStoreAll(cacheStore);
            log("PERSISTENT CACHE TIMEOUT");
        }
    }

    if (getGmValue("persistent-cache-last-timeout") == undefined) {
        setGmValue("persistent-cache-last-timeout", persistentCacheLastTimeout);
    }

    if (persistentCacheTimeout != -1) {
        persistentCacheCheck();
        setInterval(() => persistentCacheCheck(), 10 * 60 * 60 * 1000); // check every 10 hours
    }
}

function getCacheIndicator() {
    const element = document.getElementById("ehp-cache-indicator");

    const setCacheState = (state) => {
        element.setAttribute("state", state);
        element.textContent = state;
    };

    setCacheState("not cached");

    return {
        setCacheState,
        element
    };
}

async function loadCacheStore(cacheStore, options = {}) {
    const locationCacheId = getCacheKey(window.location);
    const cacheIndicator = getCacheIndicator();
    const currentPageCache = await cacheStore[locationCacheId];

    if (currentPageCache && currentPageCache.size > 10 && currentPageCache.type != "text/html") {
        log(`Using cache ${locationCacheId}`);
        document.querySelector("#img").src = URL.createObjectURL(currentPageCache);
        cacheIndicator.setCacheState("cached");
    }
    else {
        cacheIndicator.setCacheState("cache downloading");
        await cacheImage(cacheStore, document.querySelector("#img").src, locationCacheId)
        updatePersistentCache(cacheStore);
        cacheIndicator.setCacheState("cached");
        log("Current page fetched");
    }

    if (loadCacheStore.fetching) {
        log("Fetching images cancelled because another instance is already running on the same page.");
        return;
    }

    loadCacheStore.fetching = true;
    await fetchNextImageCacheBulk(cacheStore, options).finally(() => {
        loadCacheStore.fetching = false;
    });
}

async function fetchNextImageCacheBulk(
    cacheStore,
    {
        imageCachingLimit = Math.min(getGmValue("image-caching-limit"), getGmValue("page-cache-limit")),
        concurrentImageCachingLimit = getGmValue("concurrent-image-caching-limit"),
        forceAbort = false,
        skipFirstCheck = false,
        abortSignal: abortSignalParam
    } = {}
) {
    if (forceAbort) {
        info("Previous instance of fetching images is aborted");
        fetchNextImageCacheBulk.abortController?.abort();
        fetchNextImageCacheBulk.abortController = new AbortController();
    }

    const abortController = fetchNextImageCacheBulk.abortController ?? new AbortController();
    const abortSignal = abortController.signal;
    
    abortSignalParam?.addEventListener("abort", () => {
        abortController.abort();
    });

    fetchNextImageCacheBulk.abortController = abortController;
    abortSignal.throwIfAborted();

    const nextPageAnchor = document.querySelector("#next");
    
    if (nextPageAnchor == null || imageCachingLimit < 1) return;
    
    const pageReaderIterator = createPageReader("#next", { nextPageUrl: nextPageAnchor.href });
    const nextPageCacheId = getCacheKey(nextPageAnchor.href);
    const cacheStoreValue = await cacheStore[nextPageCacheId];
    
    if (cacheStoreValue && cacheStoreValue.size > 10 && !skipFirstCheck) {
        return;
    }

    const runningFetchingImagePromises = [];
    const cachedImageInfo = [];

    const lockSharedHeldLimit = Math.floor(imageCachingLimit / concurrentImageCachingLimit * 1.5);
    const lockSharedHeld = await navigator.locks.query()
        .then(locks => locks.held.filter(held => held.name == "fetching-images" && held.mode == "shared"));


    await navigator.locks.request(
        "fetching-images",
        {
            mode: lockSharedHeld.length >= lockSharedHeldLimit ? "exclusive" : "shared",
            signal: abortSignal
        },
    async (lock) => {
        log("Fetching images...");

        let lastPageReached = false;

        for (let iteration = 0; iteration < (imageCachingLimit - 1); iteration++) {
            if (lock.mode == "exclusive" && document.visibilityState == "hidden") {
                info(`Fetching images stopped because this page is inactive and ${lockSharedHeld.length} instances of fetching-images are running on other pages.`);
                return; // abruptly return without promise so other page/tab can instantly fetching images
            }
            if (lastPageReached || abortSignal?.aborted) {
                break;
            }
            if (runningFetchingImagePromises.length < concurrentImageCachingLimit) {
                const fetchingImagePromise = async function () {
                    const pageReaderResult = await pageReaderIterator
                        .next()
                        .then(it => it.value);

                    if (pageReaderResult == undefined) {
                        lastPageReached = true;
                        info("Last page has been reached");
                        return;
                    }

                    const { pageOrigin, imageUrl } = pageReaderResult;

                    const cacheId = getCacheKey(pageOrigin);
                    const cacheStoreValue = await cacheStore[cacheId];

                    if (cacheStoreValue && cacheStoreValue.size > 10) {
                        log(`Image ${cacheId} already fetched in persistent cache`);
                        return;
                    }

                    cachedImageInfo.push({ cacheId, pageOrigin, imageUrl });

                    await cacheImage(cacheStore, imageUrl, cacheId);
                    
                    for (let iteration = 0; iteration < 12 && cacheStore[cacheId].size <= 10; iteration++) {
                        info("Unexpected image blob is less than 10 bytes, refetching...");
                        await cacheImage(cacheStore, imageUrl, cacheId);
                    }

                    log(`Fetched image ${cacheId}!`);
                }();

                runningFetchingImagePromises.push(fetchingImagePromise);
                fetchingImagePromise.finally(() => {
                    const index = runningFetchingImagePromises.indexOf(fetchingImagePromise);
                    if (index > -1) {
                        runningFetchingImagePromises.splice(index, 1);
                    }
                });
            }
            else {
                await Promise.all(runningFetchingImagePromises);

                if (getGmValue("image-caching-cooldown") != -1 || iteration == 0) {
                    await delay(getGmValue("image-caching-cooldown"));
                    log("Waited cooldown (ms)", getGmValue("image-caching-cooldown"));
                }
                else {
                    log("No cooldown waited");
                }

                if (imageCachingLimit >= 14) {
                    await updatePersistentCache(cacheStore);
                }

                runningFetchingImagePromises.length = 0;
            }
        }

        return Promise.all(runningFetchingImagePromises);
    });


    if (cachedImageInfo.length != 0) {
        const cacheIds = cachedImageInfo.map(cachedImage => cachedImage.cacheId);
        log(`Cached fetched for ${cacheIds.join(", ")}`);
        updatePersistentCache(cacheStore);
    }
    else {
        log("No cached fetched");
    }

    return { cachedImageInfo };
}

function getCacheKeyMatch(storageKey) {
    return storageKey.match(/_s_\w+_(\w+)-(\d+)/)[0];
}

function getCacheKey(pageOrigin) {
    const pageOriginUrl = pageOrigin instanceof Location || pageOrigin instanceof URL ? pageOrigin : new URL(pageOrigin);

    return pageOriginUrl.pathname.replaceAll("/", "_");
}

function getCacheKeyInfo(cacheKey) {
    const [ _, pageId, pageIndex ] = cacheKey.match(/_s_\w+_(\w+)-(\d+)/);
    return { key: cacheKey, pageId, pageIndex: parseInt(pageIndex) };
}

async function convertBlobToDataUrlBase64(blob) {
    return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.onloadend = () => resolve(reader.result);
        reader.onerror = reject;
        reader.readAsDataURL(blob);
    });
}

async function convertDataUrlBase64ToBlob(dataUrlBase64) {
    const [mimeString, base64Data] = dataUrlBase64.split("data:")[1].split(";base64,");
    const uint8array = Uint8Array.from(atob(base64Data), char => char.charCodeAt(0));
    const blob = new Blob([uint8array], { type: mimeString || undefined });
    return blob;
}

async function cacheImage(cacheStore, imageUrl, cacheId) {
    // avoid CORS by using GM_fetch
    const imageBlob = await GM_fetch(imageUrl).then(res => res.blob());
    
    // update cache store
    cacheStore[cacheId] = imageBlob;
}

async function* createPageReader(persistentSelector, { nextPageUrl = document.querySelector(persistentSelector).href } = {}) {
    while (true) {
        // preview next page to get image URL
        const nextPageHtml = await fetch(nextPageUrl).then(i => i.text()); 

        const domParser = new DOMParser();
        const nextPageDom = domParser.parseFromString(nextPageHtml, "text/html");

        const nextPageImage = nextPageDom.querySelector("#img");

        yield { pageOrigin: nextPageUrl, imageUrl: nextPageImage?.src, parsedDom: nextPageDom };

        const nextPageAnchor = nextPageDom.querySelector(persistentSelector);
        
        if (!nextPageAnchor || nextPageAnchor.href === nextPageUrl) {
            break;
        }

        nextPageUrl = nextPageAnchor.href;
    }
}

function observePageChange(callback, { once } = {}) {
    const i3 = document.querySelector("#i3");
    const mut = new MutationObserver(() => {
        if (once) mut.disconnect();
        callback();
    });
    
    mut.observe(i3, {
        childList: true
    });

    return mut;
}

function delay(timeoutMs) {
    const { resolve, promise } = Promise.withResolvers();

    setTimeout(() => resolve(), timeoutMs);

    return promise;
}

}();