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 @@ [](https://kotlinlang.org) [](https://developer.android.com/jetpack/compose) [](https://developer.android.com/media/media3) -[](VERSION) +[](VERSION) [](LICENSE) [](#-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 @@