release: publish Multi-Debrid Downloader v2.0.14

Add live English and German localization, queue availability and metadata resolution, responsive package controls, polished navigation and drag interactions, clearer history and account states, and a rebuilt public README. Harden Windows packaging with verified icons and version metadata, archive inspection, and expanded release tests.
This commit is contained in:
Sucukdeluxe
2026-08-10 19:42:49 +02:00
parent 069babfd54
commit a5758aa905
61 changed files with 9117 additions and 6754 deletions
+213 -20
View File
@@ -1,4 +1,6 @@
import { readFileSync } from "node:fs";
import fs from "node:fs";
import path from "node:path";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
@@ -7,6 +9,7 @@ import {
buildDownloadSidebarCounts,
buildDownloadsViewModel,
classifyDownloadStatus,
getDownloadQueueTotalBytes,
type DownloadSidebarFilter,
type DownloadsModelInput
} from "../src/renderer/views/downloads/downloads-model";
@@ -21,13 +24,64 @@ import {
} from "../src/renderer/views/downloads/DownloadsView";
import {
DownloadsTableHeader,
ItemRowContent,
PackageCardContent,
areItemRowPropsEqual,
arePackageCardPropsEqual
arePackageCardPropsEqual,
downloadColumnDefinitions,
getAvailabilitySummary
} from "../src/renderer/views/downloads/DownloadsTable";
import { getRollingMetricDirection } from "../src/renderer/ui/RollingMetricValue";
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
describe("Downloadtabellen-Spalten", () => {
it("verteilt die Breite mit ausreichend Platz für vollständige Überschriften", () => {
expect(downloadColumnDefinitions.name.width).toBe("minmax(290px, 2.3fr)");
expect(downloadColumnDefinitions.progress.width).toBe("minmax(105px, 0.85fr)");
expect(downloadColumnDefinitions.prio.width).toBe("minmax(85px, 0.8fr)");
expect(downloadColumnDefinitions.speed).toEqual(expect.objectContaining({ label: "Geschwindigkeit", width: "minmax(120px, 1fr)" }));
expect(downloadColumnDefinitions.availability).toEqual(expect.objectContaining({ label: "Verfügbarkeit", width: "minmax(110px, 1fr)" }));
});
it("uses the normal text color for sortable and static column headers", () => {
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
expect(css).toMatch(/\.downloads-table-header\s*\{[^}]*color:\s*var\(--ui-text\);/s);
});
it("moves complete columns through pointer capture and animated transforms", () => {
const source = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/DownloadsTable.tsx"), "utf8");
const dragSource = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/column-drag.ts"), "utf8");
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
expect(source).toContain("onColumnPointerDown");
expect(source).toContain("setPointerCapture");
expect(source).toContain("onPointerMove");
expect(source).not.toMatch(/className="downloads-column-header"[\s\S]{0,180}\sdraggable/);
expect(dragSource).toContain('root.style.setProperty(`--downloads-column-drag-${id}`');
expect(css).toMatch(/\.downloads-table\.is-column-drag-active \[data-download-column\]\s*\{[^}]*transform:\s*translate3d\(var\(--downloads-column-drag-x, 0px\), 0, 0\);[^}]*transition:\s*transform 220ms/s);
expect(css).toMatch(/\.downloads-table\.is-column-drag-active \[data-column-dragging="true"\]\s*\{[^}]*transition:\s*none;/s);
expect(css).not.toMatch(/\.downloads-table\.is-column-drag-active \[data-column-dragging="true"\]\s*\{[^}]*background:/s);
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.downloads-table\.is-column-drag-active \[data-download-column\][^{]*\{[^}]*transition-duration:\s*220ms !important;/s);
});
});
describe("rollende Downloadkennzahlen", () => {
it("moves increasing values up and decreasing values down", () => {
expect(getRollingMetricDirection(300, 600)).toBe("up");
expect(getRollingMetricDirection(600, 300)).toBe("down");
expect(getRollingMetricDirection(300, 300)).toBe("none");
});
it("animates exactly the five stable sidebar metrics", () => {
const html = renderToStaticMarkup(<DownloadsSidebarStatus model={withRuntime(createInput())} />);
expect(html.match(/class="downloads-rolling-value"/g)).toHaveLength(5);
expect(html).toContain('data-status-metric="speed"');
expect(html).toContain('data-status-metric="eta"');
});
});
function item(id: string, packageId: string, status: DownloadStatus, overrides: Partial<DownloadItem> = {}): DownloadItem {
return {
id,
@@ -57,10 +111,21 @@ function pkg(id: string, name: string, itemIds: string[]): PackageEntry {
return { id, name, itemIds, createdAt: now } as PackageEntry;
}
describe("Download-Gesamtgröße", () => {
it("summiert bekannte Dateigrößen und verwendet geladene Bytes nur als Fallback", () => {
const items = [
item("known", "package-a", "queued", { totalBytes: 4_000, downloadedBytes: 500 }),
item("fallback", "package-a", "queued", { totalBytes: null, downloadedBytes: 750 })
];
expect(getDownloadQueueTotalBytes(items)).toBe(4_750);
});
});
function createInput(overrides: Partial<DownloadsModelInput> = {}): DownloadsModelInput {
const items = [
item("active", "package-a", "downloading"),
item("queued", "package-a", "queued", { provider: "debridlink", providerLabel: "Debrid-Link" }),
item("queued", "package-a", "queued", { provider: "debridlink", providerLabel: "Debrid-Link", url: "https://ddownload.com/file/queued" }),
item("failed", "package-b", "failed", { provider: "alldebrid", providerLabel: "AllDebrid" }),
item("done", "package-b", "completed", { provider: "realdebrid", providerLabel: "Real-Debrid" })
];
@@ -114,7 +179,6 @@ function createActions(overrides: Partial<DownloadsViewActions> = {}): Downloads
onToggleSelection: () => {},
onSelectionMouseDown: () => {},
onSelectionMouseEnter: () => {},
onTogglePackage: () => {},
onTogglePackageCollapse: () => {},
onStartPackageRename: () => {},
onPackageRenameChange: () => {},
@@ -126,11 +190,10 @@ function createActions(overrides: Partial<DownloadsViewActions> = {}): Downloads
onRemoveItem: () => {},
onOpenContextMenu: () => {},
onSortColumn: () => {},
onColumnDragStart: () => {},
onColumnDragOver: () => {},
onColumnDragLeave: () => {},
onColumnDrop: () => {},
onColumnDragEnd: () => {},
onColumnPointerDown: () => {},
onColumnPointerMove: () => {},
onColumnPointerUp: () => {},
onColumnPointerCancel: () => {},
onColumnContextMenu: () => {},
...overrides
};
@@ -190,7 +253,9 @@ function withRuntime(input: DownloadsModelInput, overrides: Record<string, unkno
packages: 2,
links: 4,
session: "3,00 GB",
sessionBytes: 3_000_000_000,
total: "10,00 GB",
totalBytes: 10_000_000_000,
hosters: 3,
speed: "96,00 Mbit/s",
eta: "00:05:00"
@@ -325,6 +390,39 @@ describe("downloads model", () => {
});
describe("downloads view", () => {
it("pins the clipboard toggle separately at the bottom above the status metrics", () => {
const html = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput())} />);
const actionsMarkup = html.match(/<div class="downloads-sidebar-actions">([\s\S]*?)<\/div>/)?.[1] ?? "";
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
expect(html).toContain('class="downloads-clipboard-toggle"');
expect(actionsMarkup).not.toContain("Zwischenablage überwachen");
expect(css).toMatch(/\.downloads-sidebar\s*\{[^}]*height:\s*100%;[^}]*padding:\s*14px 12px 6px;/s);
expect(css).toMatch(/\.downloads-clipboard-toggle\s*\{[^}]*margin-top:\s*auto;[^}]*border:\s*1px solid var\(--ui-border\);[^}]*background:\s*var\(--ui-input\);/s);
});
it("shows the sidebar actions as permanently recognizable buttons", () => {
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
expect(css).toMatch(/\.downloads-sidebar-actions button\s*\{[^}]*border:\s*1px solid var\(--ui-border\);[^}]*background:\s*var\(--ui-input\);[^}]*padding:\s*0 10px;/s);
});
it("marks the download filters for one measured vertical selection indicator", () => {
const html = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput())} />);
expect(html).toContain("ui-sliding-selection ui-sliding-selection-vertical");
expect(html.match(/data-sliding-selection-item="true"/g)).toHaveLength(6);
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
});
it("shows only the package mode while the file mode remains hidden", () => {
const html = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput())} />);
expect(html).toContain("Pakete");
expect(html).not.toContain(">Dateien<");
expect(html).not.toContain("downloads-mode-switch");
});
it("renders the five dense markers exactly once and the empty marker only for a true empty queue", () => {
const occupied = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput())} />);
const empty = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput({ packageOrder: [], packages: {}, items: {} }), { running: false })} />);
@@ -474,15 +572,36 @@ describe("downloads view", () => {
expect(html.indexOf("downloads-table-header")).toBeGreaterThan(html.indexOf("downloads-table"));
expect(html.indexOf("data-visual-region=\"downloads-table-body\"")).toBeGreaterThan(html.indexOf("downloads-table-header"));
expect(css).toMatch(/\.downloads-table\s*\{[^}]*overflow-x:\s*auto;/s);
expect(html).toContain('class="downloads-package-card ');
expect(html).not.toContain('class="package-card');
expect(html).toContain('class="downloads-hoster-icon" data-hoster="rapidgator" src="data:image/x-icon;base64,');
expect(html).toContain('class="downloads-hoster-icon" data-hoster="ddownload" src="./provider-icons/ddownload.ico"');
expect(html).not.toContain('title="RapidGator">RG<');
expect(html).toContain('data-download-column="name"');
expect(html).toContain('data-download-column="hoster"');
expect(html).toMatch(/grid-template-columns:[^"]+ 60px/);
expect(html).toContain('class="downloads-package-items is-expanded"');
expect(html).toContain('class="downloads-package-items-inner"');
expect(css).toMatch(/\.downloads-table\s*\{[^}]*overflow-x:\s*auto;[^}]*scrollbar-gutter:\s*stable;/s);
expect(css).toMatch(/\.downloads-table-header,\s*\.downloads-table-body\s*\{[^}]*width:\s*100%;[^}]*min-width:\s*1191px;/s);
expect(css).not.toMatch(/min-width:\s*max-content;/);
expect(css).toMatch(/\.downloads-table-header\s*\{[^}]*height:\s*41px;[^}]*position:\s*sticky;/s);
expect(css).toMatch(/\.downloads-item-row,\s*\.downloads-package-row\s*\{[^}]*height:\s*48px;/s);
expect(css).toMatch(/\.downloads-item-row,\s*\.downloads-package-row\s*\{[^}]*height:\s*40px;/s);
expect(css).toMatch(/\.downloads-item-row\s*\{[^}]*height:\s*38px;/s);
expect(css).toMatch(/\.downloads-toolbar button,\s*\.downloads-footer button,[^{]+\{[^}]*height:\s*36px;/s);
expect(css).toMatch(/\.downloads-content\s*\{[^}]*height:\s*100%;/s);
expect(css).toMatch(/\.downloads-action-cell button,\s*\.downloads-collapse-button\s*\{[^}]*width:\s*30px;[^}]*height:\s*30px;/s);
expect(css).toMatch(/\.downloads-collapse-button\s*\{[^}]*box-sizing:\s*border-box;[^}]*flex:\s*0 0 30px;[^}]*width:\s*30px;[^}]*min-width:\s*30px;[^}]*max-width:\s*30px;/s);
expect(css).toMatch(/\.downloads-cell-slot\s*\{[^}]*display:\s*flex;[^}]*justify-content:\s*center;/s);
expect(css).toMatch(/\.downloads-cell-slot\s*>\s*\.downloads-cell\s*\{[^}]*justify-content:\s*center;[^}]*text-align:\s*center;/s);
expect(css).toMatch(/\[data-download-column="name"\][^{]*\{[^}]*justify-content:\s*flex-start;[^}]*text-align:\s*left;/s);
expect(css).toMatch(/\.downloads-package-items\s*\{[^}]*height:\s*auto;[^}]*overflow:\s*hidden;/s);
expect(css).toMatch(/\.downloads-package-items\.is-collapsed\s*\{[^}]*height:\s*0;[^}]*opacity:\s*0;[^}]*pointer-events:\s*none;/s);
expect(css).toMatch(/\.downloads-item-row\s+\.downloads-meter\s*>\s*b\s*\{[^}]*color:\s*var\(--ui-text\);/s);
expect(readFileSync(new URL("../src/renderer/views/downloads/DownloadsTable.tsx", import.meta.url), "utf8")).toMatch(/\.animate\(\[\{ height: "0px", opacity: 0 \}, \{ height: `\$\{targetHeight\}px`, opacity: 1 \}\]/);
expect(css).toMatch(/\.downloads-footer\s*\{[^}]*height:\s*60px;[^}]*padding:\s*0 12px 0 60px;/s);
expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*border:\s*0;[^}]*border-bottom:\s*1px solid var\(--ui-border\);[^}]*padding:\s*0;/s);
expect(css).not.toMatch(/gradient|box-shadow|nth-child/i);
expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*border:\s*0;[^}]*border-bottom:\s*1px solid color-mix\(in srgb, var\(--ui-border\) 72%, transparent\);[^}]*padding:\s*0;/s);
expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*box-shadow:\s*none;/s);
expect(css).not.toMatch(/gradient|nth-child/i);
});
it("keeps visible interaction text non-selectable and only inputs plus copy values selectable", () => {
@@ -492,6 +611,17 @@ describe("downloads view", () => {
expect(css).toMatch(/\.downloads-copyable,\s*\.downloads-search-input,\s*\.downloads-rename-input\s*\{[^}]*user-select:\s*text;/s);
});
it("marks selected rows clearly, enlarges selection checkboxes and slows package disclosure", () => {
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
const source = readFileSync(new URL("../src/renderer/views/downloads/DownloadsTable.tsx", import.meta.url), "utf8");
expect(css).toMatch(/\.downloads-item-row\.is-selected,\s*\.downloads-package-card\.is-selected\s*>\s*\.downloads-package-row\s*\{[^}]*background:\s*color-mix\(in srgb, var\(--ui-success\) 14%, var\(--ui-canvas\)\);[^}]*box-shadow:\s*inset 3px 0 0 var\(--ui-success\);/s);
expect(css).toMatch(/\.downloads-selection-cell\s+input\[type="checkbox"\]\s*\{[^}]*width:\s*18px;[^}]*height:\s*18px;/s);
expect(css).toMatch(/\.downloads-hoster-icon\s*\{[^}]*width:\s*18px;[^}]*height:\s*18px;[^}]*object-fit:\s*contain;/s);
expect(css).toMatch(/\.downloads-hoster-icon\[data-hoster="rapidgator"\]\s*\{[^}]*transform:\s*translateY\(-4px\) scale\(2\);/s);
expect(source.match(/duration:\s*300/g)).toHaveLength(2);
});
it("keeps the 1120px layout inside the single downloads table scroll owner", () => {
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
@@ -511,6 +641,14 @@ describe("downloads view", () => {
});
describe("downloads App integration", () => {
it("updates the clipboard checkbox optimistically before IPC reconciliation", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
expect(source).toMatch(/const toggleClipboardWatcher = useCallback\(\(\): void => \{[\s\S]*setClipboardWatcherActive\(next\);[\s\S]*window\.rd\.toggleClipboard\(\)/);
expect(source).toContain("onToggleClipboardWatcher: toggleClipboardWatcher");
expect(source).not.toContain("onToggleClipboardWatcher: () => { void performQuickAction(() => window.rd.toggleClipboard()); }");
});
it("uses the extracted table only, cleans temporary drag listeners and routes the global context start through the shared callback", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
@@ -523,6 +661,63 @@ describe("downloads App integration", () => {
});
describe("download table row contracts", () => {
it("summarizes full, partial, offline and unchecked package availability", () => {
expect(getAvailabilitySummary([
item("online-a", "package-a", "queued", { onlineStatus: "online" }),
item("online-b", "package-a", "queued", { onlineStatus: "online" })
])).toEqual({ online: 2, total: 2, state: "online" });
expect(getAvailabilitySummary([
item("partial-a", "package-a", "queued", { onlineStatus: "online" }),
item("partial-b", "package-a", "queued", { onlineStatus: "offline" })
])).toEqual({ online: 1, total: 2, state: "partial" });
expect(getAvailabilitySummary([
item("offline-a", "package-a", "queued", { onlineStatus: "offline" }),
item("offline-b", "package-a", "queued", { onlineStatus: "offline" })
])).toEqual({ online: 0, total: 2, state: "offline" });
expect(getAvailabilitySummary([
item("unknown-a", "package-a", "queued", { onlineStatus: undefined })
])).toEqual({ online: 0, total: 1, state: "checking" });
});
it("renders availability for package and file rows", () => {
const onlineItem = item("online-file", "package-a", "queued", { onlineStatus: "online" });
const packageHtml = renderToStaticMarkup(PackageCardContent({
actions: createActions(),
columnOrder: ["availability"],
editing: false,
editingName: "",
gridTemplate: "110px",
packageSpeedBps: 0,
row: { package: pkg("package-a", "Paket", [onlineItem.id]), items: [onlineItem], collapsed: true },
selectedIds: new Set<string>(),
selectedVersion: 0
}));
const itemHtml = renderToStaticMarkup(ItemRowContent({
actions: createActions(),
columnOrder: ["availability"],
gridTemplate: "110px",
item: onlineItem,
selected: false
}));
expect(packageHtml).toContain("1/1 online");
expect(packageHtml).toContain("downloads-availability has-counts is-online");
expect(packageHtml).toContain("downloads-availability-count is-online-count");
expect(packageHtml).toContain("downloads-availability-separator");
expect(packageHtml).toContain("downloads-availability-count is-total-count");
expect(packageHtml).toContain("downloads-availability-label");
expect(itemHtml).toContain("Online");
expect(itemHtml).toContain("downloads-availability is-online");
});
it("aligns availability symbols, split counts and labels on fixed axes", () => {
const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8");
expect(css).toMatch(/\.downloads-availability\.has-counts\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*16px 4ch 1ch 4ch auto;[^}]*font-variant-numeric:\s*tabular-nums;/s);
expect(css).toMatch(/\.downloads-availability-count\.is-online-count\s*\{[^}]*text-align:\s*right;/s);
expect(css).toMatch(/\.downloads-availability-count\.is-total-count\s*\{[^}]*text-align:\s*left;/s);
});
it("preserves the package download and extraction phase split", () => {
const extractionPackage = {
...pkg("extracting-package", "Entpackendes Paket", ["extracting-item"]),
@@ -646,14 +841,13 @@ describe("download table row contracts", () => {
expect(commits).toEqual(["package-a:Neuer Name"]);
});
it("keeps package selection and activation as separate controls and sends context coordinates", () => {
it("removes the redundant package activation checkbox and preserves context actions", () => {
const calls: Array<unknown> = [];
const model = withRuntime(createInput());
const row = model.packageRows[0];
const component = PackageCardContent({
actions: createActions({
onToggleSelection: (id) => calls.push(["select", id]),
onTogglePackage: (id) => calls.push(["toggle", id]),
onToggleSelection: (id, ctrl, shift) => calls.push(["select", id, ctrl, shift]),
onOpenContextMenu: (id, x, y) => calls.push(["context", id, x, y])
}),
columnOrder: model.columnOrder,
@@ -666,13 +860,12 @@ describe("download table row contracts", () => {
selectedVersion: 1
});
const selection = findElement(component, (element) => element.type === "input" && element.props["aria-label"] === "Aktive Serie auswählen");
const activation = findElement(component, (element) => element.type === "input" && element.props["aria-label"] === "Aktive Serie aktivieren");
const packageElement = findElement(component, (element) => element.props["data-download-package-id"] === "package-a");
selection.props.onChange();
activation.props.onChange();
selection.props.onClick({ stopPropagation: () => {}, ctrlKey: false, metaKey: false, shiftKey: true });
packageElement.props.onContextMenu({ preventDefault: () => {}, stopPropagation: () => {}, clientX: 30, clientY: 50 });
expect(calls).toEqual([["select", "package-a"], ["toggle", "package-a"], ["context", "package-a", 30, 50]]);
expect(calls).toEqual([["select", "package-a", true, true], ["context", "package-a", 30, 50]]);
expect(() => findElement(component, (element) => element.type === "input" && element.props["aria-label"] === "Aktive Serie aktivieren")).toThrow("Element not found");
});
});