Files
ohm_streaming/app/templates/discover.html
T
Roman d5fa546dd7 Pour toi : fallback sans Sonarr et amorçage sans historique
- 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)
2026-09-25 15:14:14 +00:00

298 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 &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 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 %}