Files
Noxely/server/noxely_server/workers.py
T
tuxgyver 51b60cda70 Appairage du téléphone, en-têtes de licence (2.1.0)
Recopier un jeton de trente-trois caractères sur un clavier tactile n'était pas
un réglage, c'était une corvée. On ouvre désormais une fenêtre depuis le
serveur, on appuie sur Appairer côté téléphone, et c'est fait.

Le jeton n'est pas pour autant distribué : ouvrir la fenêtre l'exige déjà,
elle dure deux minutes, se referme dès qu'un appareil s'est servi, et ne
répond qu'à une adresse privée. Le champ de saisie reste là pour qui préfère
le coller.

Les cinquante fichiers de code portent en outre leur notice de licence, comme
la GPL le recommande. Elle est en français, comme le reste ; le texte qui fait
foi demeure celui de LICENSE, non traduit.
2026-09-13 20:15:14 +02:00

199 lines
8.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.
"""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]