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).
202 lines
7.0 KiB
Python
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
|