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:
tuxgyverandClaude Sonnet 5 committed 2026-09-20 22:39:59 +02:00
1 parent 7810bef8a3
commit 5b5e90247f
15 files changed
+593 -48

No files matched your search

+38
View File
@@ -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