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. - 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. - Start, pause, stop, cancel, reset, rename, and delete for packages and items.
- Ctrl+Click multi-select and bulk actions. - 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. - Duplicate handling when adding links: keep, skip, or overwrite.
- Optional start scheduling for a specific time. - Optional start scheduling for a specific time.
- Session recovery after restart with optional auto-resume. - 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 ### Link collection
- Paste links directly into the collector. - 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. - Clipboard watcher with automatic link detection.
- `.dlc` import via file picker and drag-and-drop. - `.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 ### Provider routing and fallback
@ -163,6 +165,29 @@ npm run dev
5. Start the queue and monitor downloads, extraction, and provider status. 5. Start the queue and monitor downloads, extraction, and provider status.
6. Review history and statistics after completion. 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 ## Project structure
- `src/main` - Electron main process, download engine, provider clients, updater, storage - `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 { encryptBackup, decryptBackup } from "./backup-crypto";
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log"; import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
import { getDebugSetupCheck } from "./debug-setup"; import { getDebugSetupCheck } from "./debug-setup";
import { buildLinkExportSelection, serializeLinkExportText } from "./link-export";
import { buildAccountSummary, diffAccountSummary } from "./support-data"; import { buildAccountSummary, diffAccountSummary } from "./support-data";
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle"; import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log"; import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
@ -460,12 +461,44 @@ export class AppController {
this.manager.togglePackage(packageId); 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 { public exportQueue(): string {
return this.manager.exportQueue(); return this.manager.exportQueue();
} }
public importQueue(json: string): { addedPackages: number; addedLinks: number } { 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 { public getSessionStats(): SessionStats {

View File

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

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

View File

@ -383,6 +383,40 @@ function registerIpcHandlers(): void {
validateString(packageId, "packageId"); validateString(packageId, "packageId");
return controller.togglePackage(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) => { ipcMain.handle(IPC_CHANNELS.RETRY_EXTRACTION, (_event: IpcMainInvokeEvent, packageId: string) => {
validateString(packageId, "packageId"); validateString(packageId, "packageId");
return controller.retryExtraction(packageId); return controller.retryExtraction(packageId);

View File

@ -201,19 +201,31 @@ export function parsePackagesFromLinksText(rawText: string, defaultPackageName:
const packages: ParsedPackageInput[] = []; const packages: ParsedPackageInput[] = [];
let currentName = String(defaultPackageName || "").trim(); let currentName = String(defaultPackageName || "").trim();
let currentLinks: string[] = []; let currentLinks: string[] = [];
let currentFileNames: string[] = [];
let pendingFileName = "";
const flush = (): void => { const flush = (): void => {
const links = uniquePreserveOrder(currentLinks.filter((line) => isHttpLink(line))); const links = uniquePreserveOrder(currentLinks.filter((line) => isHttpLink(line)));
if (links.length > 0) { if (links.length > 0) {
const normalizedCurrentName = String(currentName || "").trim(); 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 name: normalizedCurrentName
? sanitizeFilename(normalizedCurrentName) ? sanitizeFilename(normalizedCurrentName)
: inferPackageNameFromLinks(links), : inferPackageNameFromLinks(links),
links links
}); };
if (fileNames.some((fileName) => fileName.trim().length > 0)) {
nextPackage.fileNames = fileNames;
}
packages.push(nextPackage);
} }
currentLinks = []; currentLinks = [];
currentFileNames = [];
pendingFileName = "";
}; };
for (const line of lines) { for (const line of lines) {
@ -225,9 +237,20 @@ export function parsePackagesFromLinksText(rawText: string, defaultPackageName:
if (marker) { if (marker) {
flush(); flush();
currentName = String(marker[1] || "").trim(); 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; continue;
} }
currentLinks.push(text); currentLinks.push(text);
currentFileNames.push(pendingFileName);
pendingFileName = "";
} }
flush(); flush();

View File

@ -44,6 +44,8 @@ const api: ElectronApi = {
reorderPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds), reorderPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds),
removeItem: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId), removeItem: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId),
togglePackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PACKAGE, packageId), 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), 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), importQueue: (json: string): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_QUEUE, json),
toggleClipboard: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD), 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; onImportDlcRef.current = onImportDlc;
const onDrop = async (event: DragEvent<HTMLElement>): Promise<void> => { const onDrop = async (event: DragEvent<HTMLElement>): Promise<void> => {
@ -2654,6 +2678,7 @@ export function App(): ReactElement {
if (!hasFiles && !hasUri) { return; } if (!hasFiles && !hasUri) { return; }
const files = Array.from(event.dataTransfer.files ?? []) as File[]; 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 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") || ""; const droppedText = event.dataTransfer.getData("text/plain") || event.dataTransfer.getData("text/uri-list") || "";
if (dlc.length > 0) { if (dlc.length > 0) {
await performQuickAction(async () => { await performQuickAction(async () => {
@ -2669,6 +2694,27 @@ export function App(): ReactElement {
}, (error) => { }, (error) => {
showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600); 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()) { } else if (droppedText.trim()) {
const activeCollectorId = activeCollectorTabRef.current; const activeCollectorId = activeCollectorTabRef.current;
setCollectorTabs((prev) => prev.map((t) => t.id === activeCollectorId setCollectorTabs((prev) => prev.map((t) => t.id === activeCollectorId
@ -2699,7 +2745,7 @@ export function App(): ReactElement {
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
input.accept = ".json"; input.accept = ".json,.txt";
const releasePickerBusy = (): void => { const releasePickerBusy = (): void => {
actionBusyRef.current = false; actionBusyRef.current = false;
@ -2722,9 +2768,16 @@ export function App(): ReactElement {
} }
releasePickerBusy(); releasePickerBusy();
await performQuickAction(async () => { await performQuickAction(async () => {
await persistDraftSettings();
const existingIds = new Set(Object.keys(snapshotRef.current.session.packages));
const text = await file.text(); const text = await file.text();
const result = await window.rd.importQueue(text); const result = await window.rd.importQueue(text);
if (result.addedLinks > 0) {
showToast(`Importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); 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) => { }, (error) => {
showToast(`Import fehlgeschlagen: ${String(error)}`, 2600); showToast(`Import fehlgeschlagen: ${String(error)}`, 2600);
}); });
@ -3688,6 +3741,9 @@ export function App(): ReactElement {
<span>Text mit Links analysieren</span> <span>Text mit Links analysieren</span>
<span className="shortcut">Strg+L</span> <span className="shortcut">Strg+L</span>
</button> </button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void onImportQueue(); }}>
<span>Datei importieren</span>
</button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void onImportDlc(); }}> <button className="menu-dropdown-item" onClick={() => { closeMenus(); void onImportDlc(); }}>
<span>Linkcontainer laden</span> <span>Linkcontainer laden</span>
<span className="shortcut">Strg+O</span> <span className="shortcut">Strg+O</span>
@ -3997,7 +4053,7 @@ export function App(): ReactElement {
<div className="link-actions"> <div className="link-actions">
<button className="btn" disabled={actionBusy} onClick={onImportDlc}>DLC import</button> <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={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> <button className="btn accent" disabled={actionBusy} onClick={onAddLinks}>Zur Queue hinzufügen</button>
</div> </div>
</div> </div>
@ -4014,7 +4070,7 @@ export function App(): ReactElement {
value={currentCollectorTab.text} value={currentCollectorTab.text}
onChange={(e) => setCollectorTabs((prev) => prev.map((t) => t.id === currentCollectorTab.id ? { ...t, text: e.target.value } : t))} onChange={(e) => setCollectorTabs((prev) => prev.map((t) => t.id === currentCollectorTab.id ? { ...t, text: e.target.value } : t))}
onDragOver={(e) => e.preventDefault()} 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> </article>
</section> </section>
@ -5309,7 +5365,7 @@ export function App(): ReactElement {
</div> </div>
)} )}
{statusToast && <div className="toast">{statusToast}</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 && (() => { {contextMenu && (() => {
const multi = selectedIds.size > 1; const multi = selectedIds.size > 1;
const selectedPackageIds = [...selectedIds].filter((id) => snapshot.session.packages[id]); 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> <button className="ctx-menu-item" onClick={() => { void window.rd.start().catch(() => {}); setContextMenu(null); }}>Alle Downloads starten</button>
<div className="ctx-menu-sep" /> <div className="ctx-menu-sep" />
<button className="ctx-menu-item" onClick={() => showLinksPopup(contextMenu.packageId, contextMenu.itemId)}>Linkadressen anzeigen</button> <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 && ( {hasPackages && !contextMenu.itemId && (
<button className="ctx-menu-item" onClick={() => { <button className="ctx-menu-item" onClick={() => {
for (const id of selectedPackageIds) { for (const id of selectedPackageIds) {

View File

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

View File

@ -41,6 +41,8 @@ export interface ElectronApi {
reorderPackages: (packageIds: string[]) => Promise<void>; reorderPackages: (packageIds: string[]) => Promise<void>;
removeItem: (itemId: string) => Promise<void>; removeItem: (itemId: string) => Promise<void>;
togglePackage: (packageId: 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 }>; exportQueue: () => Promise<{ saved: boolean }>;
importQueue: (json: string) => Promise<{ addedPackages: number; addedLinks: number }>; importQueue: (json: string) => Promise<{ addedPackages: number; addedLinks: number }>;
toggleClipboard: () => Promise<boolean>; toggleClipboard: () => Promise<boolean>;

View File

@ -6890,6 +6890,40 @@ describe("download manager", () => {
expect(() => manager.importQueue("{not-json")).toThrow(/Ungultige Queue-Datei/i); 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 () => { it("applies global speed limit path when global mode is enabled", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); 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" // "Valid?Name*" becomes "Valid Name " -> trimmed to "Valid Name"
expect(result.map(p => p.name).sort()).toEqual(["Valid Name", "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", () => { describe("parseCollectorInput", () => {

View File

@ -40,6 +40,28 @@ describe("utils", () => {
expect(parsed[1].name).toBe("B"); 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", () => { it("formats eta", () => {
expect(formatEta(-1)).toBe("--"); expect(formatEta(-1)).toBe("--");
expect(formatEta(65)).toBe("01:05"); expect(formatEta(65)).toBe("01:05");