Onglet Radio sur le serveur, bouton de synchronisation sur le mobile (2.5.0)
Le serveur gagne un onglet Radio : les stations de l'annuaire, écoutables en direct dans le navigateur ou enregistrables — jusqu'à l'arrêt ou pour une durée choisie —, puis réécoutables depuis l'interface. Quatre enregistrements en parallèle au plus ; si le flux tombe, il est rouvert et le fichier continue. Les enregistrements rejoignent la bibliothèque : le mobile les reçoit. Un redémarrage du service clôt proprement les captures en cours, et celles restées « en cours » après un plantage sont récupérées au démarrage suivant. La copie de flux sans blocs ICY, propre au guet, est extraite pour être partagée. Côté mobile, la synchronisation manuelle n'était qu'un petit lien dans l'onglet Connexion : elle a maintenant un vrai bouton dans l'onglet Synchronisation. Deux passages ne se chevauchent plus, et une erreur inattendue s'affiche au lieu de fermer l'application. Le code Kotlin n'a pas été compilé sur cette machine (pas de JDK) : il a été relu, mais reste à construire. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N6pmSE168yjyf8vgV8mZx6
This commit is contained in:
1 parent
7810bef8a3
commit
5b5e90247f
15 files changed
+593
-48
No files matched your search
@@ -5,6 +5,40 @@ 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.5.0] — 2026-09-20
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Onglet `📻 Radio`.** Les stations de l'annuaire, filtrables par genre et par nom. Chacune
|
||||
s'écoute en direct depuis le navigateur ou s'enregistre, jusqu'à l'arrêt ou pour 15 min, 30 min,
|
||||
1 h ou 2 h. Jusqu'à quatre stations en parallèle ; une station déjà en cours est refusée.
|
||||
- **Réécoute.** Les enregistrements sont listés avec leur taille et leur durée : on les lit depuis
|
||||
le navigateur, on les supprime, et le mobile les reçoit à sa prochaine synchronisation comme
|
||||
n'importe quel média. Les captures faites par un guet y figurent aussi.
|
||||
- **Reprise sur coupure.** Quand le flux tombe, le serveur le rouvre et continue dans le même
|
||||
fichier ; il n'abandonne qu'après cinq tentatives infructueuses d'affilée, en gardant ce qui a
|
||||
été capté.
|
||||
- Routes `POST /api/radio/record`, `POST /api/radio/record/{id}/stop` et
|
||||
`GET /api/radio/recordings`. Réglage `NOXELY_RECORD_MAX_MINUTES` (180) : plafond de durée, pour
|
||||
qu'un enregistrement oublié ne remplisse pas le disque.
|
||||
|
||||
- **Bouton « Synchroniser maintenant » dans l'application.** Il était jusque-là réduit à un
|
||||
petit lien dans Réglages ▸ Connexion, visible seulement une fois connecté. Il figure
|
||||
désormais en pleine largeur dans l'onglet **Synchronisation**, avec un indicateur pendant le
|
||||
transfert, et grisé — avec la raison — tant que le serveur n'est pas renseigné.
|
||||
|
||||
### Modifié
|
||||
|
||||
- **Deux synchronisations ne se chevauchent plus.** Le passage planifié et le bouton pouvaient
|
||||
télécharger les mêmes fichiers en double ; le second est maintenant refusé avec le message
|
||||
« Synchronisation déjà en cours ». Une erreur inattendue pendant la synchronisation manuelle
|
||||
s'affiche au lieu de fermer l'application.
|
||||
- **Un redémarrage du service ne laisse plus d'enregistrement « en cours » à jamais.** Le serveur
|
||||
clôt proprement les captures à l'arrêt ; si le processus meurt brutalement, les fichiers
|
||||
qui contiennent de l'audio sont déclarés prêts au démarrage suivant, les vides écartés.
|
||||
- La copie d'un flux en retirant les blocs ICY, jusque-là propre au guet, est désormais partagée
|
||||
avec l'enregistrement à la demande.
|
||||
|
||||
## [2.4.0] — 2026-09-20
|
||||
|
||||
### Ajouté
|
||||
|
||||
@@ -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 |
|
||||
| 🎙️ **Onglet Radio** | Écoute des stations en direct, enregistrement à la demande (jusqu'à l'arrêt ou pour une durée), réécoute depuis le navigateur ; le mobile récupère les enregistrements |
|
||||
| 📈 **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 |
|
||||
@@ -59,7 +60,7 @@ synchronisations : c'est là qu'est l'économie.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 🔄 **Synchronisation sobre** | Toutes les 6 h, en Wi-Fi non facturé et batterie suffisante |
|
||||
| 🔄 **Synchronisation sobre** | Toutes les 6 h, en Wi-Fi non facturé et batterie suffisante — ou à la demande, d'un bouton |
|
||||
| 📱 **Appairage** | Un bouton côté serveur, un bouton côté téléphone : aucun jeton à recopier |
|
||||
| 🔑 **Un jeton par appareil** | Chacun le sien, révocable seul depuis l'onglet Appareils |
|
||||
| 🎵 **Lecture** | Audio et vidéo, arrière-plan, casque, écran verrouillé, ±15 s, stop |
|
||||
|
||||
@@ -21,6 +21,7 @@ import fr.tuxgyver.noxely.model.SourceKind
|
||||
import fr.tuxgyver.noxely.radio.RecordingSink
|
||||
import fr.tuxgyver.noxely.sources.openConnection
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
@@ -95,7 +96,21 @@ class SyncClient(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sync(): SyncResult = withContext(Dispatchers.IO) {
|
||||
/** Un seul passage à la fois : la synchronisation planifiée et le bouton ne se chevauchent pas. */
|
||||
private val running = Mutex()
|
||||
|
||||
/**
|
||||
* Récupère ce qui est prêt sur le serveur.
|
||||
*
|
||||
* Deux passages simultanés téléchargeraient les mêmes fichiers deux fois : le second est donc
|
||||
* refusé, avec un message clair plutôt qu'un doublon dans la bibliothèque.
|
||||
*/
|
||||
suspend fun sync(): SyncResult {
|
||||
if (!running.tryLock()) return SyncResult(0, 0, "Synchronisation déjà en cours")
|
||||
return try { runSync() } finally { running.unlock() }
|
||||
}
|
||||
|
||||
private suspend fun runSync(): SyncResult = withContext(Dispatchers.IO) {
|
||||
val config = settings.config.value
|
||||
if (!config.isConfigured) return@withContext SyncResult(0, 0, "Serveur non configuré")
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import fr.tuxgyver.noxely.AppContainer
|
||||
import fr.tuxgyver.noxely.BuildConfig
|
||||
import fr.tuxgyver.noxely.sync.SyncResult
|
||||
import fr.tuxgyver.noxely.ui.sources.UpdateCard
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -84,6 +85,19 @@ fun ServerScreen(
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var probe by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Le bouton de l'onglet Synchronisation et celui de Connexion font exactement la même chose.
|
||||
val runSync: () -> Unit = {
|
||||
if (!busy) {
|
||||
busy = true
|
||||
scope.launch {
|
||||
val result = runCatching { container.sync.sync() }
|
||||
.getOrElse { SyncResult(0, 0, it.message ?: "Synchronisation impossible") }
|
||||
busy = false
|
||||
onMessage(result.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(config.baseUrl, config.token) {
|
||||
if (url.isBlank()) url = config.baseUrl
|
||||
if (token.isBlank()) token = config.token
|
||||
@@ -191,14 +205,7 @@ fun ServerScreen(
|
||||
if (busy) {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(22.dp))
|
||||
} else if (config.isConfigured) {
|
||||
TextButton(onClick = {
|
||||
busy = true
|
||||
scope.launch {
|
||||
val result = container.sync.sync()
|
||||
busy = false
|
||||
onMessage(result.message)
|
||||
}
|
||||
}) { Text("Synchroniser") }
|
||||
TextButton(onClick = runSync) { Text("Synchroniser") }
|
||||
}
|
||||
}
|
||||
probe?.let {
|
||||
@@ -249,6 +256,38 @@ fun ServerScreen(
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Column {
|
||||
Button(
|
||||
onClick = runSync,
|
||||
enabled = config.isConfigured && !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (busy) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
modifier = Modifier.size(18.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text("Synchronisation en cours…")
|
||||
} else {
|
||||
Icon(Icons.Rounded.CloudSync, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text("Synchroniser maintenant")
|
||||
}
|
||||
}
|
||||
if (!config.isConfigured) {
|
||||
Text(
|
||||
text = "Renseignez d'abord le serveur dans l'onglet Connexion.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
else -> {
|
||||
|
||||
@@ -26,3 +26,7 @@ NOXELY_HISTORY=1
|
||||
NOXELY_LISTEN_INTERVAL=60
|
||||
# Ancienneté maximale de l'historique, en jours.
|
||||
NOXELY_HISTORY_DAYS=90
|
||||
|
||||
# Durée maximale d'un enregistrement de station, en minutes. Garde-fou contre un enregistrement
|
||||
# oublié : compter environ 57 Mo par heure pour une station à 128 kb/s.
|
||||
NOXELY_RECORD_MAX_MINUTES=180
|
||||
@@ -32,11 +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/radio/stations`, `POST /api/radio/record`, `POST /api/radio/record/{id}/stop`, `GET /api/radio/recordings` | l'interface web — stations, enregistrement à la demande |
|
||||
| `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`.
|
||||
|
||||
## Radio : écouter et enregistrer
|
||||
|
||||
L'onglet **📻 Radio** liste les stations (annuaire Radio Browser, filtrables par genre et par nom).
|
||||
Chacune peut s'écouter en direct dans le navigateur, ou s'enregistrer jusqu'à l'arrêt — ou pour
|
||||
une durée choisie (15 min à 2 h). Jusqu'à quatre stations en parallèle.
|
||||
|
||||
Un enregistrement rejoint la bibliothèque comme n'importe quel média : on le réécoute depuis
|
||||
l'interface web, et le mobile le récupère à sa prochaine synchronisation. Si le flux tombe, le
|
||||
serveur le rouvre (jusqu'à cinq essais d'affilée) et continue dans le même fichier. Un
|
||||
redémarrage du service clôt proprement les enregistrements en cours. `NOXELY_RECORD_MAX_MINUTES`
|
||||
(180) plafonne la durée, pour qu'un enregistrement oublié ne remplisse pas le disque.
|
||||
|
||||
## Historique et rotation
|
||||
|
||||
Le serveur note chaque titre annoncé par les stations qu'il balaie (ICY), même quand aucun
|
||||
|
||||
@@ -28,6 +28,7 @@ from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, Res
|
||||
|
||||
from . import config as config_module
|
||||
from . import rotation
|
||||
from .recorder import AlreadyRecording, Recorder, TooMany
|
||||
from .sources import catalogs, radio
|
||||
from .storage import Storage
|
||||
from .workers import Downloader, Watcher, safe_name
|
||||
@@ -43,6 +44,7 @@ config = config_module.load()
|
||||
storage = Storage(config.db_path)
|
||||
downloader = Downloader(config, storage)
|
||||
watcher = Watcher(config, storage)
|
||||
recorder = Recorder(config, storage)
|
||||
|
||||
|
||||
def authorised(x_noxely_token: str = Header(default="")) -> None:
|
||||
@@ -62,11 +64,15 @@ def authorised(x_noxely_token: str = Header(default="")) -> None:
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
recorder.recover()
|
||||
tasks = [
|
||||
asyncio.create_task(downloader.run_forever()),
|
||||
asyncio.create_task(watcher.run_forever()),
|
||||
]
|
||||
yield
|
||||
# Les enregistrements en cours sont clos avant de couper : sans cela, redémarrer le
|
||||
# service laisserait chaque fichier « en cours » à jamais.
|
||||
await recorder.stop_all()
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
|
||||
@@ -103,7 +109,8 @@ def media_file(media_id: str):
|
||||
|
||||
|
||||
@app.delete("/api/media/{media_id}", dependencies=[Depends(authorised)])
|
||||
def delete_media(media_id: str) -> dict:
|
||||
async def delete_media(media_id: str) -> dict:
|
||||
recorder.stop(media_id) # supprimer un enregistrement en cours l'arrête d'abord
|
||||
item = storage.delete_media(media_id)
|
||||
if item:
|
||||
(config.media_dir / item.filename).unlink(missing_ok=True)
|
||||
@@ -168,6 +175,44 @@ async def radio_stations(genre: str | None = None, name: str | None = None) -> d
|
||||
return {"stations": await radio.stations(genre, name)}
|
||||
|
||||
|
||||
@app.post("/api/radio/record", dependencies=[Depends(authorised)])
|
||||
async def radio_record(payload: dict = Body(...)) -> dict:
|
||||
"""Enregistre une station jusqu'à l'arrêt, ou jusqu'à `minutes` si elles sont données."""
|
||||
url = str(payload.get("url") or "").strip()
|
||||
if not url.startswith(("http://", "https://")):
|
||||
raise HTTPException(status_code=400, detail="Adresse de flux invalide")
|
||||
try:
|
||||
minutes = int(payload["minutes"]) if payload.get("minutes") else None
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="Durée invalide")
|
||||
station = {
|
||||
"name": str(payload.get("name") or "Station").strip()[:80] or "Station",
|
||||
"url": url,
|
||||
"codec": str(payload.get("codec") or ""),
|
||||
}
|
||||
try:
|
||||
return {"media_id": recorder.start(station, minutes)}
|
||||
except AlreadyRecording:
|
||||
raise HTTPException(status_code=409, detail="Cette station est déjà en cours d'enregistrement")
|
||||
except TooMany as error:
|
||||
raise HTTPException(status_code=409, detail=f"{error} enregistrements simultanés au plus")
|
||||
|
||||
|
||||
@app.post("/api/radio/record/{media_id}/stop", dependencies=[Depends(authorised)])
|
||||
async def radio_stop(media_id: str) -> dict:
|
||||
return {"stopped": recorder.stop(media_id)}
|
||||
|
||||
|
||||
@app.get("/api/radio/recordings", dependencies=[Depends(authorised)])
|
||||
async def radio_recordings() -> dict:
|
||||
"""Ce qui s'enregistre en ce moment, et ce qui l'a été — guets compris."""
|
||||
return {
|
||||
"active": recorder.active(),
|
||||
"items": [m.public() for m in storage.radio_media()],
|
||||
"max_minutes": config.record_max_minutes,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/watches", dependencies=[Depends(authorised)])
|
||||
def watches() -> dict:
|
||||
return {
|
||||
|
||||
@@ -60,6 +60,9 @@ class Config:
|
||||
listen_interval: int
|
||||
#: Ancienneté maximale de l'historique, en jours. Au-delà, les lignes sont purgées.
|
||||
history_days: int
|
||||
#: Durée maximale d'un enregistrement de station, en minutes : garde-fou contre un
|
||||
#: enregistrement oublié, qui remplirait le disque à raison d'environ 57 Mo par heure.
|
||||
record_max_minutes: int
|
||||
|
||||
@property
|
||||
def media_dir(self) -> Path:
|
||||
@@ -84,4 +87,5 @@ def load() -> Config:
|
||||
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")),
|
||||
record_max_minutes=int(os.environ.get("NOXELY_RECORD_MAX_MINUTES", "180")),
|
||||
)
|
||||
@@ -0,0 +1,194 @@
|
||||
# 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.
|
||||
|
||||
"""Enregistrement d'une station à la demande, jusqu'à l'arrêt ou l'expiration de la durée.
|
||||
|
||||
À la différence du guet, qui s'arrête quand la station annonce un autre morceau, on capte ici
|
||||
le flux tel quel. Le fichier rejoint la bibliothèque comme n'importe quel média : le mobile le
|
||||
récupère à sa prochaine synchronisation, et l'interface web peut le réécouter.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import Config
|
||||
from .sources import radio
|
||||
from .storage import Storage
|
||||
from .workers import UA, safe_name
|
||||
|
||||
#: Enregistrements simultanés au plus : chacun tient un flux ouvert en permanence.
|
||||
MAX_PARALLEL = 4
|
||||
#: Nouvelles tentatives d'affilée avant d'abandonner un flux qui ne revient pas.
|
||||
MAX_RECONNECTS = 5
|
||||
#: En deçà, un enregistrement interrompu ne vaut pas d'être conservé.
|
||||
MIN_KEPT_BYTES = 64 * 1024
|
||||
|
||||
|
||||
class AlreadyRecording(Exception):
|
||||
"""La station est déjà en cours d'enregistrement."""
|
||||
|
||||
|
||||
class TooMany(Exception):
|
||||
"""Trop d'enregistrements simultanés."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Active:
|
||||
media_id: str
|
||||
station: str
|
||||
url: str
|
||||
started_at: int
|
||||
limit_minutes: int
|
||||
written: int = 0
|
||||
stop: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
task: asyncio.Task | None = None
|
||||
|
||||
def public(self) -> dict:
|
||||
return {
|
||||
"media_id": self.media_id, "station": self.station, "started_at": self.started_at,
|
||||
"written": self.written, "limit_minutes": self.limit_minutes,
|
||||
}
|
||||
|
||||
|
||||
class Recorder:
|
||||
def __init__(self, config: Config, storage: Storage) -> None:
|
||||
self.config = config
|
||||
self.storage = storage
|
||||
self._active: dict[str, Active] = {}
|
||||
|
||||
# --- Pilotage -------------------------------------------------------
|
||||
|
||||
def start(self, station: dict, minutes: int | None = None) -> str:
|
||||
"""Lance l'enregistrement d'une station ; à appeler depuis la boucle d'événements."""
|
||||
if any(a.url == station["url"] for a in self._active.values()):
|
||||
raise AlreadyRecording(station["name"])
|
||||
if len(self._active) >= MAX_PARALLEL:
|
||||
raise TooMany(str(MAX_PARALLEL))
|
||||
|
||||
cap = self.config.record_max_minutes
|
||||
limit = min(minutes, cap) if minutes and minutes > 0 else cap
|
||||
codec = (station.get("codec") or "").upper()
|
||||
extension, mime = (
|
||||
("aac", "audio/aac") if codec.startswith("AAC")
|
||||
else ("ogg", "audio/ogg") if codec.startswith("OGG")
|
||||
else ("mp3", "audio/mpeg")
|
||||
)
|
||||
stamp = datetime.now()
|
||||
title = f"{station['name']} — {stamp:%d/%m/%Y %H:%M}"
|
||||
# Le suffixe aléatoire évite qu'un second enregistrement de la même station, lancé dans
|
||||
# la même minute, écrase le fichier du premier.
|
||||
filename = safe_name(f"{station['name']}_{stamp:%Y-%m-%d_%H%M}_{uuid.uuid4().hex[:6]}", extension)
|
||||
media_id = self.storage.add_media(
|
||||
title=title, artist=station["name"], source="radio", origin=station["name"],
|
||||
filename=filename, remote_url=None, mime=mime, state="downloading",
|
||||
)
|
||||
entry = Active(media_id, station["name"], station["url"], _now(), limit)
|
||||
entry.task = asyncio.create_task(self._run(entry, filename))
|
||||
self._active[media_id] = entry
|
||||
return media_id
|
||||
|
||||
def stop(self, media_id: str) -> bool:
|
||||
entry = self._active.get(media_id)
|
||||
if not entry:
|
||||
return False
|
||||
entry.stop.set()
|
||||
return True
|
||||
|
||||
async def stop_all(self, timeout: float = 8.0) -> None:
|
||||
"""À l'arrêt du serveur : clôt proprement les fichiers plutôt que de les laisser en cours."""
|
||||
tasks = [a.task for a in self._active.values() if a.task]
|
||||
for media_id in list(self._active):
|
||||
self.stop(media_id)
|
||||
if tasks:
|
||||
await asyncio.wait(tasks, timeout=timeout)
|
||||
|
||||
def active(self) -> list[dict]:
|
||||
return [a.public() for a in self._active.values()]
|
||||
|
||||
def recover(self) -> None:
|
||||
"""Au démarrage : ce qui était « en cours » ne l'est plus, l'ancien processus est mort.
|
||||
|
||||
Un fichier qui contient de l'audio reste lisible : on le déclare prêt plutôt que de
|
||||
perdre une écoute entière sur un simple redémarrage du service.
|
||||
"""
|
||||
for item in self.storage.stuck_recordings():
|
||||
path = self.config.media_dir / item.filename
|
||||
size = path.stat().st_size if path.exists() else 0
|
||||
if size >= MIN_KEPT_BYTES:
|
||||
self.storage.update_media(item.id, state="ready", size=size,
|
||||
error="interrompu par un arrêt du serveur")
|
||||
else:
|
||||
path.unlink(missing_ok=True)
|
||||
self.storage.update_media(item.id, state="failed", error="interrompu, rien de capté")
|
||||
|
||||
# --- Capture --------------------------------------------------------
|
||||
|
||||
async def _run(self, entry: Active, filename: str) -> None:
|
||||
target = self.config.media_dir / filename
|
||||
began = time.monotonic()
|
||||
deadline = began + entry.limit_minutes * 60
|
||||
failures = 0
|
||||
problem = ""
|
||||
|
||||
def keep_going(total: int, _title: str | None) -> bool:
|
||||
entry.written = base + total
|
||||
return not entry.stop.is_set() and time.monotonic() < deadline
|
||||
|
||||
try:
|
||||
while not entry.stop.is_set() and time.monotonic() < deadline:
|
||||
base = entry.written
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
headers={**UA, "Icy-MetaData": "1"}, follow_redirects=True,
|
||||
timeout=httpx.Timeout(30.0, read=60.0),
|
||||
) as client:
|
||||
async with client.stream("GET", entry.url) as response:
|
||||
response.raise_for_status()
|
||||
interval = int(response.headers.get("icy-metaint") or 0)
|
||||
with target.open("ab") as handle:
|
||||
await radio.copy_stream(response, handle, interval, keep_going)
|
||||
except Exception as error:
|
||||
problem = str(error)[:150] or type(error).__name__
|
||||
# Une connexion qui a livré de l'audio remet le compteur à zéro : seules les
|
||||
# tentatives infructueuses d'affilée comptent, pas une coupure après une heure.
|
||||
failures = 0 if entry.written > base else failures + 1
|
||||
if entry.stop.is_set() or time.monotonic() >= deadline:
|
||||
break
|
||||
if failures > MAX_RECONNECTS:
|
||||
break
|
||||
await asyncio.sleep(3)
|
||||
finally:
|
||||
self._finish(entry, target, time.monotonic() - began, problem)
|
||||
self._active.pop(entry.media_id, None)
|
||||
|
||||
def _finish(self, entry: Active, target, elapsed: float, problem: str) -> None:
|
||||
size = target.stat().st_size if target.exists() else 0
|
||||
completed = entry.stop.is_set() or elapsed >= entry.limit_minutes * 60
|
||||
if size >= MIN_KEPT_BYTES or (size > 0 and completed):
|
||||
self.storage.update_media(
|
||||
entry.media_id, state="ready", size=size, duration_ms=int(elapsed * 1000),
|
||||
error=None if completed else (f"interrompu : {problem}" if problem else "interrompu"),
|
||||
)
|
||||
else:
|
||||
target.unlink(missing_ok=True)
|
||||
self.storage.update_media(
|
||||
entry.media_id, state="failed",
|
||||
error=problem or "aucune donnée reçue de la station",
|
||||
)
|
||||
|
||||
|
||||
def _now() -> int:
|
||||
return int(time.time() * 1000)
|
||||
@@ -100,3 +100,41 @@ async def _read_title(response, interval: int) -> str | None:
|
||||
found = STREAM_TITLE.search(meta)
|
||||
return found.group(1).strip() or None if found else None
|
||||
return None
|
||||
|
||||
|
||||
async def copy_stream(response, handle, interval: int, keep_going) -> int:
|
||||
"""Copie l'audio d'un flux dans `handle`, sans les blocs de métadonnées ICY.
|
||||
|
||||
Laissés dans le fichier, ces blocs le rendraient illisible. `keep_going(total, title)` est
|
||||
interrogée après chaque bloc écrit : `total` est le nombre d'octets audio copiés, `title` le
|
||||
titre qui vient d'être annoncé (ou None). Elle décide seule quand s'arrêter — fin d'un
|
||||
morceau pour le guet, demande de l'utilisateur ou durée écoulée pour un enregistrement.
|
||||
"""
|
||||
if interval <= 0:
|
||||
total = 0
|
||||
async for chunk in response.aiter_bytes(32768):
|
||||
handle.write(chunk)
|
||||
total += len(chunk)
|
||||
if not keep_going(total, None):
|
||||
break
|
||||
return total
|
||||
|
||||
buffer = bytearray()
|
||||
total = 0
|
||||
async for chunk in response.aiter_bytes(32768):
|
||||
buffer.extend(chunk)
|
||||
while len(buffer) >= interval + 1:
|
||||
length = buffer[interval] * 16
|
||||
if len(buffer) < interval + 1 + length:
|
||||
break
|
||||
handle.write(bytes(buffer[:interval]))
|
||||
total += interval
|
||||
meta = bytes(buffer[interval + 1:interval + 1 + length]).decode("utf-8", "replace")
|
||||
del buffer[:interval + 1 + length]
|
||||
announced = STREAM_TITLE.search(meta)
|
||||
title = announced.group(1).strip() if announced else None
|
||||
if not keep_going(total, title or None):
|
||||
return total
|
||||
if not keep_going(total, None):
|
||||
return total
|
||||
return total
|
||||
@@ -169,6 +169,25 @@ class Storage:
|
||||
)
|
||||
]
|
||||
|
||||
def radio_media(self, limit: int = 100) -> list[Media]:
|
||||
"""Les enregistrements de radio, les plus récents d'abord — guets compris."""
|
||||
return [
|
||||
Media(**dict(r))
|
||||
for r in self._db.execute(
|
||||
"SELECT * FROM media WHERE source = 'radio' ORDER BY created_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
]
|
||||
|
||||
def stuck_recordings(self) -> list[Media]:
|
||||
"""Enregistrements restés « en cours » : le serveur s'est arrêté avant de les clore."""
|
||||
return [
|
||||
Media(**dict(r))
|
||||
for r in self._db.execute(
|
||||
"SELECT * FROM media WHERE source = 'radio' AND state = 'downloading'"
|
||||
)
|
||||
]
|
||||
|
||||
def delete_media(self, media_id: str) -> Media | None:
|
||||
item = self.media(media_id)
|
||||
if item:
|
||||
|
||||
@@ -34,8 +34,10 @@ const ABOUT = {
|
||||
'On nomme un titre sans désigner de station : les plus écoutées sont balayées en continu, ' +
|
||||
'et l\'enregistrement part dès que l\'une le diffuse.'],
|
||||
['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.'],
|
||||
'Le guet capte le morceau annoncé et s\'arrête quand il cède la place au suivant.'],
|
||||
['Écouter et enregistrer une station',
|
||||
'L\'onglet Radio liste les stations : on les écoute en direct, ou on les enregistre — jusqu\'à ' +
|
||||
'l\'arrêt ou pour une durée choisie — puis on réécoute depuis le navigateur ou le mobile.'],
|
||||
['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.'],
|
||||
|
||||
@@ -66,6 +66,9 @@
|
||||
.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%; }
|
||||
[hidden] { display:none !important; }
|
||||
.card button { flex:none; }
|
||||
#audio { flex:2; min-width:220px; max-width:100%; }
|
||||
h3.section { font-size:15px; margin:20px 0 10px; }
|
||||
.log h4 { margin:0 0 4px; font-size:15px; }
|
||||
</style>
|
||||
@@ -81,6 +84,7 @@
|
||||
<div class="tabs">
|
||||
<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('radio')" aria-selected="false" id="t-radio">📻 Radio</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('library')" aria-selected="false" id="t-library">📚 Bibliothèque</button>
|
||||
@@ -108,6 +112,33 @@
|
||||
<div id="watches"></div>
|
||||
</section>
|
||||
|
||||
<section id="p-radio" hidden>
|
||||
<div class="row">
|
||||
<select id="radioGenre" onchange="loadStations()"><option value="">Tous les genres</option></select>
|
||||
<input type="text" id="radioName" placeholder="Nom de la station"
|
||||
onkeydown="if(event.key==='Enter')loadStations()">
|
||||
<button class="go" onclick="loadStations()">Chercher</button>
|
||||
<select id="radioMinutes" title="Durée d'un enregistrement">
|
||||
<option value="">Enregistrer jusqu'à l'arrêt</option>
|
||||
<option value="15">Enregistrer 15 min</option>
|
||||
<option value="30">Enregistrer 30 min</option>
|
||||
<option value="60">Enregistrer 1 h</option>
|
||||
<option value="120">Enregistrer 2 h</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="card" id="radioPlayer" hidden>
|
||||
<div class="grow"><b id="playerTitle"></b><small id="playerNote"></small></div>
|
||||
<audio id="audio" controls></audio>
|
||||
<button class="ghost" onclick="stopPlayer()" title="Fermer le lecteur">×</button>
|
||||
</div>
|
||||
<h3 class="section">⏺ En cours d'enregistrement</h3>
|
||||
<div id="radioActive"></div>
|
||||
<h3 class="section">🎙️ Enregistrements</h3>
|
||||
<div id="radioRecordings"></div>
|
||||
<h3 class="section">📻 Stations</h3>
|
||||
<div id="radioStations"></div>
|
||||
</section>
|
||||
|
||||
<section id="p-heard" hidden>
|
||||
<div class="row">
|
||||
<select id="rotationWindow" onchange="loadHeard()">
|
||||
@@ -182,13 +213,14 @@ async function api(path, options = {}) {
|
||||
}
|
||||
|
||||
function show(name) {
|
||||
for (const tab of ['search', 'watch', 'heard', 'queue', 'library', 'devices', 'settings', 'about']) {
|
||||
for (const tab of ['search', 'watch', 'radio', 'heard', 'queue', 'library', 'devices', 'settings', 'about']) {
|
||||
$('p-' + tab).hidden = tab !== name;
|
||||
$('t-' + tab).setAttribute('aria-selected', String(tab === name));
|
||||
}
|
||||
if (name === 'queue') loadQueue();
|
||||
if (name === 'library') loadLibrary();
|
||||
if (name === 'watch') loadWatches();
|
||||
if (name === 'radio') loadRadio();
|
||||
if (name === 'heard') loadHeard();
|
||||
if (name === 'devices') loadDevices();
|
||||
if (name === 'settings') loadServerInfo();
|
||||
@@ -206,9 +238,11 @@ async function refreshAll() {
|
||||
$('status').className = 'pill warn';
|
||||
}
|
||||
try {
|
||||
const { catalogs } = await api('/catalogs');
|
||||
const { catalogs, genres } = await api('/catalogs');
|
||||
$('catalog').innerHTML = Object.entries(catalogs)
|
||||
.map(([id, label]) => `<option value="${id}">${label}</option>`).join('');
|
||||
$('radioGenre').innerHTML = '<option value="">Tous les genres</option>' +
|
||||
genres.map((g) => `<option value="${esc(g)}">${esc(g)}</option>`).join('');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -366,6 +400,132 @@ async function loadHeard() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Radio : stations, écoute, enregistrement ---------------------------
|
||||
|
||||
let radioStations = [];
|
||||
let radioItems = [];
|
||||
let radioTimer = null;
|
||||
let playerUrl = null;
|
||||
|
||||
const fmtSize = (n) => n >= 1e6 ? (n / 1e6).toFixed(1) + ' Mo' : Math.round(n / 1e3) + ' Ko';
|
||||
const fmtDuration = (ms) => {
|
||||
if (ms < 60000) return `${Math.max(1, Math.round(ms / 1000))} s`;
|
||||
const min = Math.round(ms / 60000);
|
||||
return min >= 60 ? `${Math.floor(min / 60)} h ${String(min % 60).padStart(2, '0')}` : `${min} min`;
|
||||
};
|
||||
|
||||
function loadRadio() {
|
||||
loadRecordings();
|
||||
if (!radioStations.length) loadStations();
|
||||
}
|
||||
|
||||
async function loadStations() {
|
||||
$('radioStations').innerHTML = '<div class="empty">Chargement des stations…</div>';
|
||||
const params = new URLSearchParams();
|
||||
if ($('radioGenre').value) params.set('genre', $('radioGenre').value);
|
||||
if ($('radioName').value.trim()) params.set('name', $('radioName').value.trim());
|
||||
try {
|
||||
radioStations = (await api('/radio/stations?' + params)).stations;
|
||||
$('radioStations').innerHTML = radioStations.length ? radioStations.map((s, i) => `
|
||||
<div class="card"><div class="grow"><b>${esc(s.name)}</b>
|
||||
<small>${esc([s.country, s.codec, s.bitrate ? s.bitrate + ' kb/s' : ''].filter(Boolean).join(' · '))}</small></div>
|
||||
<button class="ghost" onclick="listenLive(${i})">▶ Écouter</button>
|
||||
<button class="go" onclick="recordStation(${i})">⏺ Enregistrer</button></div>`).join('')
|
||||
: '<div class="empty">Aucune station</div>';
|
||||
} catch (e) {
|
||||
$('radioStations').innerHTML = `<div class="empty">Annuaire indisponible : ${esc(e.message || e)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function play(title, note, src, isBlob) {
|
||||
if (playerUrl) URL.revokeObjectURL(playerUrl);
|
||||
playerUrl = isBlob ? src : null;
|
||||
$('radioPlayer').hidden = false;
|
||||
$('playerTitle').textContent = title;
|
||||
$('playerNote').textContent = note;
|
||||
const audio = $('audio');
|
||||
audio.onerror = () => { $('playerNote').textContent = 'Lecture impossible dans ce navigateur'; };
|
||||
audio.src = src;
|
||||
audio.play().catch(() => {});
|
||||
}
|
||||
|
||||
function stopPlayer() {
|
||||
const audio = $('audio');
|
||||
audio.pause();
|
||||
audio.removeAttribute('src');
|
||||
audio.load();
|
||||
if (playerUrl) URL.revokeObjectURL(playerUrl);
|
||||
playerUrl = null;
|
||||
$('radioPlayer').hidden = true;
|
||||
}
|
||||
|
||||
const listenLive = (i) => play(radioStations[i].name, 'En direct', radioStations[i].url, false);
|
||||
|
||||
async function playRecording(i) {
|
||||
const item = radioItems[i];
|
||||
$('radioPlayer').hidden = false;
|
||||
$('playerTitle').textContent = item.title;
|
||||
$('playerNote').textContent = 'Chargement du fichier…';
|
||||
try {
|
||||
const response = await fetch('/api/media/' + item.id + '/file', { headers: { 'X-Noxely-Token': token } });
|
||||
if (!response.ok) throw new Error(response.status);
|
||||
play(item.title, 'Enregistrement', URL.createObjectURL(await response.blob()), true);
|
||||
} catch (e) { $('playerNote').textContent = 'Lecture impossible : ' + (e.message || e); }
|
||||
}
|
||||
|
||||
async function recordStation(i) {
|
||||
const s = radioStations[i];
|
||||
const minutes = $('radioMinutes').value;
|
||||
try {
|
||||
await api('/radio/record', { method: 'POST', body: JSON.stringify({
|
||||
name: s.name, url: s.url, codec: s.codec, minutes: minutes ? Number(minutes) : null }) });
|
||||
loadRecordings();
|
||||
} catch (e) { alert(e.message || e); }
|
||||
}
|
||||
|
||||
async function stopRecording(id) {
|
||||
await api('/radio/record/' + id + '/stop', { method: 'POST' });
|
||||
setTimeout(loadRecordings, 1500);
|
||||
}
|
||||
|
||||
async function deleteRecording(i) {
|
||||
if (!confirm('Supprimer définitivement « ' + radioItems[i].title + ' » du serveur ?')) return;
|
||||
await api('/media/' + radioItems[i].id, { method: 'DELETE' });
|
||||
loadRecordings();
|
||||
}
|
||||
|
||||
async function loadRecordings() {
|
||||
clearTimeout(radioTimer);
|
||||
try {
|
||||
const { active, items, max_minutes: max } = await api('/radio/recordings');
|
||||
radioItems = items;
|
||||
const running = new Set(active.map((a) => a.media_id));
|
||||
$('radioActive').innerHTML = active.length ? active.map((a) => `
|
||||
<div class="card"><div class="grow"><b>${esc(a.station)}</b>
|
||||
<small>depuis ${clock(a.started_at)} · ${fmtSize(a.written)} · s'arrête seul après ${a.limit_minutes} min (max ${max})</small></div>
|
||||
<span class="pill warn">● en cours</span>
|
||||
<button class="go" onclick="stopRecording('${a.media_id}')">■ Arrêter</button></div>`).join('')
|
||||
: '<div class="empty" style="padding:10px">Aucun enregistrement en cours</div>';
|
||||
|
||||
const shown = items.map((m, i) => [m, i]).filter(([m]) => !running.has(m.id));
|
||||
$('radioRecordings').innerHTML = shown.length ? shown.map(([m, i]) => `
|
||||
<div class="card"><div class="grow"><b>${esc(m.title)}</b>
|
||||
<small>${clock(m.created_at)}${m.size ? ' · ' + fmtSize(m.size) : ''}${
|
||||
m.duration_ms ? ' · ' + fmtDuration(m.duration_ms) : ''}${m.error ? ' · ' + esc(m.error) : ''}</small></div>
|
||||
${m.state === 'ready' ? `<button class="ghost" onclick="playRecording(${i})">▶ Écouter</button>`
|
||||
: `<span class="pill ${m.state === 'failed' ? 'warn' : ''}">${m.state === 'failed' ? 'échec' : 'en cours'}</span>`}
|
||||
<button class="ghost" onclick="deleteRecording(${i})" title="Supprimer">×</button></div>`).join('')
|
||||
: '<div class="empty" style="padding:10px">Aucun enregistrement — choisis une station ci-dessous</div>';
|
||||
|
||||
// Tant que quelque chose s'enregistre, la taille et l'état se rafraîchissent d'eux-mêmes.
|
||||
if (!$('p-radio').hidden && (active.length || items.some((m) => m.state === 'downloading'))) {
|
||||
radioTimer = setTimeout(loadRecordings, 4000);
|
||||
}
|
||||
} catch (e) {
|
||||
$('radioActive').innerHTML = `<div class="empty">${esc(e.message || e)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadServerInfo() {
|
||||
try {
|
||||
const health = await fetch('/api/health').then((r) => r.json());
|
||||
|
||||
@@ -195,44 +195,21 @@ class Watcher:
|
||||
async with client.stream("GET", station["url"]) as response:
|
||||
response.raise_for_status()
|
||||
interval = int(response.headers.get("icy-metaint") or 0)
|
||||
# Fin du morceau : la station en annonce un autre. Les gardes-fous bornent
|
||||
# le fichier quand aucune annonce ne vient (40 Mo sans ICY, 60 Mo avec).
|
||||
limit = 40_000_000 if interval <= 0 else 60_000_000
|
||||
|
||||
def keep_going(total: int, announced: str | None) -> bool:
|
||||
return not (announced and announced != title) and total <= limit
|
||||
|
||||
with target.open("wb") as handle:
|
||||
written = await self._copy(response, handle, interval, title)
|
||||
written = await radio.copy_stream(response, handle, interval, keep_going)
|
||||
self.storage.update_media(media_id, state="ready", size=written)
|
||||
except Exception as error:
|
||||
self.storage.update_media(media_id, state="failed", error=str(error)[:200])
|
||||
finally:
|
||||
self.recording = None
|
||||
|
||||
async def _copy(self, response, handle, interval: int, wanted: str) -> int:
|
||||
"""Retire les blocs ICY du flux : laissés dedans, ils rendraient le fichier illisible."""
|
||||
if interval <= 0:
|
||||
total = 0
|
||||
async for chunk in response.aiter_bytes(32768):
|
||||
handle.write(chunk)
|
||||
total += len(chunk)
|
||||
if total > 40_000_000: # garde-fou : 40 Mo sans annonce de fin
|
||||
break
|
||||
return total
|
||||
|
||||
buffer = bytearray()
|
||||
total = 0
|
||||
async for chunk in response.aiter_bytes(32768):
|
||||
buffer.extend(chunk)
|
||||
while len(buffer) >= interval + 1:
|
||||
length = buffer[interval] * 16
|
||||
if len(buffer) < interval + 1 + length:
|
||||
break
|
||||
handle.write(bytes(buffer[:interval]))
|
||||
total += interval
|
||||
meta = bytes(buffer[interval + 1:interval + 1 + length]).decode("utf-8", "replace")
|
||||
del buffer[:interval + 1 + length]
|
||||
announced = radio.STREAM_TITLE.search(meta)
|
||||
if announced and announced.group(1).strip() and announced.group(1).strip() != wanted:
|
||||
return total # le morceau est terminé
|
||||
if total > 60_000_000:
|
||||
return total
|
||||
return total
|
||||
|
||||
|
||||
def _now() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
Reference in new issue
Block a user