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).
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user