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:
1 parent
53f5fc1148
commit
7810bef8a3
12 files changed
+380
-20
No files matched your search
@@ -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."""
|
||||
|
||||
Reference in new issue
Block a user