Noxely se sépare en client et serveur (2.0.0)
Le téléphone balayait quarante radios toutes les trois minutes, cherchait dans quatre catalogues et enregistrait des directs. Tout cela le tenait éveillé pour un travail qui n'a aucune raison de se faire sur batterie. Un serveur reprend la collecte : recherche, guet, enregistrement, file de téléchargement, piloté depuis un navigateur. Le mobile demande ce qui a changé depuis son curseur, télécharge et se rendort — toutes les six heures, en Wi-Fi non facturé. Vingt fichiers quittent l'application au passage. Le balayage devient continu plutôt qu'espacé de trois minutes : une sonde ne lit que le premier bloc de métadonnées avant de refermer, ce qui rend la permanence tenable quand garder les flux ouverts coûterait plusieurs mégabits par seconde. Un morceau guetté a été capté en moins de vingt-cinq secondes. La recherche interroge les quatre catalogues à la fois et fond leurs résultats, les plus légers d'abord. Quatre défauts corrigés en chemin, tous trouvés en essayant plutôt qu'en relisant : un jeton régénéré à chaque démarrage, qui aurait obligé à reconfigurer le mobile ; un 403 de Wikimedia dû à un agent trop générique, que la fusion des sources masquait ; un dossier de données par défaut inaccessible au compte qui lance le serveur ; et une lecture de métadonnées qui n'itérait rien, faute d'être asynchrone.
This commit is contained in:
1 parent
adc6fc0486
commit
ed1d40826b
48 files changed
+2081
-3619
No files matched your search
@@ -0,0 +1,188 @@
|
||||
"""Tâches de fond : file de téléchargement, guet radio et enregistrement.
|
||||
|
||||
C'est ici que se déplace la consommation qui pesait sur le téléphone : le serveur balaie les
|
||||
stations et télécharge, le mobile ne fait plus que récupérer des fichiers prêts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import Config
|
||||
from .sources import radio
|
||||
from .storage import Storage
|
||||
|
||||
UA = {"User-Agent": "Noxely/1.0 (https://git.tuxtech.fr/tuxgyver/Noxely)"}
|
||||
|
||||
|
||||
def safe_name(text: str, extension: str) -> str:
|
||||
cleaned = re.sub(r"[^\w \-.]", "", text).strip().replace(" ", "_")[:80] or "media"
|
||||
return f"{cleaned}.{extension}"
|
||||
|
||||
|
||||
class Downloader:
|
||||
"""Vide la file des médias en attente, un à la fois."""
|
||||
|
||||
def __init__(self, config: Config, storage: Storage) -> None:
|
||||
self.config = config
|
||||
self.storage = storage
|
||||
self.config.media_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async def run_forever(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
await self.drain()
|
||||
except Exception as error: # une panne ne doit pas arrêter la boucle
|
||||
print(f"[noxely] téléchargement : {error}")
|
||||
await asyncio.sleep(10)
|
||||
|
||||
async def drain(self) -> None:
|
||||
for item in self.storage.pending_downloads():
|
||||
await self.fetch(item.id, item.remote_url, item.filename)
|
||||
|
||||
async def fetch(self, media_id: str, url: str, filename: str) -> None:
|
||||
target = self.config.media_dir / filename
|
||||
partial = target.with_suffix(target.suffix + ".part")
|
||||
self.storage.update_media(media_id, state="downloading", error=None)
|
||||
try:
|
||||
async with httpx.AsyncClient(headers=UA, timeout=httpx.Timeout(120.0),
|
||||
follow_redirects=True) as client:
|
||||
async with client.stream("GET", url) as response:
|
||||
response.raise_for_status()
|
||||
with partial.open("wb") as handle:
|
||||
async for chunk in response.aiter_bytes(65536):
|
||||
handle.write(chunk)
|
||||
partial.replace(target)
|
||||
self.storage.update_media(media_id, state="ready", size=target.stat().st_size)
|
||||
except Exception as error:
|
||||
partial.unlink(missing_ok=True)
|
||||
self.storage.update_media(media_id, state="failed", error=str(error)[:200])
|
||||
|
||||
|
||||
class Watcher:
|
||||
"""Balaie les stations à la recherche des morceaux guettés, et les enregistre."""
|
||||
|
||||
def __init__(self, config: Config, storage: Storage) -> None:
|
||||
self.config = config
|
||||
self.storage = storage
|
||||
self.recording: str | None = None
|
||||
#: Dernier tour de balayage, pour que l'interface montre que ça tourne.
|
||||
self.last_sweep: dict = {}
|
||||
self.sweeps = 0
|
||||
|
||||
async def run_forever(self) -> None:
|
||||
"""Balaie en continu : un tour enchaîne sur le suivant sans attendre.
|
||||
|
||||
Une sonde ne lit que le premier bloc de métadonnées puis referme — quelques dizaines de
|
||||
kilo-octets par station. C'est ce qui rend le balayage permanent tenable, là où garder
|
||||
quarante flux ouverts consommerait plusieurs mégabits par seconde en continu.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
if self.storage.pending_watches():
|
||||
self.last_sweep = await self.sweep()
|
||||
self.sweeps += 1
|
||||
else:
|
||||
# Rien à guetter : inutile de solliciter l'annuaire.
|
||||
await asyncio.sleep(30)
|
||||
continue
|
||||
except Exception as error:
|
||||
print(f"[noxely] guet : {error}")
|
||||
await asyncio.sleep(30)
|
||||
continue
|
||||
await asyncio.sleep(self.config.watch_interval)
|
||||
|
||||
async def sweep(self) -> dict:
|
||||
pending = self.storage.pending_watches()
|
||||
if not pending:
|
||||
return {"stations": 0, "titles": 0, "at": _now(), "matched": None}
|
||||
|
||||
found = await radio.stations(limit=self.config.watch_stations)
|
||||
titles = 0
|
||||
for group in _chunks(found, self.config.watch_concurrency):
|
||||
heard = await asyncio.gather(*(radio.now_playing(s["url"]) for s in group))
|
||||
for station, title in zip(group, heard):
|
||||
if not title:
|
||||
continue
|
||||
titles += 1
|
||||
hit = next((w for w in pending if w["query"].lower() in title.lower()), None)
|
||||
if not hit:
|
||||
continue
|
||||
if self.recording is None:
|
||||
asyncio.create_task(self.record(station, title, hit["query"]))
|
||||
self.storage.mark_watch_found(hit["id"], station["name"], title)
|
||||
self.storage.mark_watches_scanned([w["id"] for w in pending])
|
||||
return {"stations": len(found), "titles": titles, "at": _now(),
|
||||
"matched": title, "station": station["name"]}
|
||||
|
||||
self.storage.mark_watches_scanned([w["id"] for w in pending])
|
||||
return {"stations": len(found), "titles": titles, "at": _now(), "matched": None}
|
||||
|
||||
async def record(self, station: dict, title: str, trigger: str) -> None:
|
||||
"""Enregistre jusqu'à ce que la station annonce un autre morceau."""
|
||||
self.recording = station["name"]
|
||||
extension = "aac" if station["codec"].upper().startswith("AAC") else "mp3"
|
||||
filename = safe_name(title, extension)
|
||||
media_id = self.storage.add_media(
|
||||
title=title, artist=station["name"], source="radio", origin=station["name"],
|
||||
filename=filename, remote_url=None,
|
||||
mime="audio/aac" if extension == "aac" else "audio/mpeg", state="downloading",
|
||||
)
|
||||
target = self.config.media_dir / filename
|
||||
headers = {**UA, "Icy-MetaData": "1"}
|
||||
written = 0
|
||||
try:
|
||||
async with httpx.AsyncClient(headers=headers, timeout=httpx.Timeout(60.0)) as client:
|
||||
async with client.stream("GET", station["url"]) as response:
|
||||
response.raise_for_status()
|
||||
interval = int(response.headers.get("icy-metaint") or 0)
|
||||
with target.open("wb") as handle:
|
||||
written = await self._copy(response, handle, interval, title)
|
||||
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)
|
||||
|
||||
|
||||
def _chunks(items: list, size: int):
|
||||
for index in range(0, len(items), size):
|
||||
yield items[index:index + size]
|
||||
Reference in new issue
Block a user