NZB-Loader by LordBex

Automatically downloads NZB files from nzbindex.nl when links with the "nzblnk:" scheme are clicked.

// ==UserScript==
// @name                NZB-Loader by LordBex
// @description         Automatically downloads NZB files from nzbindex.nl when links with the "nzblnk:" scheme are clicked.
// @description:de_DE   Lädt NZB-Dateien automatisch von nzbindex.nl herunter, wenn auf Links mit dem Schema "nzblnk:" geklickt wird.
// @author              LordBex
// @version             v1.0.2
// @match               *://*/*
// @grant               GM_xmlhttpRequest
// @connect             nzbindex.com
// @connect             www.nzbking.com
// @connect             localhost
// @icon                https://i.imgur.com/O1ao7fL.png
// @license             MIT
// @namespace           LordBex/UserScripts/NZB-Loader
// ==/UserScript==

// ------------------------------------------------------------
//- Default Config:

const AUSGABE = 'download'
// mögliche Werte:
// - download
// - menu
// - URLtoSABnzb
// - NZBtoSABnzb (nicht kompatible mit Safari)
// - custom (um deinen eigenen Handler `customHandler()` zu verwenden)

const DISABLE_SUCCESS_ALERT = false; // default: false

// ------------------------------------------------------------
//- SabNzbd Config:

const SAB_API_KEY = '....';
const SAB_URL = 'http://localhost:8080/api'; // z.B. 'http://localhost:8080/sabnzbd/api'

// Für Output im Menu notwendig:
const SAB_CATEGORIES = [] // leer lassen, um sie direkt von SABnzbd zu holen / hierzu den api-key und nicht den nzb-key verwenden!
// Für URLtoSABnzb und NZBtoSABnzb
const SAB_DEFAULT_CATEGORY = '*' // default: *
// Sab Buttons als Untermenü
const SAB_SUB_MENU = false // default: false


// ------------------------------------------------------------
// menu buttons

async function openMenu(parameters) {
    const sabButtons = await getCategoriesButtons(parameters)

    const buttons = [
        {
            name: 'Download',
            f: () => {
                downloadAndSave(parameters)
            },
            bgColor: '#0D4715',
            icon: 'https://raw.githubusercontent.com/sabnzbd/sabnzbd/refs/heads/develop/icons/nzb.ico'
        }
    ]

    if (SAB_SUB_MENU) {
        buttons.push({
            name: 'Zu Sabnzbd',
            f: () => {
                modal.showModal([
                    {
                        name: 'Zurück',
                        bgColor: '#4F959D',
                        f: () => {
                            modal.showModal(buttons)
                        }
                    },
                    ...sabButtons
                ])
            },
            icon: 'https://raw.githubusercontent.com/sabnzbd/sabnzbd/refs/heads/develop/icons/sabnzbd.ico'
        })
    } else {
        buttons.push(...sabButtons)
    }

    infoModal.closeModal()
    modal.showModal(buttons)
}

function customHandler({downloadLink, fileName, password}) {
    // wird ausgeführt, wenn AUSGABE auf 'custom' gesetzt ist
    alert("Custom Handler") // Hier kann eigener Code eingefügt werden
}

// ------------------------------------------------------------
// sab-code

function successAlert(message) {
    if (!DISABLE_SUCCESS_ALERT) {
        alert(message)
    }
}

function addNZBtoSABnzbd({downloadLink, fileName, password, category = SAB_DEFAULT_CATEGORY}) {
    const mode = 'addurl';
    const name = encodeURIComponent(downloadLink);
    const eu_name = encodeURIComponent(fileName);
    const eu_pass = encodeURIComponent(password);
    const requestURL = `${SAB_URL}?output=json&mode=${mode}&name=${name}&nzbname=${eu_name}&cat=${category}&password=${eu_pass}&apikey=${SAB_API_KEY}`;
    console.log('Link to Sab:', requestURL);
    infoModal.print("Sende Link zu Sab ...")

    GM_xmlhttpRequest({
        method: "GET",
        url: requestURL,
        headers: {
            "User-Agent": "Mozilla/5.0",
            "Accept": "application/json"
        },
        onload: function (response) {
            console.log(response.responseText);
            let result = JSON.parse(response.responseText);

            if (result.status === true) {
                infoModal.print("Erfolg! NZB hinzugefügt. ID: " + result.nzo_ids.join(', '))
                infoModal.closeIn(3000)
            } else {
                infoModal.showModal()
                infoModal.error('Fehler beim Hinzufügen der NZB-Datei zu SABnzbd.\n' + result.error);
            }
        },
        onerror: function (response) {
            console.error('Anfrage fehlgeschlagen', response);
            alert("Anfrage an SABnzb schlug fail ! (mehr im Log)")
        }
    });
}

function uploadNZBtoSABnzbd({responseText, fileName, password, category = SAB_DEFAULT_CATEGORY}) {

    let formData = new FormData();
    let blob = new Blob([responseText], {type: "text/xml"});
    formData.append('name', blob, fileName);
    formData.append('mode', 'addfile');
    formData.append('nzbname', fileName);
    formData.append('password', password);
    formData.append('output', 'json');
    formData.append('cat', category);
    formData.append('apikey', SAB_API_KEY);
    console.log('Upload Nzb to Sab:', formData);
    infoModal.print("Lade Nzb zu SABnzbd hoch ...")

    GM_xmlhttpRequest({
        method: "POST",
        url: SAB_URL,
        data: formData,
        onload: function (response) {
            console.log('Upload response', response.status, response.statusText);
            console.log('Response body', response.responseText);
            let result = JSON.parse(response.responseText);
            if (result.status === true) {
                successAlert('Success! NZB added. ID: ' + result.nzo_ids.join(', '));
            } else {
                alert('Error adding NZB file to SABnzbd.\n' + (result.error || 'Unknown error'));
            }
        },
        onerror: function (response) {
            console.error('Error during file upload', response.status, response.statusText);
            alert("Could not upload NZB! (more in log)");
        }
    });
}

function getCatSabButton(parameters, category) {
    return {
        name: category.charAt(0).toUpperCase() + category.slice(1),
        value: category,
        f: () => {
            parameters.category = category
            addNZBtoSABnzbd(parameters)
        }
    }
}

async function getCategoriesButtons(parameters) {

    if (SAB_CATEGORIES.length > 0) {
        return SAB_CATEGORIES.map(item => {
            return getCatSabButton(parameters, item);
        })
    }

    const mode = 'get_cats'
    const requestURL = new URL(SAB_URL);
    requestURL.searchParams.append('output', 'json');
    requestURL.searchParams.append('mode', mode);
    requestURL.searchParams.append('apikey', SAB_API_KEY);

    return new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
            method: "GET",
            url: requestURL.toString(),
            headers: {
                "User-Agent": "Mozilla/5.0",
                "Accept": "application/json"
            },
            onload: function(response) {
                const data = JSON.parse(response.responseText);
                const categories = data.categories;
                resolve(categories.map(item => {
                    return getCatSabButton(parameters, item);
                }));
            },
            onerror: function(error) {
                console.error('Error during file upload', error);
                alert("Could not get Categories from SAB! (more in log)");
                reject(error);
            }
        });
    });
}


// ------------------------------------------------------------
// download file-code

function saveFile({responseText, fileName}) {
    let blob = new Blob([responseText], {type: "application/x-nzb"});
    let link = document.createElement('a');
    link.href = URL.createObjectURL(blob);
    link.download = fileName // Dateiname ändern
    link.style.display = 'none';
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
}

function downloadFile({downloadLink, fileName, password, callback}) {
    if (!fileName.endsWith('.nzb')) {
        fileName = fileName + '.nzb'
    }
    console.log("Download Nzb von " + downloadLink)
    GM_xmlhttpRequest({
        method: "GET",
        url: downloadLink,
        onload: function (nzbResponse) {
            callback({
                responseText: nzbResponse.responseText,
                fileName,
                password
            })
        },
        onerror: function () {
            console.error("Failed Download for  " + downloadLink)
            alert("Nzb könnte nicht geladen werden !")
        }
    });
}

function downloadAndSave({downloadLink, fileName, password}) {
    fileName = `${fileName}{{${password}}}.nzb`
    downloadFile({
        downloadLink, fileName, password, callback: (args) => {
            saveFile(args)
            infoModal.print("Nzb gespeichert.")

            setTimeout(() => {
                infoModal.closeModal()
            }, 3000)
        }
    })
}

function downloadAndSab({downloadLink, fileName, password, category = SAB_DEFAULT_CATEGORY}) {
    downloadFile({
        downloadLink,
        fileName,
        password,
        callback: (parameter) => {
            parameter.category = category
            uploadNZBtoSABnzbd(parameter)
        }
    })
}

// ------------------------------------------------------------
// handle menu

