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)
This commit is contained in:
+66
-14
@@ -4,6 +4,8 @@ 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
|
||||
@@ -24,42 +26,92 @@ 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, avec progression de visionnage."""
|
||||
"""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,),
|
||||
)
|
||||
return [dict(row) for row in rows]
|
||||
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")
|
||||
|
||||
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"])
|
||||
cur = parse_title(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:
|
||||
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 number is not None and other_num == number - 1:
|
||||
if cur["episode"] is not None and other["episode"] == cur["episode"] - 1:
|
||||
prev_ep = row["id"]
|
||||
if number is not None and other_num == number + 1:
|
||||
if cur["episode"] is not None and other["episode"] == cur["episode"] + 1:
|
||||
next_ep = row["id"]
|
||||
return {"previous": prev_ep, "next": next_ep}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user