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
365 lines
14 KiB
Python
365 lines
14 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.
|
|
|
|
"""API HTTP du serveur Noxely.
|
|
|
|
Le client Android ne consomme que `/api/health`, `/api/library` et `/api/media/{id}/file` :
|
|
tout le reste sert au pilotage depuis un navigateur. Cette séparation est volontaire — le
|
|
mobile doit pouvoir se contenter d'un sondage bref, c'est là qu'est le gain de batterie.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import ipaddress
|
|
import secrets
|
|
import time
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
from fastapi import Body, Depends, FastAPI, Header, HTTPException, Query, Request
|
|
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, Response
|
|
|
|
from . import config as config_module
|
|
from . import rotation
|
|
from .recorder import AlreadyRecording, Recorder, TooMany
|
|
from .sources import catalogs, radio
|
|
from .storage import Storage
|
|
from .workers import Downloader, Watcher, safe_name
|
|
|
|
# Client et serveur sortent du même dépôt, à la même version : on lit le fichier
|
|
# que la chaîne de publication incrémente, plutôt qu'un numéro à tenir à jour ici.
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
VERSION = (REPO_ROOT / "VERSION").read_text().strip() if (REPO_ROOT / "VERSION").exists() else "0.0.0"
|
|
PAIRING_WINDOW_SECONDS = 120
|
|
WEB_DIR = Path(__file__).parent / "web"
|
|
|
|
config = config_module.load()
|
|
storage = Storage(config.db_path)
|
|
downloader = Downloader(config, storage)
|
|
watcher = Watcher(config, storage)
|
|
recorder = Recorder(config, storage)
|
|
|
|
|
|
def authorised(x_noxely_token: str = Header(default="")) -> None:
|
|
"""Sans jeton valide, le serveur ne répond rien : il expose des fichiers.
|
|
|
|
Deux formes valent : le jeton maître, qui pilote le serveur depuis le navigateur, et celui
|
|
d'un appareil appairé. Les distinguer est ce qui permet de révoquer un téléphone perdu sans
|
|
déranger les autres.
|
|
"""
|
|
if x_noxely_token and x_noxely_token == config.token:
|
|
return
|
|
device = storage.device_by_token(x_noxely_token) if x_noxely_token else None
|
|
if not device:
|
|
raise HTTPException(status_code=401, detail="Jeton invalide")
|
|
storage.touch_device(device["id"])
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
recorder.recover()
|
|
tasks = [
|
|
asyncio.create_task(downloader.run_forever()),
|
|
asyncio.create_task(watcher.run_forever()),
|
|
]
|
|
yield
|
|
# Les enregistrements en cours sont clos avant de couper : sans cela, redémarrer le
|
|
# service laisserait chaque fichier « en cours » à jamais.
|
|
await recorder.stop_all()
|
|
for task in tasks:
|
|
task.cancel()
|
|
|
|
|
|
app = FastAPI(title="Noxely", version=VERSION, lifespan=lifespan)
|
|
|
|
|
|
# --- Ce que consomme le mobile ------------------------------------------
|
|
|
|
@app.get("/api/health")
|
|
def health() -> dict:
|
|
return {"name": "Noxely", "version": VERSION}
|
|
|
|
|
|
@app.get("/api/library", dependencies=[Depends(authorised)])
|
|
def library(since: int = Query(default=0)) -> dict:
|
|
"""Uniquement ce qui a changé depuis le dernier passage du client."""
|
|
items = storage.media_since(since)
|
|
return {
|
|
"items": [m.public() for m in items],
|
|
"cursor": items[-1].updated_at if items else since,
|
|
}
|
|
|
|
|
|
@app.get("/api/media/{media_id}/file", dependencies=[Depends(authorised)])
|
|
def media_file(media_id: str):
|
|
item = storage.media(media_id)
|
|
if not item or item.state != "ready":
|
|
raise HTTPException(status_code=404, detail="Média indisponible")
|
|
path = config.media_dir / item.filename
|
|
if not path.exists():
|
|
raise HTTPException(status_code=404, detail="Fichier absent")
|
|
return FileResponse(path, media_type=item.mime, filename=item.filename)
|
|
|
|
|
|
@app.delete("/api/media/{media_id}", dependencies=[Depends(authorised)])
|
|
async def delete_media(media_id: str) -> dict:
|
|
recorder.stop(media_id) # supprimer un enregistrement en cours l'arrête d'abord
|
|
item = storage.delete_media(media_id)
|
|
if item:
|
|
(config.media_dir / item.filename).unlink(missing_ok=True)
|
|
return {"deleted": bool(item)}
|
|
|
|
|
|
# --- Pilotage depuis le navigateur --------------------------------------
|
|
|
|
@app.get("/api/catalogs", dependencies=[Depends(authorised)])
|
|
def list_catalogs() -> dict:
|
|
return {
|
|
"catalogs": {"all": "Toutes les sources", **catalogs.CATALOGS},
|
|
"genres": radio.GENRES,
|
|
}
|
|
|
|
|
|
@app.get("/api/search", dependencies=[Depends(authorised)])
|
|
async def search(catalog: str, q: str, language: str | None = None) -> dict:
|
|
try:
|
|
if catalog == "all":
|
|
return {"results": await catalogs.search_all(q, language)}
|
|
return {"results": await catalogs.search(catalog, q, language)}
|
|
except Exception as error:
|
|
raise HTTPException(status_code=502, detail=str(error)[:200])
|
|
|
|
|
|
@app.get("/api/tracks", dependencies=[Depends(authorised)])
|
|
async def tracks(catalog: str, id: str, title: str = "", kind: str = "Audio") -> dict:
|
|
try:
|
|
return {"tracks": await catalogs.tracks(catalog, id, title, kind)}
|
|
except Exception as error:
|
|
raise HTTPException(status_code=502, detail=str(error)[:200])
|
|
|
|
|
|
@app.post("/api/queue", dependencies=[Depends(authorised)])
|
|
def queue(payload: dict = Body(...)) -> dict:
|
|
"""Met des pistes en file : le téléchargement se fait en tâche de fond."""
|
|
added = 0
|
|
for track in payload.get("tracks", []):
|
|
url = track.get("url")
|
|
if not url:
|
|
continue
|
|
extension = url.split("?")[0].rsplit(".", 1)[-1].lower()
|
|
if extension not in ("mp3", "m4a", "ogg", "opus", "flac", "wav", "mp4", "webm"):
|
|
extension = "mp3"
|
|
storage.add_media(
|
|
title=track.get("title") or "Sans titre",
|
|
artist=track.get("artist") or "",
|
|
source=payload.get("catalog") or "catalogue",
|
|
origin=payload.get("origin") or "",
|
|
filename=safe_name(track.get("title") or "media", extension),
|
|
remote_url=url,
|
|
duration_ms=int(track.get("duration_ms") or 0),
|
|
artwork_url=track.get("artwork_url"),
|
|
)
|
|
added += 1
|
|
return {"queued": added}
|
|
|
|
|
|
@app.get("/api/radio/stations", dependencies=[Depends(authorised)])
|
|
async def radio_stations(genre: str | None = None, name: str | None = None) -> dict:
|
|
return {"stations": await radio.stations(genre, name)}
|
|
|
|
|
|
@app.post("/api/radio/record", dependencies=[Depends(authorised)])
|
|
async def radio_record(payload: dict = Body(...)) -> dict:
|
|
"""Enregistre une station jusqu'à l'arrêt, ou jusqu'à `minutes` si elles sont données."""
|
|
url = str(payload.get("url") or "").strip()
|
|
if not url.startswith(("http://", "https://")):
|
|
raise HTTPException(status_code=400, detail="Adresse de flux invalide")
|
|
try:
|
|
minutes = int(payload["minutes"]) if payload.get("minutes") else None
|
|
except (TypeError, ValueError):
|
|
raise HTTPException(status_code=400, detail="Durée invalide")
|
|
station = {
|
|
"name": str(payload.get("name") or "Station").strip()[:80] or "Station",
|
|
"url": url,
|
|
"codec": str(payload.get("codec") or ""),
|
|
}
|
|
try:
|
|
return {"media_id": recorder.start(station, minutes)}
|
|
except AlreadyRecording:
|
|
raise HTTPException(status_code=409, detail="Cette station est déjà en cours d'enregistrement")
|
|
except TooMany as error:
|
|
raise HTTPException(status_code=409, detail=f"{error} enregistrements simultanés au plus")
|
|
|
|
|
|
@app.post("/api/radio/record/{media_id}/stop", dependencies=[Depends(authorised)])
|
|
async def radio_stop(media_id: str) -> dict:
|
|
return {"stopped": recorder.stop(media_id)}
|
|
|
|
|
|
@app.get("/api/radio/recordings", dependencies=[Depends(authorised)])
|
|
async def radio_recordings() -> dict:
|
|
"""Ce qui s'enregistre en ce moment, et ce qui l'a été — guets compris."""
|
|
return {
|
|
"active": recorder.active(),
|
|
"items": [m.public() for m in storage.radio_media()],
|
|
"max_minutes": config.record_max_minutes,
|
|
}
|
|
|
|
|
|
@app.get("/api/watches", dependencies=[Depends(authorised)])
|
|
def watches() -> dict:
|
|
return {
|
|
"watches": storage.watches(),
|
|
"recording": watcher.recording,
|
|
"sweeps": watcher.sweeps,
|
|
"last_sweep": watcher.last_sweep,
|
|
}
|
|
|
|
|
|
@app.post("/api/watches", dependencies=[Depends(authorised)])
|
|
def add_watch(payload: dict = Body(...)) -> dict:
|
|
query = (payload.get("query") or "").strip()
|
|
if not query:
|
|
raise HTTPException(status_code=400, detail="Requête vide")
|
|
return {"id": storage.add_watch(query)}
|
|
|
|
|
|
@app.delete("/api/watches/{watch_id}", dependencies=[Depends(authorised)])
|
|
def delete_watch(watch_id: str) -> dict:
|
|
storage.delete_watch(watch_id)
|
|
return {"deleted": True}
|
|
|
|
|
|
@app.post("/api/watches/sweep", dependencies=[Depends(authorised)])
|
|
async def sweep() -> dict:
|
|
try:
|
|
return await watcher.sweep()
|
|
except Exception as error:
|
|
# L'annuaire peut être momentanément injoignable : le dire, pas renvoyer une trace.
|
|
raise HTTPException(status_code=502, detail=f"Annuaire indisponible : {str(error)[:120]}")
|
|
|
|
|
|
@app.get("/api/heard", dependencies=[Depends(authorised)])
|
|
def heard(limit: int = Query(default=100, ge=1, le=500), q: str | None = None) -> dict:
|
|
"""Ce que les stations ont annoncé, le plus récent d'abord ; `q` filtre sur le texte."""
|
|
return {
|
|
"items": storage.heard(limit, q.strip() if q else None),
|
|
"total": storage.heard_count(),
|
|
"history": config.history,
|
|
}
|
|
|
|
|
|
@app.get("/api/rotation", dependencies=[Depends(authorised)])
|
|
def rotation_view(last: int = Query(default=100, ge=1, le=500),
|
|
top: int = Query(default=20, ge=1, le=100)) -> dict:
|
|
"""Ce qui tourne sur les `last` dernières remontées, tous titres et artistes confondus."""
|
|
return rotation.rotation(storage.heard(last), top)
|
|
|
|
|
|
@app.get("/api/queue", dependencies=[Depends(authorised)])
|
|
def queue_state() -> dict:
|
|
pending = storage.media_since(0, state=None)
|
|
return {"items": [m.public() for m in pending if m.state != "ready"][:50]}
|
|
|
|
|
|
#: Fenêtre d'appairage : instant d'expiration, ou None si elle est fermée.
|
|
_pairing_until: float | None = None
|
|
|
|
|
|
def _is_private(host: str) -> bool:
|
|
"""Le réseau local, et lui seul."""
|
|
try:
|
|
address = ipaddress.ip_address(host)
|
|
return address.is_private or address.is_loopback
|
|
except ValueError:
|
|
return host in ("localhost", "")
|
|
|
|
|
|
@app.post("/api/pair/open", dependencies=[Depends(authorised)])
|
|
def open_pairing() -> dict:
|
|
"""Ouvre une fenêtre de deux minutes pendant laquelle un appareil du réseau local peut
|
|
réclamer le jeton. Fermée le reste du temps : sinon le jeton ne protégerait plus rien."""
|
|
global _pairing_until
|
|
_pairing_until = time.time() + PAIRING_WINDOW_SECONDS
|
|
return {"open": True, "seconds": PAIRING_WINDOW_SECONDS}
|
|
|
|
|
|
@app.get("/api/pair/state", dependencies=[Depends(authorised)])
|
|
def pairing_state() -> dict:
|
|
remaining = int(_pairing_until - time.time()) if _pairing_until else 0
|
|
return {"open": remaining > 0, "seconds": max(0, remaining)}
|
|
|
|
|
|
@app.get("/api/devices", dependencies=[Depends(authorised)])
|
|
def devices() -> dict:
|
|
return {"devices": storage.devices()}
|
|
|
|
|
|
@app.delete("/api/devices/{device_id}", dependencies=[Depends(authorised)])
|
|
def revoke_device(device_id: str) -> dict:
|
|
"""Révoque un appareil : son jeton cesse immédiatement d'être accepté."""
|
|
return {"revoked": storage.delete_device(device_id)}
|
|
|
|
|
|
@app.post("/api/pair")
|
|
def pair(request: Request, payload: dict = Body(default={})) -> dict:
|
|
"""Livre le jeton à un appareil du réseau local, pendant la fenêtre d'appairage.
|
|
|
|
La fenêtre se referme dès qu'un appareil s'est servi : une ouverture, un appairage.
|
|
"""
|
|
global _pairing_until
|
|
client = request.client.host if request.client else ""
|
|
if not _pairing_until or time.time() > _pairing_until:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Aucun appairage en cours. Ouvrez-en un depuis l'interface du serveur.",
|
|
)
|
|
if not _is_private(client):
|
|
raise HTTPException(status_code=403, detail="Appairage réservé au réseau local")
|
|
_pairing_until = None
|
|
# Chaque appairage reçoit son propre jeton : révoquer l'un n'atteint pas les autres.
|
|
token = secrets.token_urlsafe(24)
|
|
name = str(payload.get("name") or "Appareil").strip()[:60]
|
|
storage.add_device(name, token)
|
|
return {"token": token, "name": "Noxely", "version": VERSION, "device": name}
|
|
|
|
|
|
@app.get("/api/bootstrap")
|
|
def bootstrap(request: Request) -> dict:
|
|
"""Livre le jeton — uniquement à une requête venue de la machine elle-même.
|
|
|
|
Depuis un autre poste, il faut le saisir : c'est précisément ce que le jeton protège.
|
|
`make server-token` l'affiche pour le copier.
|
|
"""
|
|
client = request.client.host if request.client else ""
|
|
if client not in ("127.0.0.1", "::1", "localhost"):
|
|
raise HTTPException(status_code=403, detail="Jeton lisible seulement en local")
|
|
return {"token": config.token}
|
|
|
|
|
|
@app.get("/api/changelog", response_class=PlainTextResponse)
|
|
def changelog() -> str:
|
|
"""Le journal du dépôt, lu à la demande plutôt que recopié dans la page."""
|
|
path = REPO_ROOT / "CHANGELOG.md"
|
|
return path.read_text(encoding="utf-8") if path.exists() else ""
|
|
|
|
|
|
@app.get("/about.js", response_class=PlainTextResponse)
|
|
def about_script() -> Response:
|
|
return Response(
|
|
(WEB_DIR / "about.js").read_text(encoding="utf-8"),
|
|
media_type="application/javascript; charset=utf-8",
|
|
)
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
def index() -> str:
|
|
return (WEB_DIR / "index.html").read_text(encoding="utf-8")
|