From 7810bef8a344c0bd0f22202220958371c787f580 Mon Sep 17 00:00:00 2001 From: tuxgyver Date: Sun, 20 Sep 2026 17:59:32 +0200 Subject: [PATCH] =?UTF-8?q?Le=20serveur=20garde=20la=20trace=20de=20ce=20q?= =?UTF-8?q?u'il=20entend,=20et=20rep=C3=A8re=20la=20rotation=20(2.4.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 33 +++++++++ README.md | 3 +- VERSION | 2 +- server/.env.example | 9 +++ server/README.md | 17 ++++- server/noxely_server/api.py | 18 +++++ server/noxely_server/config.py | 10 +++ server/noxely_server/rotation.py | 101 ++++++++++++++++++++++++++++ server/noxely_server/storage.py | 65 ++++++++++++++++++ server/noxely_server/web/about.js | 3 + server/noxely_server/web/index.html | 64 +++++++++++++++++- server/noxely_server/workers.py | 75 ++++++++++++++++----- 12 files changed, 380 insertions(+), 20 deletions(-) create mode 100644 server/noxely_server/rotation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f417218..dc1cab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ Toutes les évolutions notables de Noxely sont documentées ici. Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le versionnage [SemVer](https://semver.org/lang/fr/). +## [2.4.0] — 2026-09-20 + +### Ajouté + +- **Historique des écoutes.** Le serveur note chaque titre que les stations annoncent (ICY), + même quand aucun morceau n'est guetté : station, heure, artiste et titre. Un nouvel onglet + `📈 Écoutes` le montre, avec une recherche dans le texte. La route `GET /api/heard` le sert. +- **Rotation.** `GET /api/rotation` compte, sur les *n* dernières remontées (100 par défaut, + jusqu'à 500), les passages de chaque titre et de chaque artiste, et le nombre de stations + distinctes qui les diffusent. Les accents, la casse et la ponctuation ne séparent plus un + même morceau en deux. +- Trois réglages : `NOXELY_HISTORY` (0 pour couper), `NOXELY_LISTEN_INTERVAL` (pause entre deux + tours quand rien n'est guetté, 60 s) et `NOXELY_HISTORY_DAYS` (purge au-delà, 90 jours). + +### Modifié + +- **Le balayage ne s'arrête plus quand rien n'est guetté.** Il tourne à cadence lente pour tenir + l'historique, et reste serré (`NOXELY_WATCH_INTERVAL`) dès qu'un guet est en attente. +- **Un tour va jusqu'au bout.** Trouver un morceau guetté n'interrompt plus le tour : l'enregistrement + part aussitôt, mais le reste des stations est lu, pour que l'historique soit complet. +- **La liste des stations est mise en cache une demi-heure.** Chaque tour interrogeait + l'annuaire communautaire ; avec un guet, c'était toutes les quelques secondes. En cas de + panne de l'annuaire, la liste précédente sert au lieu de faire échouer le tour. + +### Notes + +- Une station qui n'a pas changé de morceau n'ajoute rien, et un titre déjà relevé sur la même + station dans les dix minutes est ignoré : certaines stations alternent le vrai titre et un + titre de repli, ce qui doublait les passages. Les stations qui n'annoncent qu'elles-mêmes sont + écartées. +- La séparation « Artiste - Titre » se fait sur le premier tiret entouré d'espaces. Une station + qui annonce « Titre - Artiste », ou du parlé, est comptée telle quelle. + ## [2.3.0] — 2026-09-13 ### Ajouté diff --git a/README.md b/README.md index 798e2c7..26a8f88 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![Kotlin](https://img.shields.io/badge/Kotlin-2.1.0-7F52FF?logo=kotlin&logoColor=white)](https://kotlinlang.org) [![Compose](https://img.shields.io/badge/Jetpack%20Compose-Material%203-4285F4?logo=jetpackcompose&logoColor=white)](https://developer.android.com/jetpack/compose) [![Media3](https://img.shields.io/badge/Media3-1.11.1-FF6F00?logo=googleplay&logoColor=white)](https://developer.android.com/media/media3) -[![Version](https://img.shields.io/badge/Version-2.2.0-6B4EFF)](VERSION) +[![Version](https://img.shields.io/badge/Version-2.4.0-6B4EFF)](VERSION) [![Licence](https://img.shields.io/badge/Licence-GPL%20v3-3FD8C0)](LICENSE) [![Hors ligne](https://img.shields.io/badge/Hors%20ligne-100%25-3FD8C0)](#-comment-ça-marche) @@ -51,6 +51,7 @@ synchronisations : c'est là qu'est l'économie. | 🧾 **Choix piste par piste** | Un album affiche ses pistes avec leur taille avant d'être mis en file | | 📡 **Guet multi-stations** | Nommez un morceau : les stations les plus écoutées sont balayées jusqu'à le trouver | | 📻 **Enregistrement** | Capture du direct, nommée d'après le titre annoncé par la station | +| 📈 **Historique et rotation** | Tout ce que les stations annoncent est noté ; on voit ce qui tourne sur les 100 dernières remontées | | ⏳ **File de téléchargement** | Reprise après échec, état visible | | 🖥️ **Interface web** | Tout se pilote depuis un navigateur, sans toucher au téléphone | diff --git a/VERSION b/VERSION index 276cbf9..197c4d5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.3.0 +2.4.0 diff --git a/server/.env.example b/server/.env.example index 054eef0..d4ba956 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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 diff --git a/server/README.md b/server/README.md index 3bbc01c..79db2ff 100644 --- a/server/README.md +++ b/server/README.md @@ -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 ``` diff --git a/server/noxely_server/api.py b/server/noxely_server/api.py index 2295096..0ced648 100644 --- a/server/noxely_server/api.py +++ b/server/noxely_server/api.py @@ -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) diff --git a/server/noxely_server/config.py b/server/noxely_server/config.py index 56ab421..8c2cbe6 100644 --- a/server/noxely_server/config.py +++ b/server/noxely_server/config.py @@ -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")), ) diff --git a/server/noxely_server/rotation.py b/server/noxely_server/rotation.py new file mode 100644 index 0000000..a77f2dc --- /dev/null +++ b/server/noxely_server/rotation.py @@ -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), + } diff --git a/server/noxely_server/storage.py b/server/noxely_server/storage.py index 9befe8e..542c0a2 100644 --- a/server/noxely_server/storage.py +++ b/server/noxely_server/storage.py @@ -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 diff --git a/server/noxely_server/web/about.js b/server/noxely_server/web/about.js index 7fdd4e8..ef03646 100644 --- a/server/noxely_server/web/about.js +++ b/server/noxely_server/web/about.js @@ -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', diff --git a/server/noxely_server/web/index.html b/server/noxely_server/web/index.html index cd4e2d7..c8122b3 100644 --- a/server/noxely_server/web/index.html +++ b/server/noxely_server/web/index.html @@ -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; } @@ -80,6 +81,7 @@
+ @@ -106,6 +108,24 @@
+ +