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
141 lines
5.4 KiB
Python
141 lines
5.4 KiB
Python
# 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.
|
|
|
|
"""Annuaire des stations et lecture des métadonnées ICY."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import re
|
|
import urllib.parse
|
|
|
|
import httpx
|
|
|
|
UA = {"User-Agent": "Noxely/1.0 (https://git.tuxtech.fr/tuxgyver/Noxely)"}
|
|
BASE = "https://de1.api.radio-browser.info/json/stations/search"
|
|
|
|
#: Un titre ICY court jusqu'au « '; » : les apostrophes y sont trop fréquentes pour s'arrêter
|
|
#: à la première (« Ain't That a Kick in the Head » deviendrait « Ain »).
|
|
STREAM_TITLE = re.compile(r"StreamTitle='(.*?)';")
|
|
|
|
GENRES = ["pop", "rock", "jazz", "classical", "electronic", "news", "chanson"]
|
|
|
|
|
|
async def stations(genre: str | None = None, name: str | None = None, limit: int = 60) -> list[dict]:
|
|
params = {"limit": str(limit), "hidebroken": "true", "order": "clickcount", "reverse": "true"}
|
|
if genre:
|
|
params["tag"] = genre
|
|
if name:
|
|
params["name"] = name
|
|
|
|
# L'annuaire est un service communautaire : une coupure passagère ne doit pas faire
|
|
# tomber le balayage. Mesuré en conditions réelles, d'où cette seconde tentative.
|
|
rows = None
|
|
async with httpx.AsyncClient(headers=UA, timeout=httpx.Timeout(30.0)) as client:
|
|
for attempt in range(2):
|
|
try:
|
|
response = await client.get(BASE, params=params)
|
|
response.raise_for_status()
|
|
rows = response.json()
|
|
break
|
|
except Exception:
|
|
if attempt == 1:
|
|
raise
|
|
await asyncio.sleep(2)
|
|
|
|
out = []
|
|
for row in rows:
|
|
url = row.get("url_resolved") or ""
|
|
# Les flux HLS se lisent mais ne s'enregistrent pas en copiant les octets.
|
|
if int(row.get("hls") or 0) != 0 or not url.startswith("http"):
|
|
continue
|
|
out.append({
|
|
"id": row.get("stationuuid") or url,
|
|
"name": (row.get("name") or "Station").strip(),
|
|
"url": url,
|
|
"codec": row.get("codec") or "",
|
|
"bitrate": int(row.get("bitrate") or 0),
|
|
"country": row.get("country") or "",
|
|
})
|
|
return out
|
|
|
|
|
|
async def now_playing(stream_url: str) -> str | None:
|
|
"""Le morceau diffusé à l'instant : aucun annuaire ne l'expose, il faut ouvrir le flux."""
|
|
headers = {**UA, "Icy-MetaData": "1"}
|
|
try:
|
|
async with httpx.AsyncClient(headers=headers, timeout=httpx.Timeout(10.0)) as client:
|
|
async with client.stream("GET", stream_url) as response:
|
|
if response.status_code >= 300:
|
|
return None
|
|
interval = int(response.headers.get("icy-metaint") or 0)
|
|
if interval <= 0:
|
|
return None
|
|
return await _read_title(response, interval)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
async def _read_title(response, interval: int) -> str | None:
|
|
# Client asynchrone : `iter_bytes` n'itère rien ici, il faut `aiter_bytes`.
|
|
buffer = bytearray()
|
|
needed = interval + 1
|
|
async for chunk in response.aiter_bytes():
|
|
buffer.extend(chunk)
|
|
if len(buffer) < needed:
|
|
continue
|
|
length = buffer[interval] * 16
|
|
if length == 0:
|
|
return None
|
|
needed = interval + 1 + length
|
|
if len(buffer) < needed:
|
|
continue
|
|
meta = bytes(buffer[interval + 1:needed]).decode("utf-8", "replace")
|
|
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
|