From 9500a84a5e9fa5519611b5486d1e67a9c4b2f5cf Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 22 Sep 2026 12:17:27 +0000 Subject: [PATCH] =?UTF-8?q?T=C3=A9l=C3=A9chargements=20:=20suppression=20p?= =?UTF-8?q?ar=20=C3=A9pisode=20et=20regroupement=20par=20anim=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 2 +- app/routers/downloads.py | 11 +- app/services/downloads.py | 28 ++++- app/static/css/style.css | 41 ++++++- app/templates/downloads.html | 214 ++++++++++++++++++++++++++--------- tests/test_api.py | 15 +++ tests/test_downloads.py | 30 +++++ 7 files changed, 283 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 76a718b..97b3093 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,6 @@ app/ ## Tests ```bash -uv run pytest # 61 tests +uv run pytest # 82 tests uv run ruff check . # lint ``` diff --git a/app/routers/downloads.py b/app/routers/downloads.py index 34111e0..20e22c8 100644 --- a/app/routers/downloads.py +++ b/app/routers/downloads.py @@ -64,6 +64,13 @@ async def cancel(download_id: int) -> dict: await download_manager.cancel(download_id) 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") async def cancel_all() -> dict: @@ -81,7 +88,7 @@ async def events() -> EventSourceResponse: async def stream() -> AsyncIterator[dict]: yield {"data": json.dumps({"type": "snapshot", "items": await download_manager.list_all()})} - async for update in download_manager.subscribe(): - yield {"data": json.dumps({"type": "update", "item": update}, default=str)} + async for message in download_manager.subscribe(): + yield {"data": json.dumps(message, default=str)} return EventSourceResponse(stream()) diff --git a/app/services/downloads.py b/app/services/downloads.py index 92a3c5e..23a1b46 100644 --- a/app/services/downloads.py +++ b/app/services/downloads.py @@ -223,7 +223,25 @@ class DownloadManager: ) 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( "SELECT * FROM downloads ORDER BY " "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) if data is None: 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: with contextlib.suppress(asyncio.QueueFull): - queue.put_nowait(data) + queue.put_nowait(message) # ------------------------------------------------------------ worker interne diff --git a/app/static/css/style.css b/app/static/css/style.css index bc6138a..7157814 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -344,7 +344,46 @@ button { font-family: inherit; } .status-downloading { background: rgba(229, 9, 20, 0.16); color: #ff6b73; } .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); } - +.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 */ .player-wrap { diff --git a/app/templates/downloads.html b/app/templates/downloads.html index 2d032ee..7df2865 100644 --- a/app/templates/downloads.html +++ b/app/templates/downloads.html @@ -3,18 +3,18 @@ {% block title %}Téléchargements — Ohm Stream{% endblock %} {% block content %} -
-
+
+

Téléchargements

- tâche(s) — + animé(s) · épisode(s) —

- +
@@ -22,43 +22,83 @@
📭
Aucun téléchargement. Lance une recherche !
-
-