fix download

This commit is contained in:
2025-10-30 21:13:08 +01:00
parent dbb5701660
commit 727486795c
9 changed files with 901 additions and 993 deletions

View File

@@ -208,6 +208,40 @@ async def clear_completed(
)
@router.delete("/failed", status_code=status.HTTP_200_OK)
async def clear_failed(
_: dict = Depends(require_auth),
download_service: DownloadService = Depends(get_download_service),
):
"""Clear failed downloads from history.
Removes all failed download items from the queue history. This helps
keep the queue display clean and manageable.
Requires authentication.
Returns:
dict: Status message with count of cleared items
Raises:
HTTPException: 401 if not authenticated, 500 on service error
"""
try:
cleared_count = await download_service.clear_failed()
return {
"status": "success",
"message": f"Cleared {cleared_count} failed item(s)",
"count": cleared_count,
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to clear failed items: {str(e)}",
)
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_from_queue(
item_id: str = Path(..., description="Download item ID to remove"),
@@ -485,28 +519,50 @@ async def reorder_queue(
_: dict = Depends(require_auth),
download_service: DownloadService = Depends(get_download_service),
):
"""Reorder an item in the pending queue.
"""Reorder items in the pending queue.
Changes the position of a pending download item in the queue. This only
affects items that haven't started downloading yet. The position is
0-based.
Changes the order of pending download items in the queue. This only
affects items that haven't started downloading yet. Supports both
bulk reordering with item_ids array and single item reorder.
Requires authentication.
Args:
request: Item ID and new position in queue
request: Either {"item_ids": ["id1", "id2", ...]} for bulk reorder
or {"item_id": "id", "new_position": 0} for single item
Returns:
dict: Status message indicating item has been reordered
dict: Status message indicating items have been reordered
Raises:
HTTPException: 401 if not authenticated, 404 if item not found,
400 for invalid request, 500 on service error
"""
try:
# Support legacy bulk reorder payload used by some integration tests:
# {"item_order": ["id1", "id2", ...]}
if "item_order" in request:
# Support new bulk reorder payload: {"item_ids": ["id1", "id2", ...]}
if "item_ids" in request:
item_order = request.get("item_ids", [])
if not isinstance(item_order, list):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="item_ids must be a list of item IDs",
)
success = await download_service.reorder_queue_bulk(item_order)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="One or more items in item_ids were not found in pending queue",
)
return {
"status": "success",
"message": "Queue reordered successfully",
}
# Support legacy bulk reorder payload: {"item_order": ["id1", "id2", ...]}
elif "item_order" in request:
item_order = request.get("item_order", [])
if not isinstance(item_order, list):
raise HTTPException(
@@ -515,6 +571,17 @@ async def reorder_queue(
)
success = await download_service.reorder_queue_bulk(item_order)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="One or more items in item_order were not found in pending queue",
)
return {
"status": "success",
"message": "Queue item reordered successfully",
}
else:
# Fallback to single-item reorder shape
# Validate request
@@ -531,25 +598,16 @@ async def reorder_queue(
new_position=req.new_position,
)
if not success:
# Provide an appropriate 404 message depending on request shape
if "item_order" in request:
detail = (
"One or more items in item_order were not "
"found in pending queue"
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Item {req.item_id} not found in pending queue",
)
else:
detail = f"Item {req.item_id} not found in pending queue"
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=detail,
)
return {
"status": "success",
"message": "Queue item reordered successfully",
}
return {
"status": "success",
"message": "Queue item reordered successfully",
}
except DownloadServiceError as e:
raise HTTPException(
@@ -596,6 +654,7 @@ async def retry_failed(
return {
"status": "success",
"message": f"Retrying {len(retried_ids)} failed item(s)",
"retried_count": len(retried_ids),
"retried_ids": retried_ids,
}

View File

@@ -586,6 +586,30 @@ class DownloadService:
return count
async def clear_failed(self) -> int:
"""Clear failed downloads from history.
Returns:
Number of items cleared
"""
count = len(self._failed_items)
self._failed_items.clear()
logger.info("Cleared failed items", count=count)
# Broadcast queue status update
if count > 0:
queue_status = await self.get_queue_status()
await self._broadcast_update(
"queue_status",
{
"action": "failed_cleared",
"cleared_count": count,
"queue_status": queue_status.model_dump(mode="json"),
},
)
return count
async def retry_failed(
self, item_ids: Optional[List[str]] = None
) -> List[str]:

View File

@@ -1218,6 +1218,52 @@ body {
background: linear-gradient(90deg, rgba(var(--color-accent-rgb), 0.05) 0%, transparent 10%);
}
/* Drag and Drop Styles */
.draggable-item {
cursor: move;
user-select: none;
}
.draggable-item.dragging {
opacity: 0.5;
transform: scale(0.98);
cursor: grabbing;
}
.draggable-item.drag-over {
border-top: 3px solid var(--color-primary);
margin-top: 8px;
}
.drag-handle {
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%);
color: var(--color-text-tertiary);
cursor: grab;
font-size: 1.2rem;
padding: var(--spacing-xs);
transition: color var(--transition-duration);
}
.drag-handle:hover {
color: var(--color-primary);
}
.drag-handle:active {
cursor: grabbing;
}
.sortable-list {
position: relative;
min-height: 100px;
}
.pending-queue-list {
position: relative;
}
.download-header {
display: flex;
justify-content: space-between;
@@ -1261,11 +1307,11 @@ body {
.queue-position {
position: absolute;
top: var(--spacing-sm);
left: var(--spacing-sm);
left: 48px;
background: var(--color-warning);
color: white;
width: 24px;
height: 24px;
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
@@ -1275,7 +1321,18 @@ body {
}
.download-card.pending .download-info {
margin-left: 40px;
margin-left: 80px;
}
.download-card.pending .download-header {
padding-left: 0;
}
.empty-state small {
display: block;
margin-top: var(--spacing-sm);
font-size: var(--font-size-small);
opacity: 0.7;
}
/* Progress Bars */

View File

@@ -7,6 +7,8 @@ class QueueManager {
this.socket = null;
this.refreshInterval = null;
this.isReordering = false;
this.draggedElement = null;
this.draggedId = null;
this.init();
}
@@ -17,6 +19,7 @@ class QueueManager {
this.initTheme();
this.startRefreshTimer();
this.loadQueueData();
this.initDragAndDrop();
}
initSocket() {
@@ -249,6 +252,11 @@ class QueueManager {
document.getElementById('completed-items').textContent = stats.completed_items || 0;
document.getElementById('failed-items').textContent = stats.failed_items || 0;
// Update section counts
document.getElementById('queue-count').textContent = (data.pending_queue || []).length;
document.getElementById('completed-count').textContent = stats.completed_items || 0;
document.getElementById('failed-count').textContent = stats.failed_items || 0;
document.getElementById('current-speed').textContent = stats.current_speed || '0 MB/s';
document.getElementById('average-speed').textContent = stats.average_speed || '0 MB/s';
@@ -331,12 +339,16 @@ class QueueManager {
<div class="empty-state">
<i class="fas fa-list"></i>
<p>No items in queue</p>
<small>Add episodes from the main page to start downloading</small>
</div>
`;
return;
}
container.innerHTML = queue.map((item, index) => this.createPendingQueueCard(item, index)).join('');
// Re-attach drag and drop event listeners
this.attachDragListeners();
}
createPendingQueueCard(download, index) {
@@ -344,7 +356,13 @@ class QueueManager {
const priorityClass = download.priority === 'high' ? 'high-priority' : '';
return `
<div class="download-card pending ${priorityClass}" data-id="${download.id}">
<div class="download-card pending ${priorityClass} draggable-item"
data-id="${download.id}"
data-index="${index}"
draggable="true">
<div class="drag-handle" title="Drag to reorder">
<i class="fas fa-grip-vertical"></i>
</div>
<div class="queue-position">${index + 1}</div>
<div class="download-header">
<div class="download-info">
@@ -420,7 +438,7 @@ class QueueManager {
const retryCount = download.retry_count || 0;
return `
<div class="download-card failed">
<div class="download-card failed" data-id="${download.id}">
<div class="download-header">
<div class="download-info">
<h4>${this.escapeHtml(download.serie_name)}</h4>
@@ -441,10 +459,15 @@ class QueueManager {
`;
}
async removeFailedDownload(downloadId) {
await this.removeFromQueue(downloadId);
}
updateButtonStates(data) {
const hasActive = (data.active_downloads || []).length > 0;
const hasPending = (data.pending_queue || []).length > 0;
const hasFailed = (data.failed_downloads || []).length > 0;
const hasCompleted = (data.completed_downloads || []).length > 0;
// Enable start button only if there are pending items and no active downloads
document.getElementById('start-queue-btn').disabled = !hasPending || hasActive;
@@ -461,8 +484,9 @@ class QueueManager {
document.getElementById('pause-all-btn').disabled = !hasActive;
document.getElementById('clear-queue-btn').disabled = !hasPending;
document.getElementById('reorder-queue-btn').disabled = !hasPending || (data.pending_queue || []).length < 2;
document.getElementById('retry-all-btn').disabled = !hasFailed;
document.getElementById('clear-completed-btn').disabled = !hasCompleted;
document.getElementById('clear-failed-btn').disabled = !hasFailed;
}
async clearQueue(type) {
@@ -483,7 +507,6 @@ class QueueManager {
try {
if (type === 'completed') {
// Use the new DELETE /api/queue/completed endpoint
const response = await this.makeAuthenticatedRequest('/api/queue/completed', {
method: 'DELETE'
});
@@ -491,11 +514,38 @@ class QueueManager {
if (!response) return;
const data = await response.json();
this.showToast(`Cleared ${data.cleared_count} completed downloads`, 'success');
this.showToast(`Cleared ${data.count} completed downloads`, 'success');
this.loadQueueData();
} else if (type === 'failed') {
const response = await this.makeAuthenticatedRequest('/api/queue/failed', {
method: 'DELETE'
});
if (!response) return;
const data = await response.json();
this.showToast(`Cleared ${data.count} failed downloads`, 'success');
this.loadQueueData();
} else if (type === 'pending') {
// Get all pending items
const pendingCards = document.querySelectorAll('#pending-queue .download-card.pending');
const itemIds = Array.from(pendingCards).map(card => card.dataset.id).filter(id => id);
if (itemIds.length === 0) {
this.showToast('No pending items to clear', 'info');
return;
}
const response = await this.makeAuthenticatedRequest('/api/queue/', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item_ids: itemIds })
});
if (!response) return;
this.showToast(`Cleared ${itemIds.length} pending items`, 'success');
this.loadQueueData();
} else {
// For pending and failed, use the old logic (TODO: implement backend endpoints)
this.showToast(`Clear ${type} not yet implemented`, 'warning');
}
} catch (error) {
@@ -528,14 +578,31 @@ class QueueManager {
const confirmed = await this.showConfirmModal('Retry All Failed Downloads', 'Are you sure you want to retry all failed downloads?');
if (!confirmed) return;
// Get all failed downloads and retry them individually
const failedCards = document.querySelectorAll('#failed-downloads .download-card.failed');
try {
// Get all failed download IDs
const failedCards = document.querySelectorAll('#failed-downloads .download-card.failed');
const itemIds = Array.from(failedCards).map(card => card.dataset.id).filter(id => id);
for (const card of failedCards) {
const downloadId = card.dataset.id;
if (downloadId) {
await this.retryDownload(downloadId);
if (itemIds.length === 0) {
this.showToast('No failed downloads to retry', 'info');
return;
}
const response = await this.makeAuthenticatedRequest('/api/queue/retry', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item_ids: itemIds })
});
if (!response) return;
const data = await response.json();
this.showToast(`Retried ${data.retried_count || itemIds.length} download(s)`, 'success');
this.loadQueueData();
} catch (error) {
console.error('Error retrying failed downloads:', error);
this.showToast('Failed to retry downloads', 'error');
}
}
@@ -632,8 +699,146 @@ class QueueManager {
}
toggleReorderMode() {
// TODO: Implement reorder functionality
this.showToast('Reorder functionality not yet implemented', 'info');
// Drag and drop is always enabled, no need for toggle mode
this.showToast('Drag items to reorder the queue', 'info');
}
initDragAndDrop() {
// Initialize drag and drop on the pending queue container
const container = document.getElementById('pending-queue');
if (container) {
container.addEventListener('dragover', this.handleDragOver.bind(this));
container.addEventListener('drop', this.handleDrop.bind(this));
}
}
attachDragListeners() {
// Attach listeners to all draggable items
const items = document.querySelectorAll('.draggable-item');
items.forEach(item => {
item.addEventListener('dragstart', this.handleDragStart.bind(this));
item.addEventListener('dragend', this.handleDragEnd.bind(this));
item.addEventListener('dragenter', this.handleDragEnter.bind(this));
item.addEventListener('dragleave', this.handleDragLeave.bind(this));
});
}
handleDragStart(e) {
this.draggedElement = e.currentTarget;
this.draggedId = e.currentTarget.dataset.id;
e.currentTarget.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/html', e.currentTarget.innerHTML);
}
handleDragEnd(e) {
e.currentTarget.classList.remove('dragging');
// Remove all drag-over classes
document.querySelectorAll('.drag-over').forEach(item => {
item.classList.remove('drag-over');
});
}
handleDragOver(e) {
if (e.preventDefault) {
e.preventDefault();
}
e.dataTransfer.dropEffect = 'move';
return false;
}
handleDragEnter(e) {
if (e.currentTarget.classList.contains('draggable-item') &&
e.currentTarget !== this.draggedElement) {
e.currentTarget.classList.add('drag-over');
}
}
handleDragLeave(e) {
e.currentTarget.classList.remove('drag-over');
}
async handleDrop(e) {
if (e.stopPropagation) {
e.stopPropagation();
}
e.preventDefault();
// Get the target element (the item we dropped onto)
let target = e.target;
while (target && !target.classList.contains('draggable-item')) {
target = target.parentElement;
if (target === document.getElementById('pending-queue')) {
return false;
}
}
if (!target || target === this.draggedElement) {
return false;
}
// Get all items to determine new order
const container = document.getElementById('pending-queue');
const items = Array.from(container.querySelectorAll('.draggable-item'));
const draggedIndex = items.indexOf(this.draggedElement);
const targetIndex = items.indexOf(target);
if (draggedIndex === targetIndex) {
return false;
}
// Reorder visually
if (draggedIndex < targetIndex) {
target.parentNode.insertBefore(this.draggedElement, target.nextSibling);
} else {
target.parentNode.insertBefore(this.draggedElement, target);
}
// Update position numbers
const updatedItems = Array.from(container.querySelectorAll('.draggable-item'));
updatedItems.forEach((item, index) => {
const posElement = item.querySelector('.queue-position');
if (posElement) {
posElement.textContent = index + 1;
}
item.dataset.index = index;
});
// Get the new order of IDs
const newOrder = updatedItems.map(item => item.dataset.id);
// Send reorder request to backend
await this.reorderQueue(newOrder);
return false;
}
async reorderQueue(newOrder) {
try {
const response = await this.makeAuthenticatedRequest('/api/queue/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item_ids: newOrder })
});
if (!response) return;
if (response.ok) {
this.showToast('Queue reordered successfully', 'success');
} else {
const data = await response.json();
this.showToast(`Failed to reorder: ${data.detail || 'Unknown error'}`, 'error');
// Reload to restore correct order
this.loadQueueData();
}
} catch (error) {
console.error('Error reordering queue:', error);
this.showToast('Failed to reorder queue', 'error');
// Reload to restore correct order
this.loadQueueData();
}
}
async makeAuthenticatedRequest(url, options = {}) {

View File

@@ -131,7 +131,7 @@
<div class="section-header">
<h2>
<i class="fas fa-clock"></i>
Download Queue
Download Queue (<span id="queue-count">0</span>)
</h2>
<div class="section-actions">
<button id="start-queue-btn" class="btn btn-primary" disabled>
@@ -146,17 +146,14 @@
<i class="fas fa-trash"></i>
Clear Queue
</button>
<button id="reorder-queue-btn" class="btn btn-secondary" disabled>
<i class="fas fa-sort"></i>
Reorder
</button>
</div>
</div>
<div class="pending-queue-list" id="pending-queue">
<div class="pending-queue-list sortable-list" id="pending-queue" data-sortable="true">
<div class="empty-state">
<i class="fas fa-list"></i>
<p>No items in queue</p>
<small>Add episodes from the main page to start downloading</small>
</div>
</div>
</section>
@@ -166,10 +163,10 @@
<div class="section-header">
<h2>
<i class="fas fa-check-circle"></i>
Recent Completed
Completed (<span id="completed-count">0</span>)
</h2>
<div class="section-actions">
<button id="clear-completed-btn" class="btn btn-secondary">
<button id="clear-completed-btn" class="btn btn-secondary" disabled>
<i class="fas fa-broom"></i>
Clear Completed
</button>
@@ -178,8 +175,9 @@
<div class="completed-downloads-list" id="completed-downloads">
<div class="empty-state">
<i class="fas fa-check-circle"></i>
<i class="fas fa-check-circle text-success"></i>
<p>No completed downloads</p>
<small>Completed episodes will appear here</small>
</div>
</div>
</section>
@@ -189,14 +187,14 @@
<div class="section-header">
<h2>
<i class="fas fa-exclamation-triangle"></i>
Failed Downloads
Failed (<span id="failed-count">0</span>)
</h2>
<div class="section-actions">
<button id="retry-all-btn" class="btn btn-warning" disabled>
<i class="fas fa-redo"></i>
Retry All
</button>
<button id="clear-failed-btn" class="btn btn-secondary">
<button id="clear-failed-btn" class="btn btn-secondary" disabled>
<i class="fas fa-trash"></i>
Clear Failed
</button>
@@ -207,6 +205,7 @@
<div class="empty-state">
<i class="fas fa-check-circle text-success"></i>
<p>No failed downloads</p>
<small>Failed episodes can be retried or removed</small>
</div>
</div>
</section>