secubox-deb/packages/secubox-torrent/www/torrent/index.html
CyberMind-FR 405d287f06 feat(torrent): server wiring + purge sweep + player webui (ref #917)
Adds server.js (env-driven Engine/Library/buildApi wiring, ephemeral
torrent purge sweep on a timer, static webui serving) and the runPurge
unit tests (TTL + kept-flag + disk-floor sweep, in-memory Library only).
webtorrent and @fastify/static are imported dynamically inside start()
so runPurge stays testable without the native wrtc build.

Replaces the old Transmission-container panel with a single-page
WebTorrent player webui (magnet add, list with keep/remove, in-browser
playback via /stream, BitTorrent-only status badge) per the cyan
hybrid-dark self-contained module vhost guidelines.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-28 15:22:16 +02:00

392 lines
17 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- SPDX-License-Identifier: LicenseRef-CMSD-1.0
Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
SecuBox-Deb :: secubox-torrent :: www/torrent/index.html — CyberMind https://cybermind.fr
Player webui — self-contained module vhost variant of the SecuBox WebUI
Panel Guidelines (cyan hybrid-dark terminal): no /shared/, no external
URLs (offline-clean), palette + component classes inlined verbatim. -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SecuBox — Torrent</title>
<style>
:root {
--p31-dim: #6b7b8b; --bg-dark: #0d1117; --bg-card: rgba(30, 40, 55, 0.8);
--bg-row: rgba(20, 30, 45, 0.6); --border: rgba(100, 150, 200, 0.2);
--text: #e8e6d9; --cyan: #00d4ff; --red: #ff4466; --orange: #ff9944;
--yellow: #ffcc00; --green: #00dd44; --blue: #4488ff; --purple: #a371f7;
}
* { box-sizing: border-box; }
body {
font-family: 'Courier Prime', 'Courier New', monospace;
background: var(--bg-dark); color: var(--text); margin: 0; min-height: 100vh;
background-image: linear-gradient(135deg, rgba(10,15,25,.96), rgba(15,25,40,.92));
}
a { color: var(--cyan); }
.main { max-width: 60rem; margin: 0 auto; padding: 1.5rem 1.2rem 4rem; }
.header { display: flex; justify-content: space-between; align-items: center;
margin-bottom: 1.2rem; flex-wrap: wrap; gap: .6rem; padding-bottom: 1rem;
border-bottom: 1px solid var(--border); }
.header h1 { margin: 0; font-size: 1.4rem; color: var(--cyan); text-shadow: 0 0 10px var(--cyan); }
.header .sub { font-size: .72rem; color: var(--p31-dim); letter-spacing: .05em; margin-top: .25rem; }
.btn-group { display: flex; flex-wrap: wrap; gap: .5rem; }
.btn { font-family: inherit; font-size: .72rem; text-transform: uppercase; letter-spacing: .05em;
text-decoration: none; color: var(--text); background: var(--bg-row); border: 1px solid var(--border);
border-radius: 6px; padding: .5rem .9rem; cursor: pointer; transition: border-color .15s, box-shadow .15s;
display: inline-flex; align-items: center; gap: .35rem; }
.btn:hover { border-color: var(--cyan); box-shadow: 0 0 8px rgba(0,212,255,.4); }
.btn.primary { border-color: var(--cyan); color: var(--cyan); background: rgba(0,212,255,.12); }
.btn.danger { border-color: var(--red); color: var(--red); background: rgba(255,68,102,.12); }
.btn.good { border-color: var(--green); color: var(--green); background: rgba(0,221,68,.12); }
.btn.sm { padding: .3rem .55rem; font-size: .68rem; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px,1fr)); gap: .75rem; margin-bottom: 1.2rem; }
.stat-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px;
padding: .75rem; text-align: center; backdrop-filter: blur(10px); }
.stat-card .value { font-size: 1.4rem; font-weight: bold; text-shadow: 0 0 10px currentColor;
font-variant-numeric: tabular-nums; color: var(--text); }
.stat-card .label { font-size: .6rem; color: var(--p31-dim); letter-spacing: .1em; margin-top: .2rem; text-transform: uppercase; }
.stat-card.cyan .value { color: var(--cyan); }
.stat-card.green .value { color: var(--green); }
.stat-card.orange .value { color: var(--orange); }
.badge { display: inline-block; padding: .2rem .55rem; border-radius: 999px; font-size: .62rem;
text-transform: uppercase; letter-spacing: .06em; border: 1px solid var(--border); }
.badge.bt-only { border-color: var(--orange); color: var(--orange); }
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px;
padding: 1.1rem 1.2rem; margin-bottom: 1rem; backdrop-filter: blur(10px); }
.card h3 { margin: 0 0 .8rem; font-size: .95rem; color: var(--cyan); letter-spacing: .03em; }
.add-row { display: flex; gap: .5rem; flex-wrap: wrap; }
.add-row input[type=text] { flex: 1 1 20rem; }
input[type=text] { font-family: inherit; background: var(--bg-row); border: 1px solid var(--border);
border-radius: 6px; color: var(--text); padding: .6rem .7rem; font-size: .85rem; }
input[type=text]:focus { outline: none; border-color: var(--cyan); box-shadow: 0 0 6px rgba(0,212,255,.25); }
.item { background: var(--bg-row); border-left: 3px solid var(--border); border-radius: 5px;
padding: .6rem .8rem; margin-bottom: .5rem; }
.item.kept { border-left-color: var(--green); }
.item.ephemeral { border-left-color: var(--orange); }
.item-top { display: flex; justify-content: space-between; align-items: center; gap: .6rem; flex-wrap: wrap; }
.item .title { color: var(--text); font-size: .88rem; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; max-width: 26rem; cursor: pointer; }
.item .title:hover { color: var(--cyan); }
.item .meta { color: var(--p31-dim); font-size: .68rem; margin-top: .3rem; font-variant-numeric: tabular-nums; }
.item .row-actions { display: flex; gap: .35rem; flex: none; }
.progress-bar { height: 5px; background: var(--border); border-radius: 3px; overflow: hidden; margin-top: .35rem; }
.progress-bar .fill { height: 100%; background: var(--cyan); transition: width .3s; }
.item.kept .progress-bar .fill { background: var(--green); }
.files { margin-top: .55rem; padding-top: .5rem; border-top: 1px solid var(--border); display: none; }
.files.open { display: block; }
.file-row { display: flex; justify-content: space-between; align-items: center; gap: .5rem;
padding: .3rem .4rem; border-radius: 4px; font-size: .78rem; }
.file-row:hover { background: rgba(0,212,255,.08); }
.file-row .fname { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.file-row .fname.playable:hover { color: var(--cyan); }
.file-row .fname:not(.playable) { color: var(--p31-dim); cursor: default; }
.muted { color: var(--p31-dim); font-size: .8rem; }
#player video { width: 100%; max-height: 60vh; background: #000; border-radius: 6px; }
#playerCard { display: none; }
.toast { position: fixed; bottom: 1rem; right: 1rem; max-width: 22rem; background: var(--cyan);
color: #07121a; padding: .65rem 1rem; border-radius: 6px; font-size: .78rem; opacity: 0;
pointer-events: none; transition: opacity .2s; z-index: 50; }
.toast.show { opacity: 1; pointer-events: auto; }
.toast.error { background: var(--red); color: #fff; }
.toast .x { float: right; margin-left: .6rem; cursor: pointer; font-weight: bold; }
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-thumb { background: var(--p31-dim); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--cyan); }
@media (max-width: 768px) {
.main { padding: 1rem .8rem; }
.item .title { max-width: 12rem; }
}
</style>
</head>
<body>
<main class="main">
<header class="header">
<div>
<h1>📥 Torrent</h1>
<div class="sub" id="sub">BitTorrent + WebTorrent player · chargement…</div>
</div>
<div class="btn-group">
<button class="btn" id="btnRefresh">↻ Refresh</button>
</div>
</header>
<div class="grid" id="statGrid">
<div class="stat-card cyan"><div class="value" id="statActive"></div><div class="label">📦 Actifs</div></div>
<div class="stat-card green"><div class="value" id="statDisk"></div><div class="label">💾 Disque libre</div></div>
<div class="stat-card orange" style="display:flex;align-items:center;justify-content:center">
<span class="badge bt-only" id="webrtcBadge" style="display:none">🧲 BitTorrent-only</span>
<span class="muted" id="webrtcOk" style="display:none">🕸️ WebRTC actif</span>
</div>
</div>
<div class="card">
<h3> Ajouter un magnet</h3>
<div class="add-row">
<input type="text" id="magnetInput" placeholder="magnet:?xt=urn:btih:…">
<button class="btn primary" id="btnAdd"> Add</button>
</div>
</div>
<div class="card" id="playerCard">
<h3>▶ Lecture</h3>
<div id="player"></div>
</div>
<div class="card">
<h3>📚 Torrents</h3>
<div id="list"><p class="muted">Chargement…</p></div>
</div>
</main>
<div class="toast" id="toast"></div>
<script>
(function () {
'use strict';
var API = '/api/v1/torrent';
var PLAYABLE = ['mp4', 'webm', 'm4v', 'mp3', 'm4a', 'ogg'];
var openFiles = Object.create(null); // infohash -> bool (files panel expanded)
function esc(s) {
return String(s == null ? '' : s).replace(/[<>&"]/g, function (c) {
return { '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c];
});
}
function extOf(name) {
var m = /\.([A-Za-z0-9]+)$/.exec(name || '');
return m ? m[1].toLowerCase() : '';
}
function fmtBytes(n) {
if (n == null || !isFinite(n)) return '∞';
if (n === 0) return '0 B';
var units = ['B', 'KB', 'MB', 'GB', 'TB'];
var i = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
return (n / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
}
var toastTimer = null;
function toast(msg) {
var t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast show';
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { t.className = 'toast'; }, 3000);
}
function errorToast(msg) {
var t = document.getElementById('toast');
clearTimeout(toastTimer);
t.innerHTML = '';
t.appendChild(document.createTextNode('⚠ ' + msg));
var x = document.createElement('span');
x.className = 'x';
x.textContent = '✕';
x.addEventListener('click', function () { t.className = 'toast'; });
t.appendChild(x);
t.className = 'toast show error';
}
function authHeaders() {
var h = {};
var tok = localStorage.getItem('sbx_token');
if (tok) h['Authorization'] = 'Bearer ' + tok;
return h;
}
function api(path, opts) {
opts = opts || {};
var headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers, authHeaders());
return fetch(path, Object.assign({}, opts, { headers: headers })).then(function (res) {
if (res.status === 401) {
errorToast('🔒 Login required');
throw new Error('unauthorized');
}
return res.json().catch(function () { return {}; }).then(function (body) {
if (!res.ok) throw new Error(body && body.error ? body.error : ('HTTP ' + res.status));
return body;
});
});
}
function refreshStatus() {
return api(API + '/status').then(function (s) {
document.getElementById('statActive').textContent = s.active != null ? s.active : '';
document.getElementById('statDisk').textContent = fmtBytes(s.disk_free);
var badgeEl = document.getElementById('webrtcBadge');
var okEl = document.getElementById('webrtcOk');
var btOnly = s.webrtc === false;
badgeEl.style.display = btOnly ? 'inline-block' : 'none';
okEl.style.display = btOnly ? 'none' : 'inline';
document.getElementById('sub').textContent = btOnly
? 'BitTorrent + WebTorrent player · mode BitTorrent-only (WebRTC désactivé)'
: 'BitTorrent + WebTorrent player · WebRTC actif';
}).catch(function (e) { console.error(e); });
}
function statsLine(stats) {
if (!stats) return '<span class="muted">pas de session active</span>';
var pct = Math.round((stats.progress || 0) * 100);
var wires = (stats.wires || []).length;
return pct + '% · ⬇ ' + fmtBytes(stats.downloadSpeed) + '/s · ⬆ ' + fmtBytes(stats.uploadSpeed) +
'/s · 🤝 ' + (stats.numPeers || 0) + ' peers · 🔌 ' + wires + ' wires';
}
function renderList(rows) {
var el = document.getElementById('list');
if (!rows.length) {
el.innerHTML = '<p class="muted">Aucun torrent — colle un lien magnet ci-dessus.</p>';
return;
}
el.innerHTML = rows.map(function (r) {
var pct = r.stats ? Math.round((r.stats.progress || 0) * 100) : 0;
var kept = r.kept === 1;
var filesOpen = !!openFiles[r.infohash];
return (
'<div class="item ' + (kept ? 'kept' : 'ephemeral') + '" data-ih="' + esc(r.infohash) + '">' +
'<div class="item-top">' +
'<div class="title" data-action="toggle-files" title="' + esc(r.name) + '">' +
(kept ? '📌 ' : '⏳ ') + esc(r.name) +
'</div>' +
'<div class="row-actions">' +
(kept ? '' : '<button class="btn sm good" data-action="keep">📌 Garder</button>') +
'<button class="btn sm danger" data-action="remove">🗑</button>' +
'</div>' +
'</div>' +
'<div class="progress-bar"><div class="fill" style="width:' + pct + '%"></div></div>' +
'<div class="meta">' + statsLine(r.stats) + '</div>' +
'<div class="files' + (filesOpen ? ' open' : '') + '" data-role="files">' +
'<p class="muted">Chargement des fichiers…</p>' +
'</div>' +
'</div>'
);
}).join('');
Object.keys(openFiles).forEach(function (ih) {
if (openFiles[ih]) loadFiles(ih);
});
}
function refreshList() {
return api(API + '/list').then(renderList).catch(function (e) {
document.getElementById('list').innerHTML = '<p class="muted">Erreur de chargement.</p>';
console.error(e);
});
}
function loadFiles(infohash) {
var itemEl = document.querySelector('.item[data-ih="' + cssEscape(infohash) + '"]');
if (!itemEl) return;
var filesEl = itemEl.querySelector('[data-role="files"]');
api(API + '/files/' + encodeURIComponent(infohash)).then(function (files) {
if (!files.length) {
filesEl.innerHTML = '<p class="muted">Métadonnées pas encore disponibles.</p>';
return;
}
filesEl.innerHTML = files.map(function (f) {
var ext = extOf(f.name);
var playable = PLAYABLE.indexOf(ext) !== -1;
if (playable) {
return '<div class="file-row">' +
'<span class="fname playable" data-action="play" data-idx="' + f.idx + '">▶ ' + esc(f.name) + '</span>' +
'<span class="muted">' + fmtBytes(f.length) + '</span></div>';
}
var href = '/stream/' + encodeURIComponent(infohash) + '/' + f.idx;
return '<div class="file-row">' +
'<span class="fname">' + esc(f.name) + '</span>' +
'<a class="btn sm" href="' + href + '" download>⬇ Download</a></div>';
}).join('');
}).catch(function (e) {
filesEl.innerHTML = '<p class="muted">Erreur de chargement des fichiers.</p>';
console.error(e);
});
}
function cssEscape(s) {
return String(s).replace(/["\\]/g, '\\$&');
}
function playFile(infohash, idx, name) {
var card = document.getElementById('playerCard');
var player = document.getElementById('player');
card.style.display = 'block';
var src = '/stream/' + encodeURIComponent(infohash) + '/' + idx;
player.innerHTML = '<video controls autoplay src="' + esc(src) + '"></video>' +
'<p class="muted" style="margin-top:.5rem">' + esc(name) + '</p>';
card.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function onListClick(ev) {
var actionEl = ev.target.closest('[data-action]');
if (!actionEl) return;
var itemEl = ev.target.closest('.item');
if (!itemEl) return;
var ih = itemEl.getAttribute('data-ih');
var action = actionEl.getAttribute('data-action');
if (action === 'toggle-files') {
openFiles[ih] = !openFiles[ih];
var filesEl = itemEl.querySelector('[data-role="files"]');
filesEl.classList.toggle('open', openFiles[ih]);
if (openFiles[ih]) loadFiles(ih);
return;
}
if (action === 'keep') {
api(API + '/keep/' + encodeURIComponent(ih), { method: 'POST' }).then(function () {
toast('📌 Torrent conservé');
refreshList();
}).catch(function (e) { errorToast('Échec: ' + e.message); });
return;
}
if (action === 'remove') {
if (!confirm('Supprimer ce torrent et ses données ?')) return;
api(API + '/remove/' + encodeURIComponent(ih), { method: 'POST' }).then(function () {
toast('🗑 Torrent supprimé');
delete openFiles[ih];
refreshList();
}).catch(function (e) { errorToast('Échec: ' + e.message); });
return;
}
if (action === 'play') {
var idx = actionEl.getAttribute('data-idx');
var name = actionEl.textContent.replace(/^▶\s*/, '');
playFile(ih, idx, name);
return;
}
}
function addTorrent() {
var input = document.getElementById('magnetInput');
var magnet = input.value.trim();
if (!magnet) { errorToast('Lien magnet requis'); return; }
var btn = document.getElementById('btnAdd');
btn.disabled = true;
api(API + '/add', { method: 'POST', body: JSON.stringify({ magnet: magnet }) }).then(function (meta) {
toast(' Torrent ajouté: ' + (meta.name || meta.infohash || 'ok'));
input.value = '';
refreshList();
}).catch(function (e) {
errorToast('Échec de l\'ajout: ' + e.message);
}).finally(function () { btn.disabled = false; });
}
function refresh() {
refreshStatus();
refreshList();
}
document.getElementById('btnRefresh').addEventListener('click', refresh);
document.getElementById('btnAdd').addEventListener('click', addTorrent);
document.getElementById('magnetInput').addEventListener('keydown', function (e) {
if (e.key === 'Enter') addTorrent();
});
document.getElementById('list').addEventListener('click', onListClick);
refresh();
setInterval(refresh, 15000);
})();
</script>
</body>
</html>