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 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}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+81
-15
@@ -4,27 +4,54 @@
|
|||||||
|
|
||||||
{% 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>
|
||||||
<a class="episode-row" :href="'/watch/' + f.id">
|
<h2 class="section-title">
|
||||||
<span style="font-size:1.3rem">🎬</span>
|
<span x-text="section.label"></span>
|
||||||
<span class="episode-name" style="font-weight:600" x-text="f.title"></span>
|
<span class="badge badge-type" x-text="section.count + ' fichier' + (section.count > 1 ? 's' : '')"></span>
|
||||||
<span style="font-size:0.8rem;color:var(--text-dim)" x-text="fmtBytes(f.total_bytes)"></span>
|
</h2>
|
||||||
<span x-show="f.position_seconds > 0" class="badge badge-type"
|
|
||||||
x-text="'⏵ ' + fmtTime(f.position_seconds)"></span>
|
<div class="lib-groups">
|
||||||
<span class="btn btn-sm">▶</span>
|
<template x-for="g in section.groups" :key="section.key + '|' + g.series + '|' + g.folder">
|
||||||
</a>
|
<div class="lib-group">
|
||||||
</template>
|
<button type="button" class="lib-group-head" @click="g.open = !g.open">
|
||||||
</div>
|
<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">
|
||||||
|
<span class="episode-num"
|
||||||
|
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 x-show="f.position_seconds > 0" class="badge badge-type"
|
||||||
|
x-text="'⏵ ' + fmtTime(f.position_seconds)"></span>
|
||||||
|
<span class="btn btn-sm">▶</span>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</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
@@ -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 = [
|
||||||
|
|||||||
Reference in New Issue
Block a user