customElements.define('menu-select-modal', class MenuSelectModal extends HTMLElement {
    constructor() {
        super();
    }

    connectedCallback() {
        // Create a shadow root
        const shadow = this.attachShadow({mode: "open"});
        shadow.innerHTML = `
             <style>
                .btn {
                    --_bg-color: var(--bg-color, #06f);
                    align-items: center;
                    background-color: var(--_bg-color);
                    border: 2px solid var(--_bg-color);
                    box-sizing: border-box;
                    color: #fff;
                    cursor: pointer;
                    display: inline-flex;
                    fill: #000;
                    font-size: 24px;
                    font-weight: 400;
                    height: 48px;
                    justify-content: center;
                    line-height: 24px;
                    width: 100%;
                    outline: 0;
                    padding: 0 17px;
                    text-align: center;
                    text-decoration: none;
                    transition: all .3s;
                    user-select: none;
                    -webkit-user-select: none;
                    touch-action: manipulation;
                    border-radius: 5px;
                    gap: 5px;
                }

                .btn:hover {
                    filter: brightness(70%);
                }

                dialog {
                    border: none !important;
                    border-radius: calc(5px * 3.74);
                    box-shadow: 0 0 #0000, 0 0 #0000, 0 25px 50px -12px rgba(0, 0, 0, 0.25);
                    background-color: rgb(33, 37, 41);
                    padding: 1.6rem;
                    max-height: 70%;
                    max-width: max(400px, 100vw);
                }

                .dialog-header {
                    color: white;
                    font-family: Inter, sans-serif;
                    font-size: 20px;
                    font-weight: 600;
                    display: flex;
                    justify-content: space-between;
                    padding-bottom: 10px;
                }

                .buttons {
                    display: flex;
                    flex-direction: column;
                    gap: 10px;
                    min-width: 400px;
                }

                .close {
                    all: initial;
                    background: unset;
                    padding: 5px;
                    margin: 0;
                    border: unset;
                }

                .close:not(:hover) {
                    opacity: 0.3; /* Leichte Transparenz bei Hover */
                }

                @media screen and (max-width: 450px) {
                    .buttons {
                        display: flex;
                        flex-direction: column;
                        gap: 10px;
                        min-width: 300px;
                    }
                }

                @media screen and (max-width: 350px) {
                    .buttons {
                        display: flex;
                        flex-direction: column;
                        gap: 10px;
                        min-width: 150px;
                    }
                }


            </style>

            <div data-bs-theme="dark">
                 <dialog id="dialog-1">
                    <form method="dialog">
                        <div class="dialog-header">
                            <span>Wähle ...</span>
                            <button class="close">
                                <svg xmlns='http://www.w3.org/2000/svg' width="16" height="16" viewBox='0 0 16 16' fill='#CCC'>
                                    <path d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/>
                                </svg>
                            </button>
                        </div>

                        <div class="buttons buttons-here">
                            <p>...</p>
                        </div>
                    </form>
                </dialog>
            </div>
        `

        this.dialog = shadow.querySelector('dialog')
    }

    showModal(items) {
        this.dialog.showModal()

        const modalContent = this.shadowRoot.querySelector('.buttons-here');
        modalContent.innerHTML = '';

        items.forEach(item => {
            const button = document.createElement('button');
            button.className = 'btn';

            if (item.innerHTML) {
                button.innerHTML = item.innerHTML;
            }

            if (item.icon) {
                const img = document.createElement('img');
                img.src = item.icon;
                img.style.marginRight = '8px';
                img.style.width = '24px';
                img.style.height = '24px';
                button.appendChild(img);
            }

            button.appendChild(document.createTextNode(item.name));

            if (item.bgColor) {
                button.style.setProperty('--bg-color', item.bgColor);
            }

            button.onclick = () => {
                item.f(); // call function
            };
            modalContent.appendChild(button);
        });
    }

    closeModal() {
        this.dialog.close()
    }
});

const modal = document.createElement('menu-select-modal');
document.body.appendChild(modal);

// ------------------------------------------------------------
// info dialog handler

