Files
ohm_streaming/app/templates/downloads.html
T
Roman 9500a84a5e Téléchargements : suppression par épisode et regroupement par animé
- DELETE /api/downloads/{id} (?delete_file=true pour effacer aussi le fichier)
- Événement SSE « removed » → toutes les pages ouvertes se synchronisent
- Page Téléchargements groupée par animé : groupes repliables (dépliés si
  actifs), compteurs par statut, taille totale, tri par numéro d'épisode
- Actions par groupe : 🧹 retirer les terminés, ✖ annuler les actifs
- Limite de liste 200 → 2000 tâches
2026-09-22 12:17:27 +00:00

233 lines
10 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()" @click.outside="confirmDel = null">
<div style="display:flex;align-items:center;gap:0.8rem;margin-bottom:1.6rem;flex-wrap:wrap">
<div>
<h1 class="page-title">Téléchargements</h1>
<p class="page-sub" style="margin-bottom:0">
<span x-text="groups.length"></span> animé(s) · <span x-text="items.length"></span> épisode(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="clearFinished()">🧹 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="dl-groups">
<template x-for="g in groups" :key="g.name">
<div class="dl-group">
<div class="dl-group-head" @click="toggle(g)">
<span class="dl-chevron" :class="{ open: isOpen(g) }">▶</span>
<span class="dl-group-title" x-text="g.name"></span>
<span class="dl-group-meta">
<span class="m-downloading" x-show="g.counts.downloading"
x-text="'⬇ ' + g.counts.downloading"></span>
<span class="m-pending" x-show="g.counts.pending + g.counts.paused"
x-text="'⏳ ' + (g.counts.pending + g.counts.paused)"></span>
<span class="m-done" x-show="g.counts.done" x-text="'✔ ' + g.counts.done"></span>
<span class="m-failed" x-show="g.counts.failed + g.counts.cancelled"
x-text="'✖ ' + (g.counts.failed + g.counts.cancelled)"></span>
</span>
<div style="flex:1"></div>
<span class="dl-group-size" x-show="g.bytes" x-text="fmtBytes(g.bytes)"></span>
<div class="dl-actions" @click.stop>
<button class="btn btn-sm btn-ghost" title="Retirer les terminés de ce groupe"
x-show="g.counts.done + g.counts.failed + g.counts.cancelled"
@click="cleanGroup(g)">🧹</button>
<button class="btn btn-sm btn-danger" title="Annuler les téléchargements actifs de ce groupe"
x-show="g.counts.active" @click="cancelGroup(g)">✖</button>
</div>
</div>
<div class="dl-group-body" x-show="isOpen(g)" x-cloak>
<template x-for="d in g.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._label || 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 class="dl-del-wrap">
<button class="btn btn-sm btn-ghost" title="Supprimer"
@click.stop="confirmDel = confirmDel === d.id ? null : d.id">🗑</button>
<div class="dl-menu" x-show="confirmDel === d.id" x-cloak>
<button class="btn btn-sm" @click="remove(d, false)"
x-text="d.status === 'done' ? 'Liste seulement' : 'Supprimer'"></button>
<template x-if="d.status === 'done'">
<button class="btn btn-sm btn-danger" @click="remove(d, true)">Liste + fichier</button>
</template>
</div>
</div>
</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>
</template>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function downloadsPage() {
return {
items: [], connected: false, es: null,
confirmDel: null, // id de l'épisode dont le menu 🗑 est ouvert
toggled: {}, // choix manuel de repli/dépliage par groupe
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.map(d => this.decorate(d));
else if (msg.type === 'update') this.upsert(this.decorate(msg.item));
else if (msg.type === 'removed') this.items = this.items.filter(d => d.id !== msg.id);
};
},
// Découpe « Anime - E12 (VOSTFR) » → base « Anime », numéro, libellé d'épisode
decorate(d) {
const m = d.title.match(/^(.*?)[\s\-–—]*(?:épisode|episode|ep|e)\s*(\d+(?:\.\d+)?)(.*)$/i);
if (m && m[1].trim()) {
d._base = m[1].trim();
d._num = parseFloat(m[2]);
d._label = ('E' + m[2] + ' ' + (m[3] || '')).replace(/\s+/g, ' ').trim();
} else {
d._base = d.title;
d._num = null;
d._label = '';
}
return d;
},
get groups() {
const map = new Map();
for (const d of this.items) {
if (!map.has(d._base)) map.set(d._base, []);
map.get(d._base).push(d);
}
const groups = [];
for (const [name, list] of map) {
const c = { downloading: 0, pending: 0, paused: 0, done: 0, failed: 0, cancelled: 0 };
for (const d of list) c[d.status]++;
c.active = c.downloading + c.pending + c.paused;
list.sort((a, b) => (a._num ?? Infinity) - (b._num ?? Infinity) || a.id - b.id);
groups.push({
name, items: list, counts: c,
bytes: list.reduce((s, d) => s + (d.status === 'done' ? d.total_bytes || 0 : 0), 0),
});
}
const prio = g => g.counts.downloading ? 0 : g.counts.active ? 1 : 2;
groups.sort((a, b) => prio(a) - prio(b) || a.name.localeCompare(b.name, 'fr'));
return groups;
},
isOpen(g) {
const t = this.toggled[g.name];
return t !== undefined ? t : g.counts.active > 0;
},
toggle(g) {
this.toggled[g.name] = !this.isOpen(g);
},
upsert(item) {
const i = this.items.findIndex(d => d.id === item.id);
if (i >= 0) this.items[i] = item;
else {
this.items.unshift(item);
delete this.toggled[item._base]; // nouveau groupe/épisode → retour au comportement par défaut
}
},
async action(path) {
await fetch('/api/downloads/' + path, { method: 'POST' });
},
async remove(d, deleteFile) {
this.confirmDel = null;
this.items = this.items.filter(i => i.id !== d.id);
await fetch('/api/downloads/' + d.id + (deleteFile ? '?delete_file=true' : ''), { method: 'DELETE' });
toast(deleteFile ? '🗑 Épisode et fichier supprimés' : '🗑 Retiré de la liste');
},
async clearFinished() {
await this.action('clear-finished');
const res = await fetch('/api/downloads');
this.items = (await res.json()).map(d => this.decorate(d));
},
async cleanGroup(g) {
const ids = g.items
.filter(d => ['done', 'failed', 'cancelled'].includes(d.status))
.map(d => d.id);
this.items = this.items.filter(d => !ids.includes(d.id));
await Promise.all(ids.map(id => fetch('/api/downloads/' + id, { method: 'DELETE' })));
toast('🧹 ' + ids.length + ' épisode(s) retiré(s) : ' + g.name);
},
async cancelGroup(g) {
const actives = g.items.filter(d => ['pending', 'downloading', 'paused'].includes(d.status));
await Promise.all(actives.map(d => this.action(d.id + '/cancel')));
toast('✖ ' + actives.length + ' téléchargement(s) annulé(s) : ' + g.name);
},
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 %}