Add package and item link export

This commit is contained in:
Sucukdeluxe 2026-03-09 04:11:18 +01:00
parent 7503611893
commit 85a9a2fa9f
15 changed files with 598 additions and 39 deletions

View File

@ -36,7 +36,8 @@ Desktop downloader for Windows with package-based queue management, multi-provid
- Package-based queue with item status, retries, ETA, speed, provider, and account label.
- Start, pause, stop, cancel, reset, rename, and delete for packages and items.
- Ctrl+Click multi-select and bulk actions.
- Queue import/export.
- Queue backup import/export as JSON.
- Context-menu export for selected packages or selected items as structured TXT re-import files.
- Duplicate handling when adding links: keep, skip, or overwrite.
- Optional start scheduling for a specific time.
- Session recovery after restart with optional auto-resume.
@ -45,9 +46,10 @@ Desktop downloader for Windows with package-based queue management, multi-provid
### Link collection
- Paste links directly into the collector.
- Import `.txt` export files that preserve package names and optional per-file names.
- Clipboard watcher with automatic link detection.
- `.dlc` import via file picker and drag-and-drop.
- Drag-and-drop of plain links and supported container files.
- Drag-and-drop of plain links, `.txt` export files, and supported container files.
### Provider routing and fallback
@ -163,6 +165,29 @@ npm run dev
5. Start the queue and monitor downloads, extraction, and provider status.
6. Review history and statistics after completion.
## Link export format
Selected packages or items can be exported from the context menu as a structured text file. Re-importing that file restores the original package grouping, even if it only contains a subset of items from a larger package.
Example:
```txt
# rd-link-export: 1
# package: Dave Staffel 1
# file: Dave.S01E01.rar
https://example.com/e01
# file: Dave.S01E02.rar
https://example.com/e02
```
Supported import sources:
- collector text input
- `Datei importieren`
- drag-and-drop of `.txt` and `.json`
The optional `# file:` marker preserves the original item name so the imported subset can be rebuilt with the same package name and per-item filename hints.
## Project structure
- `src/main` - Electron main process, download engine, provider clients, updater, storage

View File

