- /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)
121 lines
4.7 KiB
HTML
121 lines
4.7 KiB
HTML
{% extends "base.html" %}
|
|
{% set active = 'library' %}
|
|
{% block title %}Bibliothèque — Ohm Stream{% endblock %}
|
|
|
|
{% block content %}
|
|
<h1 class="page-title">Bibliothèque</h1>
|
|
<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 class="spinner" x-show="loading"></div>
|
|
|
|
<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>
|
|
</template>
|
|
|
|
<template x-for="section in sections" :key="section.key">
|
|
<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">
|
|
<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>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
function libraryPage() {
|
|
return {
|
|
items: [], sections: [], loading: true,
|
|
|
|
async load() {
|
|
const res = await fetch('/api/library');
|
|
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;
|
|
},
|
|
|
|
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) {
|
|
if (n == null) return '';
|
|
const units = ['o', 'Ko', 'Mo', 'Go']; let i = 0;
|
|
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
|
|
return n.toFixed(i ? 1 : 0) + ' ' + units[i];
|
|
},
|
|
fmtTime(s) {
|
|
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = Math.floor(s % 60);
|
|
return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(sec).padStart(2, '0');
|
|
},
|
|
};
|
|
}
|
|
</script>
|
|
{% endblock %}
|