Tous les appareils partageaient le jeton du serveur : perdre un téléphone obligeait à changer ce jeton, donc à réappairer tout le monde. Chaque appairage émet désormais le sien. Révoquer devient une opération locale, vérifiée en appairant deux appareils puis en coupant le premier : 401 pour lui, 200 pour l'autre, jeton maître intact. L'interface du serveur les liste avec leur modèle, leur date d'appairage et leur dernière synchronisation — sans jamais afficher les jetons eux-mêmes, ce qui reviendrait à les distribuer. La table s'ajoute sans toucher aux données existantes et le jeton maître reste accepté : rien ne casse pour un appareil appairé avant cette version.
284 lines
10 KiB
Python
284 lines
10 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
|
|
|
|
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.
|
|
|
|
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):
|
|
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.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("/", response_class=HTMLResponse)
|
|
def index() -> str:
|
|
return (WEB_DIR / "index.html").read_text(encoding="utf-8")
|