- /api/library enrichi : media_type (domaine source), série/saison/épisode (parsing du titre), dossier - Filtrage selon la préférence de contenu utilisateur (anime / serie / both) - Page bibliothèque : sections Animés / Séries & Films, groupes repliables (nb épisodes, dossier, progression, taille) - neighbors() réutilise le parseur commun (comparaison série + saison)
254 lines
9.2 KiB
Python
254 lines
9.2 KiB
Python
"""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
|