diff --git a/CHANGELOG.md b/CHANGELOG.md index dc1cab2..bafb99d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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é diff --git a/README.md b/README.md index 26a8f88..490c243 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![Kotlin](https://img.shields.io/badge/Kotlin-2.1.0-7F52FF?logo=kotlin&logoColor=white)](https://kotlinlang.org) [![Compose](https://img.shields.io/badge/Jetpack%20Compose-Material%203-4285F4?logo=jetpackcompose&logoColor=white)](https://developer.android.com/jetpack/compose) [![Media3](https://img.shields.io/badge/Media3-1.11.1-FF6F00?logo=googleplay&logoColor=white)](https://developer.android.com/media/media3) -[![Version](https://img.shields.io/badge/Version-2.4.0-6B4EFF)](VERSION) +[![Version](https://img.shields.io/badge/Version-2.5.0-6B4EFF)](VERSION) [![Licence](https://img.shields.io/badge/Licence-GPL%20v3-3FD8C0)](LICENSE) [![Hors ligne](https://img.shields.io/badge/Hors%20ligne-100%25-3FD8C0)](#-comment-ça-marche) @@ -51,6 +51,7 @@ synchronisations : c'est là qu'est l'économie. | 🧾 **Choix piste par piste** | Un album affiche ses pistes avec leur taille avant d'être mis en file | | 📡 **Guet multi-stations** | Nommez un morceau : les stations les plus écoutées sont balayées jusqu'à le trouver | | 📻 **Enregistrement** | Capture du direct, nommée d'après le titre annoncé par la station | +| 🎙️ **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 | diff --git a/VERSION b/VERSION index 197c4d5..437459c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.4.0 +2.5.0 diff --git a/app/src/main/java/fr/tuxgyver/noxely/sync/SyncClient.kt b/app/src/main/java/fr/tuxgyver/noxely/sync/SyncClient.kt index 54230ca..abc819c 100644 --- a/app/src/main/java/fr/tuxgyver/noxely/sync/SyncClient.kt +++ b/app/src/main/java/fr/tuxgyver/noxely/sync/SyncClient.kt @@ -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é") diff --git a/app/src/main/java/fr/tuxgyver/noxely/ui/server/ServerScreen.kt b/app/src/main/java/fr/tuxgyver/noxely/ui/server/ServerScreen.kt index 16a5abd..d3166ac 100644 --- a/app/src/main/java/fr/tuxgyver/noxely/ui/server/ServerScreen.kt +++ b/app/src/main/java/fr/tuxgyver/noxely/ui/server/ServerScreen.kt @@ -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(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 -> { diff --git a/server/.env.example b/server/.env.example index d4ba956..f1d236f 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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 diff --git a/server/README.md b/server/README.md index 79db2ff..9a06236 100644 --- a/server/README.md +++ b/server/README.md @@ -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 diff --git a/server/noxely_server/api.py b/server/noxely_server/api.py index 0ced648..4e90c10 100644 --- a/server/noxely_server/api.py +++ b/server/noxely_server/api.py @@ -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 { diff --git a/server/noxely_server/config.py b/server/noxely_server/config.py index 8c2cbe6..ec90d43 100644 --- a/server/noxely_server/config.py +++ b/server/noxely_server/config.py @@ -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")), ) diff --git a/server/noxely_server/recorder.py b/server/noxely_server/recorder.py new file mode 100644 index 0000000..ffbb633 --- /dev/null +++ b/server/noxely_server/recorder.py @@ -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) diff --git a/server/noxely_server/sources/radio.py b/server/noxely_server/sources/radio.py index 80edbf3..da98966 100644 --- a/server/noxely_server/sources/radio.py +++ b/server/noxely_server/sources/radio.py @@ -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 diff --git a/server/noxely_server/storage.py b/server/noxely_server/storage.py index 542c0a2..c0f74ff 100644 --- a/server/noxely_server/storage.py +++ b/server/noxely_server/storage.py @@ -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: diff --git a/server/noxely_server/web/about.js b/server/noxely_server/web/about.js index ef03646..3dab2d7 100644 --- a/server/noxely_server/web/about.js +++ b/server/noxely_server/web/about.js @@ -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.'], diff --git a/server/noxely_server/web/index.html b/server/noxely_server/web/index.html index c8122b3..ec62193 100644 --- a/server/noxely_server/web/index.html +++ b/server/noxely_server/web/index.html @@ -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; } @@ -81,6 +84,7 @@
+ @@ -108,6 +112,33 @@
+ +