Files
ohm_streaming/app/templates/title.html
T
Roman 41566ab5fb v0.1.0 — Réécriture complète de OhmStreaming
Nouvelle version réécrite de zéro : recherche multi-sources (Vostfree,
French-Manga), extraction 2 niveaux, proxy vidéo intégré, streaming/téléchargement
HLS, métadonnées Kitsu, bibliothèque locale, comptes JWT + administration,
découverte fusionnée, indexeur Torznab (Sonarr/Prowlarr).
2026-09-22 10:05:47 +00:00

177 lines
7.2 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="details.episodes.length"></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</h2>
<div class="episode-list">
<template x-for="ep in details.episodes" :key="ep.number">
<div class="episode-row">
<span class="episode-num" x-text="'E' + ep.number"></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 },
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);
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) { return `${this.details.title} - E${ep.number}`; },
// 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.details.episodes.length };
for (const ep of this.details.episodes) {
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 %}