Initial DDC release

This commit is contained in:
SirBlob committed 2026-10-25 20:24:50 -04:00
commit 434f6441da
57 files changed
+4606

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
export class ApiError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
const unauthorizedListeners = new Set();
export function onUnauthorized(listener) {
unauthorizedListeners.add(listener);
}
function parseBody(text) {
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return null;
}
}
async function request(method, path, body) {
const options = { method, credentials: "same-origin", headers: {} };
if (body !== undefined) {
options.headers["Content-Type"] = "application/json";
options.body = JSON.stringify(body);
}
const response = await fetch(path, options);
const data = parseBody(await response.text());
if (!response.ok) {
if (response.status === 401 && path !== "/api/login") {
unauthorizedListeners.forEach((listener) => listener());
}
const message = data && data.error ? data.error : `Request failed (${response.status})`;
throw new ApiError(response.status, message);
}
return data;
}
function jobPath(id, suffix = "") {
return `/api/jobs/${encodeURIComponent(id)}${suffix}`;
}
export const api = {
session: () => request("GET", "/api/session"),
login: (username, password) => request("POST", "/api/login", { username, password }),
logout: () => request("POST", "/api/logout", {}),
libraries: () => request("GET", "/api/libraries"),
disk: () => request("GET", "/api/disk"),
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 }),
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),
remove: (id) => request("DELETE", jobPath(id)),
};
+293
View File
@@ -0,0 +1,293 @@
import { api, onUnauthorized } from "./api.js";
import { element } from "./dom.js";
import { formatBytes } from "./format.js";
import { isActive, renderJobs, updateSpeeds } from "./jobs.js";
import { setupLogin } from "./login.js";
import { loadSettings, openSettings, setupSettings } from "./settings.js";
import { renderTree, topLevelSelection } from "./tree.js";
const pollIntervalMs = 1500;
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"),
downloadForm: document.getElementById("download-form"),
downloadUrl: document.getElementById("download-url"),
autoExtract: document.getElementById("auto-extract"),
downloadError: document.getElementById("download-error"),
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"),
};
const state = {
jobs: [],
selectedJobId: null,
treeStatus: null,
selection: new Set(),
pollTimer: null,
refreshing: false,
};
function showLogin() {
stopPolling();
closeDetail();
elements.appView.hidden = true;
elements.loginView.hidden = false;
}
async function showApp() {
elements.loginView.hidden = true;
elements.appView.hidden = false;
applyDefaults(await loadSettings());
await loadLibraries();
await refresh();
startPolling();
}
function applyDefaults(settings) {
elements.autoExtract.checked = settings.autoExtract;
elements.cleanupCheckbox.checked = settings.cleanupAfterMove;
}
async function showSettings() {
try {
await openSettings();
} catch (error) {
window.alert(error.message);
}
}
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() {
const destinations = (await api.libraries()) || [];
const previousValue = elements.librarySelect.value;
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", "", destination.name);
option.value = destination.path;
option.title = destination.path;
groups.get(destination.root).append(option);
}
elements.librarySelect.replaceChildren(...groups.values());
if (destinations.some((destination) => destination.path === previousValue)) {
elements.librarySelect.value = previousValue;
}
}
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);
elements.diskStatus.textContent = `${formatBytes(disk.availableBytes)} free`;
renderJobList();
await syncDetail();
} catch (error) {
if (error.status !== 401) console.error(error);
} finally {
state.refreshing = false;
}
}
function renderJobList() {
elements.jobEmpty.hidden = state.jobs.length > 0;
renderJobs(elements.jobList, state.jobs, state.selectedJobId, {
onBrowse: openDetail,
onDelete: deleteJob,
});
}
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 = error.message;
});
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 = error.message;
}
}
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 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 {
await api.extract(state.selectedJobId, path);
await refresh();
} catch (error) {
elements.actionError.textContent = error.message;
}
}
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 = error.message;
}
}
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(error.message);
}
}
async function logout() {
try {
await api.logout();
} finally {
showLogin();
}
}
async function init() {
onUnauthorized(showLogin);
setupLogin(elements.loginForm, elements.loginError, showApp);
elements.downloadForm.addEventListener("submit", submitDownload);
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();
+13
View File
@@ -0,0 +1,13 @@
export function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined && text !== null) node.textContent = text;
return node;
}
export function button(label, className, onClick) {
const node = element("button", className, label);
node.type = "button";
node.addEventListener("click", onClick);
return node;
}
+27
View File
@@ -0,0 +1,27 @@
const units = ["B", "KB", "MB", "GB", "TB"];
export const statusLabels = {
queued: "Queued",
downloading: "Downloading",
extracting: "Extracting",
ready: "Ready",
moving: "Moving",
completed: "Completed",
failed: "Failed",
cancelled: "Cancelled",
};
export function formatBytes(bytes) {
let value = Math.max(0, bytes || 0);
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
export function percentOf(done, total) {
if (!total || total <= 0) return null;
return Math.min(100, (done / total) * 100);
}
+95
View File
@@ -0,0 +1,95 @@
import { button, element } from "./dom.js";
import { formatBytes, percentOf, statusLabels } from "./format.js";
const activeStatuses = new Set(["queued", "downloading", "extracting", "moving"]);
const speedSamples = new Map();
export function isActive(job) {
return activeStatuses.has(job.status);
}
export function updateSpeeds(jobs) {
const now = performance.now();
const seenIds = new Set();
for (const job of jobs) {
seenIds.add(job.id);
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);
}
speedSamples.set(job.id, { bytes: job.bytesDone, time: now, speed });
}
for (const id of speedSamples.keys()) {
if (!seenIds.has(id)) speedSamples.delete(id);
}
}
export function renderJobs(container, jobs, selectedId, handlers) {
container.replaceChildren(...jobs.map((job) => renderJob(job, job.id === selectedId, handlers)));
}
function renderJob(job, selected, handlers) {
const item = element("li", selected ? "job selected" : "job");
const header = element("div", "job-header");
header.append(
element("span", "job-name", job.fileName || job.sourceUrl),
element("span", `badge badge-${job.status}`, statusLabels[job.status] || job.status),
);
item.append(header);
if (job.sourceUrl) item.append(element("p", "job-source muted", job.sourceUrl));
if (job.status !== "completed") item.append(renderProgress(job));
if (job.message) {
const messageClass = job.status === "failed" ? "job-message error" : "job-message muted";
item.append(element("p", messageClass, job.message));
}
item.append(renderActions(job, handlers));
return item;
}
function renderProgress(job) {
const wrapper = element("div");
const track = element("div", "progress");
const bar = element("div", "progress-bar");
const percent = percentOf(job.bytesDone, job.bytesTotal);
if (percent === null && isActive(job)) {
bar.classList.add("indeterminate");
} else {
const width = percent === null ? (job.status === "ready" ? 100 : 0) : percent;
bar.style.width = `${width}%`;
}
track.append(bar);
wrapper.append(track, element("div", "progress-text muted", progressText(job, percent)));
return wrapper;
}
function progressText(job, percent) {
const parts = [];
if (job.bytesTotal > 0) {
parts.push(`${formatBytes(job.bytesDone)} of ${formatBytes(job.bytesTotal)}`);
} else if (job.bytesDone > 0) {
parts.push(formatBytes(job.bytesDone));
}
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`);
return parts.join(" | ");
}
function renderActions(job, handlers) {
const actions = element("div", "job-actions");
if (job.status === "ready") {
actions.append(button("Browse files", "secondary small", () => handlers.onBrowse(job)));
}
if (job.status !== "moving") {
actions.append(button(isActive(job) ? "Cancel" : "Remove", "danger small", () => handlers.onDelete(job)));
}
return actions;
}
+17
View File
@@ -0,0 +1,17 @@
import { api } from "./api.js";
export function setupLogin(form, errorElement, onSuccess) {
form.addEventListener("submit", async (event) => {
event.preventDefault();
errorElement.textContent = "";
const formData = new FormData(form);
try {
await api.login(formData.get("username"), formData.get("password"));
form.reset();
await onSuccess();
} catch (error) {
errorElement.textContent = error.message;
}
});
}
+104
View File
@@ -0,0 +1,104 @@
import { api } from "./api.js";
import { element } from "./dom.js";
const themeStorageKey = "ddc-theme";
const fields = {
dialog: document.getElementById("settings-dialog"),
form: document.getElementById("settings-form"),
closeButton: document.getElementById("settings-close"),
cancelButton: document.getElementById("settings-cancel"),
error: document.getElementById("settings-error"),
concurrent: document.getElementById("setting-concurrent"),
minFree: document.getElementById("setting-min-free"),
ports: document.getElementById("setting-ports"),
autoExtract: document.getElementById("setting-auto-extract"),
cleanup: document.getElementById("setting-cleanup"),
theme: document.getElementById("setting-theme"),
downloadDir: document.getElementById("info-download-dir"),
roots: document.getElementById("info-roots"),
};
export function setupSettings(onSaved) {
fields.closeButton.addEventListener("click", () => fields.dialog.close());
fields.cancelButton.addEventListener("click", () => fields.dialog.close());
fields.form.addEventListener("submit", (event) => saveSettings(event, onSaved));
fields.theme.addEventListener("change", () => applyTheme(fields.theme.value));
fields.dialog.addEventListener("close", () => applyTheme(readTheme()));
}
export async function loadSettings() {
const data = await api.settings();
return data.settings;
}
export async function openSettings() {
fields.error.textContent = "";
const data = await api.settings();
fillForm(data);
fields.dialog.showModal();
}
function fillForm(data) {
const settings = data.settings;
fields.concurrent.value = settings.maxConcurrentDownloads;
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.theme.value = readTheme();
fields.downloadDir.textContent = data.downloadDir;
fields.roots.replaceChildren(...data.libraryRoots.map((root) => element("div", "mono", root)));
}
async function saveSettings(event, onSaved) {
event.preventDefault();
fields.error.textContent = "";
try {
const payload = {
maxConcurrentDownloads: Number(fields.concurrent.value),
minFreeGB: Number(fields.minFree.value),
allowedPorts: parsePorts(fields.ports.value),
autoExtract: fields.autoExtract.checked,
cleanupAfterMove: fields.cleanup.checked,
};
const data = await api.saveSettings(payload);
storeTheme(fields.theme.value);
fields.dialog.close();
onSaved(data.settings);
} catch (error) {
fields.error.textContent = error.message;
}
}
function parsePorts(text) {
const trimmed = text.trim();
if (trimmed === "*") return [];
const ports = trimmed.split(/[\s,]+/).filter(Boolean).map(Number);
const invalid = ports.length === 0 || ports.some((port) => !Number.isInteger(port) || port < 1 || port > 65535);
if (invalid) throw new Error("Allowed ports must be numbers between 1 and 65535, or * for any port.");
return ports;
}
function readTheme() {
try {
return localStorage.getItem(themeStorageKey) || "system";
} catch {
return "system";
}
}
function storeTheme(theme) {
try {
localStorage.setItem(themeStorageKey, theme);
} catch {
return;
} finally {
applyTheme(theme);
}
}
function applyTheme(theme) {
if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
else delete document.documentElement.dataset.theme;
}
+10
View File
@@ -0,0 +1,10 @@
(function () {
try {
var theme = localStorage.getItem("ddc-theme");
if (theme === "light" || theme === "dark") {
document.documentElement.setAttribute("data-theme", theme);
}
} catch (error) {
return;
}
})();
+50
View File
@@ -0,0 +1,50 @@
import { button, element } from "./dom.js";
import { formatBytes } from "./format.js";
export function renderTree(container, nodes, selection, handlers) {
if (nodes.length === 0) {
container.replaceChildren(element("p", "muted", "No files found."));
return;
}
container.replaceChildren(buildList(nodes, selection, handlers));
}
export function topLevelSelection(selection) {
const paths = [...selection].sort();
return paths.filter((path) => !paths.some((other) => other !== path && path.startsWith(`${other}/`)));
}
function buildList(nodes, selection, handlers) {
const list = element("ul", "tree-list");
for (const node of nodes) list.append(buildNode(node, selection, handlers));
return list;
}
function buildNode(node, selection, handlers) {
const item = element("li", "tree-item");
const row = element("div", "tree-row");
const label = element("label", "tree-label");
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = selection.has(node.path);
checkbox.addEventListener("change", () => {
if (checkbox.checked) selection.add(node.path);
else selection.delete(node.path);
handlers.onSelectionChange();
});
const displayName = node.isDir ? `${node.name}/` : node.name;
label.append(checkbox, element("span", node.isDir ? "tree-name folder" : "tree-name", displayName));
row.append(label, element("span", "tree-size", formatBytes(node.size)));
if (node.isArchive) {
row.append(button("Extract", "secondary small", () => handlers.onExtract(node.path)));
}
item.append(row);
if (node.children && node.children.length > 0) {
item.append(buildList(node.children, selection, handlers));
}
return item;
}