Files
tuxgyverandClaude Sonnet 5 7810bef8a3 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>
2026-09-20 17:59:32 +02:00

102 lines
4.0 KiB
Python

# 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),
}