# 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