Add resumable downloads, download paths, queue controls and history
This commit is contained in:
1 parent
434f6441da
commit
6027cfabb9
43 files changed
+2138
-291
No files matched your search
+112
-39
@@ -1,12 +1,17 @@
|
||||
import { api, onUnauthorized } from "./api.js";
|
||||
import { element } from "./dom.js";
|
||||
import { destinationSelect, setAutoExtractDefault, setupDownloadForm, updateSubfolderState } from "./download-form.js";
|
||||
import { formatBytes } from "./format.js";
|
||||
import { isActive, renderJobs, updateSpeeds } from "./jobs.js";
|
||||
import { recentDestinations, renderHistory } from "./history.js";
|
||||
import { isActive, matchesFilter, renderJobs, updateSpeeds } from "./jobs.js";
|
||||
import { fillLibrarySelect } from "./libraries.js";
|
||||
import { setupLogin } from "./login.js";
|
||||
import { collectTransitions, notifyTransitions } from "./notify.js";
|
||||
import { loadSettings, openSettings, setupSettings } from "./settings.js";
|
||||
import { renderTree, topLevelSelection } from "./tree.js";
|
||||
|
||||
const pollIntervalMs = 1500;
|
||||
const filterStorageKey = "ddc-job-filter";
|
||||
|
||||
const elements = {
|
||||
loginView: document.getElementById("login-view"),
|
||||
@@ -16,10 +21,8 @@ const elements = {
|
||||
logoutButton: document.getElementById("logout-button"),
|
||||
settingsButton: document.getElementById("settings-button"),
|
||||
diskStatus: document.getElementById("disk-status"),
|
||||
downloadForm: document.getElementById("download-form"),
|
||||
downloadUrl: document.getElementById("download-url"),
|
||||
autoExtract: document.getElementById("auto-extract"),
|
||||
downloadError: document.getElementById("download-error"),
|
||||
jobFilter: document.getElementById("job-filter"),
|
||||
clearCompletedButton: document.getElementById("clear-completed-button"),
|
||||
jobList: document.getElementById("job-list"),
|
||||
jobEmpty: document.getElementById("job-empty"),
|
||||
detail: document.getElementById("job-detail"),
|
||||
@@ -32,10 +35,19 @@ const elements = {
|
||||
cleanupCheckbox: document.getElementById("cleanup-checkbox"),
|
||||
moveButton: document.getElementById("move-button"),
|
||||
actionError: document.getElementById("action-error"),
|
||||
history: {
|
||||
title: document.getElementById("history-title"),
|
||||
list: document.getElementById("history-list"),
|
||||
empty: document.getElementById("history-empty"),
|
||||
clearButton: document.getElementById("clear-history-button"),
|
||||
},
|
||||
};
|
||||
|
||||
const state = {
|
||||
jobs: [],
|
||||
destinations: [],
|
||||
downloadPaths: [],
|
||||
historyEntries: [],
|
||||
selectedJobId: null,
|
||||
treeStatus: null,
|
||||
selection: new Set(),
|
||||
@@ -54,14 +66,16 @@ async function showApp() {
|
||||
elements.loginView.hidden = true;
|
||||
elements.appView.hidden = false;
|
||||
applyDefaults(await loadSettings());
|
||||
await loadLibraries();
|
||||
await Promise.all([loadLibraries(), loadHistory()]);
|
||||
await refresh();
|
||||
startPolling();
|
||||
}
|
||||
|
||||
function applyDefaults(settings) {
|
||||
elements.autoExtract.checked = settings.autoExtract;
|
||||
setAutoExtractDefault(settings.autoExtract);
|
||||
elements.cleanupCheckbox.checked = settings.cleanupAfterMove;
|
||||
state.downloadPaths = settings.downloadPaths || [];
|
||||
renderDestinationSelects();
|
||||
}
|
||||
|
||||
async function showSettings() {
|
||||
@@ -83,26 +97,25 @@ function stopPolling() {
|
||||
}
|
||||
|
||||
async function loadLibraries() {
|
||||
const destinations = (await api.libraries()) || [];
|
||||
const previousValue = elements.librarySelect.value;
|
||||
const groups = new Map();
|
||||
state.destinations = (await api.libraries()) || [];
|
||||
renderDestinationSelects();
|
||||
}
|
||||
|
||||
for (const destination of destinations) {
|
||||
if (!groups.has(destination.root)) {
|
||||
const group = document.createElement("optgroup");
|
||||
group.label = destination.root;
|
||||
groups.set(destination.root, group);
|
||||
}
|
||||
const option = element("option", "", destination.name);
|
||||
option.value = destination.path;
|
||||
option.title = destination.path;
|
||||
groups.get(destination.root).append(option);
|
||||
async function loadHistory() {
|
||||
try {
|
||||
state.historyEntries = (await api.history()) || [];
|
||||
renderHistory(elements.history, state.historyEntries);
|
||||
renderDestinationSelects();
|
||||
} catch (error) {
|
||||
if (error.status !== 401) console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
elements.librarySelect.replaceChildren(...groups.values());
|
||||
if (destinations.some((destination) => destination.path === previousValue)) {
|
||||
elements.librarySelect.value = previousValue;
|
||||
}
|
||||
function renderDestinationSelects() {
|
||||
const extras = { recentPaths: recentDestinations(state.historyEntries), savedPaths: state.downloadPaths };
|
||||
fillLibrarySelect(elements.librarySelect, state.destinations, extras);
|
||||
fillLibrarySelect(destinationSelect(), state.destinations, { ...extras, emptyLabel: "Temporary folder (move later)" });
|
||||
updateSubfolderState();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
@@ -112,6 +125,7 @@ async function refresh() {
|
||||
const [jobs, disk] = await Promise.all([api.jobs(), api.disk()]);
|
||||
state.jobs = jobs || [];
|
||||
updateSpeeds(state.jobs);
|
||||
handleTransitions(collectTransitions(state.jobs));
|
||||
elements.diskStatus.textContent = `${formatBytes(disk.availableBytes)} free`;
|
||||
renderJobList();
|
||||
await syncDetail();
|
||||
@@ -122,14 +136,40 @@ async function refresh() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleTransitions(transitions) {
|
||||
notifyTransitions(transitions);
|
||||
if (transitions.some((transition) => transition.previous === "moving")) loadHistory();
|
||||
if (transitions.length > 0) {
|
||||
loadLibraries().catch((error) => console.error(error));
|
||||
}
|
||||
}
|
||||
|
||||
function renderJobList() {
|
||||
elements.jobEmpty.hidden = state.jobs.length > 0;
|
||||
renderJobs(elements.jobList, state.jobs, state.selectedJobId, {
|
||||
const filter = elements.jobFilter.value;
|
||||
const visibleJobs = state.jobs.filter((job) => matchesFilter(job, filter));
|
||||
elements.jobEmpty.hidden = visibleJobs.length > 0;
|
||||
elements.jobEmpty.textContent = state.jobs.length === 0
|
||||
? "No downloads yet. Paste a link above to start."
|
||||
: "No downloads match this filter.";
|
||||
elements.clearCompletedButton.hidden = !state.jobs.some((job) => job.status === "completed");
|
||||
renderJobs(elements.jobList, visibleJobs, state.selectedJobId, {
|
||||
onBrowse: openDetail,
|
||||
onPause: (job) => runJobAction(() => api.pause(job.id)),
|
||||
onResume: (job) => runJobAction(() => api.resume(job.id)),
|
||||
onReorder: (job, direction) => runJobAction(() => api.reorder(job.id, direction)),
|
||||
onDelete: deleteJob,
|
||||
});
|
||||
}
|
||||
|
||||
async function runJobAction(action) {
|
||||
try {
|
||||
await action();
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
window.alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function selectedJob() {
|
||||
return state.jobs.find((job) => job.id === state.selectedJobId);
|
||||
}
|
||||
@@ -201,18 +241,6 @@ function updateSelectionSummary() {
|
||||
elements.moveButton.disabled = count === 0 || !job || job.status !== "ready";
|
||||
}
|
||||
|
||||
async function submitDownload(event) {
|
||||
event.preventDefault();
|
||||
elements.downloadError.textContent = "";
|
||||
try {
|
||||
await api.createJob(elements.downloadUrl.value.trim(), elements.autoExtract.checked);
|
||||
elements.downloadUrl.value = "";
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
elements.downloadError.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function extractArchive(path) {
|
||||
elements.actionError.textContent = "";
|
||||
try {
|
||||
@@ -257,6 +285,48 @@ async function deleteJob(job) {
|
||||
}
|
||||
}
|
||||
|
||||
async function clearCompleted() {
|
||||
const completedJobs = state.jobs.filter((job) => job.status === "completed");
|
||||
for (const job of completedJobs) {
|
||||
try {
|
||||
await api.remove(job.id);
|
||||
if (job.id === state.selectedJobId) closeDetail();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function clearHistory() {
|
||||
if (!window.confirm("Clear the move history?")) return;
|
||||
try {
|
||||
await api.clearHistory();
|
||||
await loadHistory();
|
||||
} catch (error) {
|
||||
window.alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function setupJobFilter() {
|
||||
try {
|
||||
const saved = localStorage.getItem(filterStorageKey);
|
||||
if (saved && [...elements.jobFilter.options].some((option) => option.value === saved)) {
|
||||
elements.jobFilter.value = saved;
|
||||
}
|
||||
} catch {
|
||||
elements.jobFilter.value = "all";
|
||||
}
|
||||
elements.jobFilter.addEventListener("change", () => {
|
||||
try {
|
||||
localStorage.setItem(filterStorageKey, elements.jobFilter.value);
|
||||
} catch {
|
||||
console.warn("Could not remember the download filter.");
|
||||
}
|
||||
renderJobList();
|
||||
});
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await api.logout();
|
||||
@@ -268,7 +338,10 @@ async function logout() {
|
||||
async function init() {
|
||||
onUnauthorized(showLogin);
|
||||
setupLogin(elements.loginForm, elements.loginError, showApp);
|
||||
elements.downloadForm.addEventListener("submit", submitDownload);
|
||||
setupDownloadForm(refresh);
|
||||
setupJobFilter();
|
||||
elements.clearCompletedButton.addEventListener("click", clearCompleted);
|
||||
elements.history.clearButton.addEventListener("click", clearHistory);
|
||||
elements.moveButton.addEventListener("click", moveSelection);
|
||||
elements.closeDetailButton.addEventListener("click", () => {
|
||||
closeDetail();
|
||||
|
||||
Reference in new issue
Block a user