streamio / Backend /fastapi /templates /settings.html
Karan Adhikari
`Telegram-Stremio v5.0.0`
2c41c5f unverified
Raw History Blame Contribute Delete
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 &amp; 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&#10;Or PayPal: you@example.com&#10;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 &amp; 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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
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 %}