Le téléphone balayait quarante radios toutes les trois minutes, cherchait dans quatre catalogues et enregistrait des directs. Tout cela le tenait éveillé pour un travail qui n'a aucune raison de se faire sur batterie. Un serveur reprend la collecte : recherche, guet, enregistrement, file de téléchargement, piloté depuis un navigateur. Le mobile demande ce qui a changé depuis son curseur, télécharge et se rendort — toutes les six heures, en Wi-Fi non facturé. Vingt fichiers quittent l'application au passage. Le balayage devient continu plutôt qu'espacé de trois minutes : une sonde ne lit que le premier bloc de métadonnées avant de refermer, ce qui rend la permanence tenable quand garder les flux ouverts coûterait plusieurs mégabits par seconde. Un morceau guetté a été capté en moins de vingt-cinq secondes. La recherche interroge les quatre catalogues à la fois et fond leurs résultats, les plus légers d'abord. Quatre défauts corrigés en chemin, tous trouvés en essayant plutôt qu'en relisant : un jeton régénéré à chaque démarrage, qui aurait obligé à reconfigurer le mobile ; un 403 de Wikimedia dû à un agent trop générique, que la fusion des sources masquait ; un dossier de données par défaut inaccessible au compte qui lance le serveur ; et une lecture de métadonnées qui n'itérait rien, faute d'être asynchrone.
268 lines
12 KiB
HTML
268 lines
12 KiB
HTML
<!doctype html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Noxely — serveur</title>
|
||
<style>
|
||
:root { --bg:#faf7ff; --card:#fff; --ink:#1c1b22; --muted:#5c5670; --accent:#6b4eff;
|
||
--mint:#11a894; --line:#e6e0f0; }
|
||
@media (prefers-color-scheme: dark) {
|
||
:root { --bg:#120d1c; --card:#1b1033; --ink:#e8e0f0; --muted:#a79fbb; --line:#2f2743; }
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body { margin:0; background:var(--bg); color:var(--ink); font:15px/1.5 system-ui,sans-serif; }
|
||
header { padding:18px 20px; border-bottom:1px solid var(--line); display:flex;
|
||
align-items:center; gap:12px; flex-wrap:wrap; }
|
||
h1 { font-size:19px; margin:0; }
|
||
main { max-width:920px; margin:0 auto; padding:20px; }
|
||
.tabs { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:18px; }
|
||
.tabs button { background:none; border:1px solid var(--line); border-radius:999px;
|
||
padding:7px 15px; color:var(--muted); cursor:pointer; font-size:14px; }
|
||
.tabs button[aria-selected="true"] { background:var(--accent); border-color:var(--accent); color:#fff; }
|
||
.card { background:var(--card); border:1px solid var(--line); border-radius:14px;
|
||
padding:12px 14px; margin-bottom:8px; display:flex; gap:12px; align-items:center; }
|
||
.card .grow { flex:1; min-width:0; }
|
||
.card b { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||
.card small { color:var(--muted); }
|
||
input, select { padding:9px 12px; border:1px solid var(--line); border-radius:10px;
|
||
background:var(--card); color:var(--ink); font-size:14px; }
|
||
input[type=text] { flex:1; min-width:160px; }
|
||
button.go { background:var(--accent); color:#fff; border:none; border-radius:10px;
|
||
padding:9px 16px; cursor:pointer; font-size:14px; }
|
||
button.ghost { background:none; border:1px solid var(--line); border-radius:10px;
|
||
padding:7px 12px; cursor:pointer; color:var(--muted); }
|
||
.row { display:flex; gap:8px; margin-bottom:14px; flex-wrap:wrap; }
|
||
.pill { font-size:12px; padding:3px 9px; border-radius:999px; background:var(--line); color:var(--muted); }
|
||
.pill.ok { background:rgba(17,168,148,.16); color:var(--mint); }
|
||
.pill.warn { background:rgba(224,98,155,.16); color:#e0629b; }
|
||
.empty { color:var(--muted); text-align:center; padding:40px 10px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>🎧 Noxely — serveur</h1>
|
||
<span id="status" class="pill">…</span>
|
||
<span class="grow" style="flex:1"></span>
|
||
<input type="password" id="token" placeholder="Jeton" style="width:190px">
|
||
<button class="ghost" onclick="saveToken()">Enregistrer</button>
|
||
</header>
|
||
<main>
|
||
<div class="tabs">
|
||
<button onclick="show('search')" aria-selected="true" id="t-search">🔎 Rechercher</button>
|
||
<button onclick="show('watch')" aria-selected="false" id="t-watch">📡 Guets</button>
|
||
<button onclick="show('queue')" aria-selected="false" id="t-queue">⏳ File</button>
|
||
<button onclick="show('library')" aria-selected="false" id="t-library">📚 Bibliothèque</button>
|
||
</div>
|
||
|
||
<section id="p-search">
|
||
<div class="row">
|
||
<select id="catalog"></select>
|
||
<input type="text" id="q" placeholder="Titre, artiste, album…" onkeydown="if(event.key==='Enter')doSearch()">
|
||
<button class="go" onclick="doSearch()">Chercher</button>
|
||
</div>
|
||
<div id="results"></div>
|
||
</section>
|
||
|
||
<section id="p-watch" hidden>
|
||
<div class="row">
|
||
<input type="text" id="watchQuery" placeholder="Morceau à guetter sur toutes les stations"
|
||
onkeydown="if(event.key==='Enter')addWatch()">
|
||
<button class="go" onclick="addWatch()">Guetter</button>
|
||
<button class="ghost" onclick="sweep()">Balayer maintenant</button>
|
||
</div>
|
||
<div id="watches"></div>
|
||
</section>
|
||
|
||
<section id="p-queue" hidden><div id="queue"></div></section>
|
||
<section id="p-library" hidden><div id="library"></div></section>
|
||
</main>
|
||
|
||
<script>
|
||
const $ = (id) => document.getElementById(id);
|
||
let token = localStorage.getItem('noxely-token') || '';
|
||
$('token').value = token;
|
||
|
||
function saveToken() {
|
||
token = $('token').value.trim();
|
||
localStorage.setItem('noxely-token', token);
|
||
// Consultée depuis la machine du serveur, la page se remplit seule.
|
||
(async () => {
|
||
if (!token) {
|
||
try {
|
||
const { token: found } = await fetch('/api/bootstrap').then((r) => r.ok ? r.json() : {});
|
||
if (found) { token = found; $('token').value = found; localStorage.setItem('noxely-token', found); }
|
||
} catch {}
|
||
}
|
||
refreshAll();
|
||
})();
|
||
}
|
||
|
||
async function api(path, options = {}) {
|
||
const response = await fetch('/api' + path, {
|
||
...options,
|
||
headers: { 'X-Noxely-Token': token, 'Content-Type': 'application/json', ...(options.headers || {}) },
|
||
});
|
||
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).detail || response.status);
|
||
return response.json();
|
||
}
|
||
|
||
function show(name) {
|
||
for (const tab of ['search', 'watch', 'queue', 'library']) {
|
||
$('p-' + tab).hidden = tab !== name;
|
||
$('t-' + tab).setAttribute('aria-selected', String(tab === name));
|
||
}
|
||
if (name === 'queue') loadQueue();
|
||
if (name === 'library') loadLibrary();
|
||
if (name === 'watch') loadWatches();
|
||
}
|
||
|
||
async function refreshAll() {
|
||
try {
|
||
const health = await fetch('/api/health').then((r) => r.json());
|
||
$('status').textContent = health.name + ' ' + health.version;
|
||
$('status').className = 'pill ok';
|
||
} catch { $('status').textContent = 'injoignable'; $('status').className = 'pill warn'; }
|
||
try {
|
||
const { catalogs } = await api('/catalogs');
|
||
$('catalog').innerHTML = Object.entries(catalogs)
|
||
.map(([id, label]) => `<option value="${id}">${label}</option>`).join('');
|
||
} catch {}
|
||
}
|
||
|
||
async function doSearch() {
|
||
const q = $('q').value.trim(); if (!q) return;
|
||
$('results').innerHTML = '<div class="empty">Recherche…</div>';
|
||
try {
|
||
const { results } = await api(`/search?catalog=${$('catalog').value}&q=${encodeURIComponent(q)}`);
|
||
$('results').innerHTML = results.length ? results.map((r, i) => `
|
||
<div class="card">
|
||
<div class="grow"><b>${esc(r.title)}</b><small>${
|
||
r.catalog ? '<span class="pill">' + esc(r.catalog) + '</span> ' : ''}${
|
||
esc(r.artist)} · ${r.kind}${
|
||
r.size ? ' · ~' + (r.size / 1e6).toFixed(0) + ' Mo' : ''}</small></div>
|
||
<button class="ghost" onclick='openTracks(${i})'>Pistes</button>
|
||
</div>`).join('') : '<div class="empty">Aucun résultat</div>';
|
||
window._results = results;
|
||
} catch (e) { $('results').innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
|
||
}
|
||
|
||
async function openTracks(index) {
|
||
const item = window._results[index];
|
||
$('results').innerHTML = '<div class="empty">Lecture des pistes…</div>';
|
||
try {
|
||
// En recherche « toutes sources », c'est le catalogue du résultat qui compte, pas celui du menu.
|
||
const catalog = item.catalog || $('catalog').value;
|
||
const { tracks } = await api(`/tracks?catalog=${catalog}&id=${
|
||
encodeURIComponent(item.id)}&title=${encodeURIComponent(item.title)}&kind=${item.kind}`);
|
||
window._catalog = catalog;
|
||
window._tracks = tracks;
|
||
const total = tracks.reduce((sum, t) => sum + (t.size || 0), 0);
|
||
$('results').innerHTML = `
|
||
<div class="row"><button class="ghost" onclick="doSearch()">← Résultats</button>
|
||
<button class="go" onclick="queueAll()">Tout mettre en file${
|
||
total ? ' (' + (total / 1e6).toFixed(0) + ' Mo)' : ''}</button></div>` +
|
||
tracks.map((t, i) => `
|
||
<div class="card">
|
||
<div class="grow"><b>${esc(t.title)}</b><small>${
|
||
t.size ? (t.size / 1e6).toFixed(1) + ' Mo' : 'taille inconnue'}</small></div>
|
||
<button class="ghost" onclick="queueOne(${i})">Mettre en file</button>
|
||
</div>`).join('');
|
||
} catch (e) { $('results').innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
|
||
}
|
||
|
||
const queueTracks = async (tracks) => {
|
||
const { queued } = await api('/queue', { method: 'POST',
|
||
body: JSON.stringify({ tracks, catalog: window._catalog || $('catalog').value }) });
|
||
alert(queued + ' piste(s) en file de téléchargement');
|
||
};
|
||
const queueOne = (i) => queueTracks([window._tracks[i]]);
|
||
const queueAll = () => queueTracks(window._tracks);
|
||
|
||
async function loadQueue() {
|
||
try {
|
||
const { items } = await api('/queue');
|
||
$('queue').innerHTML = items.length ? items.map((m) => `
|
||
<div class="card"><div class="grow"><b>${esc(m.title)}</b>
|
||
<small>${esc(m.artist)} · ${m.state}${m.error ? ' — ' + esc(m.error) : ''}</small></div></div>`).join('')
|
||
: '<div class="empty">File vide</div>';
|
||
} catch (e) { $('queue').innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
|
||
}
|
||
|
||
async function loadLibrary() {
|
||
try {
|
||
const { items } = await api('/library?since=0');
|
||
$('library').innerHTML = items.length ? items.map((m) => `
|
||
<div class="card"><div class="grow"><b>${esc(m.title)}</b>
|
||
<small>${esc(m.artist)} · ${(m.size / 1e6).toFixed(1)} Mo · ${esc(m.source)}</small></div>
|
||
<button class="ghost" onclick="removeMedia('${m.id}')">Supprimer</button></div>`).join('')
|
||
: '<div class="empty">Rien de prêt pour le mobile</div>';
|
||
} catch (e) { $('library').innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
|
||
}
|
||
|
||
async function removeMedia(id) {
|
||
if (!confirm('Supprimer définitivement ce média du serveur ?')) return;
|
||
await api('/media/' + id, { method: 'DELETE' });
|
||
loadLibrary();
|
||
}
|
||
|
||
async function loadWatches() {
|
||
try {
|
||
const { watches, recording, sweeps, last_sweep: last } = await api('/watches');
|
||
const scan = last && last.stations
|
||
? `${sweeps} tour(s) · dernier : ${last.stations} stations, ${last.titles} titres lus`
|
||
: 'en attente d\'un guet';
|
||
$('watches').innerHTML =
|
||
`<div class="card"><div class="grow"><b>Balayage continu</b><small>${esc(scan)}</small></div>
|
||
<span class="pill ok">actif</span></div>` +
|
||
(recording ? `<div class="card"><div class="grow">
|
||
<b>Enregistrement en cours</b><small>${esc(recording)}</small></div></div>` : '') +
|
||
(watches.length ? watches.map((w) => `
|
||
<div class="card"><div class="grow"><b>${esc(w.query)}</b><small>${
|
||
w.found_at ? 'capté sur ' + esc(w.found_on || '') + ' — ' + esc(w.found_title || '')
|
||
: w.scans + ' balayage(s)'}</small></div>
|
||
<span class="pill ${w.found_at ? 'ok' : ''}">${w.found_at ? 'trouvé' : 'en attente'}</span>
|
||
<button class="ghost" onclick="removeWatch('${w.id}')">×</button></div>`).join('')
|
||
: '<div class="empty">Aucun guet</div>');
|
||
} catch (e) { $('watches').innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
|
||
}
|
||
|
||
async function addWatch() {
|
||
const query = $('watchQuery').value.trim(); if (!query) return;
|
||
await api('/watches', { method: 'POST', body: JSON.stringify({ query }) });
|
||
$('watchQuery').value = '';
|
||
loadWatches();
|
||
}
|
||
const removeWatch = async (id) => { await api('/watches/' + id, { method: 'DELETE' }); loadWatches(); };
|
||
|
||
async function sweep() {
|
||
$('watches').innerHTML = '<div class="empty">Balayage des stations…</div>';
|
||
try {
|
||
const r = await api('/watches/sweep', { method: 'POST' });
|
||
alert(r.matched ? `Trouvé sur ${r.station} : ${r.matched}`
|
||
: `${r.stations} stations écoutées, ${r.titles} titres lus, rien de guetté`);
|
||
} catch (e) { alert(String(e)); }
|
||
loadWatches();
|
||
}
|
||
|
||
const esc = (s) => String(s).replace(/[&<>"']/g, (c) =>
|
||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
|
||
// Consultée depuis la machine du serveur, la page se remplit seule.
|
||
(async () => {
|
||
if (!token) {
|
||
try {
|
||
const { token: found } = await fetch('/api/bootstrap').then((r) => r.ok ? r.json() : {});
|
||
if (found) { token = found; $('token').value = found; localStorage.setItem('noxely-token', found); }
|
||
} catch {}
|
||
}
|
||
refreshAll();
|
||
})();
|
||
setInterval(() => {
|
||
if (!$('p-queue').hidden) loadQueue();
|
||
if (!$('p-watch').hidden) loadWatches();
|
||
}, 5000);
|
||
</script>
|
||
</body>
|
||
</html>
|