customElements.define('nzblnk-info-modal', class NzbInfoModal extends HTMLElement {
    constructor() {
        super();
        this.createModal()
        this.closeTimer = null
    }

    createModal() {
        // Create a shadow root
        const shadow = this.attachShadow({mode: "open"});
        // language=HTML
        shadow.innerHTML = `
            <style>

                .btn {
                    align-items: center;
                    background-color: #06f;
                    border: 2px solid #06f;
                    box-sizing: border-box;
                    color: #fff;
                    cursor: pointer;
                    display: inline-flex;
                    fill: #000;
                    font-size: 24px;
                    font-weight: 400;
                    height: 48px;
                    justify-content: center;
                    line-height: 24px;
                    width: 100%;
                    outline: 0;
                    padding: 0 17px;
                    text-align: center;
                    text-decoration: none;
                    transition: all .3s;
                    user-select: none;
                    -webkit-user-select: none;
                    touch-action: manipulation;
                    border-radius: 5px;
                }

                .btn:hover {
                    background-color: #3385ff;
                    border-color: #3385ff;
                    fill: #06f;
                }

                dialog {
                    border: none !important;
                    border-radius: calc(5px * 3.74);
                    box-shadow: 0 0 #0000, 0 0 #0000, 0 25px 50px -12px rgba(0, 0, 0, 0.25);
                    background-color: rgb(33, 37, 41);
                    max-width: max(400px, 100vw);
                    padding: 1.6rem;
                    width: min(400px, 90vw);
                }

                .dialog-header {
                    color: white;
                    font-family: Inter, sans-serif;
                    font-size: 20px;
                    font-weight: 600;
                    display: flex;
                    justify-content: space-between;
                    padding-bottom: 10px;
                }

                .close {
                    all: initial;
                    background: unset;
                    padding: 5px;
                    margin: 0;
                    border: unset;
                }

                .close:not(:hover) {
                    opacity: 0.3; /* Leichte Transparenz bei Hover */
                }

                .dialog-content {
                    color: whitesmoke;
                }

            </style>

            <div data-bs-theme="dark">
                <dialog id="dialog-2">
                    <form method="dialog">
                        <div class="dialog-header">
                            <span>Info</span>
                            <button class="close">
                                <svg xmlns='http://www.w3.org/2000/svg' width="16" height="16" viewBox='0 0 16 16'
                                     fill='#CCC'>
                                    <path d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/>
                                </svg>
                            </button>
                        </div>

                        <div class="dialog-content">

                        </div>
                    </form>
                </dialog>
            </div>
        `

        this.dialog = shadow.querySelector('dialog')
        this.modalContent = shadow.querySelector('.dialog-content')
    }

    showModal(callback) {
        this.dialog.showModal()
    }

    resetModal() {
        this.modalContent.innerHTML = '';
    }

    print(message) {
        let p = document.createElement('p');
        console.log("Info:", message)
        p.innerHTML = message
        this.modalContent.appendChild(p)
    }

    error(message) {
        let p = document.createElement('p');
        p.style.color = 'red'
        console.error("Error:", message)
        p.innerHTML = message
        this.modalContent.appendChild(p)
    }

    closeModal() {
        this.dialog.close()
        clearTimeout(this.closeTimer)
    }

    closeIn(time) {
        this.closeTimer = setTimeout(() => {
            this.closeModal()
        }, time)
    }
});

const infoModal = document.createElement('nzblnk-info-modal');
document.body.appendChild(infoModal);

// ------------------------------------------------------------
// nzb handler

function handleNzb(downloadLink, fileName, password) {

    infoModal.print(`Nzb wurde gefunden: <a href='${downloadLink}'>Link</a> (Fallback)`)

    const actions = {
        download: () => {
            downloadAndSave({downloadLink, fileName, password})
        },
        menu: () => {
            openMenu({downloadLink, fileName, password}).then(
                () => {
                    console.log('menu opened')
                }
            )
        },
        URLtoSABnzb: () => {
            addNZBtoSABnzbd({downloadLink, fileName, password})
        },
        NZBtoSABnzb: () => {
            downloadAndSab({downloadLink, fileName, password})
        },
        custom: () => {
            customHandler({downloadLink, fileName, password})
        }
    }

    let selected_action = actions[AUSGABE]
    if (!selected_action) {
        console.error("Ungültige AUSGABE Konfiguration")
        alert("Ungültige AUSGABE Konfiguration")
        return;
    }
    selected_action()
}

function parseNzblnkUrl(url) {
    // Entferne 'nzblnk:?' vom Anfang der URL
    let paramsString = url.slice(url.indexOf("?") + 1);

    // Analysiere die Parameter
    const params = new URLSearchParams(paramsString);

    // Füge die Parameter zu einem Objekt hinzu
    let result = {};
    for (let param of params) {
        result[param[0]] = param[1];
    }

    return result;
}

// ------------------------------------------------------------
// load from ...

