From 6d15fce5167e76989fd7e7c6ae7c17a3bffaec7d Mon Sep 17 00:00:00 2001 From: Roman Date: Sat, 26 Sep 2026 14:52:09 +0000 Subject: [PATCH] =?UTF-8?q?v0.3.0=20=E2=80=94=20Biblioth=C3=A8que=20organi?= =?UTF-8?q?s=C3=A9e=20:=20sections=20Anim=C3=A9s/S=C3=A9ries,=20groupes=20?= =?UTF-8?q?repliables=20par=20s=C3=A9rie=20et=20dossier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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) --- app/routers/library.py | 80 +++++++++++++++++++++++++------ app/static/css/style.css | 29 ++++++++++++ app/templates/library.html | 96 ++++++++++++++++++++++++++++++++------ pyproject.toml | 2 +- 4 files changed, 177 insertions(+), 30 deletions(-) diff --git a/app/routers/library.py b/app/routers/library.py index a947257..69eccfa 100644 --- a/app/routers/library.py +++ b/app/routers/library.py @@ -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} diff --git a/app/static/css/style.css b/app/static/css/style.css index faa42ac..b3710da 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -314,6 +314,35 @@ button { font-family: inherit; } } .episode-name { flex: 1; font-size: 0.92rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #d6d6dc; } +/* ------------------------------------------------------------ bibliothèque */ + +.lib-groups { display: flex; flex-direction: column; gap: 0.7rem; } +.lib-group { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } +.lib-group-head { + display: flex; + align-items: center; + gap: 0.8rem; + width: 100%; + padding: 0.75rem 1.1rem; + background: none; + border: none; + color: inherit; + font-size: 1rem; + cursor: pointer; + text-align: left; + user-select: none; + transition: background 0.15s; +} +.lib-group-head:hover { background: var(--surface-2); } +.lib-chevron { color: var(--text-dim); font-size: 0.72rem; width: 0.9rem; flex-shrink: 0; } +.lib-group-name { flex: 1; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.lib-group-meta { font-size: 0.78rem; color: var(--text-dim); font-variant-numeric: tabular-nums; white-space: nowrap; } +.lib-group-body { padding: 0.7rem; border-top: 1px solid var(--border); } +.lib-group-body .episode-row { background: transparent; } +.lib-group-body .episode-row:hover { background: var(--surface-2); } + +.section-title .badge { vertical-align: middle; margin-left: 0.5rem; } + /* ------------------------------------------------------------ téléchargements */ .progress { diff --git a/app/templates/library.html b/app/templates/library.html index 9de726f..dcb19bb 100644 --- a/app/templates/library.html +++ b/app/templates/library.html @@ -4,27 +4,54 @@ {% block content %}

Bibliothèque

-

Tes fichiers téléchargés, prêts à être regardés.

+

Tes fichiers téléchargés, regroupés par série et par dossier.

-