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).
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
{% 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 %}
|
||||
Reference in New Issue
Block a user