Files
ohm_streaming/app/templates/title.html
T
Roman affc97c527 Choix de la langue VF/VOSTFR sur la fiche titre
- Episode : nouveau champ version (vf/vostfr)
- French-Manga : épisodes listés pour chaque langue disponible
  (VOSTFR puis VF) au lieu d'un choix automatique, fragment
  #ep=<version>-<num> déjà résolu par extract_embed_links
- Vostfree : langue déduite du titre h1 ou du slug de la fiche
- UI : filtre VOSTFR/VF/Tout (défaut VOSTFR), badge par épisode,
  téléchargement de saison limité à la langue filtrée, compteur
  d'épisodes unique, version incluse dans le nom de fichier
2026-09-22 11:58:10 +00:00

208 lines
8.5 KiB
HTML

{% extends "base.html" %}
{% set active = 'search' %}
{% block title %}Fiche — Ohm Stream{% endblock %}
{% block content %}
<div x-data="titlePage()" data-source="{{ source }}" data-source-id="{{ source_id }}"
x-init="load($el.dataset.source, $el.dataset.sourceId)">
<div class="spinner" x-show="loading"></div>
<template x-if="error">
<div class="login-error" x-text="error"></div>
</template>
<template x-if="details">
<div>
<div class="title-hero">
<div class="title-banner" :style="details.banner_url || details.image_url ? `background-image:url('${details.banner_url || details.image_url}')` : ''"></div>
<div class="title-hero-inner">
<img class="title-poster" :src="details.image_url || '/static/img/placeholder.svg'"
onerror="this.src='/static/img/placeholder.svg'">
<div class="title-info">
<h1 x-text="details.title"></h1>
<div class="title-tags">
<span class="badge badge-source" x-text="details.source"></span>
<template x-for="g in details.genres.slice(0, 6)"><span class="badge badge-type" x-text="g"></span></template>
</div>
<p class="title-synopsis" x-text="details.synopsis || 'Pas de synopsis disponible.'"></p>
<div class="title-stats">
<span x-show="details.rating"><span class="rating-star">★</span> <strong x-text="details.rating"></strong>/10</span>
<span x-show="details.year">Année : <strong x-text="details.year"></strong></span>
<span><strong x-text="uniqueEpisodeCount"></strong> épisode(s)</span>
</div>
<div class="title-actions">
<button class="btn" @click="downloadSeason" :disabled="seasonJob.running">
<span x-show="!seasonJob.running">⬇ Télécharger la saison</span>
<span x-show="seasonJob.running" x-text="`⏳ ${seasonJob.done}/${seasonJob.total} ajoutés…`"></span>
</button>
<button class="btn btn-ghost" @click="toggleFavorite">
<span x-text="favorite ? '★ Dans les favoris' : '☆ Ajouter aux favoris'"></span>
</button>
</div>
</div>
</div>
</div>
<h2 class="section-title">Épisodes
<span class="version-filter" x-show="epVersions.length" x-cloak>
<template x-for="v in epVersions" :key="v">
<button class="version-tab" :class="{ active: versionFilter === v }"
@click="versionFilter = v" x-text="v.toUpperCase()"></button>
</template>
<button class="version-tab" :class="{ active: versionFilter === 'all' }"
@click="versionFilter = 'all'">Tout</button>
</span>
</h2>
<div class="episode-list">
<template x-for="ep in filteredEpisodes" :key="ep.number + '-' + (ep.version || '')">
<div class="episode-row">
<span class="episode-num" x-text="'E' + ep.number"></span>
<span class="badge badge-version" x-show="ep.version" x-text="ep.version.toUpperCase()"></span>
<span class="episode-name" x-text="ep.title || `Épisode ${ep.number}`"></span>
<button class="btn btn-sm btn-ghost" @click="streamEpisode(ep)" :disabled="ep._busy">▶ Stream</button>
<button class="btn btn-sm" @click="downloadEpisode(ep)" :disabled="ep._busy">⬇</button>
</div>
</template>
</div>
<!-- Lecteur modal -->
<div x-show="player.active" style="position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:50;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:0.8rem"
@keydown.escape.window="closePlayer()" x-transition x-cloak>
<video x-ref="player" controls autoplay style="max-width:90vw;max-height:80vh;border-radius:8px"></video>
<button class="btn btn-ghost" @click="closePlayer()">Fermer ✕</button>
</div>
</div>
</template>
</div>
{% endblock %}
{% block scripts %}
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.13/dist/hls.min.js"></script>
<script>
function titlePage() {
return {
source: null, sourceId: null,
details: null, loading: true, error: null, favorite: false,
player: { active: false, hls: null },
seasonJob: { running: false, done: 0, total: 0 },
versionFilter: 'all',
get epVersions() {
if (!this.details) return [];
return [...new Set(this.details.episodes.map(e => e.version).filter(Boolean))];
},
get filteredEpisodes() {
if (!this.details) return [];
if (this.versionFilter === 'all') return this.details.episodes;
return this.details.episodes.filter(e => e.version === this.versionFilter);
},
get uniqueEpisodeCount() {
if (!this.details) return 0;
return new Set(this.details.episodes.map(e => e.number)).size;
},
async load(source, sourceId) {
this.source = source; this.sourceId = sourceId;
try {
const res = await fetch(`/api/titles/${source}/${encodeURIComponent(sourceId)}`);
if (!res.ok) throw new Error((await res.json()).detail || 'Erreur ' + res.status);
this.details = await res.json();
this.details.episodes.forEach(e => e._busy = false);
this.versionFilter = this.epVersions.includes('vostfr') ? 'vostfr' : 'all';
document.title = this.details.title + ' — Ohm Stream';
} catch (e) { this.error = e.message; }
finally { this.loading = false; }
},
// Chaîne complète : page épisode → embeds → lien vidéo direct
async extract(ep) {
const res = await fetch('/api/extract?episode_url=' + encodeURIComponent(ep.url));
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Extraction impossible');
if (!data.links.length) throw new Error(data.errors[0] || 'Aucun lien vidéo résolu');
return data.links[0];
},
epLabel(ep) {
const version = ep.version ? ` (${ep.version.toUpperCase()})` : '';
return `${this.details.title} - E${ep.number}${version}`;
},
// Lecture via le proxy serveur (tokens liés à l'IP, Referer obligatoire)
openPlayer(link) {
const ref = link.headers?.Referer || '';
const src = '/api/proxy?url=' + encodeURIComponent(link.url)
+ (ref ? '&ref=' + encodeURIComponent(ref) : '');
const video = this.$refs.player;
if (link.is_hls && window.Hls && window.Hls.isSupported()) {
this.player.hls = new Hls();
this.player.hls.loadSource(src);
this.player.hls.attachMedia(video);
} else {
video.src = src; // Safari lit le HLS nativement
}
this.player.active = true;
},
closePlayer() {
if (this.player.hls) { this.player.hls.destroy(); this.player.hls = null; }
this.$refs.player.pause();
this.$refs.player.removeAttribute('src');
this.player.active = false;
},
async streamEpisode(ep) {
ep._busy = true;
try {
const link = await this.extract(ep);
this.openPlayer(link);
} catch (e) { toast('⚠ ' + e.message); }
finally { ep._busy = false; }
},
async downloadEpisode(ep) {
ep._busy = true;
try {
const link = await this.extract(ep);
const referer = link.headers?.Referer || ep.url;
const internal = [link.url, referer, this.epLabel(ep)].join('|');
await fetch('/api/downloads', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ internal_url: internal }),
});
toast('⬇ Ajouté : ' + this.epLabel(ep));
} catch (e) { toast('⚠ ' + e.message); }
finally { ep._busy = false; }
},
async downloadSeason() {
this.seasonJob = { running: true, done: 0, total: this.filteredEpisodes.length };
for (const ep of this.filteredEpisodes) {
await this.downloadEpisode(ep);
this.seasonJob.done++;
}
this.seasonJob.running = false;
toast('✔ Saison ajoutée à la file de téléchargement');
},
async toggleFavorite() {
if (this.favorite) return;
await fetch('/api/favorites', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source: this.source, source_id: this.sourceId,
title: this.details.title, image_url: this.details.image_url,
payload: { year: this.details.year, genres: this.details.genres },
}),
});
this.favorite = true;
toast('⭐ Ajouté aux favoris');
},
};
}
</script>
{% endblock %}