fix: boutons telechargement fonctionnels + refonte UI downloads (#17, #8)

- Route GET /api/downloads/video/{task_id} pour streamer les videos
- Route POST /api/downloads/{task_id}/retry pour relancer les failed
- Route POST /api/downloads/cancel-all pour annuler tous les actifs
- Barre de progression animee (shimmer + pulse)
- Indicateurs visuels par status (bordures colorees)
- Bouton Retry pour telechargements echoues/annules
- Actions groupees (Nettoyer termines, Tout annuler)
- Compteur de telechargements actifs
- hx-on::after-request pour refresh auto

Closes #17, Closes #8
This commit is contained in:
root
2026-04-02 22:35:49 +00:00
parent 5d264d8f3b
commit 9f9df600c1
4 changed files with 172 additions and 15 deletions
+72 -3
View File
@@ -2,13 +2,15 @@
Download management routes for Ohm Stream Downloader API. Download management routes for Ohm Stream Downloader API.
""" """
import json
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from pathlib import Path
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, FileResponse
from app.download_manager import DownloadManager from app.download_manager import DownloadManager
from app.models import DownloadRequest from app.models import DownloadRequest, DownloadStatus
from app.models.auth import User from app.models.auth import User
from app.routers.router_auth import get_current_user_from_token, get_optional_user from app.routers.router_auth import get_current_user_from_token, get_optional_user
@@ -120,6 +122,73 @@ async def cancel_download(
raise HTTPException(status_code=400, detail="Failed to cancel download") raise HTTPException(status_code=400, detail="Failed to cancel download")
@router.get("/video/{task_id}")
async def stream_video(
task_id: str,
download_manager: DownloadManager = Depends(get_download_manager),
current_user: Optional[User] = Depends(get_optional_user),
):
"""Stream a completed download as video"""
if current_user is None:
raise HTTPException(status_code=401, detail="Authentication required")
task = download_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if task.status != DownloadStatus.COMPLETED or not task.file_path:
raise HTTPException(status_code=400, detail="Download not completed")
file_path = Path(task.file_path)
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found on disk")
media_types = {
".mp4": "video/mp4", ".mkv": "video/x-matroska", ".avi": "video/x-msvideo",
".webm": "video/webm", ".flv": "video/x-flv",
}
media_type = media_types.get(file_path.suffix.lower(), "video/mp4")
return FileResponse(str(file_path), media_type=media_type)
@router.post("/{task_id}/retry")
async def retry_download(
task_id: str,
background_tasks: BackgroundTasks,
response: Response,
download_manager: DownloadManager = Depends(get_download_manager),
current_user: User = Depends(get_current_user_from_token),
):
"""Retry a failed or cancelled download"""
task = download_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if task.status not in ("failed", "cancelled"):
raise HTTPException(status_code=400, detail="Can only retry failed or cancelled downloads")
task.status = DownloadStatus.PENDING
task.progress = 0.0
if hasattr(download_manager, "_process_download"):
background_tasks.add_task(download_manager._process_download, task_id)
response.headers["HX-Trigger"] = json.dumps(
{"show-toast": {"message": "Telechargement relance", "type": "success"}}
)
return {"status": "retrying"}
@router.post("/cancel-all")
async def cancel_all_downloads(
response: Response,
download_manager: DownloadManager = Depends(get_download_manager),
current_user: User = Depends(get_current_user_from_token),
):
"""Cancel all active downloads"""
count = 0
for tid, task in list(download_manager.tasks.items()):
if task.status in ("downloading", "pending"):
download_manager.cancel_download(tid) if hasattr(download_manager, "cancel_download") else download_manager.tasks.pop(tid, None)
count += 1
response.headers["HX-Trigger"] = json.dumps(
{"show-toast": {"message": f"{count} telechargement(s) annule(s)", "type": "info"}}
)
return {"status": "cancelled", "count": count}
@router.post("/cleanup") @router.post("/cleanup")
async def cleanup_completed( async def cleanup_completed(
download_manager: DownloadManager = Depends(get_download_manager), download_manager: DownloadManager = Depends(get_download_manager),
+62
View File
@@ -646,3 +646,65 @@ h1 {
from { transform: translateX(100%); opacity: 0; } from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; } to { transform: translateX(0); opacity: 1; }
} }
/* ==================== Download Items ==================== */
.download-item {
padding: 15px 18px;
background: var(--bg-card);
border-radius: var(--card-radius);
border-left: 4px solid var(--text-dim);
margin-bottom: 10px;
transition: var(--transition);
}
.download-item:hover {
border-color: var(--primary);
transform: translateX(3px);
}
.download-item.status-downloading {
border-left-color: var(--primary);
animation: pulse-border 2s ease-in-out infinite;
}
.download-item.status-completed {
border-left-color: var(--accent);
}
.download-item.status-failed,
.download-item.status-cancelled {
border-left-color: var(--danger);
}
.download-item.status-paused {
border-left-color: #f0a500;
}
.download-item.status-pending {
border-left-color: var(--text-dim);
}
@keyframes pulse-border {
0%, 100% { border-left-color: var(--primary); }
50% { border-left-color: rgba(0, 217, 255, 0.3); }
}
/* Progress bar shimmer */
.download-item.status-downloading .progress-bar {
background: linear-gradient(90deg, var(--primary) 0%, var(--accent) 50%, var(--primary) 100%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Download action buttons */
.download-actions .btn-icon.warning {
color: #f0a500;
}
.download-actions .btn-icon.warning:hover {
background: rgba(240, 165, 0, 0.2);
}
+18 -8
View File
@@ -1,7 +1,7 @@
{% if tasks %} {% if tasks %}
<div class="downloads-grid"> <div class="downloads-grid">
{% for task in tasks %} {% for task in tasks %}
<div class="download-item task-{{ task.status }}"> <div class="download-item status-{{ task.status }}">
<div class="download-info"> <div class="download-info">
<span class="download-name" title="{{ task.filename }}">{{ task.filename }}</span> <span class="download-name" title="{{ task.filename }}">{{ task.filename }}</span>
<span class="badge badge-{{ task.status }}">{{ task.status | upper }}</span> <span class="badge badge-{{ task.status }}">{{ task.status | upper }}</span>
@@ -19,28 +19,38 @@
<div class="download-actions"> <div class="download-actions">
{% if task.status == 'downloading' or task.status == 'pending' %} {% if task.status == 'downloading' or task.status == 'pending' %}
<button class="btn-icon" hx-post="/api/downloads/{{ task.id }}/pause" hx-swap="none"> <button class="btn-icon" hx-post="/api/downloads/{{ task.id }}/pause" hx-swap="none"
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')" title="Pause">
<i class="fas fa-pause"></i> <i class="fas fa-pause"></i>
</button> </button>
{% elif task.status == 'paused' %} {% elif task.status == 'paused' %}
<button class="btn-icon success" hx-post="/api/downloads/{{ task.id }}/resume" hx-swap="none"> <button class="btn-icon success" hx-post="/api/downloads/{{ task.id }}/resume" hx-swap="none"
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')" title="Reprendre">
<i class="fas fa-play"></i> <i class="fas fa-play"></i>
</button> </button>
{% endif %} {% endif %}
{% if task.status == 'failed' or task.status == 'cancelled' %}
<button class="btn-icon warning" hx-post="/api/downloads/{{ task.id }}/retry" hx-swap="none"
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')" title="Relancer">
<i class="fas fa-redo"></i>
</button>
{% endif %}
{% if task.status == 'completed' %} {% if task.status == 'completed' %}
<a href="/video/{{ task.id }}" class="btn-icon success" title="Voir la vidéo"> <a href="/api/downloads/video/{{ task.id }}" class="btn-icon success" title="Streamer">
<i class="fas fa-external-link-alt"></i> <i class="fas fa-play-circle"></i>
</a> </a>
<a href="/downloads/{{ task.filename }}" class="btn-icon" download title="Télécharger le fichier"> <a href="/downloads/{{ task.filename }}" class="btn-icon" download title="Telecharger">
<i class="fas fa-file-download"></i> <i class="fas fa-file-download"></i>
</a> </a>
{% endif %} {% endif %}
<button class="btn-icon danger" <button class="btn-icon danger"
hx-delete="/api/downloads/{{ task.id }}" hx-delete="/api/downloads/{{ task.id }}"
hx-confirm="Supprimer ce téléchargement ?" hx-confirm="Supprimer ce telechargement ?"
hx-swap="none" hx-swap="none"
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')"
title="Supprimer"> title="Supprimer">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
</button> </button>
@@ -51,6 +61,6 @@
{% else %} {% else %}
<div class="empty-state" style="text-align: center; padding: 60px 20px; color: var(--text-dim);"> <div class="empty-state" style="text-align: center; padding: 60px 20px; color: var(--text-dim);">
<i class="fas fa-cloud-download-alt" style="font-size: 3rem; margin-bottom: 20px; opacity: 0.1; display: block;"></i> <i class="fas fa-cloud-download-alt" style="font-size: 3rem; margin-bottom: 20px; opacity: 0.1; display: block;"></i>
<p>Aucun téléchargement en cours</p> <p>Aucun telechargement en cours</p>
</div> </div>
{% endif %} {% endif %}
+20 -4
View File
@@ -1,12 +1,20 @@
<div class="section-container"> <div class="section-container">
<div class="section-header"> <div class="section-header">
<h2>📥 Téléchargements</h2> <h2>Telechargements <span id="activeDownloadsCount" class="active-downloads-counter" style="display:none;">(0 actif)</span></h2>
<div class="header-actions"> <div class="header-actions">
<button class="btn btn-sm btn-secondary" <button class="btn btn-sm btn-secondary"
hx-post="/api/downloads/cleanup" hx-post="/api/downloads/cleanup"
hx-swap="none" hx-swap="none"
hx-confirm="Nettoyer tous les telechargements termines ?"
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')"> hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')">
Nettoyer terminés <i class="fas fa-broom"></i> Nettoyer termines
</button>
<button class="btn btn-sm btn-danger"
hx-post="/api/downloads/cancel-all"
hx-swap="none"
hx-confirm="Annuler tous les telechargements actifs ?"
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')">
<i class="fas fa-stop-circle"></i> Tout annuler
</button> </button>
</div> </div>
</div> </div>
@@ -17,12 +25,20 @@
hx-trigger="load, refresh, every 3s" hx-trigger="load, refresh, every 3s"
hx-swap="innerHTML"> hx-swap="innerHTML">
<div class="loading-placeholder"> <div class="loading-placeholder">
<div class="spinner"></div> Chargement des téléchargements... <div class="spinner"></div> Chargement des telechargements...
</div> </div>
</div> </div>
</div> </div>
<style> <style>
.section-container { margin-bottom: 40px; } .section-container { margin-bottom: 40px; }
/* Styles already defined or moved to downloads_list.html */ .active-downloads-counter {
font-size: 0.85rem;
font-weight: 600;
color: var(--primary);
background: rgba(0, 217, 255, 0.1);
padding: 2px 10px;
border-radius: 12px;
margin-left: 10px;
}
</style> </style>