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).
123 lines
4.7 KiB
HTML
123 lines
4.7 KiB
HTML
{% extends "base.html" %}
|
|
{% set active = 'downloads' %}
|
|
{% block title %}Téléchargements — Ohm Stream{% endblock %}
|
|
|
|
{% block content %}
|
|
<div x-data="downloadsPage()" x-init="connect()">
|
|
<div style="display:flex;align-items:center;gap:0.8rem;margin-bottom:1.6rem">
|
|
<div>
|
|
<h1 class="page-title">Téléchargements</h1>
|
|
<p class="page-sub" style="margin-bottom:0">
|
|
<span x-text="items.length"></span> tâche(s) —
|
|
<span :style="connected ? 'color:var(--success)' : 'color:var(--danger)'"
|
|
x-text="connected ? '● temps réel' : '○ reconnecté…'"></span>
|
|
</p>
|
|
</div>
|
|
<div style="flex:1"></div>
|
|
<button class="btn btn-ghost btn-sm" @click="action('clear-finished')">🧹 Nettoyer</button>
|
|
<button class="btn btn-danger btn-sm" @click="action('cancel-all')">✖ Tout annuler</button>
|
|
</div>
|
|
|
|
<template x-if="items.length === 0">
|
|
<div class="empty-state"><div class="big">📭</div>Aucun téléchargement. Lance une recherche !</div>
|
|
</template>
|
|
|
|
<div class="episode-list">
|
|
<template x-for="d in items" :key="d.id">
|
|
<div class="dl-row">
|
|
<div class="dl-head">
|
|
<span class="status-pill" :class="'status-' + d.status" x-text="d.status_label"></span>
|
|
<span class="dl-title" x-text="d.title"></span>
|
|
<div class="dl-actions">
|
|
<template x-if="d.status === 'downloading'">
|
|
<button class="btn btn-sm btn-ghost" @click="action(d.id + '/pause')">⏸</button>
|
|
</template>
|
|
<template x-if="d.status === 'paused'">
|
|
<button class="btn btn-sm btn-ghost" @click="action(d.id + '/resume')">▶</button>
|
|
</template>
|
|
<template x-if="['failed', 'cancelled'].includes(d.status)">
|
|
<button class="btn btn-sm btn-ghost" @click="action(d.id + '/retry')">↻</button>
|
|
</template>
|
|
<template x-if="['pending', 'downloading', 'paused'].includes(d.status)">
|
|
<button class="btn btn-sm btn-danger" @click="action(d.id + '/cancel')">✖</button>
|
|
</template>
|
|
<template x-if="d.status === 'done'">
|
|
<a class="btn btn-sm" :href="'/watch/' + d.id">▶ Regarder</a>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:0.9rem">
|
|
<div class="progress">
|
|
<div class="progress-fill" :class="d.status"
|
|
:style="`width:${d.percent ?? (d.status === 'done' ? 100 : 0)}%`"></div>
|
|
</div>
|
|
<span style="min-width:4rem;text-align:right;font-size:0.85rem;font-weight:600"
|
|
x-text="d.percent != null ? d.percent + '%' : (d.status === 'done' ? '100%' : '—')"></span>
|
|
</div>
|
|
<div class="dl-stats">
|
|
<span x-text="fmtBytes(d.downloaded_bytes) + ' / ' + (d.total_bytes ? fmtBytes(d.total_bytes) : '?')"></span>
|
|
<span x-show="d.speed_bps" x-text="'⚡ ' + fmtBytes(d.speed_bps) + '/s'"></span>
|
|
<span x-show="d.eta_seconds != null" x-text="'⏱ ' + fmtEta(d.eta_seconds)"></span>
|
|
<span x-show="d.error" style="color:var(--danger)" x-text="d.error"></span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
function downloadsPage() {
|
|
return {
|
|
items: [], connected: false, es: null,
|
|
|
|
connect() {
|
|
this.es = new EventSource('/api/downloads/events');
|
|
this.es.onopen = () => this.connected = true;
|
|
this.es.onerror = () => this.connected = false;
|
|
this.es.onmessage = (ev) => {
|
|
const msg = JSON.parse(ev.data);
|
|
if (msg.type === 'snapshot') this.items = msg.items;
|
|
else if (msg.type === 'update') this.upsert(msg.item);
|
|
};
|
|
},
|
|
|
|
upsert(item) {
|
|
const i = this.items.findIndex(d => d.id === item.id);
|
|
if (i >= 0) this.items[i] = item;
|
|
else this.items.unshift(item);
|
|
this.sort();
|
|
},
|
|
|
|
sort() {
|
|
const order = { downloading: 0, pending: 1, paused: 2 };
|
|
this.items.sort((a, b) => (order[a.status] ?? 3) - (order[b.status] ?? 3));
|
|
},
|
|
|
|
async action(path) {
|
|
await fetch('/api/downloads/' + path, { method: 'POST' });
|
|
if (path === 'clear-finished') {
|
|
const res = await fetch('/api/downloads');
|
|
this.items = await res.json();
|
|
}
|
|
},
|
|
|
|
fmtBytes(n) {
|
|
if (n == null) return '?';
|
|
const units = ['o', 'Ko', 'Mo', 'Go'];
|
|
let i = 0;
|
|
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
|
|
return n.toFixed(i ? 1 : 0) + ' ' + units[i];
|
|
},
|
|
|
|
fmtEta(s) {
|
|
if (s < 60) return s + 's';
|
|
if (s < 3600) return Math.floor(s / 60) + 'min';
|
|
return Math.floor(s / 3600) + 'h' + String(Math.floor((s % 3600) / 60)).padStart(2, '0');
|
|
},
|
|
};
|
|
}
|
|
</script>
|
|
{% endblock %}
|