Download Backend/fastapi/templates/settings.html from dhyeye/streamio: direct link, hf CLI and curl.
- Browser
- Download file 41.2 kB
-
https://huggingface.co/spaces/dhyeye/streamio/resolve/main/Backend/fastapi/templates/settings.html
- Command line
-
hf download hf://spaces/dhyeye/streamio/Backend/fastapi/templates/settings.html
-
curl -L -o settings.html https://huggingface.co/spaces/dhyeye/streamio/resolve/main/Backend/fastapi/templates/settings.html
41.2 kB
| {% extends "base.html" %} | |
| {% block title %}Settings β TG Stremio{% endblock %} | |
| {% block content %} | |
| <style> | |
| /* ββ Toggle switch βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .toggle-wrap { display: flex; align-items: center; gap: 0.75rem; cursor: pointer; } | |
| .toggle-input { position: absolute; opacity: 0; width: 0; height: 0; } | |
| .toggle-track { | |
| display: inline-block; width: 3rem; height: 1.625rem; | |
| border-radius: 9999px; background: var(--border); | |
| position: relative; transition: background 0.2s ease; flex-shrink: 0; | |
| } | |
| .toggle-track::after { | |
| content: ''; position: absolute; | |
| top: 3px; left: 3px; | |
| width: 20px; height: 20px; | |
| border-radius: 50%; background: white; | |
| transition: transform 0.2s ease; | |
| box-shadow: 0 1px 4px rgba(0,0,0,0.3); | |
| } | |
| .toggle-input:checked + .toggle-track { background: var(--primary); } | |
| .toggle-input:checked + .toggle-track::after { transform: translateX(1.375rem); } | |
| .toggle-label { font-size: 0.9rem; color: var(--text); user-select: none; } | |
| /* ββ Section cards βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .settings-card { | |
| background: color-mix(in srgb, var(--card) 94%, var(--bg)); | |
| border: 1px solid var(--border); | |
| border-radius: 1.5rem; | |
| padding: 1.5rem 1.75rem; | |
| box-shadow: 0 8px 32px rgba(0,0,0,0.15); | |
| } | |
| .settings-card h2 { | |
| font-size: 1rem; font-weight: 800; | |
| letter-spacing: 0.05em; text-transform: uppercase; | |
| color: var(--primary); margin-bottom: 1.25rem; | |
| display: flex; align-items: center; gap: 0.5rem; | |
| } | |
| /* ββ Inputs ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .s-input { | |
| width: 100%; | |
| background: color-mix(in srgb, var(--bg) 70%, transparent); | |
| border: 1px solid var(--border); | |
| color: var(--text); | |
| border-radius: 0.75rem; | |
| padding: 0.6rem 1rem; | |
| font-size: 0.88rem; | |
| outline: none; | |
| transition: border-color 0.2s; | |
| } | |
| .s-input:focus { border-color: var(--primary); } | |
| .s-label { | |
| display: block; font-size: 0.78rem; font-weight: 700; | |
| color: var(--text-sec); text-transform: uppercase; | |
| letter-spacing: 0.04em; margin-bottom: 0.4rem; | |
| } | |
| /* ββ Multi-value list ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .multi-list-items { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: 0.6rem; } | |
| .multi-item { | |
| display: flex; align-items: center; gap: 0.5rem; | |
| } | |
| .multi-item .s-input { flex: 1; } | |
| .remove-btn { | |
| width: 2rem; height: 2rem; flex-shrink: 0; | |
| background: rgba(239,68,68,0.12); border: 1px solid rgba(239,68,68,0.25); | |
| color: #ef4444; border-radius: 0.5rem; | |
| font-size: 1rem; line-height: 1; | |
| cursor: pointer; transition: background 0.15s; | |
| display: flex; align-items: center; justify-content: center; | |
| } | |
| .remove-btn:hover { background: rgba(239,68,68,0.25); } | |
| .add-row { display: flex; gap: 0.5rem; } | |
| .add-btn { | |
| flex-shrink: 0; padding: 0.6rem 1rem; | |
| background: color-mix(in srgb, var(--primary) 15%, transparent); | |
| border: 1px solid color-mix(in srgb, var(--primary) 40%, transparent); | |
| color: var(--primary); border-radius: 0.75rem; | |
| font-size: 0.82rem; font-weight: 700; cursor: pointer; | |
| transition: background 0.15s; white-space: nowrap; | |
| } | |
| .add-btn:hover { background: color-mix(in srgb, var(--primary) 28%, transparent); } | |
| /* ββ Anime channel checkbox ββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .auth-item .s-input { min-width: 0; } | |
| .anime-check { | |
| display: inline-flex; align-items: center; gap: 0.35rem; flex-shrink: 0; | |
| cursor: pointer; user-select: none; white-space: nowrap; | |
| padding: 0.4rem 0.6rem; border-radius: 0.6rem; | |
| border: 1px solid var(--border); background: transparent; | |
| font-size: 0.7rem; font-weight: 700; text-transform: uppercase; | |
| letter-spacing: 0.03em; color: var(--text-sec); transition: all 0.15s; | |
| } | |
| .anime-check input { width: 0.95rem; height: 0.95rem; accent-color: var(--primary); cursor: pointer; margin: 0; } | |
| .anime-check:hover { border-color: color-mix(in srgb, var(--primary) 35%, transparent); } | |
| .anime-check.is-anime { | |
| color: var(--primary); | |
| border-color: color-mix(in srgb, var(--primary) 45%, transparent); | |
| background: color-mix(in srgb, var(--primary) 12%, transparent); | |
| } | |
| /* ββ Grid helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } | |
| @media (max-width: 640px) { .grid-2 { grid-template-columns: 1fr; } } | |
| .divider { border: none; border-top: 1px solid var(--border); margin: 1.25rem 0; } | |
| </style> | |
| <!-- Page header --> | |
| <div class="flex items-center justify-between mb-8"> | |
| <div> | |
| <h1 class="text-3xl font-extrabold">Settings</h1> | |
| <p class="mt-1 text-sm" style="color:var(--text-sec)"> | |
| Runtime configuration β stored in the database, no restart needed. | |
| </p> | |
| </div> | |
| <button id="save-btn" onclick="saveSettings()" | |
| class="flex items-center gap-2 px-6 py-2.5 rounded-full font-bold text-sm text-white shadow-lg transition-all" | |
| style="background:var(--primary); box-shadow:0 0 18px color-mix(in srgb,var(--primary) 40%,transparent)"> | |
| <i class="fa-solid fa-floppy-disk"></i> | |
| <span id="save-label">Save Settings</span> | |
| </button> | |
| </div> | |
| <div class="space-y-6" id="settings-root"> | |
| <!-- ββ General βββββββββββββββββββββββββββββββββββββββββββββββββββββββ --> | |
| <div class="settings-card"> | |
| <h2><i class="fa-solid fa-sliders"></i> General</h2> | |
| <div class="space-y-4"> | |
| <label class="toggle-wrap"> | |
| <input type="checkbox" class="toggle-input" id="replace_mode" | |
| {% if settings.replace_mode %}checked{% endif %}> | |
| <span class="toggle-track"></span> | |
| <span class="toggle-label"> | |
| <strong>Replace Mode</strong> | |
| <span style="color:var(--text-sec);font-size:0.8rem;display:block"> | |
| Overwrite existing media entries on re-index | |
| </span> | |
| </span> | |
| </label> | |
| <label class="toggle-wrap"> | |
| <input type="checkbox" class="toggle-input" id="hide_catalog" | |
| {% if settings.hide_catalog %}checked{% endif %}> | |
| <span class="toggle-track"></span> | |
| <span class="toggle-label"> | |
| <strong>Hide Catalog</strong> | |
| <span style="color:var(--text-sec);font-size:0.8rem;display:block"> | |
| Hide the public Stremio catalog from unauthenticated users | |
| </span> | |
| </span> | |
| </label> | |
| </div> | |
| </div> | |
| <!-- ββ Authentication ββββββββββββββββββββββββββββββββββββββββββββββββ --> | |
| <div class="settings-card"> | |
| <h2><i class="fa-solid fa-shield-halved"></i> Admin Authentication</h2> | |
| <div class="grid-2"> | |
| <div> | |
| <label class="s-label" for="admin_username">Admin Username</label> | |
| <input type="text" id="admin_username" class="s-input" | |
| value="{{ settings.admin_username }}" autocomplete="off"> | |
| </div> | |
| <div> | |
| <label class="s-label" for="admin_password">Admin Password</label> | |
| <div style="position:relative"> | |
| <input type="password" id="admin_password" class="s-input" | |
| placeholder="Leave blank to keep current password" | |
| style="padding-right:2.5rem" autocomplete="new-password"> | |
| <button type="button" onclick="togglePwdVisibility()" | |
| style="position:absolute;right:0.75rem;top:50%;transform:translateY(-50%); | |
| background:none;border:none;color:var(--text-sec);cursor:pointer"> | |
| <i class="fa-solid fa-eye" id="eye-icon"></i> | |
| </button> | |
| </div> | |
| <p style="font-size:0.75rem;color:var(--text-sec);margin-top:0.3rem"> | |
| {% if settings.admin_password_set is defined and settings.admin_password_set %} | |
| <i class="fa-solid fa-circle-check" style="color:#22c55e"></i> Password is set | |
| {% endif %} | |
| </p> | |
| </div> | |
| </div> | |
| <div style="margin-top:1rem"> | |
| <label class="s-label" for="session_secret">Session Secret</label> | |
| <div style="position:relative"> | |
| <input type="password" id="session_secret" class="s-input" | |
| placeholder="Leave blank to keep current secret" | |
| style="padding-right:2.5rem" autocomplete="new-password"> | |
| <button type="button" onclick="generateSessionSecret()" title="Generate a random secret" | |
| style="position:absolute;right:0.75rem;top:50%;transform:translateY(-50%); | |
| background:none;border:none;color:var(--text-sec);cursor:pointer"> | |
| <i class="fa-solid fa-dice"></i> | |
| </button> | |
| </div> | |
| <p style="font-size:0.75rem;color:var(--text-sec);margin-top:0.3rem"> | |
| Signs admin login sessions. Changing it logs out all admins after the next restart. | |
| {% if settings.session_secret_set is defined and settings.session_secret_set %} | |
| <i class="fa-solid fa-circle-check" style="color:#22c55e"></i> Secret is set | |
| {% endif %} | |
| </p> | |
| </div> | |
| <hr class="divider"> | |
| <!-- Auth Channels --> | |
| <label class="s-label">AUTH_CHANNELS | |
| <span style="font-weight:400;text-transform:none;letter-spacing:0"> | |
| β users must be a member of at least one. Tick <strong>Anime</strong> on a | |
| channel to index its files with AniList + api.ani.zip metadata.</span> | |
| </label> | |
| {% set anime_norm = settings.anime_channels | map('string') | map('replace', '-100', '') | list %} | |
| <div class="multi-list-items" id="auth_channels_items"> | |
| {% for ch in settings.auth_channels %} | |
| {% set is_anime = (ch | string | replace('-100', '')) in anime_norm %} | |
| <div class="multi-item auth-item"> | |
| <input type="text" class="s-input" value="{{ ch }}" data-list="auth_channels"> | |
| <label class="anime-check {% if is_anime %}is-anime{% endif %}" title="Mark as anime channel β uses AniList/ani.zip metadata"> | |
| <input type="checkbox" class="anime-toggle" {% if is_anime %}checked{% endif %} onchange="syncAnimeChip(this)"> | |
| <span>Anime</span> | |
| </label> | |
| <button class="remove-btn" onclick="removeItem(this)" title="Remove"> | |
| <i class="fa-solid fa-xmark"></i> | |
| </button> | |
| </div> | |
| {% endfor %} | |
| </div> | |
| <div class="add-row"> | |
| <input type="text" id="auth_channels_new" class="s-input" | |
| placeholder="Channel username or -100xxxxx ID" | |
| onkeydown="if(event.key==='Enter'){event.preventDefault();addItem('auth_channels')}"> | |
| <button class="add-btn" onclick="addItem('auth_channels')">+ Add Channel</button> | |
| </div> | |
| </div> | |
| <!-- ββ Media & Content βββββββββββββββββββββββββββββββββββββββββββββββ --> | |
| <div class="settings-card"> | |
| <h2><i class="fa-solid fa-film"></i> Media & Content</h2> | |
| <div class="grid-2"> | |
| <div> | |
| <label class="s-label" for="tmdb_api">TMDB API Key</label> | |
| <input type="text" id="tmdb_api" class="s-input" | |
| value="{{ settings.tmdb_api }}" autocomplete="off" | |
| placeholder="Your TMDB v3 API key" spellcheck="false"> | |
| </div> | |
| <div> | |
| <label class="s-label" for="base_url">Base URL</label> | |
| <input type="url" id="base_url" class="s-input" | |
| value="{{ settings.base_url }}" | |
| placeholder="https://your-domain.com"> | |
| </div> | |
| </div> | |
| <hr class="divider"> | |
| <div class="grid-2"> | |
| <div> | |
| <label class="s-label" for="upstream_repo">Upstream Repo</label> | |
| <input type="text" id="upstream_repo" class="s-input" | |
| value="{{ settings.upstream_repo }}" | |
| placeholder="user/repo"> | |
| </div> | |
| <div> | |
| <label class="s-label" for="upstream_branch">Upstream Branch</label> | |
| <input type="text" id="upstream_branch" class="s-input" | |
| value="{{ settings.upstream_branch }}" | |
| placeholder="main"> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ββ Subscription ββββββββββββββββββββββββββββββββββββββββββββββββββ --> | |
| <div class="settings-card"> | |
| <h2><i class="fa-solid fa-credit-card"></i> Subscription</h2> | |
| <label class="toggle-wrap" style="margin-bottom:1.25rem"> | |
| <input type="checkbox" class="toggle-input" id="subscription" | |
| {% if settings.subscription %}checked{% endif %} | |
| onchange="toggleSubFields()"> | |
| <span class="toggle-track"></span> | |
| <span class="toggle-label"> | |
| <strong>Enable Subscription System</strong> | |
| <span style="color:var(--text-sec);font-size:0.8rem;display:block"> | |
| Restrict Stremio access to subscribed members | |
| </span> | |
| </span> | |
| </label> | |
| <div id="sub-fields" style="{% if not settings.subscription %}display:none{% endif %}"> | |
| <div style="margin-bottom:1rem"> | |
| <label class="s-label" for="subscription_group_id">Subscription Group ID</label> | |
| <input type="number" id="subscription_group_id" class="s-input" | |
| value="{{ settings.subscription_group_id }}" | |
| placeholder="-100xxxxxxxx"> | |
| <span style="color:var(--text-sec);font-size:0.78rem;display:block;margin-top:0.4rem"> | |
| Users are pointed back to your bot automatically for renewals and joining. | |
| </span> | |
| </div> | |
| <!-- Payment method --> | |
| <div style="margin-bottom:1rem"> | |
| <label class="s-label" for="payment_instructions">Payment Instructions | |
| <span style="font-weight:400;text-transform:none;letter-spacing:0"> | |
| β shown to users when they pick a plan (UPI ID, bank details, etc.)</span> | |
| </label> | |
| <textarea id="payment_instructions" class="s-input" rows="4" | |
| placeholder="e.g. UPI: yourname@okhdfcbank Or PayPal: you@example.com After paying, send the screenshot here.">{{ settings.payment_instructions }}</textarea> | |
| </div> | |
| <div style="margin-bottom:1rem"> | |
| <label class="s-label" for="payment_qr_url">Payment QR Image URL | |
| <span style="font-weight:400;text-transform:none;letter-spacing:0"> | |
| β optional; if set, the bot sends this QR image with the plan</span> | |
| </label> | |
| <input type="url" id="payment_qr_url" class="s-input" | |
| value="{{ settings.payment_qr_url }}" | |
| placeholder="https://.../upi-qr.png"> | |
| </div> | |
| <!-- Approver IDs --> | |
| <label class="s-label">APPROVER_IDS | |
| <span style="font-weight:400;text-transform:none;letter-spacing:0"> | |
| β Telegram user IDs allowed to approve subscriptions</span> | |
| </label> | |
| <div class="multi-list-items" id="approver_ids_items"> | |
| {% for uid in settings.approver_ids %} | |
| <div class="multi-item"> | |
| <input type="number" class="s-input" value="{{ uid }}" data-list="approver_ids"> | |
| <button class="remove-btn" onclick="removeItem(this)" title="Remove"> | |
| <i class="fa-solid fa-xmark"></i> | |
| </button> | |
| </div> | |
| {% endfor %} | |
| </div> | |
| <div class="add-row"> | |
| <input type="number" id="approver_ids_new" class="s-input" | |
| placeholder="Telegram user ID (numeric)" | |
| onkeydown="if(event.key==='Enter'){event.preventDefault();addItem('approver_ids')}"> | |
| <button class="add-btn" onclick="addItem('approver_ids')">+ Add Approver</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ββ Global Search (Userbot) ββββββββββββββββββββββββββββββββββββββ --> | |
| <div class="settings-card"> | |
| <h2><i class="fa-solid fa-globe"></i> Global Search</h2> | |
| <label class="toggle-wrap" style="{% if not userbot_configured %}opacity:0.5{% endif %}"> | |
| <input type="checkbox" class="toggle-input" id="global_search" | |
| {% if settings.global_search %}checked{% endif %} | |
| {% if not userbot_configured %}disabled{% endif %} | |
| onchange="toggleGlobalSearchFields()"> | |
| <span class="toggle-track"></span> | |
| <span class="toggle-label"> | |
| <strong>Enable Global Search</strong> | |
| <span style="color:var(--text-sec);font-size:0.8rem;display:block"> | |
| Search media <strong>only in the channels listed below</strong>. | |
| Results are tagged <strong>π GLOBAL</strong> in the stream list | |
| for items not in your local catalog. | |
| </span> | |
| </span> | |
| </label> | |
| {% if not userbot_configured %} | |
| <p style="color:var(--text-sec);font-size:0.8rem;margin-top:0.75rem"> | |
| <i class="fa-solid fa-circle-info"></i> | |
| Requires <code>USER_SESSION_STRING</code> to be set in | |
| <code>config.env</code> (startup-critical, not editable here) | |
| <strong>and a full app restart</strong>. | |
| Restart the app after setting it to unlock this option. | |
| </p> | |
| {% endif %} | |
| <div id="global-search-fields" | |
| style="{% if not settings.global_search %}display:none{% endif %}"> | |
| <hr class="divider"> | |
| <div id="global-search-channels-wrap" | |
| style="{% if not userbot_configured %}opacity:0.5;pointer-events:none{% endif %}"> | |
| <label class="s-label"> | |
| GLOBAL SEARCH CHANNELS | |
| <span style="font-weight:400;text-transform:none;letter-spacing:0"> | |
| β search is restricted to these channel IDs only | |
| </span> | |
| </label> | |
| <p style="font-size:0.78rem;color:var(--text-sec);margin-bottom:0.75rem"> | |
| <i class="fa-solid fa-triangle-exclamation" | |
| style="color:var(--warning,#f59e0b)"></i> | |
| Global Search will <strong>not run</strong> unless at least one | |
| channel ID is added here. | |
| Enter numeric channel IDs | |
| (e.g. <code>-1001234567890</code> or bare | |
| <code>1234567890</code>). | |
| </p> | |
| <div class="multi-list-items" id="global_search_channels_items"> | |
| {% for ch in settings.global_search_channels %} | |
| <div class="multi-item"> | |
| <input type="text" | |
| class="s-input" | |
| value="{{ ch }}" | |
| data-list="global_search_channels" | |
| placeholder="-100xxxxxxxxxx"> | |
| <button class="remove-btn" | |
| onclick="removeItem(this)" | |
| title="Remove"> | |
| <i class="fa-solid fa-xmark"></i> | |
| </button> | |
| </div> | |
| {% endfor %} | |
| </div> | |
| <div class="add-row"> | |
| <input type="text" | |
| id="global_search_channels_new" | |
| class="s-input" | |
| placeholder="-100xxxxxxxxxx or bare numeric ID" | |
| onkeydown="if(event.key==='Enter'){event.preventDefault();addItem('global_search_channels')}"> | |
| <button class="add-btn" | |
| onclick="addItem('global_search_channels')"> | |
| + Add Channel | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ββ Proxy βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ --> | |
| <div class="settings-card"> | |
| <h2><i class="fa-solid fa-network-wired"></i> Proxy Settings</h2> | |
| <div style="margin-bottom:1rem"> | |
| <label class="s-label" for="http_proxy_url">HTTP Proxy URL</label> | |
| <input type="url" id="http_proxy_url" class="s-input" | |
| value="{{ settings.http_proxy_url }}" | |
| placeholder="http://user:pass@host:port or leave empty"> | |
| </div> | |
| <label class="toggle-wrap"> | |
| <input type="checkbox" class="toggle-input" id="show_proxy_and_non_proxy_both" | |
| {% if settings.show_proxy_and_non_proxy_both %}checked{% endif %}> | |
| <span class="toggle-track"></span> | |
| <span class="toggle-label"> | |
| <strong>Show Proxy & Non-Proxy Streams</strong> | |
| <span style="color:var(--text-sec);font-size:0.8rem;display:block"> | |
| Expose both proxied and direct stream links in Stremio | |
| </span> | |
| </span> | |
| </label> | |
| </div> | |
| <!-- ββ Databases ββββββββββββββββββββββββββββββββββββββββββββββββββββββ --> | |
| <div class="settings-card"> | |
| <h2><i class="fa-solid fa-database"></i> Databases</h2> | |
| <p style="font-size:0.83rem;color:var(--text-sec);margin-bottom:1rem"> | |
| Tracking and Storage 1 are required to boot the app and can't be edited | |
| here. Additional storage databases connect/disconnect instantly when | |
| saved β no restart needed. For data-safety reasons you can only | |
| <strong>append</strong> new databases or <strong>remove from the end</strong> | |
| of the list β editing or deleting one in the middle is rejected on save, | |
| since existing media documents reference databases by position. | |
| </p> | |
| <!-- Locked databases (rendered from db.get_database_list()) --> | |
| <div class="space-y-2 mb-4"> | |
| {% for entry in settings.database_list %} | |
| {% if entry.locked %} | |
| <div class="multi-item" style="opacity:0.65"> | |
| <input type="text" class="s-input" value="{{ entry.uri_masked }}" disabled | |
| style="cursor:not-allowed"> | |
| <span style="flex-shrink:0;font-size:0.7rem;font-weight:700;text-transform:uppercase; | |
| padding:0.4rem 0.7rem;border-radius:0.5rem; | |
| background:color-mix(in srgb,var(--text-sec) 15%,transparent); | |
| color:var(--text-sec);white-space:nowrap"> | |
| <i class="fa-solid fa-lock"></i> | |
| {{ "Tracking" if entry.type == "tracking" else "Storage 1" }} | |
| </span> | |
| </div> | |
| {% endif %} | |
| {% endfor %} | |
| </div> | |
| <hr class="divider"> | |
| <label class="s-label">EXTRA STORAGE DATABASES | |
| <span style="font-weight:400;text-transform:none;letter-spacing:0"> | |
| β additional Mongo URIs for media storage rotation</span> | |
| </label> | |
| <div class="multi-list-items" id="extra_databases_items"> | |
| {% for entry in settings.database_list %} | |
| {% if not entry.locked %} | |
| <div class="multi-item"> | |
| <input type="text" class="s-input" value="{{ entry.full_uri }}" spellcheck="false" | |
| data-list="extra_databases" placeholder="mongodb+srv://user:pass@host/..."> | |
| <span title="{{ 'Connected' if entry.connected else 'Disconnected' }}" | |
| style="flex-shrink:0;width:0.6rem;height:0.6rem;border-radius:50%; | |
| background:{{ '#22c55e' if entry.connected else '#ef4444' }}"></span> | |
| <button class="remove-btn" onclick="removeItem(this)" title="Remove (only safe from the end of the list)"> | |
| <i class="fa-solid fa-xmark"></i> | |
| </button> | |
| </div> | |
| {% endif %} | |
| {% endfor %} | |
| </div> | |
| <div class="add-row"> | |
| <input type="text" id="extra_databases_new" class="s-input" spellcheck="false" | |
| placeholder="mongodb+srv://user:pass@host/..." | |
| onkeydown="if(event.key==='Enter'){event.preventDefault();addItem('extra_databases')}"> | |
| <button class="add-btn" onclick="addItem('extra_databases')">+ Add Database</button> | |
| </div> | |
| </div> | |
| <!-- ββ Multi-Token Clients βββββββββββββββββββββββββββββββββββββββββββ --> | |
| <div class="settings-card"> | |
| <h2><i class="fa-brands fa-telegram"></i> Multi-Token Clients</h2> | |
| <p style="font-size:0.83rem;color:var(--text-sec);margin-bottom:1rem"> | |
| Additional bot tokens used for parallel streaming. Changes reinitialise | |
| the client pool immediately (no restart needed). | |
| </p> | |
| <div class="multi-list-items" id="multi_tokens_items"> | |
| {% for tok in settings.multi_tokens %} | |
| <div class="multi-item"> | |
| <input type="text" class="s-input" value="{{ tok }}" spellcheck="false" | |
| data-list="multi_tokens" placeholder="Bot token"> | |
| <button class="remove-btn" onclick="removeItem(this)" title="Remove"> | |
| <i class="fa-solid fa-xmark"></i> | |
| </button> | |
| </div> | |
| {% endfor %} | |
| </div> | |
| <div class="add-row"> | |
| <input type="text" id="multi_tokens_new" class="s-input" spellcheck="false" | |
| placeholder="New bot token (1234567890:ABCdefβ¦)" | |
| onkeydown="if(event.key==='Enter'){event.preventDefault();addItem('multi_tokens')}"> | |
| <button class="add-btn" onclick="addItem('multi_tokens')">+ Add Token</button> | |
| </div> | |
| </div> | |
| </div><!-- /settings-root --> | |
| <!-- Floating save (mobile) --> | |
| <div class="fixed bottom-6 right-6 md:hidden z-50"> | |
| <button onclick="saveSettings()" | |
| class="w-14 h-14 rounded-full flex items-center justify-center text-white shadow-xl" | |
| style="background:var(--primary)"> | |
| <i class="fa-solid fa-floppy-disk text-lg"></i> | |
| </button> | |
| </div> | |
| <script> | |
| /* ββ Toggle sub-fields visibility βββββββββββββββββββββββββββββββββββββββ */ | |
| function toggleSubFields() { | |
| const enabled = document.getElementById('subscription').checked; | |
| document.getElementById('sub-fields').style.display = enabled ? '' : 'none'; | |
| } | |
| function toggleGlobalSearchFields() { | |
| const enabled = document.getElementById('global_search').checked; | |
| const fields = document.getElementById('global-search-fields'); | |
| if (fields) { | |
| fields.style.display = enabled ? '' : 'none'; | |
| } | |
| } | |
| /* ββ Password visibility ββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function togglePwdVisibility() { | |
| const input = document.getElementById('admin_password'); | |
| const icon = document.getElementById('eye-icon'); | |
| if (input.type === 'password') { | |
| input.type = 'text'; | |
| icon.className = 'fa-solid fa-eye-slash'; | |
| } else { | |
| input.type = 'password'; | |
| icon.className = 'fa-solid fa-eye'; | |
| } | |
| } | |
| /* ββ Session secret generator βββββββββββββββββββββββββββββββββββββββββββ */ | |
| function generateSessionSecret() { | |
| const bytes = new Uint8Array(32); | |
| crypto.getRandomValues(bytes); | |
| const input = document.getElementById('session_secret'); | |
| input.value = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); | |
| input.type = 'text'; | |
| } | |
| /* ββ Multi-value list helpers βββββββββββββββββββββββββββββββββββββββββββ */ | |
| function authRowHTML(value, isAnime) { | |
| return ` | |
| <input type="text" class="s-input" value="${escapeHtmlAttr(value)}" data-list="auth_channels"> | |
| <label class="anime-check ${isAnime ? 'is-anime' : ''}" title="Mark as anime channel β uses AniList/ani.zip metadata"> | |
| <input type="checkbox" class="anime-toggle" ${isAnime ? 'checked' : ''} onchange="syncAnimeChip(this)"> | |
| <span>Anime</span> | |
| </label> | |
| <button class="remove-btn" onclick="removeItem(this)" title="Remove"> | |
| <i class="fa-solid fa-xmark"></i> | |
| </button> | |
| `; | |
| } | |
| function syncAnimeChip(cb) { | |
| const chip = cb.closest('.anime-check'); | |
| if (chip) chip.classList.toggle('is-anime', cb.checked); | |
| } | |
| function addItem(listKey) { | |
| const newInput = document.getElementById(listKey + '_new'); | |
| const value = newInput.value.trim(); | |
| if (!value) return; | |
| const container = document.getElementById(listKey + '_items'); | |
| const item = document.createElement('div'); | |
| if (listKey === 'auth_channels') { | |
| item.className = 'multi-item auth-item'; | |
| item.innerHTML = authRowHTML(value, false); | |
| } else { | |
| const isNumeric = listKey === 'approver_ids'; | |
| item.className = 'multi-item'; | |
| item.innerHTML = ` | |
| <input type="${isNumeric ? 'number' : 'text'}" | |
| class="s-input" | |
| value="${escapeHtmlAttr(value)}" | |
| data-list="${listKey}"> | |
| <button class="remove-btn" onclick="removeItem(this)" title="Remove"> | |
| <i class="fa-solid fa-xmark"></i> | |
| </button> | |
| `; | |
| } | |
| container.appendChild(item); | |
| newInput.value = ''; | |
| newInput.focus(); | |
| } | |
| function collectAnimeChannels() { | |
| const container = document.getElementById('auth_channels_items'); | |
| if (!container) return []; | |
| const out = []; | |
| container.querySelectorAll('.multi-item').forEach(row => { | |
| const toggle = row.querySelector('.anime-toggle'); | |
| const input = row.querySelector('input[data-list="auth_channels"]'); | |
| if (toggle && toggle.checked && input && input.value.trim()) { | |
| out.push(input.value.trim()); | |
| } | |
| }); | |
| return out; | |
| } | |
| function removeItem(btn) { | |
| btn.closest('.multi-item').remove(); | |
| } | |
| function collectList(listKey) { | |
| const container = document.getElementById(listKey + '_items'); | |
| if (!container) return []; | |
| return [...container.querySelectorAll('input[data-list]')] | |
| .map(i => i.value.trim()) | |
| .filter(Boolean); | |
| } | |
| /* ββ Save ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| async function saveSettings() { | |
| const btn = document.getElementById('save-btn'); | |
| const label = document.getElementById('save-label'); | |
| btn.disabled = true; | |
| label.textContent = 'Savingβ¦'; | |
| const payload = { | |
| replace_mode: document.getElementById('replace_mode').checked, | |
| hide_catalog: document.getElementById('hide_catalog').checked, | |
| admin_username: document.getElementById('admin_username').value.trim(), | |
| admin_password: document.getElementById('admin_password').value, // blank = keep | |
| session_secret: document.getElementById('session_secret').value, // blank = keep | |
| tmdb_api: document.getElementById('tmdb_api').value.trim(), | |
| base_url: document.getElementById('base_url').value.trim(), | |
| upstream_repo: document.getElementById('upstream_repo').value.trim(), | |
| upstream_branch: document.getElementById('upstream_branch').value.trim(), | |
| auth_channels: collectList('auth_channels'), | |
| anime_channels: collectAnimeChannels(), | |
| subscription: document.getElementById('subscription').checked, | |
| subscription_group_id: parseInt(document.getElementById('subscription_group_id').value || '0', 10), | |
| payment_instructions: document.getElementById('payment_instructions').value.trim(), | |
| payment_qr_url: document.getElementById('payment_qr_url').value.trim(), | |
| approver_ids: collectList('approver_ids').map(Number), | |
| http_proxy_url: document.getElementById('http_proxy_url').value.trim(), | |
| show_proxy_and_non_proxy_both: document.getElementById('show_proxy_and_non_proxy_both').checked, | |
| multi_tokens: collectList('multi_tokens'), | |
| extra_databases: collectList('extra_databases'), | |
| global_search: document.getElementById('global_search').checked, | |
| global_search_channels: collectList('global_search_channels'), | |
| }; | |
| try { | |
| const res = await fetch('/api/admin/settings', { | |
| method: 'PUT', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload), | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| showToast('Settings saved successfully!', 'success', 'Saved'); | |
| // Show reinit notices | |
| const reinit = data.reinit || {}; | |
| for (const [subsystem, msg] of Object.entries(reinit)) { | |
| showToast(`${capitalize(subsystem)}: ${msg}`, 'info'); | |
| } | |
| // Pull the freshly-saved state back from the server and patch | |
| // the UI in place β no full page reload needed. This also | |
| // picks up server-computed bits the form can't know on its | |
| // own (e.g. each database's live connection-status dot). | |
| await refreshSettingsFromServer(); | |
| } else { | |
| showToast(data.detail || 'Save failed', 'error', 'Error'); | |
| } | |
| } catch (err) { | |
| showToast('Network error β could not reach server.', 'error', 'Error'); | |
| console.error(err); | |
| } finally { | |
| btn.disabled = false; | |
| label.textContent = 'Save Settings'; | |
| } | |
| } | |
| /* ββ Utilities βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function escapeHtmlAttr(str) { | |
| return String(str) | |
| .replace(/&/g, '&') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, ''') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>'); | |
| } | |
| function capitalize(str) { | |
| return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' '); | |
| } | |
| /* ββ Post-save refresh, no page reload ββββββββββββββββββββββββββββββββββββ */ | |
| async function refreshSettingsFromServer() { | |
| try { | |
| const res = await fetch('/api/admin/settings'); | |
| if (!res.ok) return; | |
| const data = await res.json(); | |
| renderSettings(data.settings || {}); | |
| } catch (err) { | |
| console.error('Failed to refresh settings UI:', err); | |
| } | |
| } | |
| function renderSettings(s) { | |
| // Toggles | |
| document.getElementById('replace_mode').checked = !!s.replace_mode; | |
| document.getElementById('hide_catalog').checked = !!s.hide_catalog; | |
| document.getElementById('subscription').checked = !!s.subscription; | |
| document.getElementById('show_proxy_and_non_proxy_both').checked = !!s.show_proxy_and_non_proxy_both; | |
| document.getElementById('global_search').checked = !!s.global_search; | |
| toggleSubFields(); | |
| toggleGlobalSearchFields(); | |
| // Plain text/number fields | |
| document.getElementById('admin_username').value = s.admin_username || ''; | |
| document.getElementById('admin_password').value = ''; // never re-fill password | |
| document.getElementById('tmdb_api').value = s.tmdb_api || ''; | |
| document.getElementById('base_url').value = s.base_url || ''; | |
| document.getElementById('upstream_repo').value = s.upstream_repo || ''; | |
| document.getElementById('upstream_branch').value = s.upstream_branch || ''; | |
| document.getElementById('subscription_group_id').value = s.subscription_group_id || 0; | |
| document.getElementById('http_proxy_url').value = s.http_proxy_url || ''; | |
| // Auth channels (with per-channel anime checkbox) + other multi-value lists | |
| rebuildAuthChannels(s.auth_channels, s.anime_channels); | |
| rebuildSimpleList('approver_ids_items', s.approver_ids, 'number'); | |
| rebuildSimpleList('multi_tokens_items', s.multi_tokens, 'text'); | |
| rebuildSimpleList('global_search_channels_items', s.global_search_channels, 'text'); | |
| // Databases β needs the live connection-status dot per entry | |
| rebuildDatabaseList(s.database_list || []); | |
| } | |
| function rebuildSimpleList(containerId, values, inputType) { | |
| const container = document.getElementById(containerId); | |
| if (!container) return; | |
| const listKey = containerId.replace('_items', ''); | |
| container.innerHTML = ''; | |
| (values || []).forEach(val => { | |
| const item = document.createElement('div'); | |
| item.className = 'multi-item'; | |
| item.innerHTML = ` | |
| <input type="${inputType}" class="s-input" | |
| value="${escapeHtmlAttr(val)}" data-list="${listKey}"> | |
| <button class="remove-btn" onclick="removeItem(this)" title="Remove"> | |
| <i class="fa-solid fa-xmark"></i> | |
| </button> | |
| `; | |
| container.appendChild(item); | |
| }); | |
| } | |
| function rebuildAuthChannels(values, animeValues) { | |
| const container = document.getElementById('auth_channels_items'); | |
| if (!container) return; | |
| const animeNorm = (animeValues || []).map(v => String(v).split('-100').join('')); | |
| container.innerHTML = ''; | |
| (values || []).forEach(val => { | |
| const isAnime = animeNorm.includes(String(val).split('-100').join('')); | |
| const item = document.createElement('div'); | |
| item.className = 'multi-item auth-item'; | |
| item.innerHTML = authRowHTML(val, isAnime); | |
| container.appendChild(item); | |
| }); | |
| } | |
| function rebuildDatabaseList(dbList) { | |
| const container = document.getElementById('extra_databases_items'); | |
| if (!container) return; | |
| container.innerHTML = ''; | |
| dbList.filter(entry => !entry.locked).forEach(entry => { | |
| const item = document.createElement('div'); | |
| item.className = 'multi-item'; | |
| item.innerHTML = ` | |
| <input type="text" class="s-input" spellcheck="false" | |
| value="${escapeHtmlAttr(entry.full_uri || '')}" data-list="extra_databases"> | |
| <span title="${entry.connected ? 'Connected' : 'Disconnected'}" | |
| style="flex-shrink:0;width:0.6rem;height:0.6rem;border-radius:50%; | |
| background:${entry.connected ? '#22c55e' : '#ef4444'}"></span> | |
| <button class="remove-btn" onclick="removeItem(this)" title="Remove (only safe from the end of the list)"> | |
| <i class="fa-solid fa-xmark"></i> | |
| </button> | |
| `; | |
| container.appendChild(item); | |
| }); | |
| } | |
| </script> | |
| {% endblock %} | |