Files
ohm_streaming/app/routers/library.py
T
Roman 6d15fce516 v0.3.0 — Bibliothèque organisée : sections Animés/Séries, groupes repliables par série et dossier
- /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)
2026-09-26 14:52:09 +00:00

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