@ -38,6 +38,7 @@ import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-ser
import { encryptBackup, decryptBackup } from "./backup-crypto";
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
import { getDebugSetupCheck } from "./debug-setup";
import { buildLinkExportSelection, serializeLinkExportText } from "./link-export";
import { buildAccountSummary, diffAccountSummary } from "./support-data";
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
@ -460,12 +461,44 @@ export class AppController {
this.manager.togglePackage(packageId);
}
public exportPackageSelection(packageIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } {
const selection = buildLinkExportSelection(this.manager.getSnapshot(), packageIds, []);
this.audit("INFO", "Paket-Auswahl exportiert", {
packageCount: selection.packageCount,
linkCount: selection.linkCount,
packageIds
});
return {
text: serializeLinkExportText(selection.packages),
defaultFileName: selection.defaultFileName,
packageCount: selection.packageCount,
linkCount: selection.linkCount
};
}
public exportItemSelection(itemIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } {
const selection = buildLinkExportSelection(this.manager.getSnapshot(), [], itemIds);
this.audit("INFO", "Item-Auswahl exportiert", {
packageCount: selection.packageCount,
linkCount: selection.linkCount,
itemIds
});
return {
text: serializeLinkExportText(selection.packages),
defaultFileName: selection.defaultFileName,
packageCount: selection.packageCount,
linkCount: selection.linkCount
};
}
public exportQueue(): string {
return this.manager.exportQueue();
}
public importQueue(json: string): { addedPackages: number; addedLinks: number } {
return this.manager.importQueue(json);
const result = this.manager.importQueue(json);
this.audit("INFO", "Import-Datei verarbeitet", result);
return result;
}
public getSessionStats(): SessionStats {

View File

@ -30,6 +30,7 @@ import {
isProviderDailyLimitReached
} from "../shared/provider-daily-limits";
import { REQUEST_RETRIES, SAMPLE_VIDEO_EXTENSIONS, SPEED_WINDOW_SECONDS, WRITE_BUFFER_SIZE, WRITE_FLUSH_TIMEOUT_MS, ALLOCATION_UNIT_SIZE, STREAM_HIGH_WATER_MARK, DISK_BUSY_THRESHOLD_MS, DISK_BUSY_STATUS_THRESHOLD_MS } from "./constants";
import { parseCollectorInput } from "./link-parser";
// Reference counter for NODE_TLS_REJECT_UNAUTHORIZED to avoid race conditions
// when multiple parallel downloads need TLS verification disabled (e.g. DDownload).
@ -1793,11 +1794,13 @@ export class DownloadManager extends EventEmitter {
if (!pkg) {
return null;
}
const entries = pkg.itemIds
.map((itemId) => this.session.items[itemId])
.filter((item): item is DownloadItem => Boolean(item && item.url));
return {
name: pkg.name,
links: pkg.itemIds
.map((itemId) => this.session.items[itemId]?.url)
.filter(Boolean)
links: entries.map((item) => item.url),
fileNames: entries.map((item) => item.fileName || "")
};
}).filter(Boolean)
};
@ -1805,26 +1808,44 @@ export class DownloadManager extends EventEmitter {
}
public importQueue(json: string): { addedPackages: number; addedLinks: number } {
let data: { packages?: Array<{ name: string; links: string[] }> };
try {
data = JSON.parse(json) as { packages?: Array<{ name: string; links: string[] }> };
} catch {
throw new Error("Ungultige Queue-Datei (JSON)");
const trimmed = String(json || "").trim();
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
let data: { packages?: Array<{ name: string; links: string[]; fileNames?: string[] }> };
try {
data = JSON.parse(json) as { packages?: Array<{ name: string; links: string[]; fileNames?: string[] }> };
} catch {
throw new Error("Ungultige Queue-Datei (JSON)");
}
if (!Array.isArray(data.packages)) {
return { addedPackages: 0, addedLinks: 0 };
}
const inputs: ParsedPackageInput[] = data.packages
.map((pkg) => {
const name = typeof pkg?.name === "string" ? pkg.name : "";
const linksRaw = Array.isArray(pkg?.links) ? pkg.links : [];
const fileNamesRaw = Array.isArray(pkg?.fileNames) ? pkg.fileNames : [];
const entries = linksRaw
.map((link, index) => ({
link: typeof link === "string" ? link.trim() : "",
fileName: typeof fileNamesRaw[index] === "string" ? fileNamesRaw[index].trim() : ""
}))
.filter((entry) => entry.link.length > 0);
const links = entries.map((entry) => entry.link);
const fileNames = entries.map((entry) => entry.fileName);
return {
name,
links,
...(fileNames.some((fileName) => fileName.length > 0) ? { fileNames } : {})
};
})
.filter((pkg) => pkg.name.trim().length > 0 && pkg.links.length > 0);
return this.addPackages(inputs);
}
if (!Array.isArray(data.packages)) {
const inputs = parseCollectorInput(json, "");
if (inputs.length === 0) {
return { addedPackages: 0, addedLinks: 0 };
}
const inputs: ParsedPackageInput[] = data.packages
.map((pkg) => {
const name = typeof pkg?.name === "string" ? pkg.name : "";
const linksRaw = Array.isArray(pkg?.links) ? pkg.links : [];
const links = linksRaw
.filter((link) => typeof link === "string")
.map((link) => link.trim())
.filter(Boolean);
return { name, links };
})
.filter((pkg) => pkg.name.trim().length > 0 && pkg.links.length > 0);
return this.addPackages(inputs);
}

116
src/main/link-export.ts Normal file
View File

@ -0,0 +1,116 @@
import type { ParsedPackageInput, UiSnapshot } from "../shared/types";
import { sanitizeFilename } from "./utils";
export type LinkExportSelection = {
packages: ParsedPackageInput[];
packageCount: number;
linkCount: number;
defaultFileName: string;
};
function formatTimestampForFileName(date: Date): string {
const y = date.getFullYear();
const mo = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
const h = String(date.getHours()).padStart(2, "0");
const mi = String(date.getMinutes()).padStart(2, "0");
const s = String(date.getSeconds()).padStart(2, "0");
return `${y}-${mo}-${d}_${h}-${mi}-${s}`;
}
function buildDefaultFileName(packages: ParsedPackageInput[]): string {
if (packages.length === 1) {
const only = packages[0];
if (only.links.length === 1) {
const itemName = sanitizeFilename(only.fileNames?.[0] || only.name || "link-export");
return `${itemName}.txt`;
}
return `${sanitizeFilename(only.name || "paket-export")}.txt`;
}
return `rd-link-export-${formatTimestampForFileName(new Date())}.txt`;
}
export function buildLinkExportSelection(snapshot: UiSnapshot, packageIds: string[], itemIds: string[]): LinkExportSelection {
const selectedPackageIds = new Set(packageIds);
const selectedItemIds = new Set(itemIds);
const packages: ParsedPackageInput[] = [];
for (const packageId of snapshot.session.packageOrder) {
const pkg = snapshot.session.packages[packageId];
if (!pkg) {
continue;
}
const useWholePackage = selectedPackageIds.has(packageId);
const relevantItemIds = useWholePackage
? pkg.itemIds
: pkg.itemIds.filter((itemId) => selectedItemIds.has(itemId));
if (relevantItemIds.length === 0) {
continue;
}
const links: string[] = [];
const fileNames: string[] = [];
for (const itemId of relevantItemIds) {
const item = snapshot.session.items[itemId];
if (!item || !String(item.url || "").trim()) {
continue;
}
links.push(String(item.url).trim());
const rawFileName = String(item.fileName || "").trim();
fileNames.push(rawFileName ? sanitizeFilename(rawFileName) : "");
}
if (links.length === 0) {
continue;
}
const exportEntry: ParsedPackageInput = {
name: sanitizeFilename(pkg.name || "Paket"),
links
};
if (fileNames.some((fileName) => fileName.length > 0)) {
exportEntry.fileNames = fileNames;
}
packages.push(exportEntry);
}
const linkCount = packages.reduce((sum, pkg) => sum + pkg.links.length, 0);
return {
packages,
packageCount: packages.length,
linkCount,
defaultFileName: buildDefaultFileName(packages)
};
}
export function serializeLinkExportText(packages: ParsedPackageInput[]): string {
const lines: string[] = [
"# rd-link-export: 1",
"# Re-import in Real-Debrid-Downloader keeps package names and optional file names.",
""
];
for (const pkg of packages) {
if (!pkg || !pkg.name || !Array.isArray(pkg.links) || pkg.links.length === 0) {
continue;
}
lines.push(`# package: ${sanitizeFilename(pkg.name)}`);
for (let index = 0; index < pkg.links.length; index += 1) {
const link = String(pkg.links[index] || "").trim();
if (!link) {
continue;
}
const rawFileName = String(pkg.fileNames?.[index] || "").trim();
const fileName = rawFileName ? sanitizeFilename(rawFileName) : "";
if (fileName) {
lines.push(`# file: ${fileName}`);
}
lines.push(link);
}
lines.push("");
}
return `${lines.join("\n").trim()}\n`;
}

View File

@ -2,19 +2,35 @@ import { ParsedPackageInput } from "../shared/types";
import { inferPackageNameFromLinks, parsePackagesFromLinksText, sanitizeFilename, uniquePreserveOrder } from "./utils";
export function mergePackageInputs(packages: ParsedPackageInput[]): ParsedPackageInput[] {
const grouped = new Map<string, string[]>();
const grouped = new Map<string, { links: string[]; fileNameByLink: Map<string, string> }>();
for (const pkg of packages) {
const name = sanitizeFilename(pkg.name || inferPackageNameFromLinks(pkg.links));
const list = grouped.get(name) ?? [];
for (const link of pkg.links) {
list.push(link);
const current = grouped.get(name) ?? { links: [], fileNameByLink: new Map<string, string>() };
for (let index = 0; index < pkg.links.length; index += 1) {
const link = String(pkg.links[index] || "").trim();
if (!link) {
continue;
}
if (!current.links.includes(link)) {
current.links.push(link);
}
const rawFileName = String(pkg.fileNames?.[index] || "").trim();
const fileName = rawFileName ? sanitizeFilename(rawFileName) : "";
if (fileName && !current.fileNameByLink.has(link)) {
current.fileNameByLink.set(link, fileName);
}
}
grouped.set(name, list);
grouped.set(name, current);
}
return Array.from(grouped.entries()).map(([name, links]) => ({
name,
links: uniquePreserveOrder(links)
}));
return Array.from(grouped.entries()).map(([name, entry]) => {
const links = uniquePreserveOrder(entry.links);
const fileNames = links.map((link) => entry.fileNameByLink.get(link) || "");
return {
name,
links,
...(fileNames.some((fileName) => fileName.length > 0) ? { fileNames } : {})
};
});
}
export function parseCollectorInput(rawText: string, packageName = ""): ParsedPackageInput[] {

View File

@ -383,6 +383,40 @@ function registerIpcHandlers(): void {
validateString(packageId, "packageId");
return controller.togglePackage(packageId);
});
ipcMain.handle(IPC_CHANNELS.EXPORT_PACKAGE_SELECTION, async (_event: IpcMainInvokeEvent, packageIds: string[]) => {
const validPackageIds = validateStringArray(packageIds ?? [], "packageIds");
const exported = controller.exportPackageSelection(validPackageIds);
if (exported.packageCount === 0 || exported.linkCount === 0) {
return { saved: false, packageCount: 0, linkCount: 0 };
}
const options = {
defaultPath: exported.defaultFileName,
filters: [{ name: "Link Export", extensions: ["txt"] }]
};
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
if (result.canceled || !result.filePath) {
return { saved: false, packageCount: exported.packageCount, linkCount: exported.linkCount };
}
await fs.promises.writeFile(result.filePath, exported.text, "utf8");
return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath };
});
ipcMain.handle(IPC_CHANNELS.EXPORT_ITEM_SELECTION, async (_event: IpcMainInvokeEvent, itemIds: string[]) => {
const validItemIds = validateStringArray(itemIds ?? [], "itemIds");
const exported = controller.exportItemSelection(validItemIds);
if (exported.packageCount === 0 || exported.linkCount === 0) {
return { saved: false, packageCount: 0, linkCount: 0 };
}
const options = {
defaultPath: exported.defaultFileName,
filters: [{ name: "Link Export", extensions: ["txt"] }]
};
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
if (result.canceled || !result.filePath) {
return { saved: false, packageCount: exported.packageCount, linkCount: exported.linkCount };
}
await fs.promises.writeFile(result.filePath, exported.text, "utf8");
return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath };
});
ipcMain.handle(IPC_CHANNELS.RETRY_EXTRACTION, (_event: IpcMainInvokeEvent, packageId: string) => {
validateString(packageId, "packageId");
return controller.retryExtraction(packageId);

View File

@ -201,19 +201,31 @@ export function parsePackagesFromLinksText(rawText: string, defaultPackageName:
const packages: ParsedPackageInput[] = [];
let currentName = String(defaultPackageName || "").trim();
let currentLinks: string[] = [];
let currentFileNames: string[] = [];
let pendingFileName = "";
const flush = (): void => {
const links = uniquePreserveOrder(currentLinks.filter((line) => isHttpLink(line)));
if (links.length > 0) {
const normalizedCurrentName = String(currentName || "").trim();
packages.push({
const fileNames = links.map((link) => {
const firstIndex = currentLinks.findIndex((currentLink) => currentLink === link);
return firstIndex >= 0 ? currentFileNames[firstIndex] || "" : "";
});
const nextPackage: ParsedPackageInput = {
name: normalizedCurrentName
? sanitizeFilename(normalizedCurrentName)
: inferPackageNameFromLinks(links),
links
});
};
if (fileNames.some((fileName) => fileName.trim().length > 0)) {
nextPackage.fileNames = fileNames;
}
packages.push(nextPackage);
}
currentLinks = [];
currentFileNames = [];
pendingFileName = "";
};
for (const line of lines) {
@ -225,9 +237,20 @@ export function parsePackagesFromLinksText(rawText: string, defaultPackageName:
if (marker) {
flush();
currentName = String(marker[1] || "").trim();
pendingFileName = "";
continue;
}
const fileMarker = text.match(/^#\s*file\s*:\s*(.+)$/i);
if (fileMarker) {
pendingFileName = sanitizeFilename(String(fileMarker[1] || "").trim());
continue;
}
if (!isHttpLink(text)) {
continue;
}
currentLinks.push(text);
currentFileNames.push(pendingFileName);
pendingFileName = "";
}
flush();

View File

@ -44,6 +44,8 @@ const api: ElectronApi = {
reorderPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds),
removeItem: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId),
togglePackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PACKAGE, packageId),
exportPackageSelection: (packageIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_PACKAGE_SELECTION, packageIds),
exportItemSelection: (itemIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ITEM_SELECTION, itemIds),
exportQueue: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_QUEUE),
importQueue: (json: string): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_QUEUE, json),
toggleClipboard: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD),

