v0.1.0 — Réécriture complète de OhmStreaming

Nouvelle version réécrite de zéro : recherche multi-sources (Vostfree,
French-Manga), extraction 2 niveaux, proxy vidéo intégré, streaming/téléchargement
HLS, métadonnées Kitsu, bibliothèque locale, comptes JWT + administration,
découverte fusionnée, indexeur Torznab (Sonarr/Prowlarr).
This commit is contained in:
Roman
2026-09-22 10:05:47 +00:00
commit 41566ab5fb
66 changed files with 8895 additions and 0 deletions
+199
View File
@@ -0,0 +1,199 @@
{% 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 style="font-size:0.82rem;color:var(--text-dim)" x-text="s.base_url"></td>
<td><div class="toggle" :class="s.enabled && 'on'" @click="toggle(s)"></div></td>
<td>
<span x-show="s.health == null" style="color:var(--text-dim)">—</span>
<span x-show="s.health === true" style="color:var(--success)">✔ OK</span>
<span x-show="s.health === false" style="color:var(--danger)" :title="s.healthDetail">✖ 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>
<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>👥 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,
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();
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 };
}
},
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 healthCheck(s) {
s._checking = true;
const res = await fetch(`/api/admin/sources/${s.name}/health`, { method: 'POST' });
const data = await res.json();
s.health = data.healthy; s.healthDetail = data.detail;
s._checking = false;
toast(data.healthy ? `✔ ${s.label} : ${data.detail}` : `✖ ${s.label} : ${data.detail}`);
},
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;
},
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 %}