Stremio Addon Manager

Reorganize, rename, and manage Stremio addons directly from the web client.

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

You will need to install an extension such as Tampermonkey to install this script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name        Stremio Addon Manager
// @version     1.6.0
// @description Reorganize, rename, and manage Stremio addons directly from the web client.
// @author      Zcc09
// @match       https://web.stremio.com/*
// @match       https://web.strem.io/*
// @match       https://app.strem.io/*
// @grant       GM_setValue
// @grant       GM_getValue
// @grant       unsafeWindow
// @license     MIT
// @namespace https://greasyfork.org/users/1439319
// ==/UserScript==

(function () {
    'use strict';

    // --- CONFIG ---
    const STREMIO_API_BASE = "https://api.strem.io/api/";
    const DEFAULT_LOGO_URL = "https://www.stremio.com/website/stremio-logo-small.png";
    const CUSTOM_CONFIG_KEY = "stremio-addon-manager-custom-config";
    const AUTH_KEY_STORAGE = "stremio-addon-manager-auth-key";

    // --- STATE ---
    let stremioAuthKey = null;
    let addons = [];
    let draggingElement = null;

    // --- STYLES ---
    function addStyles() {
        const style = document.createElement("style");
        style.textContent = `
            :root { --theme-purple: #7b5bf5; --theme-green: #22b365; --theme-dark-bg: #1e1e1e; --theme-light-bg: #2a2a2a; --theme-border: #444; --theme-text: white; --theme-danger: #c62828; --theme-danger-hover: #b71c1c; }

            #addon-manager-btn { display: none; position: fixed; bottom: 20px; right: 20px; z-index: 9999; padding: 12px 20px; background-color: var(--theme-purple); color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; box-shadow: 0 4px 12px rgba(0,0,0,0.4); transition: opacity 0.3s; }
            #addon-manager-btn:hover { background-color: #6145b8; }

            .sam-modal { display: none; position: fixed; z-index: 10000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.8); }
            .sam-modal-content { background-color: var(--theme-dark-bg); color: var(--theme-text); margin: 5vh auto; width: 90%; max-width: 600px; border-radius: 8px; border-top: 4px solid var(--theme-purple); display: flex; flex-direction: column; max-height: 90vh; }

            .sam-header { padding: 15px 20px; border-bottom: 1px solid var(--theme-border); display: flex; justify-content: space-between; align-items: center; }
            .sam-header h2 { margin: 0; color: var(--theme-purple); }
            .sam-close { font-size: 28px; cursor: pointer; color: #aaa; }

            .sam-body { padding: 20px; overflow-y: auto; flex-grow: 1; }
            .sam-footer { padding: 15px 20px; background-color: var(--theme-light-bg); text-align: right; border-top: 1px solid var(--theme-border); border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; }

            /* Login Form */
            .sam-login-form { display: flex; flex-direction: column; gap: 15px; max-width: 300px; margin: 0 auto; text-align: center; }
            .sam-login-desc { color: #ccc; margin-bottom: 10px; font-size: 0.9em; }

            /* Lists */
            .sam-addon-list { list-style: none; padding: 0; margin: 0; }
            .sam-addon-item { display: flex; justify-content: space-between; align-items: center; padding: 10px; margin-bottom: 8px; background-color: #2c2c2c; border-radius: 4px; cursor: grab; border: 1px solid transparent; }
            .sam-addon-item:hover { background-color: #333; }
            .sam-addon-item.dragging { opacity: 0.5; border: 1px dashed var(--theme-purple); }

            .sam-addon-left { display: flex; align-items: center; gap: 10px; overflow: hidden; }
            .sam-addon-logo { width: 32px; height: 32px; object-fit: contain; background: #000; border-radius: 4px; }
            .sam-addon-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 500; }

            .sam-addon-actions { display: flex; gap: 5px; flex-shrink: 0; }

            .sam-btn { padding: 6px 10px; border: none; border-radius: 4px; cursor: pointer; color: white; font-size: 12px; font-weight: bold; }
            .sam-btn-primary { background-color: var(--theme-purple); }
            .sam-btn-danger { background-color: var(--theme-danger); }
            .sam-btn-secondary { background-color: #555; }
            .sam-btn:disabled { opacity: 0.3; cursor: default; }

            .sam-input { width: 100%; padding: 10px; margin-bottom: 10px; background: #333; border: 1px solid #555; color: white; border-radius: 4px; box-sizing: border-box; }
            .sam-label { display: block; margin-bottom: 5px; font-size: 0.9em; color: #ccc; }

            .sam-catalog-row { border: 1px solid var(--theme-border); padding: 10px; margin-bottom: 10px; border-radius: 4px; }
            .sam-badge { background: #555; font-size: 10px; padding: 2px 6px; border-radius: 4px; margin-left: 5px; vertical-align: middle; }
            .sam-badge.search { background: #d38f18; color: black; }
        `;
        document.head.appendChild(style);
    }

    // --- AUTH UTILS ---
    function listenForAuthKey() {
        window.addEventListener("message", (event) => {
            if (event.source !== window) return;
            if (event.data && event.data.type === "FROM_STREMIO_PAGE" && event.data.authKey) {
                console.log("Stremio Addon Manager: Auth key detected from page.");
                stremioAuthKey = event.data.authKey;
                GM_setValue(AUTH_KEY_STORAGE, stremioAuthKey); // Cache it
            }
        });
    }

    function triggerAuthKeyRetrieval() {
        const existing = document.getElementById("sam-auth-injector");
        if (existing) existing.remove();

        const script = document.createElement('script');
        script.id = "sam-auth-injector";
        script.textContent = `
            try {
                const profileStr = localStorage.getItem("profile");
                if (profileStr) {
                    const profile = JSON.parse(profileStr);
                    if (profile && profile.auth && profile.auth.key) {
                        window.postMessage({ type: "FROM_STREMIO_PAGE", authKey: profile.auth.key }, "*");
                    }
                }
            } catch (e) {}
        `;
        (document.head || document.documentElement).appendChild(script);
        setTimeout(() => { if(script.parentNode) script.remove(); }, 1000);
    }

    async function loginWithCredentials(email, password) {
        try {
            const resp = await fetch(`${STREMIO_API_BASE}login`, {
                method: "POST",
                body: JSON.stringify({ type: "Login", email, password, facebook: false })
            });
            const data = await resp.json();

            if (data.result && data.result.authKey) {
                stremioAuthKey = data.result.authKey;
                GM_setValue(AUTH_KEY_STORAGE, stremioAuthKey);
                return true;
            } else {
                alert("Login failed: " + (data.result?.error || "Unknown error"));
                return false;
            }
        } catch (e) {
            alert("Network error: " + e.message);
            return false;
        }
    }

    function logout() {
        if(confirm("Log out of Addon Manager script?")) {
            stremioAuthKey = null;
            GM_setValue(AUTH_KEY_STORAGE, null);
            renderLoginView();
        }
    }

    // --- LOGIC ---
    async function fetchOriginalManifest(url) {
        try {
            const manifestUrl = url.endsWith('/manifest.json') ? url : `${url.replace(/\/$/, "")}/manifest.json`;
            const resp = await fetch(manifestUrl);
            if (!resp.ok) throw new Error(`Status ${resp.status}`);
            const data = await resp.json();
            return data.manifest || data;
        } catch (e) {
            console.warn(`Could not fetch original manifest for ${url}`, e);
            return null;
        }
    }

    async function loadAddons() {
        // 1. Try Cached Key
        if (!stremioAuthKey) {
            stremioAuthKey = GM_getValue(AUTH_KEY_STORAGE, null);
        }

        // 2. Try Page Injection (Wait briefly)
        if (!stremioAuthKey) {
            triggerAuthKeyRetrieval();
            await new Promise(resolve => setTimeout(resolve, 300));
        }

        // 3. Fallback: Manual Login
        if (!stremioAuthKey) {
            renderLoginView();
            return;
        }

        // If we have a key, load list
        const container = document.getElementById("sam-list-container");
        container.innerHTML = "Loading addons...";
        // Show normal footer
        document.getElementById("sam-footer-main").style.display = "block";
        document.getElementById("sam-footer-login").style.display = "none";

        try {
            const resp = await fetch(`${STREMIO_API_BASE}addonCollectionGet`, {
                method: "POST",
                body: JSON.stringify({ type: "AddonCollectionGet", authKey: stremioAuthKey, update: true })
            });
            const data = await resp.json();

            if (data.error || (data.result && data.result.error)) {
                // If error is related to auth, clear key and ask for login
                if (JSON.stringify(data).toLowerCase().includes("auth")) {
                    GM_setValue(AUTH_KEY_STORAGE, null);
                    stremioAuthKey = null;
                    renderLoginView();
                    return;
                }
                throw new Error("API Error: " + (data.error || data.result.error));
            }

            if (!data.result || !data.result.addons) throw new Error("Failed to fetch addons.");

            const remoteAddons = data.result.addons;
            const customConfig = GM_getValue(CUSTOM_CONFIG_KEY, {});

            addons = [];

            for (const addon of remoteAddons) {
                const baseManifest = await fetchOriginalManifest(addon.transportUrl) || addon.manifest;

                const workingManifest = JSON.parse(JSON.stringify(baseManifest));
                const savedConfig = customConfig[addon.transportUrl];

                if (savedConfig) {
                    if (savedConfig.name) workingManifest.name = savedConfig.name;
                    if (savedConfig.description) workingManifest.description = savedConfig.description;
                    if (savedConfig.logo) workingManifest.logo = savedConfig.logo;
                    if (savedConfig.background) workingManifest.background = savedConfig.background;

                    if (workingManifest.catalogs && savedConfig.catalogs) {
                        workingManifest.catalogs.forEach(baseCat => {
                            const savedCat = savedConfig.catalogs.find(c => c.id === baseCat.id && c.type === baseCat.type);
                            if (savedCat) {
                                baseCat.name = savedCat.name;
                                baseCat.hidden = savedCat.hidden;
                            }
                        });
                    }
                }

                const processedAddon = {
                    transportUrl: addon.transportUrl,
                    flags: addon.flags || {},
                    manifest: workingManifest
                };

                addons.push(processedAddon);
            }

            renderAddonList();

        } catch (error) {
            container.innerHTML = `<div style="color:var(--theme-danger); padding:10px;">Error: ${error.message}</div>`;
            console.error(error);
        }
    }

    async function syncToStremio() {
        const btn = document.getElementById("sam-sync-btn");
        btn.textContent = "Syncing...";
        btn.disabled = true;

        try {
            const addonsToSync = JSON.parse(JSON.stringify(addons));

            addonsToSync.forEach(addon => {
                if (addon.manifest.catalogs) {
                    addon.manifest.catalogs = addon.manifest.catalogs.filter(c => !c.hidden);
                }
            });

            const resp = await fetch(`${STREMIO_API_BASE}addonCollectionSet`, {
                method: "POST",
                body: JSON.stringify({
                    type: "AddonCollectionSet",
                    authKey: stremioAuthKey,
                    addons: addonsToSync
                })
            });
            const data = await resp.json();

            if (data.result && data.result.success) {
                alert("Synced successfully! Refresh the page to see changes.");
                location.reload();
            } else {
                throw new Error(data.result?.error || "Unknown error");
            }

        } catch (e) {
            alert("Sync failed: " + e.message);
        } finally {
            btn.textContent = "Sync to Stremio";
            btn.disabled = false;
        }
    }

    async function saveLocalConfig(transportUrl, newManifest) {
        const customConfig = GM_getValue(CUSTOM_CONFIG_KEY, {});

        const configEntry = {
            name: newManifest.name,
            description: newManifest.description,
            logo: newManifest.logo,
            background: newManifest.background,
            catalogs: newManifest.catalogs ? newManifest.catalogs.map(c => ({
                id: c.id,
                type: c.type,
                name: c.name,
                hidden: !!c.hidden
            })) : []
        };

        customConfig[transportUrl] = configEntry;
        GM_setValue(CUSTOM_CONFIG_KEY, customConfig);
    }

    // --- UI RENDERERS ---

    function renderLoginView() {
        const container = document.getElementById("sam-list-container");
        document.getElementById("sam-footer-main").style.display = "none"; // Hide main footer
        document.getElementById("sam-footer-login").style.display = "block"; // Show login footer placeholder if needed, mostly empty

        container.innerHTML = `
            <div class="sam-login-form">
                <p class="sam-login-desc">Could not auto-detect login. Please log in to Stremio manually to manage your addons.</p>
                <input type="email" id="sam-email" class="sam-input" placeholder="Email">
                <input type="password" id="sam-password" class="sam-input" placeholder="Password">
                <button id="sam-login-submit" class="sam-btn sam-btn-primary" style="padding:10px; font-size:14px;">Log In</button>
            </div>
        `;

        document.getElementById("sam-login-submit").onclick = async () => {
            const email = document.getElementById("sam-email").value;
            const pass = document.getElementById("sam-password").value;
            if(!email || !pass) return alert("Please fill in fields");

            document.getElementById("sam-login-submit").textContent = "Logging in...";
            const success = await loginWithCredentials(email, pass);
            if(success) {
                loadAddons();
            } else {
                document.getElementById("sam-login-submit").textContent = "Log In";
            }
        };
    }

    function renderAddonList() {
        const container = document.getElementById("sam-list-container");
        container.innerHTML = "";
        const ul = document.createElement("ul");
        ul.className = "sam-addon-list";

        addons.forEach((addon, index) => {
            const li = document.createElement("li");
            li.className = "sam-addon-item";
            li.draggable = true;
            li.dataset.index = index;

            const logo = addon.manifest.logo || DEFAULT_LOGO_URL;

            li.innerHTML = `
                <div class="sam-addon-left">
                    <img src="${logo}" class="sam-addon-logo" onerror="this.src='${DEFAULT_LOGO_URL}'">
                    <span class="sam-addon-name">${addon.manifest.name}</span>
                </div>
                <div class="sam-addon-actions">
                    <button class="sam-btn sam-btn-secondary move-up-btn" title="Move Up" ${index === 0 ? 'disabled' : ''}>↑</button>
                    <button class="sam-btn sam-btn-secondary move-down-btn" title="Move Down" ${index === addons.length - 1 ? 'disabled' : ''}>↓</button>
                    <button class="sam-btn sam-btn-primary edit-btn">Edit</button>
                    <button class="sam-btn sam-btn-danger del-btn" ${addon.flags.protected ? "disabled" : ""}>Del</button>
                </div>
            `;

            li.addEventListener("dragstart", (e) => { draggingElement = index; li.classList.add('dragging'); });
            li.addEventListener("dragend", (e) => { li.classList.remove('dragging'); draggingElement = null; });
            li.addEventListener("dragover", (e) => { e.preventDefault(); });
            li.addEventListener("drop", (e) => handleDrop(e, index));

            li.querySelector(".move-up-btn").onclick = () => moveAddon(index, -1);
            li.querySelector(".move-down-btn").onclick = () => moveAddon(index, 1);
            li.querySelector(".edit-btn").onclick = () => openEditModal(index);
            li.querySelector(".del-btn").onclick = () => deleteAddon(index);

            ul.appendChild(li);
        });

        container.appendChild(ul);
    }

    function moveAddon(index, direction) {
        const newIndex = index + direction;
        if (newIndex >= 0 && newIndex < addons.length) {
            [addons[index], addons[newIndex]] = [addons[newIndex], addons[index]];
            renderAddonList();
        }
    }

    function handleDrop(e, targetIndex) {
        e.preventDefault();
        if (draggingElement === null || draggingElement === targetIndex) return;
        const movedItem = addons.splice(draggingElement, 1)[0];
        addons.splice(targetIndex, 0, movedItem);
        renderAddonList();
    }

    function deleteAddon(index) {
        if (confirm(`Remove ${addons[index].manifest.name}?`)) {
            const customConfig = GM_getValue(CUSTOM_CONFIG_KEY, {});
            delete customConfig[addons[index].transportUrl];
            GM_setValue(CUSTOM_CONFIG_KEY, customConfig);
            addons.splice(index, 1);
            renderAddonList();
        }
    }

    function openEditModal(index) {
        const addon = addons[index];
        const manifest = addon.manifest;
        const modalBody = document.querySelector("#sam-modal .sam-body");

        const isSearch = c => c.id?.includes('search') || (c.extra || []).some(e => e.name === 'search');
        const catalogs = (manifest.catalogs || []).map((c, i) => ({...c, _idx: i}));
        catalogs.sort((a, b) => isSearch(a) ? -1 : isSearch(b) ? 1 : 0);

        let catalogsHtml = catalogs.map(c => `
            <div class="sam-catalog-row">
                <label class="sam-label">
                    Catalog: ${c.type}
                    ${isSearch(c) ? '<span class="sam-badge search">Search</span>' : ''}
                    <span class="sam-badge">${c.id}</span>
                </label>
                <input type="text" class="sam-input cat-name" data-idx="${c._idx}" value="${c.name || ''}" placeholder="Catalog Name">
                <select class="sam-input cat-hidden" data-idx="${c._idx}">
                    <option value="visible" ${!c.hidden ? 'selected' : ''}>Visible</option>
                    <option value="hidden" ${c.hidden ? 'selected' : ''}>Hidden</option>
                </select>
            </div>
        `).join('');

        modalBody.innerHTML = `
            <h3 style="margin-top:0; color:var(--theme-purple)">Edit ${manifest.name}</h3>
            <label class="sam-label">Name</label>
            <input type="text" id="edit-name" class="sam-input" value="${manifest.name}">

            <label class="sam-label">Description</label>
            <textarea id="edit-desc" class="sam-input" rows="2">${manifest.description || ''}</textarea>

            <details>
                <summary style="cursor:pointer; margin-bottom:10px; color:#aaa">Advanced (Logos & Backgrounds)</summary>
                <label class="sam-label">Logo URL</label>
                <input type="text" id="edit-logo" class="sam-input" value="${manifest.logo || ''}">
                <label class="sam-label">Background URL</label>
                <input type="text" id="edit-bg" class="sam-input" value="${manifest.background || ''}">
            </details>

            <h4 style="border-bottom:1px solid #444; padding-bottom:5px;">Catalogs / Lists</h4>
            <div id="catalog-editor">${catalogsHtml.length ? catalogsHtml : '<p style="color:#777">No configurable catalogs found.</p>'}</div>

            <div style="margin-top:15px; text-align:right;">
                <button id="sam-cancel-edit" class="sam-btn sam-btn-secondary">Cancel</button>
                <button id="sam-save-edit" class="sam-btn sam-btn-primary">Save Changes</button>
            </div>
        `;

        document.getElementById("sam-cancel-edit").onclick = () => renderMainView();
        document.getElementById("sam-save-edit").onclick = () => {
            manifest.name = document.getElementById("edit-name").value;
            manifest.description = document.getElementById("edit-desc").value;
            manifest.logo = document.getElementById("edit-logo").value;
            manifest.background = document.getElementById("edit-bg").value;

            const catNameInputs = document.querySelectorAll(".cat-name");
            const catHiddenInputs = document.querySelectorAll(".cat-hidden");

            catNameInputs.forEach((input, i) => {
                const realIdx = parseInt(input.dataset.idx);
                if (manifest.catalogs[realIdx]) {
                    manifest.catalogs[realIdx].name = input.value;
                    manifest.catalogs[realIdx].hidden = (catHiddenInputs[i].value === "hidden");
                }
            });

            saveLocalConfig(addon.transportUrl, manifest);
            renderMainView();
        };
    }

    function renderMainView() {
        const modalBody = document.querySelector("#sam-modal .sam-body");
        modalBody.innerHTML = `<div id="sam-list-container"></div>`;
        document.getElementById("sam-footer-main").style.display = "block";
        document.getElementById("sam-footer-login").style.display = "none";
        renderAddonList();
    }

    function createUI() {
        const btn = document.createElement("button");
        btn.id = "addon-manager-btn";
        btn.textContent = "Manage Addons";
        btn.onclick = () => {
            document.getElementById("sam-modal").style.display = "block";
            loadAddons();
        };
        document.body.appendChild(btn);

        const modal = document.createElement("div");
        modal.id = "sam-modal";
        modal.className = "sam-modal";
        modal.innerHTML = `
            <div class="sam-modal-content">
                <div class="sam-header">
                    <h2>Addon Manager</h2>
                    <span class="sam-close">&times;</span>
                </div>
                <div class="sam-body">
                    <div id="sam-list-container">Initialize...</div>
                </div>
                <div id="sam-footer-main" class="sam-footer" style="display:none;">
                    <button id="sam-logout-btn" class="sam-btn sam-btn-secondary" style="float:left">Logout</button>
                    <button id="sam-reset-all" class="sam-btn sam-btn-danger" style="margin-right:10px;">Reset Config</button>
                    <button id="sam-sync-btn" class="sam-btn sam-btn-primary">Sync to Stremio</button>
                </div>
                <div id="sam-footer-login" class="sam-footer" style="display:none; text-align:center;">
                    <span style="font-size:10px; color:#666;">Credentials are sent directly to Stremio API.</span>
                </div>
            </div>
        `;
        document.body.appendChild(modal);

        modal.querySelector(".sam-close").onclick = () => modal.style.display = "none";
        window.onclick = (e) => { if (e.target === modal) modal.style.display = "none"; };

        // Footer Events
        document.getElementById("sam-sync-btn").onclick = syncToStremio;
        document.getElementById("sam-logout-btn").onclick = logout;
        document.getElementById("sam-reset-all").onclick = () => {
            if (confirm("Reset all local customizations (renames, hidden lists)?")) {
                GM_setValue(CUSTOM_CONFIG_KEY, {});
                loadAddons();
            }
        };

        // URL Check
        const checkUrl = () => {
            const hash = window.location.hash;
            const isOnAddonsPage = hash.startsWith('#/addons');
            const btn = document.getElementById('addon-manager-btn');
            if (btn) {
                btn.style.display = isOnAddonsPage ? 'block' : 'none';
            }
        };

        window.addEventListener('popstate', checkUrl);
        window.addEventListener('hashchange', checkUrl);

        const originalPushState = history.pushState;
        history.pushState = function(...args) {
            originalPushState.apply(this, args);
            checkUrl();
        };
        const originalReplaceState = history.replaceState;
        history.replaceState = function(...args) {
            originalReplaceState.apply(this, args);
            checkUrl();
        };

        checkUrl();
    }

    function init() {
        addStyles();
        createUI();
        listenForAuthKey();
    }

    if (document.body) init();
    else window.addEventListener("DOMContentLoaded", init);

})();