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

+9
View File
@@ -17,3 +17,12 @@ NOXELY_PORT=8710
NOXELY_WATCH_STATIONS=40
NOXELY_WATCH_INTERVAL=5
NOXELY_WATCH_CONCURRENCY=8
# Historique des écoutes : le serveur note ce que les stations annoncent, même sans guet en
# cours, et en tire ce qui tourne. Mettre 0 pour le couper (le balayage s'arrête alors quand
# plus rien n'est guetté).
NOXELY_HISTORY=1
# Pause entre deux tours quand rien n'est guetté, en secondes. Plus haut = moins de trafic.
NOXELY_LISTEN_INTERVAL=60
# Ancienneté maximale de l'historique, en jours.
NOXELY_HISTORY_DAYS=90
+16 -1
View File
@@ -32,9 +32,24 @@ make server-logs # journal suivi
| `GET /api/media/{id}/file` | **le mobile** — le fichier |
| `GET /api/search`, `/api/tracks`, `POST /api/queue` | l'interface web |
| `GET/POST/DELETE /api/watches`, `POST /api/watches/sweep` | l'interface web |
| `GET /api/heard?limit=&q=` | l'interface web — l'historique de ce qui a été entendu |
| `GET /api/rotation?last=100&top=20` | l'interface web — ce qui tourne sur les *n* dernières remontées |
Toutes les routes sauf `/api/health` exigent l'en-tête `X-Noxely-Token`.
## Historique et rotation
Le serveur note chaque titre annoncé par les stations qu'il balaie (ICY), même quand aucun
morceau n'est guetté. Une station qui n'a pas changé de morceau depuis le dernier tour n'ajoute
rien, et celles qui n'annoncent qu'elles-mêmes sont écartées.
La **rotation** compte, sur les *n* dernières remontées (100 par défaut), les passages de chaque
titre et de chaque artiste, avec le nombre de stations distinctes qui les diffusent.
Sans guet en cours, un tour a lieu toutes les `NOXELY_LISTEN_INTERVAL` secondes (60 par défaut)
— quelques dizaines de kilo-octets par station. `NOXELY_HISTORY=0` coupe l'historique ; les
lignes plus vieilles que `NOXELY_HISTORY_DAYS` (90) sont purgées.
## Licence
GPL v3, comme le reste du projet — voir [`../LICENSE`](../LICENSE).
@@ -43,6 +58,6 @@ GPL v3, comme le reste du projet — voir [`../LICENSE`](../LICENSE).
```
$NOXELY_DATA/
├── noxely.sqlite3 index des médias et des guets
├── noxely.sqlite3 index des médias, des guets et de l'historique des écoutes
└── media/ les fichiers eux-mêmes
```
+18
View File
@@ -27,6 +27,7 @@ from fastapi import Body, Depends, FastAPI, Header, HTTPException, Query, Reques
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, Response
from . import config as config_module
from . import rotation
from .sources import catalogs, radio
from .storage import Storage
from .workers import Downloader, Watcher, safe_name
@@ -200,6 +201,23 @@ async def sweep() -> dict:
raise HTTPException(status_code=502, detail=f"Annuaire indisponible : {str(error)[:120]}")
@app.get("/api/heard", dependencies=[Depends(authorised)])
def heard(limit: int = Query(default=100, ge=1, le=500), q: str | None = None) -> dict:
"""Ce que les stations ont annoncé, le plus récent d'abord ; `q` filtre sur le texte."""
return {
"items": storage.heard(limit, q.strip() if q else None),
"total": storage.heard_count(),
"history": config.history,
}
@app.get("/api/rotation", dependencies=[Depends(authorised)])
def rotation_view(last: int = Query(default=100, ge=1, le=500),
top: int = Query(default=20, ge=1, le=100)) -> dict:
"""Ce qui tourne sur les `last` dernières remontées, tous titres et artistes confondus."""
return rotation.rotation(storage.heard(last), top)
@app.get("/api/queue", dependencies=[Depends(authorised)])
def queue_state() -> dict:
pending = storage.media_since(0, state=None)
+10
View File
@@ -53,6 +53,13 @@ class Config:
watch_interval: int
#: Stations sondées en parallèle. Plus il est haut, plus un tour complet est bref.
watch_concurrency: int
#: Garder la trace de ce que les stations annoncent, même sans guet en cours.
history: bool
#: Pause entre deux tours quand rien n'est guetté : l'historique n'a pas besoin de la
#: cadence d'un guet, et cela ménage la bande passante.
listen_interval: int
#: Ancienneté maximale de l'historique, en jours. Au-delà, les lignes sont purgées.
history_days: int
@property
def media_dir(self) -> Path:
@@ -74,4 +81,7 @@ def load() -> Config:
watch_stations=int(os.environ.get("NOXELY_WATCH_STATIONS", "40")),
watch_interval=int(os.environ.get("NOXELY_WATCH_INTERVAL", "5")),
watch_concurrency=int(os.environ.get("NOXELY_WATCH_CONCURRENCY", "8")),
history=os.environ.get("NOXELY_HISTORY", "1").strip().lower() not in ("0", "false", "non", "no"),
listen_interval=int(os.environ.get("NOXELY_LISTEN_INTERVAL", "60")),
history_days=int(os.environ.get("NOXELY_HISTORY_DAYS", "90")),
)
+101
View File
@@ -0,0 +1,101 @@
# 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.
"""Lecture de ce que les stations annoncent : découpage des titres et calcul de la rotation.
Rien ici ne touche au réseau ni à la base : ce sont des fonctions pures, faciles à vérifier.
"""
from __future__ import annotations
import re
import unicodedata
#: « Artiste - Titre » : le séparateur porte des espaces, pour ne pas couper « Jay-Z » ni
#: « Ain't That a Kick in the Head ». Le tiret cadratin et le demi-cadratin comptent aussi.
_SEPARATOR = re.compile(r"\s+[-–—]\s+")
#: Ce que certaines stations annoncent quand elles n'ont rien à dire.
_PLACEHOLDERS = {"", "unknown", "n a", "none", "null", "untitled"}
def normalise(text: str) -> str:
"""Clé de comparaison : sans accents, sans casse, sans ponctuation.
« Beyoncé — Halo » et « BEYONCE - HALO » doivent tomber dans la même case, sans quoi la
rotation compterait deux fois le même morceau.
"""
decomposed = unicodedata.normalize("NFKD", text)
stripped = "".join(c for c in decomposed if not unicodedata.combining(c))
return re.sub(r"[^a-z0-9]+", " ", stripped.casefold()).strip()
def split_title(raw: str) -> tuple[str, str]:
"""Sépare « Artiste - Titre ». Sans séparateur, tout est titre : on ne devine pas l'artiste."""
parts = _SEPARATOR.split(raw.strip(), maxsplit=1)
if len(parts) == 2 and parts[0].strip() and parts[1].strip():
return parts[0].strip(), parts[1].strip()
return "", raw.strip()
def is_track(raw: str, station: str) -> bool:
"""Écarte ce qui n'est pas un morceau : une station qui s'annonce elle-même, ou rien."""
key = normalise(raw)
return key not in _PLACEHOLDERS and key != normalise(station)
def rotation(rows: list[dict], top: int = 20) -> dict:
"""Ce qui tourne, d'après les dernières remontées.
`rows` est le résultat brut de la base (artist, title, station, heard_at). Un morceau se
compte par passage ; le nombre de stations distinctes dit s'il tourne partout ou sur une
seule antenne, ce qui n'a pas le même sens.
"""
tracks: dict[str, dict] = {}
artists: dict[str, dict] = {}
for row in rows:
key = normalise(row["artist"]) + "|" + normalise(row["title"])
entry = tracks.setdefault(key, {
"artist": row["artist"], "title": row["title"], "plays": 0,
"stations": set(), "last_heard": 0,
})
entry["plays"] += 1
entry["stations"].add(row["station"])
entry["last_heard"] = max(entry["last_heard"], row["heard_at"])
if row["artist"]:
who = artists.setdefault(normalise(row["artist"]), {
"artist": row["artist"], "plays": 0, "tracks": set(),
"stations": set(), "last_heard": 0,
})
who["plays"] += 1
who["tracks"].add(key)
who["stations"].add(row["station"])
who["last_heard"] = max(who["last_heard"], row["heard_at"])
def ranked(groups: dict[str, dict]) -> list[dict]:
ordered = sorted(
groups.values(),
key=lambda g: (-g["plays"], -len(g["stations"]), -g["last_heard"]),
)
out = []
for group in ordered[:top]:
shaped = {**group, "stations": sorted(group["stations"])}
if "tracks" in shaped:
shaped["tracks"] = len(shaped["tracks"])
out.append(shaped)
return out
return {
"window": len(rows),
"since": min((r["heard_at"] for r in rows), default=None),
"tracks": ranked(tracks),
"artists": ranked(artists),
}
+65
View File
@@ -61,9 +61,26 @@ CREATE TABLE IF NOT EXISTS watches (
found_on TEXT,
found_title TEXT
);
CREATE TABLE IF NOT EXISTS heard (
id INTEGER PRIMARY KEY AUTOINCREMENT,
heard_at INTEGER NOT NULL,
station_id TEXT NOT NULL,
station TEXT NOT NULL,
raw TEXT NOT NULL,
artist TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL,
method TEXT NOT NULL DEFAULT 'icy'
);
CREATE INDEX IF NOT EXISTS heard_station ON heard(station_id, id);
CREATE INDEX IF NOT EXISTS heard_at ON heard(heard_at);
"""
#: Délai avant qu'une même station puisse rejouer un même titre, pour l'historique.
REPEAT_WINDOW_MS = 10 * 60 * 1000
@dataclass
class Media:
id: str
@@ -228,3 +245,51 @@ class Storage:
def delete_watch(self, watch_id: str) -> None:
self._db.execute("DELETE FROM watches WHERE id = ?", (watch_id,))
self._db.commit()
# --- Historique des écoutes ------------------------------------------
def add_heard(self, *, station_id: str, station: str, raw: str, artist: str, title: str,
method: str = "icy") -> bool:
"""Note un titre entendu, sauf si cette station vient déjà de l'annoncer.
Un balayage revient sur la même station toutes les quelques dizaines de secondes, pour
un morceau de plusieurs minutes : sans garde, chaque passage ajouterait une ligne et la
rotation compterait dix fois ce qui n'a été diffusé qu'une. Certaines stations alternent
en plus le vrai titre et un titre de repli ; on ignore donc tout titre déjà relevé sur la
même station dans les `REPEAT_WINDOW_MS` — une station ne rejoue pas un morceau si tôt.
"""
recent = self._db.execute(
"SELECT raw, heard_at FROM heard WHERE station_id = ? ORDER BY id DESC LIMIT 20",
(station_id,),
).fetchall()
if recent and recent[0]["raw"] == raw:
return False
horizon = now() - REPEAT_WINDOW_MS
if any(r["raw"] == raw and r["heard_at"] > horizon for r in recent):
return False
self._db.execute(
"INSERT INTO heard (heard_at,station_id,station,raw,artist,title,method) "
"VALUES (?,?,?,?,?,?,?)",
(now(), station_id, station, raw, artist, title, method),
)
self._db.commit()
return True
def heard(self, limit: int = 100, query: str | None = None) -> list[dict]:
"""Les dernières remontées, la plus récente d'abord."""
sql = "SELECT * FROM heard"
params: list = []
if query:
sql += " WHERE instr(lower(raw), ?) > 0"
params.append(query.lower())
sql += " ORDER BY id DESC LIMIT ?"
params.append(limit)
return [dict(r) for r in self._db.execute(sql, params)]
def heard_count(self) -> int:
return self._db.execute("SELECT COUNT(*) FROM heard").fetchone()[0]
def prune_heard(self, older_than_ms: int) -> int:
cursor = self._db.execute("DELETE FROM heard WHERE heard_at < ?", (older_than_ms,))
self._db.commit()
return cursor.rowcount
+3
View File
@@ -36,6 +36,9 @@ const ABOUT = {
['Enregistrer un direct',
'La capture prend le nom du morceau annoncé par la station, et s\'arrête quand il cède la ' +
'place au suivant.'],
['Garder l\'historique et repérer la rotation',
'Chaque titre annoncé par les stations est noté ; l\'onglet Écoutes montre ce qui a été ' +
'entendu et ce qui tourne le plus sur les dernières remontées, titres comme artistes.'],
['Tenir la file',
'Les téléchargements s\'enchaînent seuls ; un échec est repris, son état reste visible.'],
['Servir le mobile',
+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());
+60 -15
View File
@@ -22,6 +22,7 @@ from pathlib import Path
import httpx
from . import rotation
from .config import Config
from .sources import radio
from .storage import Storage
@@ -83,54 +84,98 @@ class Watcher:
#: Dernier tour de balayage, pour que l'interface montre que ça tourne.
self.last_sweep: dict = {}
self.sweeps = 0
self._stations: list[dict] = []
self._stations_at = 0.0
self._pruned_at = 0.0
async def run_forever(self) -> None:
"""Balaie en continu : un tour enchaîne sur le suivant sans attendre.
"""Balaie en continu : un tour enchaîne sur le suivant.
Une sonde ne lit que le premier bloc de métadonnées puis referme — quelques dizaines de
kilo-octets par station. C'est ce qui rend le balayage permanent tenable, là où garder
quarante flux ouverts consommerait plusieurs mégabits par seconde en continu.
Le rythme dépend de ce qu'on attend de lui : serré quand un morceau est guetté, plus
lâche quand il ne fait que tenir l'historique, arrêté si l'historique est coupé.
"""
while True:
try:
if self.storage.pending_watches():
self.last_sweep = await self.sweep()
self.sweeps += 1
else:
# Rien à guetter : inutile de solliciter l'annuaire.
guetting = bool(self.storage.pending_watches())
if not guetting and not self.config.history:
# Rien à guetter et rien à retenir : inutile de solliciter l'annuaire.
await asyncio.sleep(30)
continue
self.last_sweep = await self.sweep()
self.sweeps += 1
self._prune()
except Exception as error:
print(f"[noxely] guet : {error}")
await asyncio.sleep(30)
continue
await asyncio.sleep(self.config.watch_interval)
await asyncio.sleep(self.config.watch_interval if guetting
else self.config.listen_interval)
async def _known_stations(self) -> list[dict]:
"""Les stations à sonder, relues de l'annuaire une fois par demi-heure.
Sans ce cache, chaque tour — toutes les quelques secondes en cas de guet — interrogerait
l'annuaire, un service communautaire. Si celui-ci est injoignable, on garde la liste
précédente plutôt que de renoncer au tour.
"""
fresh = time.monotonic() - self._stations_at < 1800
if self._stations and fresh:
return self._stations
try:
self._stations = await radio.stations(limit=self.config.watch_stations)
self._stations_at = time.monotonic()
except Exception:
if not self._stations:
raise
return self._stations
def _prune(self) -> None:
"""Purge l'historique périmé, au plus une fois par heure."""
if time.monotonic() - self._pruned_at < 3600:
return
self._pruned_at = time.monotonic()
limit = _now() - self.config.history_days * 86_400_000
self.storage.prune_heard(limit)
async def sweep(self) -> dict:
pending = self.storage.pending_watches()
if not pending:
return {"stations": 0, "titles": 0, "at": _now(), "matched": None}
found = await radio.stations(limit=self.config.watch_stations)
found = await self._known_stations()
titles = 0
noted = 0
matched: dict | None = None
for group in _chunks(found, self.config.watch_concurrency):
heard = await asyncio.gather(*(radio.now_playing(s["url"]) for s in group))
for station, title in zip(group, heard):
if not title:
continue
titles += 1
if self.config.history and rotation.is_track(title, station["name"]):
artist, name = rotation.split_title(title)
noted += self.storage.add_heard(
station_id=station["id"], station=station["name"], raw=title,
artist=artist, title=name,
)
if matched:
continue
hit = next((w for w in pending if w["query"].lower() in title.lower()), None)
if not hit:
continue
# L'enregistrement part tout de suite : attendre la fin du tour ferait manquer
# le début du morceau. Le tour, lui, continue, pour que l'historique soit complet.
if self.recording is None:
asyncio.create_task(self.record(station, title, hit["query"]))
self.storage.mark_watch_found(hit["id"], station["name"], title)
self.storage.mark_watches_scanned([w["id"] for w in pending])
return {"stations": len(found), "titles": titles, "at": _now(),
"matched": title, "station": station["name"]}
matched = {"matched": title, "station": station["name"]}
self.storage.mark_watches_scanned([w["id"] for w in pending])
return {"stations": len(found), "titles": titles, "at": _now(), "matched": None}
return {"stations": len(found), "titles": titles, "heard": noted, "at": _now(),
"matched": matched["matched"] if matched else None,
**({"station": matched["station"]} if matched else {})}
async def record(self, station: dict, title: str, trigger: str) -> None:
"""Enregistre jusqu'à ce que la station annonce un autre morceau."""