Files
ohm_streaming/app/routers/library.py
T
Roman 41566ab5fb v0.1.0 — Réécriture complète de OhmStreaming
Nouvelle version réécrite de zéro : recherche multi-sources (Vostfree,
French-Manga), extraction 2 niveaux, proxy vidéo intégré, streaming/téléchargement
HLS, métadonnées Kitsu, bibliothèque locale, comptes JWT + administration,
découverte fusionnée, indexeur Torznab (Sonarr/Prowlarr).
2026-09-22 10:05:47 +00:00

202 lines
7.0 KiB
Python

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