- Sans Sonarr, les séries/films téléchargés donnent leurs genres via leur fiche French-Stream (page_url des téléchargements), convertis vers les catégories Kitsu (mapping FR→EN ; genres sans équivalent ignorés) - Aucun historique (ni téléchargement, ni favori, ni Sonarr) : réponse cold_start non cachée → carte d'amorçage « téléchargez un premier titre » - Fin de téléchargement → invalidation immédiate des recommandations - genres_of_page ignore les fiches hors French-Stream (URLs d'animés)
298 lines
14 KiB
HTML
298 lines
14 KiB
HTML
{% extends "base.html" %}
|
||
{% set active = 'discover' %}
|
||
{% block title %}Découvrir — Ohm Stream{% endblock %}
|
||
|
||
{% block content %}
|
||
<h1 class="page-title">Découvrir</h1>
|
||
<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 }">
|
||
<div class="skel skel-title"></div>
|
||
<div class="rail rail-skel">
|
||
<template x-for="i in n"><div class="rail-card skel skel-card"></div></template>
|
||
</div>
|
||
</section>
|
||
<section class="rail-section" x-data="{ n: 8 }">
|
||
<div class="skel skel-title"></div>
|
||
<div class="rail rail-skel">
|
||
<template x-for="i in n"><div class="rail-card skel skel-card"></div></template>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<template x-if="error">
|
||
<div class="login-error" x-text="error"></div>
|
||
</template>
|
||
|
||
<!-- ------------------------------------------------ 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 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'">
|
||
<span class="card-chip" x-text="item.label"></span>
|
||
<span class="card-year" x-text="item.start_date ? item.start_date.slice(0, 4) : ''"></span>
|
||
<span class="card-airing" x-show="item.status === 'current'" title="En cours de diffusion">● EN COURS</span>
|
||
<div class="card-overlay">
|
||
<div class="card-title" x-text="item.title"></div>
|
||
<div class="card-meta">
|
||
<span class="badge badge-type" x-show="item.rating" x-text="item.rating ? '★ ' + item.rating : ''"></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>
|
||
</section>
|
||
|
||
<!-- ------------------------------------------------ Nouveautés séries & films -->
|
||
<section class="rail-section" x-show="!loading && latestSerie.length > 0">
|
||
<h2 class="rail-title">🆕 Nouveautés séries & 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 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>
|
||
<div class="rail">
|
||
<template x-for="(item, i) in mustWatch" :key="'mw' + item.kitsu_id">
|
||
<a class="card rail-card" :style="`--i:${i}`" :href="`/?q=${encodeURIComponent(item.title)}`" :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.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.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>
|
||
</section>
|
||
|
||
<!-- ------------------------------------------------ Pour toi -->
|
||
<section class="rail-section" x-show="!loading && (forYou.items.length > 0 || forYou.cold_start)">
|
||
<h2 class="rail-title">✨ Pour toi
|
||
<span class="rail-source" x-show="forYou.based_on.length"
|
||
x-text="forYou.based_on.length ? 'parce que tu aimes : ' + forYou.based_on.join(', ') : ''"></span>
|
||
</h2>
|
||
|
||
|
||
<!-- Aucun historique : carte d'amorçage au lieu d'une section muette -->
|
||
<div class="cta-card" x-show="!loading && forYou.cold_start">
|
||
<div class="cta-emoji">🧭</div>
|
||
<div class="cta-body">
|
||
<div class="cta-title">On ne te connaît pas encore…</div>
|
||
<p>Télécharge tes premiers animés ou séries, ou ajoute des favoris :
|
||
« Pour toi » s'ajustera automatiquement à tes genres préférés.</p>
|
||
<div class="cta-actions">
|
||
<a class="cta-btn" href="/">Chercher un premier titre</a>
|
||
<a class="cta-btn ghost" href="/discover">Explorer par genre</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<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 forYou.items" :key="'fy' + item.kitsu_id">
|
||
<a class="card rail-card" :style="`--i:${i}`" :href="`/?q=${encodeURIComponent(item.title)}`" :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.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.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>
|
||
</section>
|
||
|
||
<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>
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
<script>
|
||
function discoverPage() {
|
||
return {
|
||
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 [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;
|
||
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;
|
||
}
|
||
},
|
||
};
|
||
}
|
||
</script>
|
||
{% endblock %}
|