- 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
208 lines
8.5 KiB
HTML
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 %}
|