319 lines
14 KiB
HTML
319 lines
14 KiB
HTML
{% extends "base.html" %}
|
|
{% set active = 'admin' %}
|
|
{% block title %}Administration — Ohm Stream{% endblock %}
|
|
|
|
{% block content %}
|
|
<h1 class="page-title">Administration</h1>
|
|
<p class="page-sub">Utilisateurs, sources et santé du système.</p>
|
|
|
|
<div x-data="adminPage()" x-init="load()">
|
|
|
|
<div class="panel">
|
|
<h2>📊 Statistiques</h2>
|
|
<div class="title-stats" style="margin-top:0">
|
|
<span>Utilisateurs : <strong x-text="stats.users ?? '—'"></strong></span>
|
|
<template x-for="(n, st) in stats.downloads || {}" :key="st">
|
|
<span><span class="status-pill" :class="'status-' + st" x-text="st"></span> <strong x-text="n"></strong></span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<h2>🌐 Sources de scraping</h2>
|
|
<table class="table">
|
|
<thead><tr><th>Source</th><th>URL</th><th>Activée</th><th>Santé</th><th></th></tr></thead>
|
|
<tbody>
|
|
<template x-for="s in sources" :key="s.name">
|
|
<tr>
|
|
<td><strong x-text="s.label"></strong> <span style="color:var(--text-dim);font-size:0.8rem" x-text="'(' + s.name + ')'"></span></td>
|
|
<td>
|
|
<div style="display:flex;gap:0.35rem;align-items:center;flex-wrap:wrap">
|
|
<input class="input" x-model="s._url" :placeholder="s.default_base_url"
|
|
style="min-width:230px;font-size:0.8rem;padding:0.3rem 0.5rem"
|
|
@change="s._urlDirty = s._url.trim() !== s.base_url">
|
|
<button class="btn btn-sm btn-ghost" @click="saveUrl(s)" :disabled="!s._urlDirty || s._saving"
|
|
x-text="s._saving ? '…' : 'Enregistrer'"></button>
|
|
<button class="btn btn-sm btn-ghost" x-show="s.overridden" @click="resetUrl(s)"
|
|
:disabled="s._saving" title="Restaurer l'URL par défaut">↺</button>
|
|
</div>
|
|
</td>
|
|
<td><div class="toggle" :class="s.enabled && 'on'" @click="toggle(s)"></div></td>
|
|
<td>
|
|
<span x-show="!s.health" style="color:var(--text-dim)">—</span>
|
|
<span x-show="s.health" :style="s.health?.healthy ? 'color:var(--success)' : 'color:var(--danger)'"
|
|
:title="s.health ? s.health.detail + (s.health.checked_at ? ' — vérifié le ' + new Date(s.health.checked_at).toLocaleString('fr-FR') : '') : ''"
|
|
x-text="s.health?.healthy ? '✔ OK' : '✖ KO'"></span>
|
|
</td>
|
|
<td><button class="btn btn-sm btn-ghost" @click="healthCheck(s)" :disabled="s._checking" x-text="s._checking ? '…' : 'Tester'"></button></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<h2>🔗 Intégrations Sonarr / Prowlarr</h2>
|
|
|
|
<h3 style="margin:1rem 0 0.4rem;font-size:0.95rem">OhmStreaming comme indexeur (Torznab)</h3>
|
|
<p style="color:var(--text-dim);font-size:0.85rem;margin:0 0 0.6rem">
|
|
Ajoutez un indexeur « Torznab » dans Prowlarr ou Sonarr avec cette URL et cette clé —
|
|
les épisodes grabés par Sonarr entrent directement dans la file de téléchargements OhmStreaming.
|
|
</p>
|
|
<p style="color:var(--text-dim);font-size:0.85rem;margin:0.6rem 0 0">
|
|
<strong>Encore mieux — client de téléchargement :</strong> dans Sonarr, ajoutez un client
|
|
<em>qBittorrent</em> pointant vers OhmStreaming (URL <code>http://<hote-ohm>:8777</code>,
|
|
utilisateur <code>ohm</code>, mot de passe = la clé API ci-dessus). Les épisodes seront
|
|
suivis en temps réel puis importés et renommés par Sonarr dans votre bibliothèque.
|
|
</p>
|
|
<table class="table">
|
|
<tbody>
|
|
<tr>
|
|
<td style="width:110px"><strong>URL</strong></td>
|
|
<td><code x-text="integrations.torznab.endpoint"></code></td>
|
|
<td style="width:90px"><button class="btn btn-sm btn-ghost" @click="copy(integrations.torznab.endpoint)">Copier</button></td>
|
|
</tr>
|
|
<tr>
|
|
<td><strong>Clé API</strong></td>
|
|
<td><code x-text="integrations.torznab.apikey"></code></td>
|
|
<td style="display:flex;gap:0.4rem">
|
|
<button class="btn btn-sm btn-ghost" @click="copy(integrations.torznab.apikey)">Copier</button>
|
|
<button class="btn btn-sm btn-ghost" @click="regenerateKey()" :disabled="integrations._regen">Régénérer</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<h3 style="margin:1.4rem 0 0.4rem;font-size:0.95rem">Sonarr → « Pour toi »</h3>
|
|
<p style="color:var(--text-dim);font-size:0.85rem;margin:0 0 0.6rem">
|
|
Les genres des séries téléchargées sur Sonarr personnalisent la section
|
|
« Pour toi » (ce qui y est possédé n'est pas re-recommandé).
|
|
</p>
|
|
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center">
|
|
<input class="input" x-model="integrations.sonarr.url" placeholder="http://sonarr:8989"
|
|
style="flex:1;min-width:200px" @change="integrations.sonarr._dirty = true">
|
|
<input class="input" x-model="integrations.sonarr.apikey" placeholder="Clé API Sonarr"
|
|
style="flex:1;min-width:200px" @change="integrations.sonarr._dirty = true">
|
|
<button class="btn btn-sm" @click="saveSonarr()" :disabled="!integrations.sonarr._dirty">Enregistrer</button>
|
|
<button class="btn btn-sm btn-ghost" @click="testSonarr()" :disabled="integrations._testing"
|
|
x-text="integrations._testing ? '…' : 'Tester'"></button>
|
|
</div>
|
|
<p x-show="integrations.testResult" style="margin:0.5rem 0 0;font-size:0.85rem"
|
|
:style="integrations.testResult?.ok ? 'color:var(--success)' : 'color:var(--danger)'"
|
|
x-text="integrations.testResult?.detail"></p>
|
|
</div>
|
|
<div class="panel">
|
|
<h2>🔄 Mise à jour</h2>
|
|
<p style="color:var(--text-dim);font-size:0.85rem;margin:0 0 0.6rem">
|
|
Version installée : <strong x-text="upd.current || '—'"></strong>
|
|
<span x-show="upd.update_available" class="badge" style="background:var(--accent);color:#fff"
|
|
x-text="(upd.latest || '?') + ' disponible'"></span>
|
|
<span x-show="upd.latest && !upd.update_available" style="color:var(--success)">✔ à jour</span>
|
|
</p>
|
|
|
|
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center">
|
|
<button class="btn btn-sm btn-ghost" @click="checkUpdate()" :disabled="upd._checking"
|
|
x-text="upd._checking ? '…' : 'Vérifier'"></button>
|
|
<button class="btn btn-sm btn-accent" x-show="upd.update_available" @click="applyUpdate()"
|
|
:disabled="upd._applying" x-text="upd._applying ? 'Mise à jour…' : '⬆ Mettre à jour maintenant'"></button>
|
|
</div>
|
|
<details x-show="upd.notes" style="margin:0.6rem 0 0">
|
|
<summary style="cursor:pointer;font-size:0.85rem;color:var(--text-dim)">
|
|
Patchnote <span x-text="upd.latest"></span>
|
|
</summary>
|
|
<pre style="white-space:pre-wrap;font-size:0.82rem;margin:0.5rem 0 0;color:var(--text-dim)"
|
|
x-text="upd.notes"></pre>
|
|
</details>
|
|
<p x-show="!upd.docker" style="margin:0.5rem 0 0;font-size:0.85rem;color:var(--text-dim)">
|
|
⚠ Mise à jour automatique disponible uniquement en déploiement Docker
|
|
(<code>docker compose pull && docker compose up -d</code> sinon).
|
|
</p>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<h2>👥 Utilisateurs</h2>
|
|
<table class="table">
|
|
<thead><tr><th>ID</th><th>Nom</th><th>Rôle</th><th>Actif</th><th>Créé le</th><th></th></tr></thead>
|
|
<tbody>
|
|
<template x-for="u in users" :key="u.id">
|
|
<tr>
|
|
<td x-text="u.id"></td>
|
|
<td><strong x-text="u.username"></strong></td>
|
|
<td>
|
|
<span class="badge" :class="u.is_admin ? 'badge-source' : 'badge-type'"
|
|
x-text="u.is_admin ? 'admin' : 'utilisateur'"></span>
|
|
</td>
|
|
<td><div class="toggle" :class="u.is_active && 'on'" @click="post(`/api/admin/users/${u.id}/toggle-active`)"></div></td>
|
|
<td style="font-size:0.82rem;color:var(--text-dim)" x-text="u.created_at?.slice(0, 10)"></td>
|
|
<td style="display:flex;gap:0.4rem">
|
|
<button class="btn btn-sm btn-ghost" @click="post(`/api/admin/users/${u.id}/toggle-admin`)" x-text="u.is_admin ? 'Rétrograder' : 'Promouvoir'"></button>
|
|
<button class="btn btn-sm btn-danger" @click="del(u)">Supprimer</button>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<template x-if="forbidden">
|
|
<div class="login-error">Cette page est réservée aux administrateurs.</div>
|
|
</template>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
function adminPage() {
|
|
return {
|
|
users: [], sources: [], stats: {}, forbidden: false,
|
|
upd: {
|
|
current: '', latest: null, notes: null, update_available: false, docker: false,
|
|
_checking: false, _applying: false,
|
|
},
|
|
integrations: {
|
|
torznab: { apikey: '', endpoint: '' }, sonarr: { url: '', apikey: '' },
|
|
_regen: false, _testing: false, testResult: null,
|
|
},
|
|
|
|
async load() {
|
|
const [u, s, st] = await Promise.all([
|
|
fetch('/api/admin/users'), fetch('/api/admin/sources'), fetch('/api/admin/stats'),
|
|
]);
|
|
if (u.status === 403) { this.forbidden = true; return; }
|
|
this.users = await u.json();
|
|
this.sources = (await s.json()).map((x) => ({ ...x, _url: x.base_url, _urlDirty: false, _saving: false }));
|
|
this.stats = await st.json();
|
|
const itg = await fetch('/api/admin/integrations');
|
|
if (itg.ok) {
|
|
const data = await itg.json();
|
|
this.integrations.torznab = data.torznab;
|
|
this.integrations.sonarr = { ...data.sonarr, _dirty: false };
|
|
}
|
|
const upd = await fetch('/api/admin/update');
|
|
if (upd.ok) this.applyUpdateData(await upd.json());
|
|
this.checkAllSources();
|
|
},
|
|
copy(value) {
|
|
navigator.clipboard.writeText(value).then(() => toast('✔ Copié'));
|
|
},
|
|
|
|
async regenerateKey() {
|
|
if (!confirm('Régénérer la clé API Torznab ? Les indexeurs configurés devront être mis à jour.')) return;
|
|
this.integrations._regen = true;
|
|
const res = await fetch('/api/admin/integrations/torznab/regenerate', { method: 'POST' });
|
|
if (res.ok) { this.integrations.torznab.apikey = (await res.json()).apikey; toast('✔ Nouvelle clé générée'); }
|
|
this.integrations._regen = false;
|
|
},
|
|
|
|
async saveSonarr() {
|
|
const res = await fetch('/api/admin/integrations/sonarr', {
|
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url: this.integrations.sonarr.url, apikey: this.integrations.sonarr.apikey }),
|
|
});
|
|
if (res.ok) { this.integrations.sonarr._dirty = false; toast('✔ Configuration Sonarr enregistrée'); }
|
|
},
|
|
|
|
async toggle(s) {
|
|
const res = await fetch(`/api/admin/sources/${s.name}/toggle`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ enabled: !s.enabled }),
|
|
});
|
|
if (res.ok) s.enabled = !s.enabled;
|
|
},
|
|
|
|
async saveUrl(s) {
|
|
s._saving = true;
|
|
try {
|
|
const res = await fetch(`/api/admin/sources/${s.name}/url`, {
|
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url: s._url }),
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
s.base_url = data.base_url;
|
|
s.overridden = data.overridden;
|
|
s._url = data.base_url;
|
|
s._urlDirty = false;
|
|
toast('✔ URL enregistrée — test de la source…');
|
|
this.healthCheck(s);
|
|
} else {
|
|
toast('✖ ' + ((await res.json()).detail || 'Erreur'));
|
|
}
|
|
} catch {
|
|
toast('✖ Erreur réseau');
|
|
} finally {
|
|
s._saving = false;
|
|
}
|
|
},
|
|
|
|
async resetUrl(s) {
|
|
s._url = '';
|
|
await this.saveUrl(s);
|
|
},
|
|
|
|
async healthCheck(s, notify = true) {
|
|
s._checking = true;
|
|
try {
|
|
const res = await fetch(`/api/admin/sources/${s.name}/health`, { method: 'POST' });
|
|
const data = res.ok ? await res.json() : { healthy: false, detail: `Erreur serveur (${res.status})` };
|
|
s.health = { healthy: data.healthy, detail: data.detail, checked_at: data.checked_at };
|
|
if (notify) toast(`${data.healthy ? '✔' : '✖'} ${s.label} : ${data.detail}`);
|
|
} catch {
|
|
s.health = { healthy: false, detail: 'Erreur réseau', checked_at: null };
|
|
if (notify) toast(`✖ ${s.label} : erreur réseau`);
|
|
} finally {
|
|
s._checking = false;
|
|
}
|
|
},
|
|
|
|
checkAllSources() {
|
|
return Promise.allSettled(this.sources.filter(s => s.enabled).map(s => this.healthCheck(s, false)));
|
|
},
|
|
|
|
async post(url) { await fetch(url, { method: 'POST' }); await this.load(); },
|
|
|
|
async testSonarr() {
|
|
this.integrations._testing = true;
|
|
const res = await fetch('/api/admin/integrations/sonarr/test', { method: 'POST' });
|
|
this.integrations.testResult = res.ok ? await res.json() : { ok: false, detail: 'Erreur serveur' };
|
|
this.integrations._testing = false;
|
|
},
|
|
|
|
applyUpdateData(data) {
|
|
this.upd.current = data.current;
|
|
this.upd.latest = data.latest;
|
|
this.upd.notes = data.notes;
|
|
this.upd.update_available = data.update_available;
|
|
this.upd.docker = data.docker;
|
|
},
|
|
|
|
async checkUpdate() {
|
|
this.upd._checking = true;
|
|
const res = await fetch('/api/admin/update/check', { method: 'POST' });
|
|
if (res.ok) this.applyUpdateData(await res.json());
|
|
this.upd._checking = false;
|
|
if (this.upd.update_available) toast('⬆ ' + this.upd.latest + ' disponible');
|
|
},
|
|
|
|
async applyUpdate() {
|
|
if (!confirm('Mettre à jour maintenant ? Le service sera indisponible quelques secondes.')) return;
|
|
this.upd._applying = true;
|
|
sessionStorage.setItem('ohm-updating', '1');
|
|
const res = await fetch('/api/admin/update/apply', { method: 'POST' });
|
|
if (!res.ok) {
|
|
sessionStorage.removeItem('ohm-updating');
|
|
const detail = (await res.json()).detail || 'Erreur';
|
|
toast('✖ ' + detail); this.upd._applying = false;
|
|
}
|
|
// sinon : le conteneur est recréé, update-watcher.js affiche le bandeau et recharge la page
|
|
},
|
|
|
|
async del(u) {
|
|
if (!confirm(`Supprimer le compte « ${u.username} » ?`)) return;
|
|
await fetch('/api/admin/users/' + u.id, { method: 'DELETE' });
|
|
await this.load();
|
|
},
|
|
};
|
|
}
|
|
</script>
|
|
{% endblock %}
|