Add package and item link export
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
+25
-9
@@ -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);
|
||||
|
||||
+25
-2
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user