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.
199 lines
6.9 KiB
Python
199 lines
6.9 KiB
Python
"""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
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
from fastapi import Body, Depends, FastAPI, Header, HTTPException, Query, Request
|
|
from fastapi.responses import FileResponse, HTMLResponse
|
|
|
|
from . import config as config_module
|
|
from .sources import catalogs, radio
|
|
from .storage import Storage
|
|
from .workers import Downloader, Watcher, safe_name
|
|
|
|
VERSION = "1.0.0"
|
|
WEB_DIR = Path(__file__).parent / "web"
|
|
|
|
config = config_module.load()
|
|
storage = Storage(config.db_path)
|
|
downloader = Downloader(config, storage)
|
|
watcher = Watcher(config, storage)
|
|
|
|
|
|
def authorised(x_noxely_token: str = Header(default="")) -> None:
|
|
"""Sans jeton valide, le serveur ne répond rien : il expose des fichiers."""
|
|
if x_noxely_token != config.token:
|
|
raise HTTPException(status_code=401, detail="Jeton invalide")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
tasks = [
|
|
asyncio.create_task(downloader.run_forever()),
|
|
asyncio.create_task(watcher.run_forever()),
|
|
]
|
|
yield
|
|
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)])
|
|
def delete_media(media_id: str) -> dict:
|
|
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.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/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]}
|
|
|
|
|
|
@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("/", response_class=HTMLResponse)
|
|
def index() -> str:
|
|
return (WEB_DIR / "index.html").read_text(encoding="utf-8")
|