Files
Noxely/server/noxely_server/api.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

259 lines
9.2 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 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
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"
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)
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]}
#: 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.post("/api/pair")
def pair(request: Request) -> 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
return {"token": config.token, "name": "Noxely", "version": VERSION}
@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")