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:
Roman
2026-09-26 14:52:09 +00:00
parent f79c0cc86e
commit 6d15fce516
4 changed files with 177 additions and 30 deletions
+66 -14
View File
@@ -4,6 +4,8 @@ import logging
import mimetypes import mimetypes
import re import re
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from pathlib import PurePosixPath
from urllib.parse import urlparse
import aiosqlite import aiosqlite
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
@@ -24,42 +26,92 @@ CHUNK_SIZE = 1 << 20 # 1 Mio
# ---------------------------------------------------------------- bibliothèque # ---------------------------------------------------------------- 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") @router.get("/library")
async def library(user: CurrentUser) -> list[dict]: 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( rows = await db.fetchall(
"SELECT d.*, wp.position_seconds FROM downloads d " "SELECT d.*, wp.position_seconds FROM downloads d "
"LEFT JOIN watch_progress wp ON wp.download_id = d.id AND wp.user_id = ? " "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", "WHERE d.status = 'done' ORDER BY d.updated_at DESC",
(user.id,), (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") @router.get("/library/{download_id}/neighbors")
async def neighbors(download_id: int) -> dict: async def neighbors(download_id: int) -> dict:
"""Épisode précédent/suivant : heuristique sur les titres (même série, N±1).""" """É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") 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,)) current = await db.fetchone("SELECT id, title FROM downloads WHERE id = ?", (download_id,))
if current is None: if current is None:
raise HTTPException(404, "Fichier introuvable") raise HTTPException(404, "Fichier introuvable")
base, number = parse(current["title"]) cur = parse_title(current["title"])
prev_ep = next_ep = None prev_ep = next_ep = None
for row in rows: for row in rows:
other_base, other_num = parse(row["title"]) other = parse_title(row["title"])
if other_base != base or other_num is None or row["id"] == download_id: if (
other["series"] != cur["series"]
or other["season"] != cur["season"]
or other["episode"] is None
or row["id"] == download_id
):
continue 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"] 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"] next_ep = row["id"]
return {"previous": prev_ep, "next": next_ep} return {"previous": prev_ep, "next": next_ep}
+29
View File
@@ -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; } .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 */ /* ------------------------------------------------------------ téléchargements */
.progress { .progress {
+73 -7
View File
@@ -4,20 +4,42 @@
{% block content %} {% block content %}
<h1 class="page-title">Bibliothèque</h1> <h1 class="page-title">Bibliothèque</h1>
<p class="page-sub">Tes fichiers téléchargés, prêts à être regardés.</p> <p class="page-sub">Tes fichiers téléchargés, regroupés par série et par dossier.</p>
<div x-data="libraryPage()" x-init="load()"> <div x-data="libraryPage()" x-init="load()">
<div class="spinner" x-show="loading"></div> <div class="spinner" x-show="loading"></div>
<template x-if="!loading && items.length === 0"> <template x-if="!loading && sections.length === 0">
<div class="empty-state"><div class="big">🎞️</div>Rien ici pour l'instant — télécharge des épisodes !</div> <div class="empty-state"><div class="big">🎞️</div>Rien ici pour l'instant — télécharge des épisodes !</div>
</template> </template>
<div class="episode-list"> <template x-for="section in sections" :key="section.key">
<template x-for="f in items" :key="f.id"> <section>
<h2 class="section-title">
<span x-text="section.label"></span>
<span class="badge badge-type" x-text="section.count + ' fichier' + (section.count > 1 ? 's' : '')"></span>
</h2>
<div class="lib-groups">
<template x-for="g in section.groups" :key="section.key + '|' + g.series + '|' + g.folder">
<div class="lib-group">
<button type="button" class="lib-group-head" @click="g.open = !g.open">
<span class="lib-chevron" x-text="g.open ? '▾' : '▸'"></span>
<span class="lib-group-name" x-text="g.series"></span>
<span class="badge badge-type" x-text="g.items.length + ' ép.'"></span>
<span x-show="g.folder" class="badge badge-version" :title="g.folder"
x-text="'📁 ' + g.folder"></span>
<span x-show="g.watched > 0" class="lib-group-meta"
x-text="'👁 ' + g.watched + '/' + g.items.length"></span>
<span class="lib-group-meta" x-text="fmtBytes(g.total)"></span>
</button>
<div class="episode-list lib-group-body" x-show="g.open" x-transition>
<template x-for="f in g.items" :key="f.id">
<a class="episode-row" :href="'/watch/' + f.id"> <a class="episode-row" :href="'/watch/' + f.id">
<span style="font-size:1.3rem">🎬</span> <span class="episode-num"
<span class="episode-name" style="font-weight:600" x-text="f.title"></span> x-text="f.episode != null ? 'E' + f.episode : '🎬'"></span>
<span class="episode-name" style="font-weight:600" x-text="shortTitle(g, f)"></span>
<span style="font-size:0.8rem;color:var(--text-dim)" x-text="fmtBytes(f.total_bytes)"></span> <span style="font-size:0.8rem;color:var(--text-dim)" x-text="fmtBytes(f.total_bytes)"></span>
<span x-show="f.position_seconds > 0" class="badge badge-type" <span x-show="f.position_seconds > 0" class="badge badge-type"
x-text="'⏵ ' + fmtTime(f.position_seconds)"></span> x-text="'⏵ ' + fmtTime(f.position_seconds)"></span>
@@ -25,6 +47,11 @@
</a> </a>
</template> </template>
</div> </div>
</div>
</template>
</div>
</section>
</template>
</div> </div>
{% endblock %} {% endblock %}
@@ -32,12 +59,51 @@
<script> <script>
function libraryPage() { function libraryPage() {
return { return {
items: [], loading: true, items: [], sections: [], loading: true,
async load() { async load() {
const res = await fetch('/api/library'); const res = await fetch('/api/library');
this.items = await res.json(); this.items = await res.json();
this.sections = [
{ key: 'anime', label: '⛩ Animés' },
{ key: 'serie', label: '📺 Séries & Films' },
]
.map(s => {
const groups = this.buildGroups(this.items.filter(i => i.media_type === s.key));
return { ...s, groups, count: groups.reduce((n, g) => n + g.items.length, 0) };
})
.filter(s => s.groups.length > 0);
this.loading = false; this.loading = false;
}, },
buildGroups(items) {
const map = new Map();
for (const f of items) {
const key = (f.series || f.title) + '|' + (f.folder || '');
if (!map.has(key)) map.set(key, { series: f.series || f.title, folder: f.folder || '', items: [], open: false });
map.get(key).items.push(f);
}
const groups = [...map.values()].sort((a, b) => a.series.localeCompare(b.series, 'fr'));
for (const g of groups) {
g.items.sort((a, b) =>
(a.season ?? 0) - (b.season ?? 0) ||
(a.episode ?? Infinity) - (b.episode ?? Infinity) ||
a.title.localeCompare(b.title, 'fr'));
g.total = g.items.reduce((n, i) => n + (i.total_bytes || 0), 0);
g.watched = g.items.filter(i => i.position_seconds > 0).length;
}
if (groups.length === 1) groups[0].open = true;
return groups;
},
shortTitle(g, f) {
let t = f.title;
if (t.toLowerCase().startsWith(g.series.toLowerCase())) {
t = t.slice(g.series.length).replace(/^[\s\-–—_.:]+/, '');
}
return t || f.title;
},
fmtBytes(n) { fmtBytes(n) {
if (n == null) return ''; if (n == null) return '';
const units = ['o', 'Ko', 'Mo', 'Go']; let i = 0; const units = ['o', 'Ko', 'Mo', 'Go']; let i = 0;
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "ohm-stream" name = "ohm-stream"
version = "0.2.0" version = "0.3.0"
description = "Ohm Stream Downloader — centre de contrôle auto-hébergé pour animes et séries VOSTFR" description = "Ohm Stream Downloader — centre de contrôle auto-hébergé pour animes et séries VOSTFR"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [