"""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")