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
@@ -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/)
|
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/)
|
||||||
et le versionnage [SemVer](https://semver.org/lang/fr/).
|
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
|
## [2.3.0] — 2026-09-13
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
[](https://kotlinlang.org)
|
[](https://kotlinlang.org)
|
||||||
[](https://developer.android.com/jetpack/compose)
|
[](https://developer.android.com/jetpack/compose)
|
||||||
[](https://developer.android.com/media/media3)
|
[](https://developer.android.com/media/media3)
|
||||||
[](VERSION)
|
[](VERSION)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](#-comment-ça-marche)
|
[](#-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 |
|
| 🧾 **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 |
|
| 📡 **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 |
|
| 📻 **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 |
|
| ⏳ **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 |
|
| 🖥️ **Interface web** | Tout se pilote depuis un navigateur, sans toucher au téléphone |
|
||||||
|
|
||||||
|
|||||||
@@ -17,3 +17,12 @@ NOXELY_PORT=8710
|
|||||||
NOXELY_WATCH_STATIONS=40
|
NOXELY_WATCH_STATIONS=40
|
||||||
NOXELY_WATCH_INTERVAL=5
|
NOXELY_WATCH_INTERVAL=5
|
||||||
NOXELY_WATCH_CONCURRENCY=8
|
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
|
||||||
+16
-1
@@ -32,9 +32,24 @@ make server-logs # journal suivi
|
|||||||
| `GET /api/media/{id}/file` | **le mobile** — le fichier |
|
| `GET /api/media/{id}/file` | **le mobile** — le fichier |
|
||||||
| `GET /api/search`, `/api/tracks`, `POST /api/queue` | l'interface web |
|
| `GET /api/search`, `/api/tracks`, `POST /api/queue` | l'interface web |
|
||||||
| `GET/POST/DELETE /api/watches`, `POST /api/watches/sweep` | 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`.
|
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
|
## Licence
|
||||||
|
|
||||||
GPL v3, comme le reste du projet — voir [`../LICENSE`](../LICENSE).
|
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_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
|
└── media/ les fichiers eux-mêmes
|
||||||
```
|
```
|
||||||
@@ -27,6 +27,7 @@ from fastapi import Body, Depends, FastAPI, Header, HTTPException, Query, Reques
|
|||||||
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, Response
|
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, Response
|
||||||
|
|
||||||
from . import config as config_module
|
from . import config as config_module
|
||||||
|
from . import rotation
|
||||||
from .sources import catalogs, radio
|
from .sources import catalogs, radio
|
||||||
from .storage import Storage
|
from .storage import Storage
|
||||||
from .workers import Downloader, Watcher, safe_name
|
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]}")
|
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)])
|
@app.get("/api/queue", dependencies=[Depends(authorised)])
|
||||||
def queue_state() -> dict:
|
def queue_state() -> dict:
|
||||||
pending = storage.media_since(0, state=None)
|
pending = storage.media_since(0, state=None)
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ class Config:
|
|||||||
watch_interval: int
|
watch_interval: int
|
||||||
#: Stations sondées en parallèle. Plus il est haut, plus un tour complet est bref.
|
#: Stations sondées en parallèle. Plus il est haut, plus un tour complet est bref.
|
||||||
watch_concurrency: int
|
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
|
@property
|
||||||
def media_dir(self) -> Path:
|
def media_dir(self) -> Path:
|
||||||
@@ -74,4 +81,7 @@ def load() -> Config:
|
|||||||
watch_stations=int(os.environ.get("NOXELY_WATCH_STATIONS", "40")),
|
watch_stations=int(os.environ.get("NOXELY_WATCH_STATIONS", "40")),
|
||||||
watch_interval=int(os.environ.get("NOXELY_WATCH_INTERVAL", "5")),
|
watch_interval=int(os.environ.get("NOXELY_WATCH_INTERVAL", "5")),
|
||||||
watch_concurrency=int(os.environ.get("NOXELY_WATCH_CONCURRENCY", "8")),
|
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")),
|
||||||
)
|
)
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -61,9 +61,26 @@ CREATE TABLE IF NOT EXISTS watches (
|
|||||||
found_on TEXT,
|
found_on TEXT,
|
||||||
found_title 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
|
@dataclass
|
||||||
class Media:
|
class Media:
|
||||||
id: str
|
id: str
|
||||||
@@ -228,3 +245,51 @@ class Storage:
|
|||||||
def delete_watch(self, watch_id: str) -> None:
|
def delete_watch(self, watch_id: str) -> None:
|
||||||
self._db.execute("DELETE FROM watches WHERE id = ?", (watch_id,))
|
self._db.execute("DELETE FROM watches WHERE id = ?", (watch_id,))
|
||||||
self._db.commit()
|
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
|
||||||
@@ -36,6 +36,9 @@ const ABOUT = {
|
|||||||
['Enregistrer un direct',
|
['Enregistrer un direct',
|
||||||
'La capture prend le nom du morceau annoncé par la station, et s\'arrête quand il cède la ' +
|
'La capture prend le nom du morceau annoncé par la station, et s\'arrête quand il cède la ' +
|
||||||
'place au suivant.'],
|
'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',
|
['Tenir la file',
|
||||||
'Les téléchargements s\'enchaînent seuls ; un échec est repris, son état reste visible.'],
|
'Les téléchargements s\'enchaînent seuls ; un échec est repris, son état reste visible.'],
|
||||||
['Servir le mobile',
|
['Servir le mobile',
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
.tech { width:100%; border-collapse:collapse; }
|
.tech { width:100%; border-collapse:collapse; }
|
||||||
.tech td { padding:9px 4px; border-bottom:1px solid var(--line); vertical-align:top; }
|
.tech td { padding:9px 4px; border-bottom:1px solid var(--line); vertical-align:top; }
|
||||||
.tech td:first-child { color:var(--muted); width:38%; }
|
.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; }
|
.log h4 { margin:0 0 4px; font-size:15px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -80,6 +81,7 @@
|
|||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button onclick="show('search')" aria-selected="true" id="t-search">🔎 Rechercher</button>
|
<button onclick="show('search')" aria-selected="true" id="t-search">🔎 Rechercher</button>
|
||||||
<button onclick="show('watch')" aria-selected="false" id="t-watch">📡 Guets</button>
|
<button onclick="show('watch')" aria-selected="false" id="t-watch">📡 Guets</button>
|
||||||
|
<button onclick="show('heard')" aria-selected="false" id="t-heard">📈 Écoutes</button>
|
||||||
<button onclick="show('queue')" aria-selected="false" id="t-queue">⏳ File</button>
|
<button onclick="show('queue')" aria-selected="false" id="t-queue">⏳ File</button>
|
||||||
<button onclick="show('library')" aria-selected="false" id="t-library">📚 Bibliothèque</button>
|
<button onclick="show('library')" aria-selected="false" id="t-library">📚 Bibliothèque</button>
|
||||||
<button onclick="show('devices')" aria-selected="false" id="t-devices">📱 Appareils</button>
|
<button onclick="show('devices')" aria-selected="false" id="t-devices">📱 Appareils</button>
|
||||||
@@ -106,6 +108,24 @@
|
|||||||
<div id="watches"></div>
|
<div id="watches"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="p-heard" hidden>
|
||||||
|
<div class="row">
|
||||||
|
<select id="rotationWindow" onchange="loadHeard()">
|
||||||
|
<option value="50">50 dernières remontées</option>
|
||||||
|
<option value="100" selected>100 dernières remontées</option>
|
||||||
|
<option value="200">200 dernières remontées</option>
|
||||||
|
<option value="500">500 dernières remontées</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" id="heardQuery" placeholder="Chercher dans l'historique"
|
||||||
|
onkeydown="if(event.key==='Enter')loadHeard()">
|
||||||
|
<button class="ghost" onclick="loadHeard()">Actualiser</button>
|
||||||
|
</div>
|
||||||
|
<h3 class="section">🔥 En rotation</h3>
|
||||||
|
<div id="rotation"></div>
|
||||||
|
<h3 class="section">🕘 Historique</h3>
|
||||||
|
<div id="heard"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="p-queue" hidden><div id="queue"></div></section>
|
<section id="p-queue" hidden><div id="queue"></div></section>
|
||||||
<section id="p-library" hidden><div id="library"></div></section>
|
<section id="p-library" hidden><div id="library"></div></section>
|
||||||
<section id="p-settings" hidden>
|
<section id="p-settings" hidden>
|
||||||
@@ -162,13 +182,14 @@ async function api(path, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function show(name) {
|
function show(name) {
|
||||||
for (const tab of ['search', 'watch', 'queue', 'library', 'devices', 'settings', 'about']) {
|
for (const tab of ['search', 'watch', 'heard', 'queue', 'library', 'devices', 'settings', 'about']) {
|
||||||
$('p-' + tab).hidden = tab !== name;
|
$('p-' + tab).hidden = tab !== name;
|
||||||
$('t-' + tab).setAttribute('aria-selected', String(tab === name));
|
$('t-' + tab).setAttribute('aria-selected', String(tab === name));
|
||||||
}
|
}
|
||||||
if (name === 'queue') loadQueue();
|
if (name === 'queue') loadQueue();
|
||||||
if (name === 'library') loadLibrary();
|
if (name === 'library') loadLibrary();
|
||||||
if (name === 'watch') loadWatches();
|
if (name === 'watch') loadWatches();
|
||||||
|
if (name === 'heard') loadHeard();
|
||||||
if (name === 'devices') loadDevices();
|
if (name === 'devices') loadDevices();
|
||||||
if (name === 'settings') loadServerInfo();
|
if (name === 'settings') loadServerInfo();
|
||||||
if (name === 'about') loadAbout();
|
if (name === 'about') loadAbout();
|
||||||
@@ -271,7 +292,8 @@ async function loadWatches() {
|
|||||||
try {
|
try {
|
||||||
const { watches, recording, sweeps, last_sweep: last } = await api('/watches');
|
const { watches, recording, sweeps, last_sweep: last } = await api('/watches');
|
||||||
const scan = last && last.stations
|
const scan = last && last.stations
|
||||||
? `${sweeps} tour(s) · dernier : ${last.stations} stations, ${last.titles} titres lus`
|
? `${sweeps} tour(s) · dernier : ${last.stations} stations, ${last.titles} titres lus, ` +
|
||||||
|
`${last.heard || 0} nouveau(x) dans l'historique`
|
||||||
: 'en attente d\'un guet';
|
: 'en attente d\'un guet';
|
||||||
$('watches').innerHTML =
|
$('watches').innerHTML =
|
||||||
`<div class="card"><div class="grow"><b>Balayage continu</b><small>${esc(scan)}</small></div>
|
`<div class="card"><div class="grow"><b>Balayage continu</b><small>${esc(scan)}</small></div>
|
||||||
@@ -306,6 +328,44 @@ async function sweep() {
|
|||||||
loadWatches();
|
loadWatches();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clock = (ms) => new Date(ms).toLocaleString('fr-FR',
|
||||||
|
{ day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
|
async function loadHeard() {
|
||||||
|
const last = $('rotationWindow').value;
|
||||||
|
const query = $('heardQuery').value.trim();
|
||||||
|
try {
|
||||||
|
const [rot, list] = await Promise.all([
|
||||||
|
api('/rotation?last=' + last + '&top=15'),
|
||||||
|
api('/heard?limit=100' + (query ? '&q=' + encodeURIComponent(query) : '')),
|
||||||
|
]);
|
||||||
|
const label = (t) => t.artist ? `${esc(t.artist)} — ${esc(t.title)}` : esc(t.title);
|
||||||
|
const stations = (n) => n.length > 2 ? `${n.length} stations` : esc(n.join(', '));
|
||||||
|
$('rotation').innerHTML = rot.tracks.length
|
||||||
|
? `<div class="empty" style="padding:0 0 10px;text-align:left">${rot.window} remontée(s) analysée(s)</div>` +
|
||||||
|
rot.tracks.map((t) => `
|
||||||
|
<div class="card"><div class="grow"><b>${label(t)}</b>
|
||||||
|
<small>${stations(t.stations)} · dernier passage ${clock(t.last_heard)}</small></div>
|
||||||
|
<span class="pill ${t.plays > 1 ? 'ok' : ''}">×${t.plays}</span></div>`).join('') +
|
||||||
|
(rot.artists.length ? `<h3 class="section">🎤 Artistes</h3>` + rot.artists.map((a) => `
|
||||||
|
<div class="card"><div class="grow"><b>${esc(a.artist)}</b>
|
||||||
|
<small>${a.tracks} titre(s) · ${a.stations.length} station(s)</small></div>
|
||||||
|
<span class="pill ${a.plays > 1 ? 'ok' : ''}">×${a.plays}</span></div>`).join('') : '')
|
||||||
|
: `<div class="empty">${list.history
|
||||||
|
? 'Rien d\'entendu pour l\'instant — le premier tour de balayage peut prendre une minute.'
|
||||||
|
: 'L\'historique est désactivé (NOXELY_HISTORY=0).'}</div>`;
|
||||||
|
$('heard').innerHTML = list.items.length
|
||||||
|
? `<div class="empty" style="padding:0 0 10px;text-align:left">${list.total} ligne(s) au total</div>` +
|
||||||
|
list.items.map((h) => `
|
||||||
|
<div class="card"><div class="grow"><b>${h.artist ? esc(h.artist) + ' — ' : ''}${esc(h.title)}</b>
|
||||||
|
<small>${esc(h.station)} · ${clock(h.heard_at)}</small></div></div>`).join('')
|
||||||
|
: '<div class="empty">Aucun résultat</div>';
|
||||||
|
} catch (e) {
|
||||||
|
$('rotation').innerHTML = `<div class="empty">${esc(String(e))}</div>`;
|
||||||
|
$('heard').innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadServerInfo() {
|
async function loadServerInfo() {
|
||||||
try {
|
try {
|
||||||
const health = await fetch('/api/health').then((r) => r.json());
|
const health = await fetch('/api/health').then((r) => r.json());
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from . import rotation
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .sources import radio
|
from .sources import radio
|
||||||
from .storage import Storage
|
from .storage import Storage
|
||||||
@@ -83,54 +84,98 @@ class Watcher:
|
|||||||
#: Dernier tour de balayage, pour que l'interface montre que ça tourne.
|
#: Dernier tour de balayage, pour que l'interface montre que ça tourne.
|
||||||
self.last_sweep: dict = {}
|
self.last_sweep: dict = {}
|
||||||
self.sweeps = 0
|
self.sweeps = 0
|
||||||
|
self._stations: list[dict] = []
|
||||||
|
self._stations_at = 0.0
|
||||||
|
self._pruned_at = 0.0
|
||||||
|
|
||||||
async def run_forever(self) -> None:
|
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
|
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
|
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.
|
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:
|
while True:
|
||||||
try:
|
try:
|
||||||
if self.storage.pending_watches():
|
guetting = bool(self.storage.pending_watches())
|
||||||
self.last_sweep = await self.sweep()
|
if not guetting and not self.config.history:
|
||||||
self.sweeps += 1
|
# Rien à guetter et rien à retenir : inutile de solliciter l'annuaire.
|
||||||
else:
|
|
||||||
# Rien à guetter : inutile de solliciter l'annuaire.
|
|
||||||
await asyncio.sleep(30)
|
await asyncio.sleep(30)
|
||||||
continue
|
continue
|
||||||
|
self.last_sweep = await self.sweep()
|
||||||
|
self.sweeps += 1
|
||||||
|
self._prune()
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
print(f"[noxely] guet : {error}")
|
print(f"[noxely] guet : {error}")
|
||||||
await asyncio.sleep(30)
|
await asyncio.sleep(30)
|
||||||
continue
|
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:
|
async def sweep(self) -> dict:
|
||||||
pending = self.storage.pending_watches()
|
pending = self.storage.pending_watches()
|
||||||
if not pending:
|
found = await self._known_stations()
|
||||||
return {"stations": 0, "titles": 0, "at": _now(), "matched": None}
|
|
||||||
|
|
||||||
found = await radio.stations(limit=self.config.watch_stations)
|
|
||||||
titles = 0
|
titles = 0
|
||||||
|
noted = 0
|
||||||
|
matched: dict | None = None
|
||||||
|
|
||||||
for group in _chunks(found, self.config.watch_concurrency):
|
for group in _chunks(found, self.config.watch_concurrency):
|
||||||
heard = await asyncio.gather(*(radio.now_playing(s["url"]) for s in group))
|
heard = await asyncio.gather(*(radio.now_playing(s["url"]) for s in group))
|
||||||
for station, title in zip(group, heard):
|
for station, title in zip(group, heard):
|
||||||
if not title:
|
if not title:
|
||||||
continue
|
continue
|
||||||
titles += 1
|
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)
|
hit = next((w for w in pending if w["query"].lower() in title.lower()), None)
|
||||||
if not hit:
|
if not hit:
|
||||||
continue
|
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:
|
if self.recording is None:
|
||||||
asyncio.create_task(self.record(station, title, hit["query"]))
|
asyncio.create_task(self.record(station, title, hit["query"]))
|
||||||
self.storage.mark_watch_found(hit["id"], station["name"], title)
|
self.storage.mark_watch_found(hit["id"], station["name"], title)
|
||||||
self.storage.mark_watches_scanned([w["id"] for w in pending])
|
matched = {"matched": title, "station": station["name"]}
|
||||||
return {"stations": len(found), "titles": titles, "at": _now(),
|
|
||||||
"matched": title, "station": station["name"]}
|
|
||||||
|
|
||||||
self.storage.mark_watches_scanned([w["id"] for w in pending])
|
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:
|
async def record(self, station: dict, title: str, trigger: str) -> None:
|
||||||
"""Enregistre jusqu'à ce que la station annonce un autre morceau."""
|
"""Enregistre jusqu'à ce que la station annonce un autre morceau."""
|
||||||
|
|||||||
Reference in new issue
Block a user