import { api, describeError, onUnauthorized } from "./api.js"; import { element } from "./dom.js"; import { destinationSelect, setAutoExtractDefault, setupDownloadForm, updateSubfolderState } from "./download-form.js"; import { formatBytes } from "./format.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 failuresBeforeBanner = 2; const elements = { loginView: document.getElementById("login-view"), loginForm: document.getElementById("login-form"), loginError: document.getElementById("login-error"), appView: document.getElementById("app-view"), logoutButton: document.getElementById("logout-button"), settingsButton: document.getElementById("settings-button"), diskStatus: document.getElementById("disk-status"), connectionBanner: document.getElementById("connection-banner"), 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"), detailTitle: document.getElementById("detail-title"), closeDetailButton: document.getElementById("close-detail-button"), fileTree: document.getElementById("file-tree"), selectionSummary: document.getElementById("selection-summary"), librarySelect: document.getElementById("library-select"), subfolderInput: document.getElementById("subfolder-input"), 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(), pollTimer: null, refreshing: false, failedRefreshes: 0, }; function showLogin() { stopPolling(); setConnectionProblem(null); closeDetail(); elements.appView.hidden = true; elements.loginView.hidden = false; } async function showApp() { elements.loginView.hidden = true; elements.appView.hidden = false; applyDefaults(await loadSettings()); await Promise.all([loadLibraries(), loadHistory()]); await refresh(); startPolling(); } function applyDefaults(settings) { setAutoExtractDefault(settings.autoExtract); elements.cleanupCheckbox.checked = settings.cleanupAfterMove; state.downloadPaths = settings.downloadPaths || []; renderDestinationSelects(); } async function showSettings() { try { await openSettings(); } catch (error) { window.alert(describeError(error)); } } function startPolling() { stopPolling(); state.pollTimer = window.setInterval(refresh, pollIntervalMs); } function stopPolling() { if (state.pollTimer !== null) window.clearInterval(state.pollTimer); state.pollTimer = null; } async function loadLibraries() { state.destinations = (await api.libraries()) || []; renderDestinationSelects(); } async function loadHistory() { try { state.historyEntries = (await api.history()) || []; renderHistory(elements.history, state.historyEntries); renderDestinationSelects(); } catch (error) { if (error.status !== 401) console.error(error); } } 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() { if (state.refreshing) return; state.refreshing = true; try { 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`; setConnectionProblem(null); renderJobList(); await syncDetail(); } catch (error) { if (error.status !== 401) { console.error(error); setConnectionProblem(error); } } finally { state.refreshing = false; } } function setConnectionProblem(error) { if (!error) { state.failedRefreshes = 0; elements.connectionBanner.hidden = true; return; } state.failedRefreshes += 1; if (state.failedRefreshes < failuresBeforeBanner) return; elements.connectionBanner.textContent = `${describeError(error)} Retrying automatically.`; elements.connectionBanner.hidden = false; } 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() { 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(describeError(error)); } } function selectedJob() { return state.jobs.find((job) => job.id === state.selectedJobId); } function openDetail(job) { state.selectedJobId = job.id; state.treeStatus = null; state.selection.clear(); elements.actionError.textContent = ""; elements.detail.hidden = false; renderJobList(); syncDetail(); loadLibraries().catch((error) => { elements.actionError.textContent = describeError(error); }); elements.detail.scrollIntoView({ behavior: "smooth", block: "start" }); } function closeDetail() { state.selectedJobId = null; state.treeStatus = null; state.selection.clear(); elements.detail.hidden = true; elements.fileTree.replaceChildren(); } async function syncDetail() { const job = selectedJob(); if (!job) { if (state.selectedJobId) closeDetail(); return; } elements.detailTitle.textContent = job.fileName; if (job.status === state.treeStatus) return; state.treeStatus = job.status; if (job.status === "ready") { await loadTree(job.id); return; } state.selection.clear(); updateSelectionSummary(); const text = job.status === "completed" ? "Files were moved and temporary files were removed." : "Files will be available when this download is ready."; elements.fileTree.replaceChildren(element("p", "muted", text)); } async function loadTree(jobId) { try { const nodes = (await api.files(jobId)) || []; state.selection.clear(); renderTree(elements.fileTree, nodes, state.selection, { onSelectionChange: updateSelectionSummary, onExtract: extractArchive, }); updateSelectionSummary(); } catch (error) { elements.actionError.textContent = describeError(error); } } function updateSelectionSummary() { const count = topLevelSelection(state.selection).length; const job = selectedJob(); elements.selectionSummary.textContent = count === 0 ? "Nothing selected" : `${count} item(s) selected`; elements.moveButton.disabled = count === 0 || !job || job.status !== "ready"; } async function extractArchive(path) { elements.actionError.textContent = ""; try { await api.extract(state.selectedJobId, path); await refresh(); } catch (error) { elements.actionError.textContent = describeError(error); } } async function moveSelection() { elements.actionError.textContent = ""; const paths = topLevelSelection(state.selection); if (paths.length === 0) return; try { await api.move(state.selectedJobId, { paths, destination: elements.librarySelect.value, subfolder: elements.subfolderInput.value.trim(), cleanup: elements.cleanupCheckbox.checked, }); elements.subfolderInput.value = ""; await refresh(); } catch (error) { elements.actionError.textContent = describeError(error); } } async function deleteJob(job) { const question = isActive(job) ? `Cancel and remove "${job.fileName}"?` : `Remove "${job.fileName}" and its temporary files?`; if (!window.confirm(question)) return; try { await api.remove(job.id); if (job.id === state.selectedJobId) closeDetail(); await refresh(); } catch (error) { window.alert(describeError(error)); } } 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(describeError(error)); } } 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(); } finally { showLogin(); } } async function init() { window.addEventListener("unhandledrejection", (event) => console.error("Unhandled error", event.reason)); onUnauthorized(showLogin); setupLogin(elements.loginForm, elements.loginError, showApp); setupDownloadForm(refresh); setupJobFilter(); elements.clearCompletedButton.addEventListener("click", clearCompleted); elements.history.clearButton.addEventListener("click", clearHistory); elements.moveButton.addEventListener("click", moveSelection); elements.closeDetailButton.addEventListener("click", () => { closeDetail(); renderJobList(); }); elements.logoutButton.addEventListener("click", logout); elements.settingsButton.addEventListener("click", showSettings); setupSettings((settings) => { applyDefaults(settings); refresh(); }); try { const session = await api.session(); if (session && session.authenticated) await showApp(); else showLogin(); } catch { showLogin(); } } init();