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.
|
- 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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,26 +1808,44 @@ 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();
|
||||||
try {
|
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||||
data = JSON.parse(json) as { packages?: Array<{ name: string; links: string[] }> };
|
let data: { packages?: Array<{ name: string; links: string[]; fileNames?: string[] }> };
|
||||||
} catch {
|
try {
|
||||||
throw new Error("Ungultige Queue-Datei (JSON)");
|
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 };
|
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);
|
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";
|
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;
|
||||||
|
}
|
||||||
|
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]) => ({
|
return Array.from(grouped.entries()).map(([name, entry]) => {
|
||||||
name,
|
const links = uniquePreserveOrder(entry.links);
|
||||||
links: uniquePreserveOrder(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[] {
|
export function parseCollectorInput(rawText: string, packageName = ""): ParsedPackageInput[] {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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);
|
||||||
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) => {
|
}, (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) {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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
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"
|
// "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", () => {
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user