Découvrir v2 : rails par type, exploration par genre

- Rails Nouveautés séparés : 🆕 animés (tri date Kitsu) et 🆕 séries & films
  (French-Stream) — chaque type garde son rail, plus d'écrasement mutuel
- Section 🎭 Explorer : chips type × genre — animés via catégories Kitsu,
  séries (/<genre>-series-/) et films (/films/<genre>/) via French-Stream ;
  état dans l'URL (/discover?t=serie&g=medical), partageable
- Scraper : browse(media_type, genre) + catalogue YAML surchargeable ;
  parsing div.short mutualisé avec les nouveautés
- Films réels retirés du mode « Animés » (ils suivent le rail séries)
- Endpoints /api/discover (latest_anime/latest_serie), /api/discover/genres,
  /api/discover/browse — parcours filtré par la préférence du compte
- Warmup démarrage : latest_by_type()
This commit is contained in:
Roman
2026-09-25 14:48:29 +00:00
parent 248ec60d35
commit d747c574b8
12 changed files with 526 additions and 84 deletions
+155 -14
View File
@@ -4,10 +4,61 @@
{% block content %}
<h1 class="page-title">Découvrir</h1>
<p class="page-sub">Nouveautés de tes sources, incontournables et suggestions basées sur tes téléchargements.</p>
<p class="page-sub">Explore par genre, nouveautés de tes sources, incontournables et suggestions basées sur tes téléchargements.</p>
<div x-data="discoverPage()" x-init="load()" x-cloak>
<!-- ------------------------------------------------ Explorer par genre -->
<section class="rail-section explore-section" x-show="!loading && types.length > 0">
<h2 class="rail-title">🎭 Explorer
<span class="rail-source" x-text="exploreSubtitle"></span>
</h2>
<div class="explore-filters">
<div class="chip-row" role="tablist" aria-label="Type de contenu">
<template x-for="t in types" :key="t">
<button class="genre-chip" :class="{ active: exploreType === t }" role="tab"
:aria-selected="exploreType === t" @click="setType(t)"
x-text="typeLabels[t]"></button>
</template>
</div>
<div class="chip-row" role="tablist" aria-label="Genre" x-show="currentGenres.length > 0">
<template x-for="g in currentGenres" :key="g.key">
<button class="genre-chip" :class="{ active: exploreGenre === g.key }" role="tab"
:aria-selected="exploreGenre === g.key" @click="setGenre(g.key)"
x-text="g.label"></button>
</template>
</div>
</div>
<div class="rail-wrap" x-show="!exploreLoading">
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
@click="scrollRail($el, -1)">‹</button>
<div class="rail">
<template x-for="(item, i) in exploreItems" :key="'ex' + i + (item.source_id || item.kitsu_id || item.title)">
<a class="card rail-card" :style="`--i:${i}`" :href="exploreHref(item)"
:title="item.source ? item.title : `Rechercher « ${item.title} »`">
<img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title"
loading="lazy" onerror="this.src='/static/img/placeholder.svg'">
<span class="card-chip" x-show="item.label" x-text="item.label"></span>
<span class="card-chip" x-show="!item.label && item.rating" x-text="item.rating ? '★ ' + item.rating : ''"></span>
<div class="card-overlay">
<div class="card-title" x-text="item.title"></div>
<div class="card-meta">
<span class="badge badge-type" x-text="item.start_date ? item.start_date.slice(0, 4) : (item.year || '')"></span>
</div>
</div>
</a>
</template>
</div>
<button class="rail-nav rail-next" type="button" aria-label="Défiler à droite"
@click="scrollRail($el, 1)">›</button>
</div>
<div class="explore-skel" x-show="exploreLoading">
<div class="rail rail-skel">
<template x-for="i in 8" :key="'sk' + i"><div class="rail-card skel skel-card"></div></template>
</div>
</div>
</section>
<!-- ------------------------------------------------ Skeletons de chargement -->
<div x-show="loading">
<section class="rail-section" x-data="{ n: 8 }">
@@ -28,14 +79,14 @@
<div class="login-error" x-text="error"></div>
</template>
<!-- ------------------------------------------------ Nouveautés -->
<section class="rail-section" x-show="!loading && latest.length > 0">
<h2 class="rail-title">🆕 Nouveautés <span class="rail-source">de tes sources, triées par date de sortie</span></h2>
<!-- ------------------------------------------------ Nouveautés animés -->
<section class="rail-section" x-show="!loading && latestAnime.length > 0">
<h2 class="rail-title">🆕 Nouveautés animés <span class="rail-source">triées par date de sortie</span></h2>
<div class="rail-wrap">
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
@click="scrollRail($el, -1)">‹</button>
<div class="rail">
<template x-for="(item, i) in latest" :key="item.source + item.source_id">
<template x-for="(item, i) in latestAnime" :key="item.source + item.source_id">
<a class="card rail-card" :style="`--i:${i}`" :href="`/title/${item.source}/${encodeURIComponent(item.source_id)}`">
<img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title"
loading="lazy" onerror="this.src='/static/img/placeholder.svg'">
@@ -56,9 +107,33 @@
</div>
</section>
<!-- ------------------------------------------------ Nouveautés séries & films -->
<section class="rail-section" x-show="!loading && latestSerie.length > 0">
<h2 class="rail-title">🆕 Nouveautés séries &amp; films <span class="rail-source">ajouts récents de French-Stream</span></h2>
<div class="rail-wrap">
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
@click="scrollRail($el, -1)">‹</button>
<div class="rail">
<template x-for="(item, i) in latestSerie" :key="item.source + item.source_id">
<a class="card rail-card" :style="`--i:${i}`" :href="`/title/${item.source}/${encodeURIComponent(item.source_id)}`">
<img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title"
loading="lazy" onerror="this.src='/static/img/placeholder.svg'">
<span class="card-chip" x-text="item.label"></span>
<span class="card-year" x-text="item.media_type === 'film' ? 'Film' : ''"></span>
<div class="card-overlay">
<div class="card-title" x-text="item.title"></div>
</div>
</a>
</template>
</div>
<button class="rail-nav rail-next" type="button" aria-label="Défiler à droite"
@click="scrollRail($el, 1)">›</button>
</div>
</section>
<!-- ------------------------------------------------ Incontournables -->
<section class="rail-section" x-show="!loading && mustWatch.length > 0">
<h2 class="rail-title">🔥 Incontournables <span class="rail-source">les classiques les mieux notés</span></h2>
<h2 class="rail-title">🔥 Incontournables animés <span class="rail-source">les classiques les mieux notés</span></h2>
<div class="rail-wrap">
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
@click="scrollRail($el, -1)">‹</button>
@@ -107,7 +182,7 @@
</div>
</section>
<template x-if="!loading && !error && latest.length === 0 && mustWatch.length === 0 && forYou.items.length === 0">
<template x-if="!loading && !error && latestAnime.length === 0 && latestSerie.length === 0 && mustWatch.length === 0 && forYou.items.length === 0 && exploreItems.length === 0">
<div class="empty-state"><div class="big">🏜️</div>Rien à afficher pour le moment — réessaie plus tard.</div>
</template>
</div>
@@ -117,23 +192,89 @@
<script>
function discoverPage() {
return {
latest: [], mustWatch: [], forYou: { based_on: [], items: [] },
latestAnime: [], latestSerie: [], mustWatch: [], forYou: { based_on: [], items: [] },
genres: {}, exploreType: null, exploreGenre: null,
exploreItems: [], exploreLoading: false,
loading: true, error: null,
typeLabels: { anime: '⛩ Animés', serie: '📺 Séries', film: '🎬 Films' },
get types() { return Object.keys(this.genres); },
get currentGenres() { return this.genres[this.exploreType] || []; },
get exploreSubtitle() {
const genre = (this.currentGenres.find(g => g.key === this.exploreGenre) || {}).label;
return genre ? `${this.typeLabels[this.exploreType] || ''} · ${genre}` : 'choisis un genre';
},
scrollRail(el, dir) {
const rail = el.closest('.rail-wrap').querySelector('.rail');
rail.scrollBy({ left: dir * rail.clientWidth * 0.85, behavior: 'smooth' });
},
exploreHref(item) {
return item.source
? `/title/${item.source}/${encodeURIComponent(item.source_id)}`
: `/?q=${encodeURIComponent(item.title)}`;
},
setType(type) {
this.exploreType = type;
this.exploreGenre = (this.genres[type][0] || {}).key || null;
this.browse();
},
setGenre(genre) {
this.exploreGenre = genre;
this.browse();
},
async browse() {
if (!this.exploreType || !this.exploreGenre) return;
this.exploreLoading = true;
const params = new URLSearchParams({ type: this.exploreType, genre: this.exploreGenre });
const url = `${location.pathname}?${params}`;
history.replaceState(null, '', url); // état partageable
try {
const res = await fetch(`/api/discover/browse?${params}`);
if (!res.ok) throw new Error('Erreur ' + res.status);
this.exploreItems = (await res.json()).items;
} catch (e) {
this.exploreItems = [];
console.error('Explorer :', e);
} finally {
this.exploreLoading = false;
}
},
async load() {
this.loading = true; this.error = null;
try {
const res = await fetch('/api/discover');
if (!res.ok) throw new Error('Erreur ' + res.status);
const data = await res.json();
this.latest = data.latest;
const [discoverRes, genresRes] = await Promise.all([
fetch('/api/discover'), fetch('/api/discover/genres'),
]);
if (!discoverRes.ok) throw new Error('Erreur ' + discoverRes.status);
const data = await discoverRes.json();
this.latestAnime = data.latest_anime;
this.latestSerie = data.latest_serie;
this.mustWatch = data.must_watch;
this.forYou = data.for_you;
} catch (e) { this.error = e.message; }
finally { this.loading = false; }
this.genres = await genresRes.json();
// État initial : paramètres d'URL (?t=&g=) sinon premier type/genre dispo
const params = new URLSearchParams(location.search);
let type = params.get('t');
if (!this.genres[type]) type = this.types[0];
let genre = params.get('g');
if (!(this.genres[type] || []).some(g => g.key === genre)) {
genre = ((this.genres[type] || [])[0] || {}).key;
}
this.exploreType = type || null;
this.exploreGenre = genre || null;
await this.browse();
} catch (e) {
this.error = e.message;
} finally {
this.loading = false;
}
},
};
}