Le serveur garde la trace de ce qu'il entend, et repère la rotation (2.4.0)

Le balayage ne servait qu'à trouver un morceau guetté, puis oubliait tout. Il
note désormais chaque titre annoncé par les stations, même sans guet en cours :
un onglet Écoutes montre l'historique, et la rotation — passages et stations
distinctes, par titre et par artiste — sur les 100 dernières remontées.

Pour que l'historique soit fiable, un tour va maintenant jusqu'au bout après une
trouvaille (l'enregistrement part aussitôt, le début du morceau n'est pas
perdu), et la liste des stations est mise en cache une demi-heure au lieu
d'interroger l'annuaire à chaque tour.

Essayé sur de vraies radios : une station alternait le vrai titre et un titre de
repli, ce qui doublait les passages. Un titre déjà relevé sur la même station
dans les dix minutes est donc ignoré.

Première moitié de la reconnaissance des morceaux ; l'écoute continue et
l'empreinte sonore suivront.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
tuxgyverandClaude Sonnet 5 committed 2026-09-20 17:59:32 +02:00
1 parent 53f5fc1148
commit 7810bef8a3
12 files changed
+380 -20

No files matched your search

+62 -2
View File
@@ -66,6 +66,7 @@
.tech { width:100%; border-collapse:collapse; }
.tech td { padding:9px 4px; border-bottom:1px solid var(--line); vertical-align:top; }
.tech td:first-child { color:var(--muted); width:38%; }
h3.section { font-size:15px; margin:20px 0 10px; }
.log h4 { margin:0 0 4px; font-size:15px; }
</style>
</head>
@@ -80,6 +81,7 @@
<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('heard')" aria-selected="false" id="t-heard">📈 Écoutes</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>
@@ -106,6 +108,24 @@
<div id="watches"></div>
</section>
<section id="p-heard" hidden>
<div class="row">
<select id="rotationWindow" onchange="loadHeard()">
<option value="50">50 dernières remontées</option>
<option value="100" selected>100 dernières remontées</option>
<option value="200">200 dernières remontées</option>
<option value="500">500 dernières remontées</option>
</select>
<input type="text" id="heardQuery" placeholder="Chercher dans l'historique"
onkeydown="if(event.key==='Enter')loadHeard()">
<button class="ghost" onclick="loadHeard()">Actualiser</button>
</div>
<h3 class="section">🔥 En rotation</h3>
<div id="rotation"></div>
<h3 class="section">🕘 Historique</h3>
<div id="heard"></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-settings" hidden>
@@ -162,13 +182,14 @@ async function api(path, options = {}) {
}
function show(name) {
for (const tab of ['search', 'watch', 'queue', 'library', 'devices', 'settings', 'about']) {
for (const tab of ['search', 'watch', 'heard', 'queue', 'library', 'devices', 'settings', 'about']) {
$('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 === 'heard') loadHeard();
if (name === 'devices') loadDevices();
if (name === 'settings') loadServerInfo();
if (name === 'about') loadAbout();
@@ -271,7 +292,8 @@ 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`
? `${sweeps} tour(s) · dernier : ${last.stations} stations, ${last.titles} titres lus, ` +
`${last.heard || 0} nouveau(x) dans l'historique`
: 'en attente d\'un guet';
$('watches').innerHTML =
`<div class="card"><div class="grow"><b>Balayage continu</b><small>${esc(scan)}</small></div>
@@ -306,6 +328,44 @@ async function sweep() {
loadWatches();
}
const clock = (ms) => new Date(ms).toLocaleString('fr-FR',
{ day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
async function loadHeard() {
const last = $('rotationWindow').value;
const query = $('heardQuery').value.trim();
try {
const [rot, list] = await Promise.all([
api('/rotation?last=' + last + '&top=15'),
api('/heard?limit=100' + (query ? '&q=' + encodeURIComponent(query) : '')),
]);
const label = (t) => t.artist ? `${esc(t.artist)} — ${esc(t.title)}` : esc(t.title);
const stations = (n) => n.length > 2 ? `${n.length} stations` : esc(n.join(', '));
$('rotation').innerHTML = rot.tracks.length
? `<div class="empty" style="padding:0 0 10px;text-align:left">${rot.window} remontée(s) analysée(s)</div>` +
rot.tracks.map((t) => `
<div class="card"><div class="grow"><b>${label(t)}</b>
<small>${stations(t.stations)} · dernier passage ${clock(t.last_heard)}</small></div>
<span class="pill ${t.plays > 1 ? 'ok' : ''}">×${t.plays}</span></div>`).join('') +
(rot.artists.length ? `<h3 class="section">🎤 Artistes</h3>` + rot.artists.map((a) => `
<div class="card"><div class="grow"><b>${esc(a.artist)}</b>
<small>${a.tracks} titre(s) · ${a.stations.length} station(s)</small></div>
<span class="pill ${a.plays > 1 ? 'ok' : ''}">×${a.plays}</span></div>`).join('') : '')
: `<div class="empty">${list.history
? 'Rien d\'entendu pour l\'instant — le premier tour de balayage peut prendre une minute.'
: 'L\'historique est désactivé (NOXELY_HISTORY=0).'}</div>`;
$('heard').innerHTML = list.items.length
? `<div class="empty" style="padding:0 0 10px;text-align:left">${list.total} ligne(s) au total</div>` +
list.items.map((h) => `
<div class="card"><div class="grow"><b>${h.artist ? esc(h.artist) + ' — ' : ''}${esc(h.title)}</b>
<small>${esc(h.station)} · ${clock(h.heard_at)}</small></div></div>`).join('')
: '<div class="empty">Aucun résultat</div>';
} catch (e) {
$('rotation').innerHTML = `<div class="empty">${esc(String(e))}</div>`;
$('heard').innerHTML = '';
}
}
async function loadServerInfo() {
try {
const health = await fetch('/api/health').then((r) => r.json());