Add package and item link export
This commit is contained in:
parent
7503611893
commit
85a9a2fa9f
29
README.md
29
README.md
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
116
src/main/link-export.ts
Normal 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`;
|
||||
}
|
||||
@ -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[] {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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
149
tests/link-export.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@ -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", () => {
|
||||
|
||||
@ -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");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user