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
This commit is contained in:
@@ -152,6 +152,6 @@ app/
|
|||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run pytest # 61 tests
|
uv run pytest # 82 tests
|
||||||
uv run ruff check . # lint
|
uv run ruff check . # lint
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ async def cancel(download_id: int) -> dict:
|
|||||||
await download_manager.cancel(download_id)
|
await download_manager.cancel(download_id)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
@router.delete("/{download_id}")
|
||||||
|
async def delete_download(download_id: int, delete_file: bool = False) -> dict:
|
||||||
|
"""Retire une tâche de la file ; delete_file=true efface aussi le fichier."""
|
||||||
|
if not await download_manager.delete(download_id, delete_file=delete_file):
|
||||||
|
raise HTTPException(404, detail="Téléchargement introuvable")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/cancel-all")
|
@router.post("/cancel-all")
|
||||||
async def cancel_all() -> dict:
|
async def cancel_all() -> dict:
|
||||||
@@ -81,7 +88,7 @@ async def events() -> EventSourceResponse:
|
|||||||
|
|
||||||
async def stream() -> AsyncIterator[dict]:
|
async def stream() -> AsyncIterator[dict]:
|
||||||
yield {"data": json.dumps({"type": "snapshot", "items": await download_manager.list_all()})}
|
yield {"data": json.dumps({"type": "snapshot", "items": await download_manager.list_all()})}
|
||||||
async for update in download_manager.subscribe():
|
async for message in download_manager.subscribe():
|
||||||
yield {"data": json.dumps({"type": "update", "item": update}, default=str)}
|
yield {"data": json.dumps(message, default=str)}
|
||||||
|
|
||||||
return EventSourceResponse(stream())
|
return EventSourceResponse(stream())
|
||||||
|
|||||||
@@ -223,7 +223,25 @@ class DownloadManager:
|
|||||||
)
|
)
|
||||||
return cursor.rowcount or 0
|
return cursor.rowcount or 0
|
||||||
|
|
||||||
async def list_all(self, limit: int = 200) -> list[dict]:
|
async def delete(self, download_id: int, delete_file: bool = False) -> bool:
|
||||||
|
"""Supprime une tâche de la file (annulée avant si active).
|
||||||
|
|
||||||
|
delete_file=True efface aussi le fichier téléchargé du disque.
|
||||||
|
"""
|
||||||
|
row = await db.fetchone("SELECT * FROM downloads WHERE id = ?", (download_id,))
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
if row["status"] in ACTIVE_STATUSES:
|
||||||
|
await self.cancel(download_id)
|
||||||
|
if delete_file and row["file_path"]:
|
||||||
|
(get_settings().download_dir / row["file_path"]).unlink(missing_ok=True)
|
||||||
|
self._part_path(row["file_path"]).unlink(missing_ok=True)
|
||||||
|
await db.execute("DELETE FROM downloads WHERE id = ?", (download_id,))
|
||||||
|
await self._emit_removed(download_id)
|
||||||
|
logger.info("Téléchargement supprimé (id=%s, fichier=%s)", download_id, delete_file)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def list_all(self, limit: int = 2000) -> list[dict]:
|
||||||
rows = await db.fetchall(
|
rows = await db.fetchall(
|
||||||
"SELECT * FROM downloads ORDER BY "
|
"SELECT * FROM downloads ORDER BY "
|
||||||
"CASE status WHEN 'downloading' THEN 0 WHEN 'pending' THEN 1 WHEN 'paused' THEN 2 "
|
"CASE status WHEN 'downloading' THEN 0 WHEN 'pending' THEN 1 WHEN 'paused' THEN 2 "
|
||||||
@@ -251,9 +269,15 @@ class DownloadManager:
|
|||||||
data = await self.get(download_id)
|
data = await self.get(download_id)
|
||||||
if data is None:
|
if data is None:
|
||||||
return
|
return
|
||||||
|
self._publish({"type": "update", "item": data})
|
||||||
|
|
||||||
|
async def _emit_removed(self, download_id: int) -> None:
|
||||||
|
self._publish({"type": "removed", "id": download_id})
|
||||||
|
|
||||||
|
def _publish(self, message: dict) -> None:
|
||||||
for queue in self._listeners:
|
for queue in self._listeners:
|
||||||
with contextlib.suppress(asyncio.QueueFull):
|
with contextlib.suppress(asyncio.QueueFull):
|
||||||
queue.put_nowait(data)
|
queue.put_nowait(message)
|
||||||
|
|
||||||
# ------------------------------------------------------------ worker interne
|
# ------------------------------------------------------------ worker interne
|
||||||
|
|
||||||
|
|||||||
@@ -344,7 +344,46 @@ button { font-family: inherit; }
|
|||||||
.status-downloading { background: rgba(229, 9, 20, 0.16); color: #ff6b73; }
|
.status-downloading { background: rgba(229, 9, 20, 0.16); color: #ff6b73; }
|
||||||
.status-done { background: rgba(70, 211, 105, 0.14); color: var(--success); }
|
.status-done { background: rgba(70, 211, 105, 0.14); color: var(--success); }
|
||||||
.status-failed, .status-cancelled { background: rgba(255, 92, 92, 0.14); color: var(--danger); }
|
.status-failed, .status-cancelled { background: rgba(255, 92, 92, 0.14); color: var(--danger); }
|
||||||
|
.dl-groups { display: flex; flex-direction: column; gap: 0.9rem; }
|
||||||
|
.dl-group { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); }
|
||||||
|
.dl-group-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.9rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0.75rem 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.dl-group-head:hover { background: var(--surface-2); }
|
||||||
|
.dl-chevron { color: var(--text-dim); font-size: 0.72rem; transition: transform 0.2s; }
|
||||||
|
.dl-chevron.open { transform: rotate(90deg); }
|
||||||
|
.dl-group-title { font-weight: 700; font-size: 1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dl-group-meta { display: flex; gap: 0.6rem; font-size: 0.78rem; font-weight: 600; white-space: nowrap; }
|
||||||
|
.dl-group-meta .m-downloading { color: #ff6b73; }
|
||||||
|
.dl-group-meta .m-pending { color: var(--warning); }
|
||||||
|
.dl-group-meta .m-done { color: var(--success); }
|
||||||
|
.dl-group-meta .m-failed { color: var(--danger); }
|
||||||
|
.dl-group-size { font-size: 0.8rem; color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
||||||
|
.dl-group-body { display: flex; flex-direction: column; gap: 0.4rem; padding: 0.75rem; border-top: 1px solid var(--border); }
|
||||||
|
.dl-group-body .dl-row { border-color: transparent; background: transparent; padding: 0.55rem 0.7rem; }
|
||||||
|
.dl-group-body .dl-row:hover { background: var(--surface-2); border-color: var(--border); }
|
||||||
|
.dl-del-wrap { position: relative; }
|
||||||
|
.dl-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 0.35rem);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.45rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
/* ------------------------------------------------------------ lecteur */
|
/* ------------------------------------------------------------ lecteur */
|
||||||
|
|
||||||
.player-wrap {
|
.player-wrap {
|
||||||
|
|||||||
+162
-52
@@ -3,18 +3,18 @@
|
|||||||
{% block title %}Téléchargements — Ohm Stream{% endblock %}
|
{% block title %}Téléchargements — Ohm Stream{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div x-data="downloadsPage()" x-init="connect()">
|
<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">
|
<div style="display:flex;align-items:center;gap:0.8rem;margin-bottom:1.6rem;flex-wrap:wrap">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="page-title">Téléchargements</h1>
|
<h1 class="page-title">Téléchargements</h1>
|
||||||
<p class="page-sub" style="margin-bottom:0">
|
<p class="page-sub" style="margin-bottom:0">
|
||||||
<span x-text="items.length"></span> tâche(s) —
|
<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)'"
|
<span :style="connected ? 'color:var(--success)' : 'color:var(--danger)'"
|
||||||
x-text="connected ? '● temps réel' : '○ reconnecté…'"></span>
|
x-text="connected ? '● temps réel' : '○ reconnecté…'"></span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style="flex:1"></div>
|
<div style="flex:1"></div>
|
||||||
<button class="btn btn-ghost btn-sm" @click="action('clear-finished')">🧹 Nettoyer</button>
|
<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>
|
<button class="btn btn-danger btn-sm" @click="action('cancel-all')">✖ Tout annuler</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -22,43 +22,83 @@
|
|||||||
<div class="empty-state"><div class="big">📭</div>Aucun téléchargement. Lance une recherche !</div>
|
<div class="empty-state"><div class="big">📭</div>Aucun téléchargement. Lance une recherche !</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="episode-list">
|
<div class="dl-groups">
|
||||||
<template x-for="d in items" :key="d.id">
|
<template x-for="g in groups" :key="g.name">
|
||||||
<div class="dl-row">
|
<div class="dl-group">
|
||||||
<div class="dl-head">
|
<div class="dl-group-head" @click="toggle(g)">
|
||||||
<span class="status-pill" :class="'status-' + d.status" x-text="d.status_label"></span>
|
<span class="dl-chevron" :class="{ open: isOpen(g) }">▶</span>
|
||||||
<span class="dl-title" x-text="d.title"></span>
|
<span class="dl-group-title" x-text="g.name"></span>
|
||||||
<div class="dl-actions">
|
<span class="dl-group-meta">
|
||||||
<template x-if="d.status === 'downloading'">
|
<span class="m-downloading" x-show="g.counts.downloading"
|
||||||
<button class="btn btn-sm btn-ghost" @click="action(d.id + '/pause')">⏸</button>
|
x-text="'⬇ ' + g.counts.downloading"></span>
|
||||||
</template>
|
<span class="m-pending" x-show="g.counts.pending + g.counts.paused"
|
||||||
<template x-if="d.status === 'paused'">
|
x-text="'⏳ ' + (g.counts.pending + g.counts.paused)"></span>
|
||||||
<button class="btn btn-sm btn-ghost" @click="action(d.id + '/resume')">▶</button>
|
<span class="m-done" x-show="g.counts.done" x-text="'✔ ' + g.counts.done"></span>
|
||||||
</template>
|
<span class="m-failed" x-show="g.counts.failed + g.counts.cancelled"
|
||||||
<template x-if="['failed', 'cancelled'].includes(d.status)">
|
x-text="'✖ ' + (g.counts.failed + g.counts.cancelled)"></span>
|
||||||
<button class="btn btn-sm btn-ghost" @click="action(d.id + '/retry')">↻</button>
|
</span>
|
||||||
</template>
|
<div style="flex:1"></div>
|
||||||
<template x-if="['pending', 'downloading', 'paused'].includes(d.status)">
|
<span class="dl-group-size" x-show="g.bytes" x-text="fmtBytes(g.bytes)"></span>
|
||||||
<button class="btn btn-sm btn-danger" @click="action(d.id + '/cancel')">✖</button>
|
<div class="dl-actions" @click.stop>
|
||||||
</template>
|
<button class="btn btn-sm btn-ghost" title="Retirer les terminés de ce groupe"
|
||||||
<template x-if="d.status === 'done'">
|
x-show="g.counts.done + g.counts.failed + g.counts.cancelled"
|
||||||
<a class="btn btn-sm" :href="'/watch/' + d.id">▶ Regarder</a>
|
@click="cleanGroup(g)">🧹</button>
|
||||||
</template>
|
<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>
|
</div>
|
||||||
<div style="display:flex;align-items:center;gap:0.9rem">
|
|
||||||
<div class="progress">
|
<div class="dl-group-body" x-show="isOpen(g)" x-cloak>
|
||||||
<div class="progress-fill" :class="d.status"
|
<template x-for="d in g.items" :key="d.id">
|
||||||
:style="`width:${d.percent ?? (d.status === 'done' ? 100 : 0)}%`"></div>
|
<div class="dl-row">
|
||||||
</div>
|
<div class="dl-head">
|
||||||
<span style="min-width:4rem;text-align:right;font-size:0.85rem;font-weight:600"
|
<span class="status-pill" :class="'status-' + d.status" x-text="d.status_label"></span>
|
||||||
x-text="d.percent != null ? d.percent + '%' : (d.status === 'done' ? '100%' : '—')"></span>
|
<span class="dl-title" x-text="d._label || d.title"></span>
|
||||||
</div>
|
<div class="dl-actions">
|
||||||
<div class="dl-stats">
|
<template x-if="d.status === 'downloading'">
|
||||||
<span x-text="fmtBytes(d.downloaded_bytes) + ' / ' + (d.total_bytes ? fmtBytes(d.total_bytes) : '?')"></span>
|
<button class="btn btn-sm btn-ghost" @click="action(d.id + '/pause')">⏸</button>
|
||||||
<span x-show="d.speed_bps" x-text="'⚡ ' + fmtBytes(d.speed_bps) + '/s'"></span>
|
</template>
|
||||||
<span x-show="d.eta_seconds != null" x-text="'⏱ ' + fmtEta(d.eta_seconds)"></span>
|
<template x-if="d.status === 'paused'">
|
||||||
<span x-show="d.error" style="color:var(--danger)" x-text="d.error"></span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -71,6 +111,8 @@
|
|||||||
function downloadsPage() {
|
function downloadsPage() {
|
||||||
return {
|
return {
|
||||||
items: [], connected: false, es: null,
|
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() {
|
connect() {
|
||||||
this.es = new EventSource('/api/downloads/events');
|
this.es = new EventSource('/api/downloads/events');
|
||||||
@@ -78,29 +120,97 @@ function downloadsPage() {
|
|||||||
this.es.onerror = () => this.connected = false;
|
this.es.onerror = () => this.connected = false;
|
||||||
this.es.onmessage = (ev) => {
|
this.es.onmessage = (ev) => {
|
||||||
const msg = JSON.parse(ev.data);
|
const msg = JSON.parse(ev.data);
|
||||||
if (msg.type === 'snapshot') this.items = msg.items;
|
if (msg.type === 'snapshot') this.items = msg.items.map(d => this.decorate(d));
|
||||||
else if (msg.type === 'update') this.upsert(msg.item);
|
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) {
|
upsert(item) {
|
||||||
const i = this.items.findIndex(d => d.id === item.id);
|
const i = this.items.findIndex(d => d.id === item.id);
|
||||||
if (i >= 0) this.items[i] = item;
|
if (i >= 0) this.items[i] = item;
|
||||||
else this.items.unshift(item);
|
else {
|
||||||
this.sort();
|
this.items.unshift(item);
|
||||||
},
|
delete this.toggled[item._base]; // nouveau groupe/épisode → retour au comportement par défaut
|
||||||
|
}
|
||||||
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) {
|
async action(path) {
|
||||||
await fetch('/api/downloads/' + path, { method: 'POST' });
|
await fetch('/api/downloads/' + path, { method: 'POST' });
|
||||||
if (path === 'clear-finished') {
|
},
|
||||||
const res = await fetch('/api/downloads');
|
|
||||||
this.items = await res.json();
|
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) {
|
fmtBytes(n) {
|
||||||
|
|||||||
@@ -69,6 +69,21 @@ async def test_stream_range(client, admin_cookies):
|
|||||||
)
|
)
|
||||||
assert r.status_code == 416
|
assert r.status_code == 416
|
||||||
|
|
||||||
|
async def test_delete_download(client, admin_cookies):
|
||||||
|
path = get_settings().download_dir / "del1.mp4"
|
||||||
|
path.write_bytes(b"data" * 100)
|
||||||
|
cursor = await db.execute(
|
||||||
|
"INSERT INTO downloads (source_key, video_url, page_url, title, file_path, status, "
|
||||||
|
"total_bytes, downloaded_bytes) "
|
||||||
|
"VALUES ('k2', 'u', 'p', 'Anime Y - E9 (VOSTFR)', 'del1.mp4', 'done', 400, 400)"
|
||||||
|
)
|
||||||
|
download_id = cursor.lastrowid
|
||||||
|
r = await client.delete(f"/api/downloads/{download_id}?delete_file=true", cookies=admin_cookies)
|
||||||
|
assert r.status_code == 200 and r.json() == {"ok": True}
|
||||||
|
assert not path.exists()
|
||||||
|
r = await client.delete(f"/api/downloads/{download_id}", cookies=admin_cookies)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
async def test_admin_required(client):
|
async def test_admin_required(client):
|
||||||
await client.post("/auth/register", data={"username": "admin2", "password": "secret123"})
|
await client.post("/auth/register", data={"username": "admin2", "password": "secret123"})
|
||||||
|
|||||||
@@ -102,3 +102,33 @@ async def test_cancel(file_server, manager):
|
|||||||
await dm.cancel(d["id"])
|
await dm.cancel(d["id"])
|
||||||
data = await dm.get(d["id"])
|
data = await dm.get(d["id"])
|
||||||
assert data["status"] in ("cancelled", "done") # course possible si déjà fini
|
assert data["status"] in ("cancelled", "done") # course possible si déjà fini
|
||||||
|
|
||||||
|
async def test_delete_keeps_file(file_server, manager):
|
||||||
|
d = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Anime X - E1 (VOSTFR)")
|
||||||
|
data = await _wait_status(d["id"], {"done", "failed"})
|
||||||
|
assert data["status"] == "done", data.get("error")
|
||||||
|
path = get_settings().download_dir / data["file_path"]
|
||||||
|
assert path.is_file()
|
||||||
|
assert await dm.delete(d["id"]) is True
|
||||||
|
assert await dm.get(d["id"]) is None
|
||||||
|
assert path.is_file() # le fichier vidéo est conservé
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_with_file(file_server, manager):
|
||||||
|
d = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Anime X - E2 (VOSTFR)")
|
||||||
|
data = await _wait_status(d["id"], {"done", "failed"})
|
||||||
|
path = get_settings().download_dir / data["file_path"]
|
||||||
|
assert await dm.delete(d["id"], delete_file=True) is True
|
||||||
|
assert await dm.get(d["id"]) is None
|
||||||
|
assert not path.exists()
|
||||||
|
assert await dm.delete(d["id"]) is False # déjà supprimé → introuvable
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_active(file_server, manager):
|
||||||
|
d = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Anime X - E3")
|
||||||
|
filename = d["file_path"]
|
||||||
|
assert await dm.delete(d["id"]) is True
|
||||||
|
assert await dm.get(d["id"]) is None
|
||||||
|
download_dir = get_settings().download_dir
|
||||||
|
assert not (download_dir / filename).exists()
|
||||||
|
assert not (download_dir / (filename + ".part")).exists()
|
||||||
|
|||||||
Reference in New Issue
Block a user