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

+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."""