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
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#3b6ef5"/>
|
||||
<path d="M32 14v26M21 30l11 11 11-11" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M18 49h28" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 352 B |
+57
-3
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#16181d">
|
||||
<title>DDC</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<script src="/js/theme.js"></script>
|
||||
<script type="module" src="/js/app.js"></script>
|
||||
@@ -46,9 +46,25 @@
|
||||
<section class="card">
|
||||
<h2>New download</h2>
|
||||
<form id="download-form" class="download-form">
|
||||
<input id="download-url" type="url" inputmode="url" placeholder="https://example.com/file.zip" autocomplete="off" autocapitalize="off" required>
|
||||
<textarea id="download-urls" rows="2" placeholder="https://example.com/file.zip (one link per line, Ctrl+Enter to start)" autocomplete="off" autocapitalize="off" spellcheck="false" required></textarea>
|
||||
<button type="submit" class="primary">Download</button>
|
||||
</form>
|
||||
<div class="download-options">
|
||||
<label class="field">
|
||||
<span>File name (optional, single link)</span>
|
||||
<input id="download-name" placeholder="Keep the server name" autocomplete="off" autocapitalize="off">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Download to</span>
|
||||
<select id="download-destination">
|
||||
<option value="">Temporary folder (move later)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Subfolder (optional)</span>
|
||||
<input id="download-subfolder" placeholder="Movie Name (2024)" autocomplete="off" disabled>
|
||||
</label>
|
||||
</div>
|
||||
<label class="checkbox">
|
||||
<input id="auto-extract" type="checkbox" checked>
|
||||
<span>Extract archives automatically</span>
|
||||
@@ -57,7 +73,20 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Downloads</h2>
|
||||
<div class="list-toolbar">
|
||||
<h2>Downloads</h2>
|
||||
<div class="list-toolbar-actions">
|
||||
<select id="job-filter" aria-label="Filter downloads">
|
||||
<option value="all">All</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="ready">Ready</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
<button id="clear-completed-button" type="button" class="ghost small">Clear completed</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul id="job-list" class="job-list"></ul>
|
||||
<p id="job-empty" class="empty-state">No downloads yet. Paste a link above to start.</p>
|
||||
</section>
|
||||
@@ -86,6 +115,15 @@
|
||||
<button id="move-button" type="button" class="primary block-mobile" disabled>Move</button>
|
||||
<p id="action-error" class="error" role="alert"></p>
|
||||
</section>
|
||||
|
||||
<details id="history-card" class="card history-card">
|
||||
<summary>
|
||||
<span id="history-title">History</span>
|
||||
</summary>
|
||||
<ul id="history-list" class="history-list"></ul>
|
||||
<p id="history-empty" class="empty-state">Moved files will be listed here.</p>
|
||||
<button id="clear-history-button" type="button" class="ghost small">Clear history</button>
|
||||
</details>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -104,6 +142,10 @@
|
||||
<span>Parallel downloads</span>
|
||||
<input id="setting-concurrent" type="number" inputmode="numeric" min="1" max="10" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Connections per download</span>
|
||||
<input id="setting-connections" type="number" inputmode="numeric" min="1" max="16" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Keep free on disk (GB)</span>
|
||||
<input id="setting-min-free" type="number" inputmode="numeric" min="0" required>
|
||||
@@ -127,6 +169,14 @@
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="settings-group">
|
||||
<legend>Download paths</legend>
|
||||
<label class="field">
|
||||
<span>One folder per line, inside a destination root. Missing folders are created on first use.</span>
|
||||
<textarea id="setting-download-paths" rows="4" placeholder="/media/blob/TeaNet/Plex/Movies/Kids" autocomplete="off" autocapitalize="off" spellcheck="false"></textarea>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="settings-group">
|
||||
<legend>Appearance</legend>
|
||||
<label class="field">
|
||||
@@ -137,6 +187,10 @@
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input id="setting-notifications" type="checkbox">
|
||||
<span>Notify when downloads finish (this browser)</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="settings-group">
|
||||
|
||||
@@ -53,9 +53,14 @@ export const api = {
|
||||
settings: () => request("GET", "/api/settings"),
|
||||
saveSettings: (settings) => request("POST", "/api/settings", settings),
|
||||
jobs: () => request("GET", "/api/jobs"),
|
||||
createJob: (url, autoExtract) => request("POST", "/api/jobs", { url, autoExtract }),
|
||||
createJob: (payload) => request("POST", "/api/jobs", payload),
|
||||
files: (id) => request("GET", jobPath(id, "/files")),
|
||||
extract: (id, path) => request("POST", jobPath(id, "/extract"), { path }),
|
||||
move: (id, payload) => request("POST", jobPath(id, "/move"), payload),
|
||||
pause: (id) => request("POST", jobPath(id, "/pause"), {}),
|
||||
resume: (id) => request("POST", jobPath(id, "/resume"), {}),
|
||||
reorder: (id, direction) => request("POST", jobPath(id, "/queue"), { direction }),
|
||||
remove: (id) => request("DELETE", jobPath(id)),
|
||||
history: () => request("GET", "/api/history"),
|
||||
clearHistory: () => request("DELETE", "/api/history"),
|
||||
};
|
||||
+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();
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { api } from "./api.js";
|
||||
|
||||
const fields = {
|
||||
form: document.getElementById("download-form"),
|
||||
urls: document.getElementById("download-urls"),
|
||||
name: document.getElementById("download-name"),
|
||||
autoExtract: document.getElementById("auto-extract"),
|
||||
destination: document.getElementById("download-destination"),
|
||||
subfolder: document.getElementById("download-subfolder"),
|
||||
error: document.getElementById("download-error"),
|
||||
};
|
||||
|
||||
export function setupDownloadForm(onCreated) {
|
||||
fields.form.addEventListener("submit", (event) => submitDownloads(event, onCreated));
|
||||
fields.urls.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault();
|
||||
fields.form.requestSubmit();
|
||||
}
|
||||
});
|
||||
fields.destination.addEventListener("change", updateSubfolderState);
|
||||
}
|
||||
|
||||
export function setAutoExtractDefault(enabled) {
|
||||
fields.autoExtract.checked = enabled;
|
||||
}
|
||||
|
||||
export function destinationSelect() {
|
||||
return fields.destination;
|
||||
}
|
||||
|
||||
export function updateSubfolderState() {
|
||||
fields.subfolder.disabled = fields.destination.value === "";
|
||||
}
|
||||
|
||||
async function submitDownloads(event, onCreated) {
|
||||
event.preventDefault();
|
||||
fields.error.textContent = "";
|
||||
|
||||
const urls = fields.urls.value.split("\n").map((line) => line.trim()).filter(Boolean);
|
||||
const fileName = fields.name.value.trim();
|
||||
if (urls.length === 0) {
|
||||
fields.error.textContent = "Paste at least one link.";
|
||||
return;
|
||||
}
|
||||
if (fileName && urls.length > 1) {
|
||||
fields.error.textContent = "A file name can only be set when downloading a single link.";
|
||||
return;
|
||||
}
|
||||
|
||||
const failedUrls = [];
|
||||
let firstError = "";
|
||||
for (const url of urls) {
|
||||
try {
|
||||
await api.createJob({
|
||||
url,
|
||||
fileName,
|
||||
autoExtract: fields.autoExtract.checked,
|
||||
destination: fields.destination.value,
|
||||
subfolder: fields.subfolder.value.trim(),
|
||||
});
|
||||
} catch (error) {
|
||||
failedUrls.push(url);
|
||||
if (!firstError) firstError = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
fields.urls.value = failedUrls.join("\n");
|
||||
if (failedUrls.length === 0) {
|
||||
fields.name.value = "";
|
||||
fields.subfolder.value = "";
|
||||
} else {
|
||||
const prefix = urls.length > 1 ? `${failedUrls.length} of ${urls.length} links failed: ` : "";
|
||||
fields.error.textContent = prefix + firstError;
|
||||
}
|
||||
await onCreated();
|
||||
}
|
||||
@@ -3,6 +3,7 @@ const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
export const statusLabels = {
|
||||
queued: "Queued",
|
||||
downloading: "Downloading",
|
||||
paused: "Paused",
|
||||
extracting: "Extracting",
|
||||
ready: "Ready",
|
||||
moving: "Moving",
|
||||
@@ -21,6 +22,21 @@ export function formatBytes(bytes) {
|
||||
return `${value.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export function formatDuration(seconds) {
|
||||
const total = Math.max(0, Math.round(seconds));
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const remainingSeconds = total % 60;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
if (minutes > 0) return `${minutes}m ${remainingSeconds}s`;
|
||||
return `${remainingSeconds}s`;
|
||||
}
|
||||
|
||||
export function formatDate(value) {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString();
|
||||
}
|
||||
|
||||
export function percentOf(done, total) {
|
||||
if (!total || total <= 0) return null;
|
||||
return Math.min(100, (done / total) * 100);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { element } from "./dom.js";
|
||||
import { formatDate } from "./format.js";
|
||||
|
||||
const maxRecentPaths = 5;
|
||||
|
||||
export function recentDestinations(entries) {
|
||||
const paths = [];
|
||||
for (const entry of entries) {
|
||||
if (!paths.includes(entry.destination)) paths.push(entry.destination);
|
||||
if (paths.length >= maxRecentPaths) break;
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function renderHistory(elements, entries) {
|
||||
elements.title.textContent = entries.length > 0 ? `History (${entries.length})` : "History";
|
||||
elements.empty.hidden = entries.length > 0;
|
||||
elements.clearButton.hidden = entries.length === 0;
|
||||
elements.list.replaceChildren(...entries.map(renderEntry));
|
||||
}
|
||||
|
||||
function renderEntry(entry) {
|
||||
const item = element("li", "history-item");
|
||||
const items = entry.items || [];
|
||||
const itemText = items.length === 1 ? items[0] : `${items.length} items`;
|
||||
item.append(
|
||||
element("div", "history-name", entry.fileName || itemText),
|
||||
element("p", "history-meta muted", `${itemText} moved to ${entry.destination}`),
|
||||
element("p", "history-meta muted", formatDate(entry.movedAt)),
|
||||
);
|
||||
if (entry.sourceUrl) item.append(element("p", "history-meta muted", entry.sourceUrl));
|
||||
return item;
|
||||
}
|
||||
+55
-4
@@ -1,13 +1,21 @@
|
||||
import { button, element } from "./dom.js";
|
||||
import { formatBytes, percentOf, statusLabels } from "./format.js";
|
||||
import { formatBytes, formatDuration, percentOf, statusLabels } from "./format.js";
|
||||
|
||||
const activeStatuses = new Set(["queued", "downloading", "extracting", "moving"]);
|
||||
const pausableStatuses = new Set(["queued", "downloading"]);
|
||||
const speedSmoothing = 0.3;
|
||||
const speedSamples = new Map();
|
||||
|
||||
export function isActive(job) {
|
||||
return activeStatuses.has(job.status);
|
||||
}
|
||||
|
||||
export function matchesFilter(job, filter) {
|
||||
if (filter === "all") return true;
|
||||
if (filter === "active") return isActive(job);
|
||||
return job.status === filter;
|
||||
}
|
||||
|
||||
export function updateSpeeds(jobs) {
|
||||
const now = performance.now();
|
||||
const seenIds = new Set();
|
||||
@@ -17,7 +25,8 @@ export function updateSpeeds(jobs) {
|
||||
const previous = speedSamples.get(job.id);
|
||||
let speed = 0;
|
||||
if (previous && job.bytesDone >= previous.bytes && now > previous.time) {
|
||||
speed = ((job.bytesDone - previous.bytes) * 1000) / (now - previous.time);
|
||||
const instantSpeed = ((job.bytesDone - previous.bytes) * 1000) / (now - previous.time);
|
||||
speed = previous.speed > 0 ? previous.speed + speedSmoothing * (instantSpeed - previous.speed) : instantSpeed;
|
||||
}
|
||||
speedSamples.set(job.id, { bytes: job.bytesDone, time: now, speed });
|
||||
}
|
||||
@@ -27,8 +36,25 @@ export function updateSpeeds(jobs) {
|
||||
}
|
||||
}
|
||||
|
||||
function displayRank(job) {
|
||||
if (isActive(job) && !job.queuePosition) return 0;
|
||||
if (job.queuePosition) return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function sortForDisplay(jobs) {
|
||||
return [...jobs].sort((first, second) => {
|
||||
const rankDifference = displayRank(first) - displayRank(second);
|
||||
if (rankDifference !== 0) return rankDifference;
|
||||
if (first.queuePosition && second.queuePosition) return first.queuePosition - second.queuePosition;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function renderJobs(container, jobs, selectedId, handlers) {
|
||||
container.replaceChildren(...jobs.map((job) => renderJob(job, job.id === selectedId, handlers)));
|
||||
const queueLength = jobs.filter((job) => job.queuePosition).length;
|
||||
const context = { ...handlers, queueLength };
|
||||
container.replaceChildren(...sortForDisplay(jobs).map((job) => renderJob(job, job.id === selectedId, context)));
|
||||
}
|
||||
|
||||
function renderJob(job, selected, handlers) {
|
||||
@@ -42,6 +68,12 @@ function renderJob(job, selected, handlers) {
|
||||
item.append(header);
|
||||
|
||||
if (job.sourceUrl) item.append(element("p", "job-source muted", job.sourceUrl));
|
||||
if (job.moveTarget && job.status !== "completed") {
|
||||
item.append(element("p", "job-source muted", `Download to: ${job.moveTarget}`));
|
||||
}
|
||||
if (job.queuePosition) {
|
||||
item.append(element("p", "job-source muted", `Queue position ${job.queuePosition} of ${handlers.queueLength}`));
|
||||
}
|
||||
if (job.status !== "completed") item.append(renderProgress(job));
|
||||
if (job.message) {
|
||||
const messageClass = job.status === "failed" ? "job-message error" : "job-message muted";
|
||||
@@ -79,7 +111,12 @@ function progressText(job, percent) {
|
||||
if (percent !== null) parts.push(`${percent.toFixed(1)}%`);
|
||||
|
||||
const sample = speedSamples.get(job.id);
|
||||
if (isActive(job) && sample && sample.speed > 0) parts.push(`${formatBytes(sample.speed)}/s`);
|
||||
if (isActive(job) && sample && sample.speed > 0) {
|
||||
parts.push(`${formatBytes(sample.speed)}/s`);
|
||||
if (job.bytesTotal > job.bytesDone) {
|
||||
parts.push(`${formatDuration((job.bytesTotal - job.bytesDone) / sample.speed)} left`);
|
||||
}
|
||||
}
|
||||
return parts.join(" | ");
|
||||
}
|
||||
|
||||
@@ -88,6 +125,20 @@ function renderActions(job, handlers) {
|
||||
if (job.status === "ready") {
|
||||
actions.append(button("Browse files", "secondary small", () => handlers.onBrowse(job)));
|
||||
}
|
||||
if (job.queuePosition > 1) {
|
||||
actions.append(button("Start next", "secondary small", () => handlers.onReorder(job, "first")));
|
||||
actions.append(button("Move up", "secondary small", () => handlers.onReorder(job, "up")));
|
||||
}
|
||||
if (job.queuePosition && job.queuePosition < handlers.queueLength) {
|
||||
actions.append(button("Move down", "secondary small", () => handlers.onReorder(job, "down")));
|
||||
}
|
||||
if (pausableStatuses.has(job.status)) {
|
||||
actions.append(button("Pause", "secondary small", () => handlers.onPause(job)));
|
||||
}
|
||||
if (job.status === "paused" || job.status === "failed") {
|
||||
const label = job.status === "paused" ? "Resume" : "Retry";
|
||||
actions.append(button(label, "secondary small", () => handlers.onResume(job)));
|
||||
}
|
||||
if (job.status !== "moving") {
|
||||
actions.append(button(isActive(job) ? "Cancel" : "Remove", "danger small", () => handlers.onDelete(job)));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { element } from "./dom.js";
|
||||
import { formatBytes } from "./format.js";
|
||||
|
||||
function withFreeSpace(label, destination) {
|
||||
if (!destination || destination.freeBytes < 0) return label;
|
||||
return `${label} - ${formatBytes(destination.freeBytes)} free`;
|
||||
}
|
||||
|
||||
function destinationForPath(destinations, path) {
|
||||
return destinations.find((destination) => path === destination.path || path.startsWith(`${destination.path}/`));
|
||||
}
|
||||
|
||||
function libraryGroups(destinations) {
|
||||
const groups = new Map();
|
||||
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", "", withFreeSpace(destination.name, destination));
|
||||
option.value = destination.path;
|
||||
option.title = destination.path;
|
||||
groups.get(destination.root).append(option);
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
function pathGroup(label, paths, destinations) {
|
||||
const group = document.createElement("optgroup");
|
||||
group.label = label;
|
||||
for (const path of paths) {
|
||||
const option = element("option", "", withFreeSpace(path, destinationForPath(destinations, path)));
|
||||
option.value = path;
|
||||
option.title = path;
|
||||
group.append(option);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
export function fillLibrarySelect(select, destinations, extras = {}) {
|
||||
const previousValue = select.value;
|
||||
const leadingOptions = [];
|
||||
if (extras.emptyLabel) {
|
||||
const emptyOption = element("option", "", extras.emptyLabel);
|
||||
emptyOption.value = "";
|
||||
leadingOptions.push(emptyOption);
|
||||
}
|
||||
if (extras.recentPaths && extras.recentPaths.length > 0) {
|
||||
leadingOptions.push(pathGroup("Recent", extras.recentPaths, destinations));
|
||||
}
|
||||
if (extras.savedPaths && extras.savedPaths.length > 0) {
|
||||
leadingOptions.push(pathGroup("Saved paths", extras.savedPaths, destinations));
|
||||
}
|
||||
|
||||
select.replaceChildren(...leadingOptions, ...libraryGroups(destinations));
|
||||
const availableValues = [...select.options].map((option) => option.value);
|
||||
if (availableValues.includes(previousValue)) {
|
||||
select.value = previousValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
const storageKey = "ddc-notifications";
|
||||
const finishedTitles = {
|
||||
ready: "Download ready",
|
||||
completed: "Download moved",
|
||||
failed: "Download failed",
|
||||
};
|
||||
const previousStatuses = new Map();
|
||||
|
||||
function notificationsSupported() {
|
||||
return "Notification" in window;
|
||||
}
|
||||
|
||||
function readPreference() {
|
||||
try {
|
||||
return localStorage.getItem(storageKey) === "on";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function storePreference(enabled) {
|
||||
try {
|
||||
localStorage.setItem(storageKey, enabled ? "on" : "off");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function notificationsEnabled() {
|
||||
return notificationsSupported() && Notification.permission === "granted" && readPreference();
|
||||
}
|
||||
|
||||
export async function setNotificationsEnabled(enabled) {
|
||||
if (!enabled) {
|
||||
storePreference(false);
|
||||
return;
|
||||
}
|
||||
if (!notificationsSupported()) throw new Error("This browser does not support notifications.");
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== "granted") throw new Error("Notifications are blocked for this site in the browser settings.");
|
||||
storePreference(true);
|
||||
}
|
||||
|
||||
export function collectTransitions(jobs) {
|
||||
const transitions = [];
|
||||
const seenIds = new Set();
|
||||
for (const job of jobs) {
|
||||
seenIds.add(job.id);
|
||||
const previous = previousStatuses.get(job.id);
|
||||
if (previous && previous !== job.status) transitions.push({ job, previous });
|
||||
previousStatuses.set(job.id, job.status);
|
||||
}
|
||||
for (const id of previousStatuses.keys()) {
|
||||
if (!seenIds.has(id)) previousStatuses.delete(id);
|
||||
}
|
||||
return transitions;
|
||||
}
|
||||
|
||||
export function notifyTransitions(transitions) {
|
||||
if (!notificationsEnabled()) return;
|
||||
for (const { job, previous } of transitions) {
|
||||
const title = finishedTitles[job.status];
|
||||
const wasWorking = previous === "queued" || previous === "downloading" || previous === "extracting" || previous === "moving";
|
||||
if (!title || !wasWorking) continue;
|
||||
if (job.status === "ready" && previous === "moving") continue;
|
||||
new Notification(title, { body: job.fileName || job.sourceUrl, tag: `ddc-${job.id}` });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from "./api.js";
|
||||
import { element } from "./dom.js";
|
||||
import { notificationsEnabled, setNotificationsEnabled } from "./notify.js";
|
||||
|
||||
const themeStorageKey = "ddc-theme";
|
||||
|
||||
@@ -10,10 +11,13 @@ const fields = {
|
||||
cancelButton: document.getElementById("settings-cancel"),
|
||||
error: document.getElementById("settings-error"),
|
||||
concurrent: document.getElementById("setting-concurrent"),
|
||||
connections: document.getElementById("setting-connections"),
|
||||
notifications: document.getElementById("setting-notifications"),
|
||||
minFree: document.getElementById("setting-min-free"),
|
||||
ports: document.getElementById("setting-ports"),
|
||||
autoExtract: document.getElementById("setting-auto-extract"),
|
||||
cleanup: document.getElementById("setting-cleanup"),
|
||||
downloadPaths: document.getElementById("setting-download-paths"),
|
||||
theme: document.getElementById("setting-theme"),
|
||||
downloadDir: document.getElementById("info-download-dir"),
|
||||
roots: document.getElementById("info-roots"),
|
||||
@@ -42,10 +46,13 @@ export async function openSettings() {
|
||||
function fillForm(data) {
|
||||
const settings = data.settings;
|
||||
fields.concurrent.value = settings.maxConcurrentDownloads;
|
||||
fields.connections.value = settings.connectionsPerDownload;
|
||||
fields.notifications.checked = notificationsEnabled();
|
||||
fields.minFree.value = settings.minFreeGB;
|
||||
fields.ports.value = settings.allowedPorts.length > 0 ? settings.allowedPorts.join(", ") : "*";
|
||||
fields.autoExtract.checked = settings.autoExtract;
|
||||
fields.cleanup.checked = settings.cleanupAfterMove;
|
||||
fields.downloadPaths.value = (settings.downloadPaths || []).join("\n");
|
||||
fields.theme.value = readTheme();
|
||||
fields.downloadDir.textContent = data.downloadDir;
|
||||
fields.roots.replaceChildren(...data.libraryRoots.map((root) => element("div", "mono", root)));
|
||||
@@ -57,11 +64,14 @@ async function saveSettings(event, onSaved) {
|
||||
try {
|
||||
const payload = {
|
||||
maxConcurrentDownloads: Number(fields.concurrent.value),
|
||||
connectionsPerDownload: Number(fields.connections.value),
|
||||
minFreeGB: Number(fields.minFree.value),
|
||||
allowedPorts: parsePorts(fields.ports.value),
|
||||
autoExtract: fields.autoExtract.checked,
|
||||
cleanupAfterMove: fields.cleanup.checked,
|
||||
downloadPaths: parseDownloadPaths(fields.downloadPaths.value),
|
||||
};
|
||||
await setNotificationsEnabled(fields.notifications.checked);
|
||||
const data = await api.saveSettings(payload);
|
||||
storeTheme(fields.theme.value);
|
||||
fields.dialog.close();
|
||||
@@ -80,6 +90,10 @@ function parsePorts(text) {
|
||||
return ports;
|
||||
}
|
||||
|
||||
function parseDownloadPaths(text) {
|
||||
return text.split("\n").map((line) => line.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function readTheme() {
|
||||
try {
|
||||
return localStorage.getItem(themeStorageKey) || "system";
|
||||
|
||||
+108
-2
@@ -97,13 +97,15 @@ h2 {
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
input:not([type="checkbox"]),
|
||||
select {
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 9px 12px;
|
||||
@@ -113,8 +115,13 @@ select {
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
input:not([type="checkbox"]):focus,
|
||||
select:focus {
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
@@ -724,3 +731,102 @@ button.block {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-paused {
|
||||
color: var(--muted);
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.download-form {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.download-form textarea {
|
||||
flex: 1 1 auto;
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.download-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.list-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.list-toolbar h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.list-toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.list-toolbar-actions select {
|
||||
width: auto;
|
||||
min-height: 36px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.history-card summary {
|
||||
cursor: pointer;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 14px 0 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.history-list:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-small);
|
||||
padding: 10px 12px;
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
|
||||
.history-name {
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.history-meta {
|
||||
font-size: 0.8rem;
|
||||
margin: 4px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.history-card .empty-state {
|
||||
margin: 14px 0 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.download-form textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.download-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user