function loadFromNzbKing(nzb_info, when_failed) {
    console.log("Suche auf nzbking.com")
    GM_xmlhttpRequest({
        method: "GET",
        url: "https://www.nzbking.com/?q=" + nzb_info.h,
        onload: function (response) {
            console.log("King:", response)
            let parser = new DOMParser();
            let doc = parser.parseFromString(response.responseText, 'text/html');

            const nzbLink = doc.querySelector('a[href^="/nzb:"]');
            if (nzbLink) {
                console.log("Auf nzbking gefunden");
                handleNzb("https://www.nzbking.com" + nzbLink.getAttribute('href'), nzb_info.t, nzb_info.p)
                return;
            }

            return when_failed()
        },
        onerror: function () {
            console.error("Request zu NzbKing fehlgeschlagen")
            when_failed()
        }
    });
}

function loadFromNzbIndex(nzb_info, when_failed) {
    console.log("Suche auf nzbindex.com")
    let url = `https://nzbindex.com/api/search?q=${nzb_info.h}&max=5&sort=agedesc`

    GM_xmlhttpRequest({
        method: "GET",
        url: url,
        onload: function (response) {
            console.log("betaindex:", response)
            let data = JSON.parse(response.responseText);

            if (!data?.data) {
                console.error("Irgengendwas ist komisch bei nzbindex.com")
                console.log(data)
                return when_failed()
            }

            data = data.data

            if (data.page.totalElements === 0) {
                console.log("Nichts auf nzbindex.com gefunden")
                return when_failed()
            }

            if (data.content === undefined) {
                console.log("Keine Ergebnisse auf nzbindex.com gefunden - result is undefined")
                return when_failed()
            }

            if (data.content.length === 0) {
                console.log("Keine Ergebnisse auf nzbindex.com gefunden - result is empty")
                return when_failed()
            }

            if (!data.content[0]?.id) {
                console.log("Id ist nicht gesetzt bei nzbindex.com")
                return when_failed()
            }

            let ids = data.content.map(item => item.id).join('%2C');
            console.log("Auf nzbindex.com gefunden")
            handleNzb("https://nzbindex.com/download?ids=" + ids, nzb_info.t, nzb_info.p)
        },
        onerror: function (response) {
            console.log("Request zu nzbindex.com fehlgeschlagen")
            console.error(response)
            return when_failed()
        }
    });
}

function loadNzb(nzblnk) {
    let nzb_info = parseNzblnkUrl(nzblnk)

    infoModal.resetModal()
    infoModal.showModal()

    const loadFunctions = [
        {
            info: "NzbIndex",
            func: loadFromNzbIndex,
        },
        {
            info: "NzbKing",
            func: loadFromNzbKing,
        }
    ]

    let load = function () {
        infoModal.print(`Keine Nzb gefunden :( `)
        setTimeout(() => {
            infoModal.closeModal()
        }, 6000)
    };

    Array.from(loadFunctions).reverse().forEach(function (f) {
        const old_load = load
        load = function () {
            infoModal.print(`Versuche ${f.info} ....`)

            return f.func(nzb_info, old_load)
        }
    })

    load()
}

// ------------------------------------------------------------
// svg-icons

const downloadIcon = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="3" stroke="currentColor" style="width: 20px; height: 20px;">
  <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
`

const regexIcon = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="3" stroke="currentColor" style="width: 20px; height: 20px;">
  <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 0 1-1.125-1.125v-3.75ZM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 0 1-1.125-1.125v-8.25ZM3.75 16.125c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 0 1-1.125-1.125v-2.25Z" />
</svg>
`

// ------------------------------------------------------------
// trigger

// Event-Delegation für alle nzblnk-Links
document.body.addEventListener('click', (event) => {
    const link = event.target.closest('a[href^="nzblnk"]');
    if (link) {
        event.preventDefault();
        loadNzb(link.href);
    }
});

// Findet bereits vorhandene Links
function processExistingLinks() {
    document.querySelectorAll('a[href^="nzblnk"]').forEach((link) => {
        // Optional: Link-Styling oder andere Anpassungen
        link.setAttribute('title', 'NZB herunterladen');
    });
}

// Überwacht DOM-Änderungen für dynamisch hinzugefügte Links
function observeSiteChanges() {
    const observer = new MutationObserver((mutationsList) => {
        const hasNewContent = mutationsList.some(mutation =>
            mutation.type === 'childList' && mutation.addedNodes.length > 0);

        if (hasNewContent) {
            // Nur bei tatsächlichen Änderungen Links verarbeiten
            processExistingLinks();
        }
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true
    });
}

processExistingLinks();
observeSiteChanges();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址