- 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:
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user