Tous les appareils partageaient le jeton du serveur : perdre un téléphone obligeait à changer ce jeton, donc à réappairer tout le monde. Chaque appairage émet désormais le sien. Révoquer devient une opération locale, vérifiée en appairant deux appareils puis en coupant le premier : 401 pour lui, 200 pour l'autre, jeton maître intact. L'interface du serveur les liste avec leur modèle, leur date d'appairage et leur dernière synchronisation — sans jamais afficher les jetons eux-mêmes, ce qui reviendrait à les distribuer. La table s'ajoute sans toucher aux données existantes et le jeton maître reste accepté : rien ne casse pour un appareil appairé avant cette version.
314 lines
14 KiB
HTML
314 lines
14 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>
|
||
<button onclick="show('devices')" aria-selected="false" id="t-devices">📱 Appareils</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>
|
||
<section id="p-devices" hidden>
|
||
<div class="row"><button class="go" onclick="openPairing()">Appairer un appareil</button></div>
|
||
<div id="devices"></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', 'devices']) {
|
||
$('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();
|
||
if (name === 'devices') loadDevices();
|
||
}
|
||
|
||
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 loadDevices() {
|
||
try {
|
||
const { devices } = await api('/devices');
|
||
$('devices').innerHTML = devices.length ? devices.map((d) => `
|
||
<div class="card"><div class="grow"><b>${esc(d.name)}</b><small>appairé le ${
|
||
new Date(d.created_at).toLocaleString('fr-FR')}${
|
||
d.last_seen ? ' · vu le ' + new Date(d.last_seen).toLocaleString('fr-FR')
|
||
: ' · jamais synchronisé'}</small></div>
|
||
<button class="ghost" onclick="revokeDevice('${d.id}','${esc(d.name)}')">Révoquer</button></div>`).join('')
|
||
: '<div class="empty">Aucun appareil appairé</div>';
|
||
} catch (e) { $('devices').innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
|
||
}
|
||
|
||
async function revokeDevice(id, name) {
|
||
if (!confirm(`Révoquer « ${name} » ? Il ne pourra plus se synchroniser tant qu'il n'est pas réappairé.`)) return;
|
||
await api('/devices/' + id, { method: 'DELETE' });
|
||
loadDevices();
|
||
}
|
||
|
||
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>
|