"""Bibliothèque locale, streaming de fichiers (range requests) et favoris.""" import logging import mimetypes import re from collections.abc import AsyncIterator from pathlib import PurePosixPath from urllib.parse import urlparse 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 _SEASON_EP_RE = re.compile(r"\bs(\d{1,2})\s*e(\d{1,4}(?:\.\d(?!\d))?)", re.IGNORECASE) _SEASON_RE = re.compile(r"\b(?:saison|season|s)\s*0?(\d+)", re.IGNORECASE) _EPISODE_RE = re.compile(r"\b(?:épisode|episode|ep|e)\s*0?(\d+(?:\.\d(?!\d))?)", re.IGNORECASE) _VIDEO_EXTS = {".mp4", ".mkv", ".avi", ".webm", ".mov", ".m4v", ".ts"} def parse_title(title: str) -> dict: """Extrait nom de série, saison et épisode depuis un titre de téléchargement.""" stem, dot, ext = title.rpartition(".") if dot and f".{ext.lower()}" in _VIDEO_EXTS: title = stem season = episode = None cut = len(title) if match := _SEASON_EP_RE.search(title): # format compact S02E05 season, episode = int(match.group(1)), float(match.group(2)) cut = match.start() else: if match := _SEASON_RE.search(title): season = int(match.group(1)) cut = min(cut, match.start()) if match := _EPISODE_RE.search(title): episode = float(match.group(1)) cut = min(cut, match.start()) series = re.sub(r"\s+", " ", title[:cut].replace(".", " ").replace("_", " ")).strip(" -–—:") return {"series": series or title, "season": season, "episode": episode} def classify_media(page_url: str | None, video_url: str | None) -> str: """'anime' ou 'serie' selon le domaine de la page/URL source (défaut : anime).""" from app.scrapers.base import all_sources hosts = [ urlparse(u).netloc.lower() for u in (page_url, video_url) if u and urlparse(u).netloc ] for src in all_sources(): domain = urlparse(src.base_url).netloc.lower() if domain and any(h == domain or h.endswith(f".{domain}") for h in hosts): return "anime" if "anime" in src.media_types else "serie" return "anime" @router.get("/library") async def library(user: CurrentUser) -> list[dict]: """Fichiers téléchargés, streamables, enrichis (type, série, dossier) pour le regroupement.""" 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,), ) items = [] for row in rows: item = dict(row) item["media_type"] = classify_media(row["page_url"], row["video_url"]) item.update(parse_title(row["title"])) folder = str(PurePosixPath(row["file_path"]).parent) if row["file_path"] else "" item["folder"] = "" if folder == "." else folder items.append(item) if user.content_preference in ("anime", "serie"): items = [i for i in items if i["media_type"] == user.content_preference] return items @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") current = await db.fetchone("SELECT id, title FROM downloads WHERE id = ?", (download_id,)) if current is None: raise HTTPException(404, "Fichier introuvable") cur = parse_title(current["title"]) prev_ep = next_ep = None for row in rows: other = parse_title(row["title"]) if ( other["series"] != cur["series"] or other["season"] != cur["season"] or other["episode"] is None or row["id"] == download_id ): continue if cur["episode"] is not None and other["episode"] == cur["episode"] - 1: prev_ep = row["id"] if cur["episode"] is not None and other["episode"] == cur["episode"] + 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