View File

@ -2642,6 +2642,30 @@ export function App(): ReactElement {
});
};
const onExportPackageSelection = async (packageIds: string[]): Promise<void> => {
closeMenus();
await performQuickAction(async () => {
const result = await window.rd.exportPackageSelection(packageIds);
if (result.saved) {
showToast(`${result.packageCount} Paket(e), ${result.linkCount} Link(s) exportiert`, 2800);
}
}, (error) => {
showToast(`Export fehlgeschlagen: ${String(error)}`, 2600);
});
};
const onExportItemSelection = async (itemIds: string[]): Promise<void> => {
closeMenus();
await performQuickAction(async () => {
const result = await window.rd.exportItemSelection(itemIds);
if (result.saved) {
showToast(`${result.packageCount} Paket(e), ${result.linkCount} Link(s) exportiert`, 2800);
}
}, (error) => {
showToast(`Export fehlgeschlagen: ${String(error)}`, 2600);
});
};
onImportDlcRef.current = onImportDlc;
const onDrop = async (event: DragEvent<HTMLElement>): Promise<void> => {
@ -2654,6 +2678,7 @@ export function App(): ReactElement {
if (!hasFiles && !hasUri) { return; }
const files = Array.from(event.dataTransfer.files ?? []) as File[];
const dlc = files.filter((f) => f.name.toLowerCase().endsWith(".dlc")).map((f) => (f as unknown as { path?: string }).path).filter((v): v is string => !!v);
const importFiles = files.filter((f) => /\.(json|txt)$/i.test(f.name));
const droppedText = event.dataTransfer.getData("text/plain") || event.dataTransfer.getData("text/uri-list") || "";
if (dlc.length > 0) {
await performQuickAction(async () => {
@ -2669,6 +2694,27 @@ export function App(): ReactElement {
}, (error) => {
showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600);
});
} else if (importFiles.length > 0) {
await performQuickAction(async () => {
await persistDraftSettings();
const existingIds = new Set(Object.keys(snapshotRef.current.session.packages));
let addedPackages = 0;
let addedLinks = 0;
for (const file of importFiles) {
const text = await file.text();
const result = await window.rd.importQueue(text);
addedPackages += result.addedPackages;
addedLinks += result.addedLinks;
}
if (addedLinks > 0) {
showToast(`Importiert: ${addedPackages} Paket(e), ${addedLinks} Link(s)`);
if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); }
} else {
showToast("Keine gültigen Links in den Import-Dateien gefunden", 3000);
}
}, (error) => {
showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600);
});
} else if (droppedText.trim()) {
const activeCollectorId = activeCollectorTabRef.current;
setCollectorTabs((prev) => prev.map((t) => t.id === activeCollectorId
@ -2699,7 +2745,7 @@ export function App(): ReactElement {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";
input.accept = ".json,.txt";
const releasePickerBusy = (): void => {
actionBusyRef.current = false;
@ -2722,9 +2768,16 @@ export function App(): ReactElement {
}
releasePickerBusy();
await performQuickAction(async () => {
await persistDraftSettings();
const existingIds = new Set(Object.keys(snapshotRef.current.session.packages));
const text = await file.text();
const result = await window.rd.importQueue(text);
showToast(`Importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
if (result.addedLinks > 0) {
showToast(`Importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); }
} else {
showToast("Keine gültigen Links in der Datei gefunden", 3000);
}
}, (error) => {
showToast(`Import fehlgeschlagen: ${String(error)}`, 2600);
});
@ -3688,6 +3741,9 @@ export function App(): ReactElement {
<span>Text mit Links analysieren</span>
<span className="shortcut">Strg+L</span>
</button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void onImportQueue(); }}>
<span>Datei importieren</span>
</button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void onImportDlc(); }}>
<span>Linkcontainer laden</span>
<span className="shortcut">Strg+O</span>
@ -3997,7 +4053,7 @@ export function App(): ReactElement {
<div className="link-actions">
<button className="btn" disabled={actionBusy} onClick={onImportDlc}>DLC import</button>
<button className="btn" disabled={actionBusy} onClick={onExportQueue}>Queue Export</button>
<button className="btn" disabled={actionBusy} onClick={onImportQueue}>Queue Import</button>
<button className="btn" disabled={actionBusy} onClick={onImportQueue}>Datei Import</button>
<button className="btn accent" disabled={actionBusy} onClick={onAddLinks}>Zur Queue hinzufügen</button>
</div>
</div>
@ -4014,7 +4070,7 @@ export function App(): ReactElement {
value={currentCollectorTab.text}
onChange={(e) => setCollectorTabs((prev) => prev.map((t) => t.id === currentCollectorTab.id ? { ...t, text: e.target.value } : t))}
onDragOver={(e) => e.preventDefault()}
placeholder={"# package: Release-Name\nhttps://...\nhttps://...\n\nLinks oder .dlc Dateien hier ablegen"}
placeholder={"# package: Release-Name\n# file: Folge 01.rar\nhttps://...\nhttps://...\n\nLinks, .dlc oder Export-Dateien hier ablegen"}
/>
</article>
</section>
@ -5309,7 +5365,7 @@ export function App(): ReactElement {
</div>
)}
{statusToast && <div className="toast">{statusToast}</div>}
{dragOver && <div className="drop-overlay">Links oder .dlc Dateien hier ablegen</div>}
{dragOver && <div className="drop-overlay">Links, .dlc oder Export-Dateien hier ablegen</div>}
{contextMenu && (() => {
const multi = selectedIds.size > 1;
const selectedPackageIds = [...selectedIds].filter((id) => snapshot.session.packages[id]);
@ -5332,6 +5388,18 @@ export function App(): ReactElement {
<button className="ctx-menu-item" onClick={() => { void window.rd.start().catch(() => {}); setContextMenu(null); }}>Alle Downloads starten</button>
<div className="ctx-menu-sep" />
<button className="ctx-menu-item" onClick={() => showLinksPopup(contextMenu.packageId, contextMenu.itemId)}>Linkadressen anzeigen</button>
{hasPackages && !contextMenu.itemId && (
<button className="ctx-menu-item" onClick={() => {
void onExportPackageSelection(selectedPackageIds);
setContextMenu(null);
}}>{multi ? `Ausgewählte Pakete exportieren (${selectedPackageIds.length})` : "Paket exportieren"}</button>
)}
{contextMenu.itemId && (
<button className="ctx-menu-item" onClick={() => {
void onExportItemSelection(multi ? selectedItemIds : [contextMenu.itemId!]);
setContextMenu(null);
}}>{multi ? `Ausgewählte Dateien exportieren (${selectedItemIds.length})` : "Datei exportieren"}</button>
)}
{hasPackages && !contextMenu.itemId && (
<button className="ctx-menu-item" onClick={() => {
for (const id of selectedPackageIds) {

View File

@ -22,6 +22,8 @@ export const IPC_CHANNELS = {
REORDER_PACKAGES: "queue:reorder-packages",
REMOVE_ITEM: "queue:remove-item",
TOGGLE_PACKAGE: "queue:toggle-package",
EXPORT_PACKAGE_SELECTION: "queue:export-package-selection",
EXPORT_ITEM_SELECTION: "queue:export-item-selection",
EXPORT_QUEUE: "queue:export",
IMPORT_QUEUE: "queue:import",
PICK_FOLDER: "dialog:pick-folder",

View File

@ -41,6 +41,8 @@ export interface ElectronApi {
reorderPackages: (packageIds: string[]) => Promise<void>;
removeItem: (itemId: string) => Promise<void>;
togglePackage: (packageId: string) => Promise<void>;
exportPackageSelection: (packageIds: string[]) => Promise<{ saved: boolean; packageCount: number; linkCount: number; filePath?: string }>;
exportItemSelection: (itemIds: string[]) => Promise<{ saved: boolean; packageCount: number; linkCount: number; filePath?: string }>;
exportQueue: () => Promise<{ saved: boolean }>;
importQueue: (json: string) => Promise<{ addedPackages: number; addedLinks: number }>;
toggleClipboard: () => Promise<boolean>;

View File

@ -6890,6 +6890,40 @@ describe("download manager", () => {
expect(() => manager.importQueue("{not-json")).toThrow(/Ungultige Queue-Datei/i);
});
it("imports structured text exports and preserves package names and file hints", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract")
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
const result = manager.importQueue([
"# rd-link-export: 1",
"# package: Dave Staffel 1",
"# file: Dave.S01E01.rar",
"https://example.com/e01",
"# file: Dave.S01E02.rar",
"https://example.com/e02"
].join("\n"));
expect(result).toEqual({ addedPackages: 1, addedLinks: 2 });
const snapshot = manager.getSnapshot();
const packageId = snapshot.session.packageOrder[0];
const pkg = snapshot.session.packages[packageId];
expect(pkg?.name).toBe("Dave Staffel 1");
const importedItems = pkg?.itemIds.map((itemId) => snapshot.session.items[itemId]);
expect(importedItems?.map((item) => item?.fileName)).toEqual(["Dave.S01E01.rar", "Dave.S01E02.rar"]);
});
it("applies global speed limit path when global mode is enabled", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);

149
tests/link-export.test.ts Normal file
View File

@ -0,0 +1,149 @@
import { describe, expect, it } from "vitest";
import { buildLinkExportSelection, serializeLinkExportText } from "../src/main/link-export";
import { parseCollectorInput } from "../src/main/link-parser";
import type { UiSnapshot } from "../src/shared/types";
function buildSnapshot(): UiSnapshot {
return {
settings: {} as UiSnapshot["settings"],
session: {
version: 1,
packageOrder: ["pkg-1", "pkg-2"],
packages: {
"pkg-1": {
id: "pkg-1",
name: "Dave Staffel 1",
outputDir: "C:\\Downloads\\Dave Staffel 1",
extractDir: "C:\\Extract\\Dave Staffel 1",
status: "queued",
itemIds: ["item-1", "item-2"],
cancelled: false,
enabled: true,
priority: "normal",
createdAt: 1,
updatedAt: 1
},
"pkg-2": {
id: "pkg-2",
name: "Andere Staffel",
outputDir: "C:\\Downloads\\Andere Staffel",
extractDir: "C:\\Extract\\Andere Staffel",
status: "queued",
itemIds: ["item-3"],
cancelled: false,
enabled: true,
priority: "normal",
createdAt: 1,
updatedAt: 1
}
},
items: {
"item-1": {
id: "item-1",
packageId: "pkg-1",
url: "https://example.com/e01",
provider: null,
status: "queued",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: "Dave.S01E01.rar",
targetPath: "",
resumable: true,
attempts: 0,
lastError: "",
fullStatus: "Wartet",
createdAt: 1,
updatedAt: 1
},
"item-2": {
id: "item-2",
packageId: "pkg-1",
url: "https://example.com/e02",
provider: null,
status: "queued",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: "Dave.S01E02.rar",
targetPath: "",
resumable: true,
attempts: 0,
lastError: "",
fullStatus: "Wartet",
createdAt: 1,
updatedAt: 1
},
"item-3": {
id: "item-3",
packageId: "pkg-2",
url: "https://example.com/other",
provider: null,
status: "queued",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: "Andere.S01E01.rar",
targetPath: "",
resumable: true,
attempts: 0,
lastError: "",
fullStatus: "Wartet",
createdAt: 1,
updatedAt: 1
}
},
runStartedAt: 0,
totalDownloadedBytes: 0,
summaryText: "",
reconnectUntil: 0,
reconnectReason: "",
paused: false,
running: false,
updatedAt: 1
},
summary: null,
stats: {
totalDownloaded: 0,
totalDownloadedAllTime: 0,
totalFilesSession: 0,
totalFilesAllTime: 0,
totalPackages: 2,
sessionStartedAt: 0
},
speedText: "",
etaText: "",
canStart: true,
canStop: false,
canPause: false,
clipboardActive: false,
reconnectSeconds: 0,
packageSpeedBps: {}
};
}
describe("link-export", () => {
it("keeps original package names when exporting selected items", () => {
const selection = buildLinkExportSelection(buildSnapshot(), [], ["item-1", "item-3"]);
expect(selection.packageCount).toBe(2);
expect(selection.linkCount).toBe(2);
expect(selection.packages.map((pkg) => pkg.name)).toEqual(["Dave Staffel 1", "Andere Staffel"]);
});
it("roundtrips exported text back into parsed package inputs", () => {
const selection = buildLinkExportSelection(buildSnapshot(), [], ["item-1", "item-2"]);
const text = serializeLinkExportText(selection.packages);
const reparsed = parseCollectorInput(text, "");
expect(reparsed).toHaveLength(1);
expect(reparsed[0]?.name).toBe("Dave Staffel 1");
expect(reparsed[0]?.links).toEqual(["https://example.com/e01", "https://example.com/e02"]);
expect(reparsed[0]?.fileNames).toEqual(["Dave.S01E01.rar", "Dave.S01E02.rar"]);
});
});

View File

@ -33,6 +33,18 @@ describe("link-parser", () => {
// "Valid?Name*" becomes "Valid Name " -> trimmed to "Valid Name"
expect(result.map(p => p.name).sort()).toEqual(["Valid Name", "Valid_Name"]);
});
it("preserves file name hints when merging packages", () => {
const input = [
{ name: "Package A", links: ["http://link1", "http://link2"], fileNames: ["one.rar", "two.rar"] },
{ name: "Package A", links: ["http://link3", "http://link1"], fileNames: ["three.rar", "ignored.rar"] }
];
const result = mergePackageInputs(input);
expect(result).toHaveLength(1);
expect(result[0]?.links).toEqual(["http://link1", "http://link2", "http://link3"]);
expect(result[0]?.fileNames).toEqual(["one.rar", "two.rar", "three.rar"]);
});
});
describe("parseCollectorInput", () => {

View File

@ -40,6 +40,28 @@ describe("utils", () => {
expect(parsed[1].name).toBe("B");
});
it("parses optional file markers for roundtrip imports", () => {
const parsed = parsePackagesFromLinksText(
"# rd-link-export: 1\n# package: Dave Staffel 1\n# file: Folge 001.rar\nhttps://a.com/1\n# file: Folge 002.rar\nhttps://a.com/2\n",
"Default"
);
expect(parsed).toHaveLength(1);
expect(parsed[0].name).toBe("Dave Staffel 1");
expect(parsed[0].links).toEqual(["https://a.com/1", "https://a.com/2"]);
expect(parsed[0].fileNames).toEqual(["Folge 001.rar", "Folge 002.rar"]);
});
it("does not carry a file marker across package boundaries", () => {
const parsed = parsePackagesFromLinksText(
"# package: Dave Staffel 1\n# file: Folge 001.rar\n# package: Dave Staffel 2\nhttps://a.com/2\n",
"Default"
);
expect(parsed).toHaveLength(1);
expect(parsed[0].name).toBe("Dave Staffel 2");
expect(parsed[0].links).toEqual(["https://a.com/2"]);
expect(parsed[0].fileNames).toBeUndefined();
});
it("formats eta", () => {
expect(formatEta(-1)).toBe("--");
expect(formatEta(65)).toBe("01:05");