Recopier un jeton de trente-trois caractères sur un clavier tactile n'était pas un réglage, c'était une corvée. On ouvre désormais une fenêtre depuis le serveur, on appuie sur Appairer côté téléphone, et c'est fait. Le jeton n'est pas pour autant distribué : ouvrir la fenêtre l'exige déjà, elle dure deux minutes, se referme dès qu'un appareil s'est servi, et ne répond qu'à une adresse privée. Le champ de saisie reste là pour qui préfère le coller. Les cinquante fichiers de code portent en outre leur notice de licence, comme la GPL le recommande. Elle est en français, comme le reste ; le texte qui fait foi demeure celui de LICENSE, non traduit.
289 lines
13 KiB
HTML
289 lines
13 KiB
HTML
<!doctype html>
|
||
<!--
|
||
Noxely — collecte côté serveur, écoute hors connexion côté mobile.
|
||
Copyright © 2026 Johnny (tuxgyver)
|
||
|
||
Ce programme est un logiciel libre : vous pouvez le redistribuer et le modifier
|
||
selon les termes de la GNU General Public License, version 3 ou ultérieure,
|
||
telle que publiée par la Free Software Foundation.
|
||
|
||
Il est distribué dans l'espoir d'être utile, mais SANS AUCUNE GARANTIE.
|
||
Voir le fichier LICENSE à la racine du dépôt pour le texte complet.
|
||
-->
|
||
<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>
|
||
<button class="go" onclick="openPairing()">📱 Appairer</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();
|
||
}
|
||
|
||
async function openPairing() {
|
||
try {
|
||
const { seconds } = await api('/pair/open', { method: 'POST' });
|
||
alert(`Appairage ouvert pour ${seconds} secondes.\n\n` +
|
||
`Sur le téléphone : Réglages ▸ Connexion ▸ Appairer.\n` +
|
||
`La fenêtre se referme dès qu'un appareil s'est servi.`);
|
||
} catch (e) { alert('Appairage impossible : ' + e); }
|
||
}
|
||
|
||
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>
|