diff --git a/app/src/api.js b/app/src/api.js
index 1fd82867..219ef240 100644
--- a/app/src/api.js
+++ b/app/src/api.js
@@ -486,6 +486,10 @@ export async function killDownload(download_id) {
return response;
}
+export async function restartDownload(download_id) {
+ return await apiPost(`${API_URI}/download/${download_id}/restart`);
+}
+
export async function killDownloads() {
let response = await apiPost(`${API_URI}/download/kill`);
return response;
diff --git a/app/src/components/admin/Downloads.js b/app/src/components/admin/Downloads.js
index 4d1f67e0..ca29f16b 100644
--- a/app/src/components/admin/Downloads.js
+++ b/app/src/components/admin/Downloads.js
@@ -1,5 +1,5 @@
import React from "react";
-import {clearCompletedDownloads, clearFailedDownloads, deleteDownload, killDownload, postDownload} from "../../api";
+import {clearCompletedDownloads, clearFailedDownloads, deleteDownload, killDownload, restartDownload} from "../../api";
import {Link} from "react-router-dom";
import {
APIButton,
@@ -24,6 +24,7 @@ import {
} from "semantic-ui-react";
import {Button, Header, Modal, ModalActions, ModalContent, ModalHeader, Placeholder, Segment, Table} from "../Theme";
import {useDownloads} from "../../hooks/customHooks";
+import {toast} from "react-semantic-toasts-2";
function ClearCompleteDownloads({callback}) {
async function localClearDownloads() {
@@ -67,7 +68,7 @@ function ClearFailedDownloads({callback}) {
}
-class DownloadRow extends React.Component {
+class RecurringDownloadRow extends React.Component {
constructor(props) {
super(props);
this.state = {
@@ -86,6 +87,28 @@ class DownloadRow extends React.Component {
}
}
+ handleRetry = async () => {
+ const {id} = this.props;
+ try {
+ const response = await restartDownload(id);
+ if (response.status !== 204) {
+ throw Error('Unable to restart download');
+ }
+ } catch (e) {
+ toast({
+ type: 'error',
+ title: 'Error',
+ description: 'Unable to start download',
+ time: 5000,
+ })
+ throw e;
+ } finally {
+ if (this.props.fetchDownloads) {
+ this.props.fetchDownloads();
+ }
+ }
+ }
+
render() {
let {url, frequency, last_successful_download, status, location, next_download, error} = this.props;
const {errorModalOpen} = this.state;
@@ -121,6 +144,17 @@ class DownloadRow extends React.Component {
/>
>;
+ const retryButton = <>
+
+ >;
+
// Show "now" if we have passed the next_download.
let next = 'now';
if (next_download) {
@@ -142,12 +176,13 @@ class DownloadRow extends React.Component {
{error && errorModal}
{deleteButton}
+ {retryButton}
}
}
-class StoppableRow extends React.Component {
+class OnceDownloadRow extends React.Component {
constructor(props) {
super(props);
}
@@ -163,8 +198,17 @@ class StoppableRow extends React.Component {
};
handleStart = async () => {
- let downloader = this.props.downloader;
- await postDownload(`${this.props.url}`, downloader);
+ try {
+ await restartDownload(this.props.id);
+ } catch (e) {
+ toast({
+ type: 'error',
+ title: 'Error',
+ description: 'Unable to start download',
+ time: 5000,
+ })
+ throw e;
+ }
await this.props.fetchDownloads();
};
@@ -203,11 +247,12 @@ class StoppableRow extends React.Component {
/>
Retry
+ />
);
} else if (status === 'complete' && location) {
@@ -256,7 +301,7 @@ export function OnceDownloadsTable({downloads, fetchDownloads}) {
- {downloads.map(i => )}
+ {downloads.map(i => )}
@@ -292,7 +337,7 @@ export function RecurringDownloadsTable({downloads, fetchDownloads}) {
- {downloads.map(i => )}
+ {downloads.map(i => )}
} else if (downloads) {
diff --git a/wrolpi/downloader.py b/wrolpi/downloader.py
index 6e8bfa8b..b14b5f57 100644
--- a/wrolpi/downloader.py
+++ b/wrolpi/downloader.py
@@ -766,6 +766,17 @@ class DownloadManager:
return True
return False
+ @optional_session
+ def restart_download(self, download_id: int, session: Session = None) -> Download:
+ """Renews a download and resets its download attempts."""
+ download = self.get_download(session, id_=download_id)
+ if not download:
+ raise InvalidDownload(f'No download with id {download_id}')
+ download.renew(reset_attempts=True)
+ session.commit()
+
+ return download
+
def kill_download(self, download_id: int):
"""Fail a Download. If it is pending, kill the Downloader so the download stops."""
with get_db_session(commit=True) as session:
diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py
index 015c1d13..3567cb74 100644
--- a/wrolpi/root_api.py
+++ b/wrolpi/root_api.py
@@ -251,6 +251,13 @@ async def post_download(_: Request, body: schema.DownloadRequest):
return response.empty()
+@api_bp.post('/download//restart')
+@openapi.description('Restart a download.')
+async def restart_download(_: Request, download_id: int):
+ download_manager.restart_download(download_id)
+ return response.empty()
+
+
@api_bp.get('/download')
@openapi.description('Get all Downloads that need to be processed.')
async def get_downloads(_: Request):
diff --git a/wrolpi/test/test_root_api.py b/wrolpi/test/test_root_api.py
index ec669e8b..492b07e0 100644
--- a/wrolpi/test/test_root_api.py
+++ b/wrolpi/test/test_root_api.py
@@ -1,3 +1,4 @@
+import asyncio
import json
from http import HTTPStatus
from itertools import zip_longest
@@ -374,6 +375,25 @@ def test_get_downloaders(test_client):
'No downloaders returned'
+@pytest.mark.asyncio
+async def test_restart_download(test_session, test_async_client, test_download_manager, test_downloader):
+ """A Download can be restarted."""
+ # Create a download, fail it, it should be restarted.
+ download = test_download_manager.create_download('https://example.com', test_downloader.name)
+ download.fail()
+ test_session.commit()
+ assert test_session.query(Download).one().status == 'failed'
+
+ # Download is now "new" again.
+ request, response = await test_async_client.post(f'/api/download/{download.id}/restart')
+ assert response.status_code == HTTPStatus.NO_CONTENT
+ assert test_session.query(Download).one().status == 'new'
+
+ # Wait for the background download to fail. It should be deferred.
+ await asyncio.sleep(0.5)
+ assert test_session.query(Download).one().status == 'deferred'
+
+
def test_get_global_statistics(test_session, test_client):
request, response = test_client.get('/api/statistics')
assert response.json['global_statistics']['db_size'] > 1