Add Download Queue Management & Concurrent Downloads (#231)

## Summary

This PR completely overhauls the download system to support concurrent
downloads, cancellation, and advanced queue
management. No more waiting for stuck downloads to block the entire
queue!

  ## New Features

  ###  **Concurrent Downloads**
  - **3 simultaneous downloads** by default (configurable)
- ThreadPoolExecutor-based architecture replaces single-threaded
bottleneck
  - Downloads no longer block each other

  ###  **Download Cancellation**
  - **Cancel button** for active/queued downloads
  - Clean cancellation with partial file cleanup
  - Thread-safe cancellation flags

  ### **Queue Priority & Reordering**
  - **Priority-based queue** (lower number = higher priority)
  - **Editable priority inputs** in UI for queued items
  - **Bulk reordering** API support

  ## UI Enhancements

- **Enhanced status table** with Priority, Progress, and Actions columns
  - **Progress bars** for active downloads
  - **Cancel buttons** for downloads
- **Queue management controls**: Refresh, Clear Completed, Active
Counter
  - **Real-time updates** every 60 seconds

## Implementation

  ### New Architecture
  - `queue.PriorityQueue` replaces simple `set()` for proper ordering
  - `concurrent.futures.ThreadPoolExecutor` handles worker threads
  - `threading.Event` flags enable clean cancellation
  - Thread-safe operations with proper locking

  ### New API Endpoints
  DELETE /api/download/{id}/cancel     # Cancel downloads
PUT /api/queue/{id}/priority # Set priorityPOST /api/queue/reorder #
Bulk reorder
  GET /api/downloads/active          # Active tracking
  DELETE /api/queue/clear            # Clear completed

  ### New Environment Variables
  ```bash
  MAX_CONCURRENT_DOWNLOADS=3          # Worker thread count
  DOWNLOAD_PROGRESS_UPDATE_INTERVAL=5  # Progress frequency
  ```

### Fully backward compatible - existing functionality unchanged

### Fixes

  - Resolves: Downloads blocking when one gets stuck
  - Resolves: No way to cancel problematic downloads
  - Resolves: No queue management or prioritization
  
  #183

---------

Co-authored-by: CaliBrain <calibrain@l4n.xyz>
This commit is contained in:
Stephon Parker
2025-08-25 13:28:35 -04:00
committed by GitHub
co-authored by CaliBrain
parent 09bd5ae9f0
commit 5e04b6bfb8
8 changed files with 988 additions and 100 deletions
+204 -12
View File
@@ -54,7 +54,13 @@ document.addEventListener('DOMContentLoaded', () => {
search: '/request/api/search',
info: '/request/api/info',
download: '/request/api/download',
status: '/request/api/status'
status: '/request/api/status',
cancelDownload: '/request/api/download',
setPriority: '/request/api/queue',
reorderQueue: '/request/api/queue/reorder',
queueOrder: '/request/api/queue/order',
activeDownloads: '/request/api/downloads/active',
clearCompleted: '/request/api/queue/clear'
};
const FILTERS = ['isbn', 'author', 'title', 'lang' , 'sort', "content", "format"];
@@ -231,10 +237,27 @@ document.addEventListener('DOMContentLoaded', () => {
modal.open();
},
async confirmDownload(bookIds) {
bookIds.map((bookId) =>
utils.fetchJson(`${API_ENDPOINTS.download}?id=${encodeURIComponent(bookId)}`)
);
async confirmDownload(bookIds, priority = 0) {
try {
await Promise.all(bookIds.map((bookId) =>
utils.fetchJson(`${API_ENDPOINTS.download}?id=${encodeURIComponent(bookId)}&priority=${priority}`)
));
UIkit.notification({
message: `Successfully queued ${bookIds.length} book${bookIds.length > 1 ? 's' : ''} for download!`,
status: 'success',
timeout: 3000
});
status.fetch();
} catch (error) {
console.error('Download error:', error);
UIkit.notification({
message: 'Failed to queue some books for download.',
status: 'danger',
timeout: 3000
});
}
this.clearAllCheckboxes();
modal.close();
@@ -254,6 +277,106 @@ document.addEventListener('DOMContentLoaded', () => {
}
};
// Queue Management Functions
const queue = {
async cancelDownload(bookId) {
try {
const response = await fetch(`${API_ENDPOINTS.cancelDownload}/${bookId}/cancel`, {
method: 'DELETE'
});
if (response.ok) {
UIkit.notification({
message: 'Download cancelled successfully',
status: 'success',
timeout: 2000
});
status.fetch();
this.updateActiveDownloads();
} else {
throw new Error('Failed to cancel download');
}
} catch (error) {
console.error('Cancel download error:', error);
UIkit.notification({
message: 'Failed to cancel download',
status: 'danger',
timeout: 3000
});
}
},
async setPriority(bookId, priority) {
try {
const response = await fetch(`${API_ENDPOINTS.setPriority}/${bookId}/priority`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ priority })
});
if (response.ok) {
UIkit.notification({
message: `Priority updated to ${priority}`,
status: 'success',
timeout: 2000
});
status.fetch();
} else {
throw new Error('Failed to update priority');
}
} catch (error) {
console.error('Set priority error:', error);
UIkit.notification({
message: 'Failed to update priority',
status: 'danger',
timeout: 3000
});
}
},
async clearCompleted() {
try {
const response = await fetch(API_ENDPOINTS.clearCompleted, {
method: 'DELETE'
});
if (response.ok) {
const data = await response.json();
UIkit.notification({
message: `Cleared ${data.removed_count} completed items`,
status: 'success',
timeout: 2000
});
status.fetch();
} else {
throw new Error('Failed to clear completed items');
}
} catch (error) {
console.error('Clear completed error:', error);
UIkit.notification({
message: 'Failed to clear completed items',
status: 'danger',
timeout: 3000
});
}
},
async updateActiveDownloads() {
try {
const data = await utils.fetchJson(API_ENDPOINTS.activeDownloads);
const count = data.active_downloads ? data.active_downloads.length : 0;
const element = document.getElementById('active-downloads-count');
if (element) {
element.textContent = `Active: ${count}`;
}
} catch (error) {
console.error('Update active downloads error:', error);
}
}
};
// Search Functions
const search = {
async performSearch(query) {
@@ -522,6 +645,7 @@ document.addEventListener('DOMContentLoaded', () => {
utils.showLoading(elements.statusLoading);
const data = await utils.fetchJson(API_ENDPOINTS.status);
this.display(data);
queue.updateActiveDownloads();
} catch (error) {
this.handleError(error);
} finally {
@@ -552,23 +676,70 @@ document.addEventListener('DOMContentLoaded', () => {
textContent: status
});
// Priority cell with editable input for queued items
const priorityCell = utils.createElement('td');
if (status === 'queued') {
const priorityInput = utils.createElement('input', {
type: 'number',
className: 'uk-input uk-form-small uk-form-width-xsmall',
value: book.priority || 0,
min: 0,
onchange: () => queue.setPriority(book.id, parseInt(priorityInput.value))
});
priorityCell.appendChild(priorityInput);
} else {
priorityCell.textContent = book.priority || '-';
}
// Title with download link if available
let titleElement;
if (book.download_path != null) {
titleElement = utils.createElement('a', {
href: `/request/api/localdownload?id=${book.id}`,
target: '_blank',
textContent: book.title || 'N/A'
textContent: book.title || 'N/A',
className: 'uk-link'
});
} else {
titleElement = document.createTextNode(book.title || 'N/A');
}
else {
titleElement = utils.createElement('td', { textContent: book.title || 'N/A' })
const titleCell = utils.createElement('td');
titleCell.appendChild(titleElement);
// Progress cell
const progressCell = utils.createElement('td');
if (status === 'downloading' && book.progress !== undefined) {
const progressBar = utils.createElement('progress', {
className: 'uk-progress',
value: book.progress,
max: 100
});
progressCell.appendChild(progressBar);
progressCell.appendChild(document.createTextNode(` ${Math.round(book.progress)}%`));
} else {
progressCell.textContent = '-';
}
const row = utils.createElement('tr', {}, [
// Actions cell
const actionsCell = utils.createElement('td');
if (status === 'queued' || status === 'downloading') {
const cancelBtn = utils.createElement('button', {
className: 'uk-button uk-button-danger uk-button-small',
textContent: 'Cancel',
onclick: () => queue.cancelDownload(book.id)
});
actionsCell.appendChild(cancelBtn);
}
const row = utils.createElement('tr', {
'data-book-id': book.id,
'data-status': status
}, [
statusCell,
utils.createElement('td', { textContent: book.id }),
titleElement,
this.createPreviewCell(book.preview)
priorityCell,
titleCell,
progressCell,
actionsCell
]);
elements.statusTableBody.appendChild(row);
@@ -838,6 +1009,26 @@ document.addEventListener('DOMContentLoaded', () => {
});
// Download selected books
elements.downloadSelectedButton.addEventListener('click', utils.handleDownloadSelected);
// Queue management buttons
const refreshButton = document.getElementById('refresh-status-button');
if (refreshButton) {
refreshButton.addEventListener('click', () => {
status.fetch();
queue.updateActiveDownloads();
});
}
const clearCompletedButton = document.getElementById('clear-completed-button');
if (clearCompletedButton) {
clearCompletedButton.addEventListener('click', () => {
UIkit.modal.confirm('Are you sure you want to clear all completed downloads?').then(() => {
queue.clearCompleted();
}, () => {
// User cancelled
});
});
}
// Check/uncheck all book checkboxes
elements.selectAllCheckbox.addEventListener('change', (event) => {
@@ -880,6 +1071,7 @@ document.addEventListener('DOMContentLoaded', () => {
debug.init(); // Initialize debug functionality
restart.init(); // Initialize restart functionality
status.fetch();
queue.updateActiveDownloads();
setInterval(() => status.fetch(), REFRESH_INTERVAL);
}