"""Bibliothèque locale, streaming de fichiers (range requests) et favoris.""" import logging import mimetypes import re from collections.abc import AsyncIterator import aiosqlite from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from app.config import get_settings from app.db import db from app.routers.auth import CurrentUser, current_user from app.services.downloads import download_manager logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", tags=["library"], dependencies=[Depends(current_user)]) CHUNK_SIZE = 1 << 20 # 1 Mio # ---------------------------------------------------------------- bibliothèque @router.get("/library") async def library(user: CurrentUser) -> list[dict]: """Fichiers téléchargés, streamables, avec progression de visionnage.""" rows = await db.fetchall( "SELECT d.*, wp.position_seconds FROM downloads d " "LEFT JOIN watch_progress wp ON wp.download_id = d.id AND wp.user_id = ? " "WHERE d.status = 'done' ORDER BY d.updated_at DESC", (user.id,), ) return [dict(row) for row in rows] @router.get("/library/{download_id}/neighbors") async def neighbors(download_id: int) -> dict: """Épisode précédent/suivant : heuristique sur les titres (même série, N±1).""" rows = await db.fetchall("SELECT id, title FROM downloads WHERE status = 'done' ORDER BY title") def parse(title: str) -> tuple[str, float | None]: match = re.search(r"(?:épisode|episode|ep|e)\s*(\d+(?:\.\d+)?)", title, re.IGNORECASE) if not match: return title, None return title[: match.start()].strip(), float(match.group(1)) current = await db.fetchone("SELECT id, title FROM downloads WHERE id = ?", (download_id,)) if current is None: raise HTTPException(404, "Fichier introuvable") base, number = parse(current["title"]) prev_ep = next_ep = None for row in rows: other_base, other_num = parse(row["title"]) if other_base != base or other_num is None or row["id"] == download_id: continue if number is not None and other_num == number - 1: prev_ep = row["id"] if number is not None and other_num == number + 1: next_ep = row["id"] return {"previous": prev_ep, "next": next_ep} # ---------------------------------------------------------------- streaming _RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)") @router.get("/stream/{download_id}") async def stream(download_id: int, request: Request) -> StreamingResponse: """Streaming d'un fichier local avec support des requêtes Range (206).""" row = await db.fetchone( "SELECT file_path FROM downloads WHERE id = ? AND status = 'done'", (download_id,) ) if row is None: raise HTTPException(404, "Fichier introuvable ou téléchargement incomplet") path = get_settings().download_dir / row["file_path"] if not path.is_file(): logger.error("Fichier manquant sur disque : %s", path) raise HTTPException(404, "Fichier absent du disque") size = path.stat().st_size content_type = mimetypes.guess_type(path.name)[0] or "video/mp4" start, end = 0, size - 1 status_code = 200 range_header = request.headers.get("range") if range_header: match = _RANGE_RE.fullmatch(range_header) if match: if match.group(1): start = int(match.group(1)) if match.group(2): end = min(int(match.group(2)), size - 1) if start >= size: raise HTTPException(416, "Plage invalide") status_code = 206 length = end - start + 1 async def iter_file() -> AsyncIterator[bytes]: with path.open("rb") as fh: fh.seek(start) remaining = length while remaining > 0: chunk = fh.read(min(CHUNK_SIZE, remaining)) if not chunk: break remaining -= len(chunk) yield chunk headers = { "Content-Range": f"bytes {start}-{end}/{size}", "Accept-Ranges": "bytes", "Content-Length": str(length), } return StreamingResponse( iter_file(), status_code=status_code, headers=headers, media_type=content_type ) class ProgressRequest(BaseModel): position_seconds: float = Field(ge=0) @router.post("/stream/{download_id}/progress") async def save_progress(download_id: int, payload: ProgressRequest, user: CurrentUser) -> dict: """Reprise de lecture : mémorise la position de visionnage.""" await db.execute( "INSERT INTO watch_progress (user_id, download_id, position_seconds, updated_at) " "VALUES (?, ?, ?, datetime('now')) " "ON CONFLICT(user_id, download_id) DO UPDATE SET " "position_seconds = excluded.position_seconds, updated_at = excluded.updated_at", (user.id, download_id, payload.position_seconds), ) return {"ok": True} # ---------------------------------------------------------------- favoris class FavoriteRequest(BaseModel): source: str source_id: str title: str image_url: str | None = None payload: dict | None = None @router.get("/favorites") async def list_favorites(user: CurrentUser, offset: int = 0, limit: int = 24) -> dict: total = await db.fetchone("SELECT COUNT(*) AS n FROM favorites WHERE user_id = ?", (user.id,)) rows = await db.fetchall( "SELECT * FROM favorites WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?", (user.id, limit, offset), ) return {"total": total["n"], "items": [dict(row) for row in rows]} @router.post("/favorites", status_code=201) async def add_favorite(payload: FavoriteRequest, user: CurrentUser) -> dict: import json try: await db.execute( "INSERT INTO favorites (user_id, source, source_id, title, image_url, payload) " "VALUES (?, ?, ?, ?, ?, ?)", ( user.id, payload.source, payload.source_id, payload.title, payload.image_url, json.dumps(payload.payload, ensure_ascii=False) if payload.payload else None, ), ) except aiosqlite.IntegrityError as exc: raise HTTPException(409, "Déjà dans les favoris") from exc return {"ok": True} @router.delete("/favorites/{favorite_id}") async def remove_favorite(favorite_id: int, user: CurrentUser) -> dict: cursor = await db.execute( "DELETE FROM favorites WHERE id = ? AND user_id = ?", (favorite_id, user.id) ) if cursor.rowcount == 0: raise HTTPException(404, "Favori introuvable") return {"ok": True} # raccourci pratique pour l'UI @router.get("/downloads/{download_id}") async def get_download(download_id: int) -> dict: data = await download_manager.get(download_id) if data is None: raise HTTPException(404, "Téléchargement introuvable") return data