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