Implement full UX upgrade and Rapidgator filename hardening in v1.3.2
This commit is contained in:
parent
7381e54f4f
commit
447dd7feff
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2",
|
||||
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
||||
"main": "build/main/main/main.js",
|
||||
"author": "Sucukdeluxe",
|
||||
|
||||
@ -126,6 +126,30 @@ export class AppController {
|
||||
this.manager.cancelPackage(packageId);
|
||||
}
|
||||
|
||||
public renamePackage(packageId: string, newName: string): void {
|
||||
this.manager.renamePackage(packageId, newName);
|
||||
}
|
||||
|
||||
public reorderPackages(packageIds: string[]): void {
|
||||
this.manager.reorderPackages(packageIds);
|
||||
}
|
||||
|
||||
public removeItem(itemId: string): void {
|
||||
this.manager.removeItem(itemId);
|
||||
}
|
||||
|
||||
public togglePackage(packageId: string): void {
|
||||
this.manager.togglePackage(packageId);
|
||||
}
|
||||
|
||||
public exportQueue(): string {
|
||||
return this.manager.exportQueue();
|
||||
}
|
||||
|
||||
public importQueue(json: string): { addedPackages: number; addedLinks: number } {
|
||||
return this.manager.importQueue(json);
|
||||
}
|
||||
|
||||
public shutdown(): void {
|
||||
this.manager.stop();
|
||||
this.megaWebFallback.dispose();
|
||||
|
||||
@ -58,6 +58,10 @@ export function defaultSettings(): AppSettings {
|
||||
speedLimitKbps: 0,
|
||||
speedLimitMode: "global",
|
||||
updateRepo: DEFAULT_UPDATE_REPO,
|
||||
autoUpdateCheck: true
|
||||
autoUpdateCheck: true,
|
||||
clipboardWatch: false,
|
||||
minimizeToTray: false,
|
||||
theme: "dark" as const,
|
||||
bandwidthSchedules: []
|
||||
};
|
||||
}
|
||||
|
||||
@ -141,6 +141,71 @@ function decodeHtmlEntities(text: string): string {
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function safeDecode(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeFileName(value: string): boolean {
|
||||
return /\.(?:part\d+\.rar|r\d{2}|rar|zip|7z|tar|gz|bz2|xz|iso|mkv|mp4|avi|mov|wmv|m4v|m2ts|ts|webm|mp3|flac|aac|srt|ass|sub)$/i.test(value);
|
||||
}
|
||||
|
||||
function normalizeResolvedFilename(value: string): string {
|
||||
const candidate = decodeHtmlEntities(String(value || ""))
|
||||
.replace(/<[^>]*>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/^['"]+|['"]+$/g, "")
|
||||
.replace(/^download\s+file\s+/i, "")
|
||||
.replace(/\s*[-|]\s*rapidgator.*$/i, "")
|
||||
.trim();
|
||||
if (!candidate || !looksLikeFileName(candidate) || looksLikeOpaqueFilename(candidate)) {
|
||||
return "";
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function filenameFromRapidgatorUrlPath(link: string): string {
|
||||
try {
|
||||
const parsed = new URL(link);
|
||||
const pathParts = parsed.pathname.split("/").filter(Boolean);
|
||||
for (let index = pathParts.length - 1; index >= 0; index -= 1) {
|
||||
const raw = safeDecode(pathParts[index]).replace(/\.html?$/i, "").trim();
|
||||
const normalized = normalizeResolvedFilename(raw);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function extractRapidgatorFilenameFromHtml(html: string): string {
|
||||
const patterns = [
|
||||
/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i,
|
||||
/<meta[^>]+name=["']title["'][^>]+content=["']([^"']+)["']/i,
|
||||
/<title>([^<]+)<\/title>/i,
|
||||
/(?:Dateiname|File\s*name)\s*[:\-]\s*<[^>]*>\s*([^<]+)\s*</i,
|
||||
/(?:Dateiname|File\s*name)\s*[:\-]\s*([^<\r\n]+)/i,
|
||||
/download\s+file\s+([^<\r\n]+)/i,
|
||||
/([A-Za-z0-9][A-Za-z0-9._\-()[\] ]{2,220}\.(?:part\d+\.rar|r\d{2}|rar|zip|7z|tar|gz|bz2|xz|iso|mkv|mp4|avi|mov|wmv|m4v|m2ts|ts|webm|mp3|flac|aac|srt|ass|sub))/i
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = html.match(pattern);
|
||||
const normalized = normalizeResolvedFilename(match?.[1] || "");
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
async function runWithConcurrency<T>(items: T[], concurrency: number, worker: (item: T) => Promise<void>): Promise<void> {
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
@ -161,31 +226,43 @@ async function resolveRapidgatorFilename(link: string): Promise<string> {
|
||||
if (!isRapidgatorLink(link)) {
|
||||
return "";
|
||||
}
|
||||
const fromUrl = filenameFromRapidgatorUrlPath(link);
|
||||
if (fromUrl) {
|
||||
return fromUrl;
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= REQUEST_RETRIES + 2; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(link, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9,de;q=0.8"
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES + 2) {
|
||||
await sleep(retryDelay(attempt));
|
||||
continue;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
const html = await response.text();
|
||||
const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
|
||||
const title = decodeHtmlEntities((titleMatch?.[1] || "").trim());
|
||||
if (!title) {
|
||||
return "";
|
||||
const fromHtml = extractRapidgatorFilenameFromHtml(html);
|
||||
if (fromHtml) {
|
||||
return fromHtml;
|
||||
}
|
||||
const preferred = title.match(/download\s+file\s+(.+)$/i)?.[1]?.trim() || title;
|
||||
if (!preferred) {
|
||||
return "";
|
||||
}
|
||||
const withoutSuffix = preferred.replace(/\s*-\s*rapidgator.*$/i, "").trim();
|
||||
return withoutSuffix;
|
||||
} catch {
|
||||
return "";
|
||||
// retry below
|
||||
}
|
||||
|
||||
if (attempt < REQUEST_RETRIES + 2) {
|
||||
await sleep(retryDelay(attempt));
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildBestDebridRequests(link: string, token: string): BestDebridRequest[] {
|
||||
@ -492,7 +569,7 @@ export class DebridService {
|
||||
}
|
||||
|
||||
const remaining = unresolved.filter((link) => !clean.has(link) && isRapidgatorLink(link));
|
||||
await runWithConcurrency(remaining, 6, async (link) => {
|
||||
await runWithConcurrency(remaining, 3, async (link) => {
|
||||
const fromPage = await resolveRapidgatorFilename(link);
|
||||
if (fromPage && !looksLikeOpaqueFilename(fromPage)) {
|
||||
clean.set(link, fromPage);
|
||||
@ -523,8 +600,16 @@ export class DebridService {
|
||||
|
||||
try {
|
||||
const result = await this.unrestrictViaProvider(provider, link);
|
||||
let fileName = result.fileName;
|
||||
if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
|
||||
const fromPage = await resolveRapidgatorFilename(link);
|
||||
if (fromPage) {
|
||||
fileName = fromPage;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
fileName,
|
||||
provider,
|
||||
providerLabel: PROVIDER_LABELS[provider]
|
||||
};
|
||||
|
||||
@ -3,8 +3,8 @@ import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { AppSettings, DownloadItem, DownloadSummary, DownloadStatus, PackageEntry, ParsedPackageInput, SessionState, UiSnapshot } from "../shared/types";
|
||||
import { CHUNK_SIZE, REQUEST_RETRIES } from "./constants";
|
||||
import { AppSettings, DownloadItem, DownloadStats, DownloadSummary, DownloadStatus, PackageEntry, ParsedPackageInput, SessionState, UiSnapshot } from "../shared/types";
|
||||
import { REQUEST_RETRIES } from "./constants";
|
||||
import { cleanupCancelledPackageArtifactsAsync } from "./cleanup";
|
||||
import { DebridService, MegaWebUnrestrictor } from "./debrid";
|
||||
import { extractPackageArchives } from "./extractor";
|
||||
@ -17,7 +17,7 @@ type ActiveTask = {
|
||||
itemId: string;
|
||||
packageId: string;
|
||||
abortController: AbortController;
|
||||
abortReason: "stop" | "cancel" | "reconnect" | "none";
|
||||
abortReason: "stop" | "cancel" | "reconnect" | "package_toggle" | "none";
|
||||
resumable: boolean;
|
||||
speedEvents: Array<{ at: number; bytes: number }>;
|
||||
nonResumableCounted: boolean;
|
||||
@ -43,6 +43,35 @@ function parseContentRangeTotal(contentRange: string | null): number | null {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function parseContentDispositionFilename(contentDisposition: string | null): string {
|
||||
if (!contentDisposition) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const encodedMatch = contentDisposition.match(/filename\*\s*=\s*([^;]+)/i);
|
||||
if (encodedMatch?.[1]) {
|
||||
let value = encodedMatch[1].trim();
|
||||
value = value.replace(/^UTF-8''/i, "");
|
||||
value = value.replace(/^['"]+|['"]+$/g, "");
|
||||
try {
|
||||
const decoded = decodeURIComponent(value).trim();
|
||||
if (decoded) {
|
||||
return decoded;
|
||||
}
|
||||
} catch {
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const plainMatch = contentDisposition.match(/filename\s*=\s*([^;]+)/i);
|
||||
if (!plainMatch?.[1]) {
|
||||
return "";
|
||||
}
|
||||
return plainMatch[1].trim().replace(/^['"]+|['"]+$/g, "");
|
||||
}
|
||||
|
||||
function canRetryStatus(status: number): boolean {
|
||||
return status === 429 || status >= 500;
|
||||
}
|
||||
@ -186,18 +215,189 @@ export class DownloadManager extends EventEmitter {
|
||||
const remaining = totalItems - doneItems;
|
||||
const eta = remaining > 0 && rate > 0 ? remaining / rate : -1;
|
||||
|
||||
const reconnectMs = Math.max(0, this.session.reconnectUntil - now);
|
||||
|
||||
return {
|
||||
settings: this.settings,
|
||||
session: this.getSession(),
|
||||
summary: this.summary,
|
||||
stats: this.getStats(),
|
||||
speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`,
|
||||
etaText: paused ? "ETA: --" : `ETA: ${formatEta(eta)}`,
|
||||
canStart: !this.session.running,
|
||||
canStop: this.session.running,
|
||||
canPause: this.session.running
|
||||
canPause: this.session.running,
|
||||
clipboardActive: this.settings.clipboardWatch,
|
||||
reconnectSeconds: Math.ceil(reconnectMs / 1000)
|
||||
};
|
||||
}
|
||||
|
||||
public getStats(): DownloadStats {
|
||||
let totalDownloaded = 0;
|
||||
let totalFiles = 0;
|
||||
for (const item of Object.values(this.session.items)) {
|
||||
if (item.status === "completed") {
|
||||
totalDownloaded += item.downloadedBytes;
|
||||
totalFiles += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.session.running) {
|
||||
let visibleRunBytes = 0;
|
||||
for (const itemId of this.runItemIds) {
|
||||
const item = this.session.items[itemId];
|
||||
if (item) {
|
||||
visibleRunBytes += item.downloadedBytes;
|
||||
}
|
||||
}
|
||||
totalDownloaded += Math.max(0, this.session.totalDownloadedBytes - visibleRunBytes);
|
||||
} else {
|
||||
totalDownloaded = Math.max(totalDownloaded, this.session.totalDownloadedBytes);
|
||||
}
|
||||
|
||||
return {
|
||||
totalDownloaded,
|
||||
totalFiles,
|
||||
totalPackages: Object.keys(this.session.packages).length,
|
||||
sessionStartedAt: this.session.runStartedAt
|
||||
};
|
||||
}
|
||||
|
||||
public renamePackage(packageId: string, newName: string): void {
|
||||
const pkg = this.session.packages[packageId];
|
||||
if (!pkg) {
|
||||
return;
|
||||
}
|
||||
pkg.name = sanitizeFilename(newName) || pkg.name;
|
||||
pkg.updatedAt = nowMs();
|
||||
this.persistSoon();
|
||||
this.emitState(true);
|
||||
}
|
||||
|
||||
public reorderPackages(packageIds: string[]): void {
|
||||
const valid = packageIds.filter((id) => this.session.packages[id]);
|
||||
const remaining = this.session.packageOrder.filter((id) => !valid.includes(id));
|
||||
this.session.packageOrder = [...valid, ...remaining];
|
||||
this.persistSoon();
|
||||
this.emitState(true);
|
||||
}
|
||||
|
||||
public removeItem(itemId: string): void {
|
||||
const item = this.session.items[itemId];
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
this.recordRunOutcome(itemId, "cancelled");
|
||||
const active = this.activeTasks.get(itemId);
|
||||
if (active) {
|
||||
active.abortReason = "cancel";
|
||||
active.abortController.abort("cancel");
|
||||
}
|
||||
const pkg = this.session.packages[item.packageId];
|
||||
if (pkg) {
|
||||
pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId);
|
||||
if (pkg.itemIds.length === 0) {
|
||||
this.removePackageFromSession(item.packageId, []);
|
||||
} else {
|
||||
pkg.updatedAt = nowMs();
|
||||
}
|
||||
}
|
||||
delete this.session.items[itemId];
|
||||
this.releaseTargetPath(itemId);
|
||||
this.persistSoon();
|
||||
this.emitState(true);
|
||||
}
|
||||
|
||||
public togglePackage(packageId: string): void {
|
||||
const pkg = this.session.packages[packageId];
|
||||
if (!pkg) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEnabled = !pkg.enabled;
|
||||
pkg.enabled = nextEnabled;
|
||||
|
||||
if (!nextEnabled) {
|
||||
if (pkg.status === "downloading") {
|
||||
pkg.status = "paused";
|
||||
}
|
||||
for (const itemId of pkg.itemIds) {
|
||||
const item = this.session.items[itemId];
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
if (this.session.running && !isFinishedStatus(item.status) && !this.runOutcomes.has(itemId)) {
|
||||
this.runItemIds.delete(itemId);
|
||||
}
|
||||
const active = this.activeTasks.get(itemId);
|
||||
if (active) {
|
||||
active.abortReason = "package_toggle";
|
||||
active.abortController.abort("package_toggle");
|
||||
continue;
|
||||
}
|
||||
if (item.status === "queued" || item.status === "reconnect_wait") {
|
||||
item.status = "queued";
|
||||
item.speedBps = 0;
|
||||
item.fullStatus = "Paket gestoppt";
|
||||
item.updatedAt = nowMs();
|
||||
}
|
||||
}
|
||||
this.runPackageIds.delete(packageId);
|
||||
this.runCompletedPackages.delete(packageId);
|
||||
} else {
|
||||
if (pkg.status === "paused") {
|
||||
pkg.status = "queued";
|
||||
}
|
||||
for (const itemId of pkg.itemIds) {
|
||||
const item = this.session.items[itemId];
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
if (item.status === "queued" && item.fullStatus === "Paket gestoppt") {
|
||||
item.fullStatus = "Wartet";
|
||||
item.updatedAt = nowMs();
|
||||
}
|
||||
}
|
||||
if (this.session.running) {
|
||||
void this.ensureScheduler();
|
||||
}
|
||||
}
|
||||
|
||||
pkg.updatedAt = nowMs();
|
||||
this.persistSoon();
|
||||
this.emitState(true);
|
||||
}
|
||||
|
||||
public exportQueue(): string {
|
||||
const exportData = {
|
||||
version: 1,
|
||||
packages: this.session.packageOrder.map((id) => {
|
||||
const pkg = this.session.packages[id];
|
||||
if (!pkg) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name: pkg.name,
|
||||
links: pkg.itemIds
|
||||
.map((itemId) => this.session.items[itemId]?.url)
|
||||
.filter(Boolean)
|
||||
};
|
||||
}).filter(Boolean)
|
||||
};
|
||||
return JSON.stringify(exportData, null, 2);
|
||||
}
|
||||
|
||||
public importQueue(json: string): { addedPackages: number; addedLinks: number } {
|
||||
const data = JSON.parse(json) as { packages?: Array<{ name: string; links: string[] }> };
|
||||
if (!Array.isArray(data.packages)) {
|
||||
return { addedPackages: 0, addedLinks: 0 };
|
||||
}
|
||||
const inputs: ParsedPackageInput[] = data.packages
|
||||
.filter((pkg) => pkg.name && Array.isArray(pkg.links) && pkg.links.length > 0)
|
||||
.map((pkg) => ({ name: pkg.name, links: pkg.links }));
|
||||
return this.addPackages(inputs);
|
||||
}
|
||||
|
||||
public clearAll(): void {
|
||||
this.stop();
|
||||
this.session.packageOrder = [];
|
||||
@ -238,6 +438,7 @@ export class DownloadManager extends EventEmitter {
|
||||
status: "queued",
|
||||
itemIds: [],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt: nowMs(),
|
||||
updatedAt: nowMs()
|
||||
};
|
||||
@ -378,7 +579,13 @@ export class DownloadManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
const runItems = Object.values(this.session.items)
|
||||
.filter((item) => item.status === "queued" || item.status === "reconnect_wait");
|
||||
.filter((item) => {
|
||||
if (item.status !== "queued" && item.status !== "reconnect_wait") {
|
||||
return false;
|
||||
}
|
||||
const pkg = this.session.packages[item.packageId];
|
||||
return Boolean(pkg && !pkg.cancelled && pkg.enabled);
|
||||
});
|
||||
if (runItems.length === 0) {
|
||||
this.runItemIds.clear();
|
||||
this.runPackageIds.clear();
|
||||
@ -464,6 +671,9 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
for (const pkg of Object.values(this.session.packages)) {
|
||||
if (pkg.enabled === undefined) {
|
||||
pkg.enabled = true;
|
||||
}
|
||||
if (pkg.status === "downloading"
|
||||
|| pkg.status === "validating"
|
||||
|| pkg.status === "extracting"
|
||||
@ -749,6 +959,10 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
private markQueuedAsReconnectWait(): void {
|
||||
for (const item of Object.values(this.session.items)) {
|
||||
const pkg = this.session.packages[item.packageId];
|
||||
if (!pkg || pkg.cancelled || !pkg.enabled) {
|
||||
continue;
|
||||
}
|
||||
if (item.status === "queued") {
|
||||
item.status = "reconnect_wait";
|
||||
item.fullStatus = `Reconnect-Wait (${Math.ceil((this.session.reconnectUntil - nowMs()) / 1000)}s)`;
|
||||
@ -761,7 +975,7 @@ export class DownloadManager extends EventEmitter {
|
||||
private findNextQueuedItem(): { packageId: string; itemId: string } | null {
|
||||
for (const packageId of this.session.packageOrder) {
|
||||
const pkg = this.session.packages[packageId];
|
||||
if (!pkg || pkg.cancelled) {
|
||||
if (!pkg || pkg.cancelled || !pkg.enabled) {
|
||||
continue;
|
||||
}
|
||||
for (const itemId of pkg.itemIds) {
|
||||
@ -778,13 +992,28 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
|
||||
private hasQueuedItems(): boolean {
|
||||
return Object.values(this.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait");
|
||||
for (const packageId of this.session.packageOrder) {
|
||||
const pkg = this.session.packages[packageId];
|
||||
if (!pkg || pkg.cancelled || !pkg.enabled) {
|
||||
continue;
|
||||
}
|
||||
for (const itemId of pkg.itemIds) {
|
||||
const item = this.session.items[itemId];
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
if (item.status === "queued" || item.status === "reconnect_wait") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private startItem(packageId: string, itemId: string): void {
|
||||
const item = this.session.items[itemId];
|
||||
const pkg = this.session.packages[packageId];
|
||||
if (!item || !pkg || pkg.cancelled) {
|
||||
if (!item || !pkg || pkg.cancelled || !pkg.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -927,6 +1156,10 @@ export class DownloadManager extends EventEmitter {
|
||||
} else if (reason === "reconnect") {
|
||||
item.status = "queued";
|
||||
item.fullStatus = "Wartet auf Reconnect";
|
||||
} else if (reason === "package_toggle") {
|
||||
item.status = "queued";
|
||||
item.speedBps = 0;
|
||||
item.fullStatus = "Paket gestoppt";
|
||||
} else {
|
||||
const errorText = compactErrorText(error);
|
||||
const shouldFreshRetry = !freshRetryUsed && isFetchFailure(errorText);
|
||||
@ -978,8 +1211,9 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
|
||||
let lastError = "";
|
||||
let effectiveTargetPath = targetPath;
|
||||
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
||||
const existingBytes = fs.existsSync(targetPath) ? fs.statSync(targetPath).size : 0;
|
||||
const existingBytes = fs.existsSync(effectiveTargetPath) ? fs.statSync(effectiveTargetPath).size : 0;
|
||||
const headers: Record<string, string> = {};
|
||||
if (existingBytes > 0) {
|
||||
headers.Range = `bytes=${existingBytes}-`;
|
||||
@ -1042,6 +1276,22 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
const acceptRanges = (response.headers.get("accept-ranges") || "").toLowerCase().includes("bytes");
|
||||
try {
|
||||
if (existingBytes === 0) {
|
||||
const rawHeaderName = parseContentDispositionFilename(response.headers.get("content-disposition")).trim();
|
||||
const fromHeader = rawHeaderName ? sanitizeFilename(rawHeaderName) : "";
|
||||
if (fromHeader && !looksLikeOpaqueFilename(fromHeader) && fromHeader !== item.fileName) {
|
||||
const pkg = this.session.packages[item.packageId];
|
||||
if (pkg) {
|
||||
this.releaseTargetPath(item.id);
|
||||
effectiveTargetPath = this.claimTargetPath(item.id, path.join(pkg.outputDir, fromHeader));
|
||||
item.fileName = fromHeader;
|
||||
item.targetPath = effectiveTargetPath;
|
||||
item.updatedAt = nowMs();
|
||||
this.emitState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resumable = response.status === 206 || acceptRanges;
|
||||
active.resumable = resumable;
|
||||
|
||||
@ -1057,10 +1307,10 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
const writeMode = existingBytes > 0 && response.status === 206 ? "a" : "w";
|
||||
if (writeMode === "w" && existingBytes > 0) {
|
||||
fs.rmSync(targetPath, { force: true });
|
||||
fs.rmSync(effectiveTargetPath, { force: true });
|
||||
}
|
||||
|
||||
const stream = fs.createWriteStream(targetPath, { flags: writeMode });
|
||||
const stream = fs.createWriteStream(effectiveTargetPath, { flags: writeMode });
|
||||
let written = writeMode === "a" ? existingBytes : 0;
|
||||
let windowBytes = 0;
|
||||
let windowStarted = nowMs();
|
||||
@ -1183,11 +1433,35 @@ export class DownloadManager extends EventEmitter {
|
||||
throw new Error(lastError || "Download fehlgeschlagen");
|
||||
}
|
||||
|
||||
private getEffectiveSpeedLimitKbps(): number {
|
||||
const schedules = this.settings.bandwidthSchedules;
|
||||
if (schedules.length > 0) {
|
||||
const hour = new Date().getHours();
|
||||
for (const entry of schedules) {
|
||||
if (!entry.enabled) {
|
||||
continue;
|
||||
}
|
||||
const wraps = entry.startHour > entry.endHour;
|
||||
const inRange = wraps
|
||||
? hour >= entry.startHour || hour < entry.endHour
|
||||
: hour >= entry.startHour && hour < entry.endHour;
|
||||
if (inRange) {
|
||||
return entry.speedLimitKbps;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.settings.speedLimitEnabled && this.settings.speedLimitKbps > 0) {
|
||||
return this.settings.speedLimitKbps;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private async applySpeedLimit(chunkBytes: number, localWindowBytes: number, localWindowStarted: number): Promise<void> {
|
||||
if (!this.settings.speedLimitEnabled || this.settings.speedLimitKbps <= 0) {
|
||||
const limitKbps = this.getEffectiveSpeedLimitKbps();
|
||||
if (limitKbps <= 0) {
|
||||
return;
|
||||
}
|
||||
const bytesPerSecond = this.settings.speedLimitKbps * 1024;
|
||||
const bytesPerSecond = limitKbps * 1024;
|
||||
const now = nowMs();
|
||||
const elapsed = Math.max((now - localWindowStarted) / 1000, 0.1);
|
||||
if (this.settings.speedLimitMode === "per_download") {
|
||||
|
||||
118
src/main/main.ts
118
src/main/main.ts
@ -1,5 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { app, BrowserWindow, dialog, ipcMain, IpcMainInvokeEvent, shell } from "electron";
|
||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, shell, Tray } from "electron";
|
||||
import { AddLinksPayload, AppSettings } from "../shared/types";
|
||||
import { AppController } from "./app-controller";
|
||||
import { IPC_CHANNELS } from "../shared/ipc";
|
||||
@ -7,6 +7,9 @@ import { logger } from "./logger";
|
||||
import { APP_NAME } from "./constants";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
let clipboardTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let lastClipboardText = "";
|
||||
const controller = new AppController();
|
||||
|
||||
function isDevMode(): boolean {
|
||||
@ -37,6 +40,87 @@ function createWindow(): BrowserWindow {
|
||||
return window;
|
||||
}
|
||||
|
||||
function createTray(): void {
|
||||
if (tray) {
|
||||
return;
|
||||
}
|
||||
const iconPath = path.join(app.getAppPath(), "assets", "app_icon.ico");
|
||||
try {
|
||||
tray = new Tray(iconPath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
tray.setToolTip(APP_NAME);
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{ label: "Anzeigen", click: () => { mainWindow?.show(); mainWindow?.focus(); } },
|
||||
{ type: "separator" },
|
||||
{ label: "Start", click: () => { controller.start(); } },
|
||||
{ label: "Stop", click: () => { controller.stop(); } },
|
||||
{ type: "separator" },
|
||||
{ label: "Beenden", click: () => { app.quit(); } }
|
||||
]);
|
||||
tray.setContextMenu(contextMenu);
|
||||
tray.on("double-click", () => {
|
||||
mainWindow?.show();
|
||||
mainWindow?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function destroyTray(): void {
|
||||
if (tray) {
|
||||
tray.destroy();
|
||||
tray = null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractLinksFromText(text: string): string[] {
|
||||
const matches = text.match(/https?:\/\/[^\s<>"']+/gi);
|
||||
return matches ? Array.from(new Set(matches)) : [];
|
||||
}
|
||||
|
||||
function startClipboardWatcher(): void {
|
||||
if (clipboardTimer) {
|
||||
return;
|
||||
}
|
||||
lastClipboardText = clipboard.readText();
|
||||
clipboardTimer = setInterval(() => {
|
||||
const text = clipboard.readText();
|
||||
if (text === lastClipboardText || !text.trim()) {
|
||||
return;
|
||||
}
|
||||
lastClipboardText = text;
|
||||
const links = extractLinksFromText(text);
|
||||
if (links.length > 0 && mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.CLIPBOARD_DETECTED, links);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function stopClipboardWatcher(): void {
|
||||
if (clipboardTimer) {
|
||||
clearInterval(clipboardTimer);
|
||||
clipboardTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateClipboardWatcher(): void {
|
||||
const settings = controller.getSettings();
|
||||
if (settings.clipboardWatch) {
|
||||
startClipboardWatcher();
|
||||
} else {
|
||||
stopClipboardWatcher();
|
||||
}
|
||||
}
|
||||
|
||||
function updateTray(): void {
|
||||
const settings = controller.getSettings();
|
||||
if (settings.minimizeToTray) {
|
||||
createTray();
|
||||
} else {
|
||||
destroyTray();
|
||||
}
|
||||
}
|
||||
|
||||
function registerIpcHandlers(): void {
|
||||
ipcMain.handle(IPC_CHANNELS.GET_SNAPSHOT, () => controller.getSnapshot());
|
||||
ipcMain.handle(IPC_CHANNELS.GET_VERSION, () => controller.getVersion());
|
||||
@ -62,7 +146,12 @@ function registerIpcHandlers(): void {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: Partial<AppSettings>) => controller.updateSettings(partial ?? {}));
|
||||
ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: Partial<AppSettings>) => {
|
||||
const result = controller.updateSettings(partial ?? {});
|
||||
updateClipboardWatcher();
|
||||
updateTray();
|
||||
return result;
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => controller.addLinks(payload));
|
||||
ipcMain.handle(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => controller.addContainers(filePaths ?? []));
|
||||
ipcMain.handle(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll());
|
||||
@ -70,6 +159,19 @@ function registerIpcHandlers(): void {
|
||||
ipcMain.handle(IPC_CHANNELS.STOP, () => controller.stop());
|
||||
ipcMain.handle(IPC_CHANNELS.TOGGLE_PAUSE, () => controller.togglePause());
|
||||
ipcMain.handle(IPC_CHANNELS.CANCEL_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => controller.cancelPackage(packageId));
|
||||
ipcMain.handle(IPC_CHANNELS.RENAME_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string, newName: string) => controller.renamePackage(packageId, newName));
|
||||
ipcMain.handle(IPC_CHANNELS.REORDER_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => controller.reorderPackages(packageIds));
|
||||
ipcMain.handle(IPC_CHANNELS.REMOVE_ITEM, (_event: IpcMainInvokeEvent, itemId: string) => controller.removeItem(itemId));
|
||||
ipcMain.handle(IPC_CHANNELS.TOGGLE_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => controller.togglePackage(packageId));
|
||||
ipcMain.handle(IPC_CHANNELS.EXPORT_QUEUE, () => controller.exportQueue());
|
||||
ipcMain.handle(IPC_CHANNELS.IMPORT_QUEUE, (_event: IpcMainInvokeEvent, json: string) => controller.importQueue(json));
|
||||
ipcMain.handle(IPC_CHANNELS.TOGGLE_CLIPBOARD, () => {
|
||||
const settings = controller.getSettings();
|
||||
const next = !settings.clipboardWatch;
|
||||
controller.updateSettings({ clipboardWatch: next });
|
||||
updateClipboardWatcher();
|
||||
return next;
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.PICK_FOLDER, async () => {
|
||||
const options = {
|
||||
properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory">
|
||||
@ -100,6 +202,16 @@ function registerIpcHandlers(): void {
|
||||
app.whenReady().then(() => {
|
||||
registerIpcHandlers();
|
||||
mainWindow = createWindow();
|
||||
updateClipboardWatcher();
|
||||
updateTray();
|
||||
|
||||
mainWindow.on("close", (event) => {
|
||||
const settings = controller.getSettings();
|
||||
if (settings.minimizeToTray && tray) {
|
||||
event.preventDefault();
|
||||
mainWindow?.hide();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
@ -115,6 +227,8 @@ app.on("window-all-closed", () => {
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
stopClipboardWatcher();
|
||||
destroyTray();
|
||||
try {
|
||||
controller.shutdown();
|
||||
} catch (error) {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { AppSettings, SessionState } from "../shared/types";
|
||||
import { AppSettings, BandwidthScheduleEntry, SessionState } from "../shared/types";
|
||||
import { defaultSettings } from "./constants";
|
||||
import { logger } from "./logger";
|
||||
|
||||
@ -10,6 +10,7 @@ const VALID_CLEANUP_MODES = new Set(["none", "trash", "delete"]);
|
||||
const VALID_CONFLICT_MODES = new Set(["overwrite", "skip", "rename", "ask"]);
|
||||
const VALID_FINISHED_POLICIES = new Set(["never", "immediate", "on_start", "package_done"]);
|
||||
const VALID_SPEED_MODES = new Set(["global", "per_download"]);
|
||||
const VALID_THEMES = new Set(["dark", "light"]);
|
||||
|
||||
function asText(value: unknown): string {
|
||||
return String(value ?? "").trim();
|
||||
@ -23,6 +24,27 @@ function clampNumber(value: unknown, fallback: number, min: number, max: number)
|
||||
return Math.max(min, Math.min(max, Math.floor(num)));
|
||||
}
|
||||
|
||||
function normalizeBandwidthSchedules(raw: unknown): BandwidthScheduleEntry[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalized: BandwidthScheduleEntry[] = [];
|
||||
for (const entry of raw) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
const value = entry as Partial<BandwidthScheduleEntry>;
|
||||
normalized.push({
|
||||
startHour: clampNumber(value.startHour, 0, 0, 23),
|
||||
endHour: clampNumber(value.endHour, 8, 0, 23),
|
||||
speedLimitKbps: clampNumber(value.speedLimitKbps, 0, 0, 500000),
|
||||
enabled: value.enabled === undefined ? true : Boolean(value.enabled)
|
||||
});
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeSettings(settings: AppSettings): AppSettings {
|
||||
const defaults = defaultSettings();
|
||||
const normalized: AppSettings = {
|
||||
@ -51,7 +73,11 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
||||
speedLimitKbps: clampNumber(settings.speedLimitKbps, defaults.speedLimitKbps, 0, 500000),
|
||||
reconnectWaitSeconds: clampNumber(settings.reconnectWaitSeconds, defaults.reconnectWaitSeconds, 10, 600),
|
||||
autoUpdateCheck: Boolean(settings.autoUpdateCheck),
|
||||
updateRepo: asText(settings.updateRepo) || defaults.updateRepo
|
||||
updateRepo: asText(settings.updateRepo) || defaults.updateRepo,
|
||||
clipboardWatch: Boolean(settings.clipboardWatch),
|
||||
minimizeToTray: Boolean(settings.minimizeToTray),
|
||||
theme: VALID_THEMES.has(settings.theme) ? settings.theme : defaults.theme,
|
||||
bandwidthSchedules: normalizeBandwidthSchedules(settings.bandwidthSchedules)
|
||||
};
|
||||
|
||||
if (!VALID_PRIMARY_PROVIDERS.has(normalized.providerPrimary)) {
|
||||
|
||||
@ -19,6 +19,13 @@ const api: ElectronApi = {
|
||||
stop: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.STOP),
|
||||
togglePause: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PAUSE),
|
||||
cancelPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_PACKAGE, packageId),
|
||||
renamePackage: (packageId: string, newName: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RENAME_PACKAGE, packageId, newName),
|
||||
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),
|
||||
exportQueue: (): Promise<string> => 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),
|
||||
pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER),
|
||||
pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
|
||||
onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => {
|
||||
@ -27,6 +34,13 @@ const api: ElectronApi = {
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.STATE_UPDATE, listener);
|
||||
};
|
||||
},
|
||||
onClipboardDetected: (callback: (links: string[]) => void): (() => void) => {
|
||||
const listener = (_event: unknown, links: string[]): void => callback(links);
|
||||
ipcRenderer.on(IPC_CHANNELS.CLIPBOARD_DETECTED, listener);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.CLIPBOARD_DETECTED, listener);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -1,76 +1,49 @@
|
||||
import { DragEvent, ReactElement, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AppSettings, DebridFallbackProvider, DebridProvider, DownloadItem, PackageEntry, UiSnapshot, UpdateCheckResult } from "../shared/types";
|
||||
import { DragEvent, KeyboardEvent, ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AppSettings, AppTheme, BandwidthScheduleEntry, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStats, PackageEntry, UiSnapshot, UpdateCheckResult } from "../shared/types";
|
||||
|
||||
type Tab = "collector" | "downloads" | "settings";
|
||||
|
||||
interface CollectorTab {
|
||||
id: string;
|
||||
name: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const emptyStats = (): DownloadStats => ({
|
||||
totalDownloaded: 0,
|
||||
totalFiles: 0,
|
||||
totalPackages: 0,
|
||||
sessionStartedAt: 0
|
||||
});
|
||||
|
||||
const emptySnapshot = (): UiSnapshot => ({
|
||||
settings: {
|
||||
token: "",
|
||||
megaLogin: "",
|
||||
megaPassword: "",
|
||||
bestToken: "",
|
||||
allDebridToken: "",
|
||||
rememberToken: true,
|
||||
providerPrimary: "realdebrid",
|
||||
providerSecondary: "megadebrid",
|
||||
providerTertiary: "bestdebrid",
|
||||
autoProviderFallback: true,
|
||||
outputDir: "",
|
||||
packageName: "",
|
||||
autoExtract: true,
|
||||
extractDir: "",
|
||||
createExtractSubfolder: true,
|
||||
hybridExtract: true,
|
||||
cleanupMode: "none",
|
||||
extractConflictMode: "overwrite",
|
||||
removeLinkFilesAfterExtract: false,
|
||||
removeSamplesAfterExtract: false,
|
||||
enableIntegrityCheck: true,
|
||||
autoResumeOnStart: true,
|
||||
autoReconnect: false,
|
||||
reconnectWaitSeconds: 45,
|
||||
completedCleanupPolicy: "never",
|
||||
maxParallel: 4,
|
||||
speedLimitEnabled: false,
|
||||
speedLimitKbps: 0,
|
||||
speedLimitMode: "global",
|
||||
updateRepo: "",
|
||||
autoUpdateCheck: true
|
||||
token: "", megaLogin: "", megaPassword: "", bestToken: "", allDebridToken: "",
|
||||
rememberToken: true, providerPrimary: "realdebrid", providerSecondary: "megadebrid",
|
||||
providerTertiary: "bestdebrid", autoProviderFallback: true, outputDir: "", packageName: "",
|
||||
autoExtract: true, extractDir: "", createExtractSubfolder: true, hybridExtract: true,
|
||||
cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false,
|
||||
removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true,
|
||||
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
|
||||
maxParallel: 4, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
|
||||
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
|
||||
theme: "dark", bandwidthSchedules: []
|
||||
},
|
||||
session: {
|
||||
version: 2,
|
||||
packageOrder: [],
|
||||
packages: {},
|
||||
items: {},
|
||||
runStartedAt: 0,
|
||||
totalDownloadedBytes: 0,
|
||||
summaryText: "",
|
||||
reconnectUntil: 0,
|
||||
reconnectReason: "",
|
||||
paused: false,
|
||||
running: false,
|
||||
updatedAt: Date.now()
|
||||
version: 2, packageOrder: [], packages: {}, items: {}, runStartedAt: 0,
|
||||
totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0, reconnectReason: "",
|
||||
paused: false, running: false, updatedAt: Date.now()
|
||||
},
|
||||
summary: null,
|
||||
speedText: "Geschwindigkeit: 0 B/s",
|
||||
etaText: "ETA: --",
|
||||
canStart: true,
|
||||
canStop: false,
|
||||
canPause: false
|
||||
summary: null, stats: emptyStats(), speedText: "Geschwindigkeit: 0 B/s", etaText: "ETA: --",
|
||||
canStart: true, canStop: false, canPause: false, clipboardActive: false, reconnectSeconds: 0
|
||||
});
|
||||
|
||||
const cleanupLabels: Record<string, string> = {
|
||||
never: "Nie",
|
||||
immediate: "Sofort",
|
||||
on_start: "Beim App-Start",
|
||||
package_done: "Sobald Paket fertig ist"
|
||||
never: "Nie", immediate: "Sofort", on_start: "Beim App-Start", package_done: "Sobald Paket fertig ist"
|
||||
};
|
||||
|
||||
const providerLabels: Record<DebridProvider, string> = {
|
||||
realdebrid: "Real-Debrid",
|
||||
megadebrid: "Mega-Debrid",
|
||||
bestdebrid: "BestDebrid",
|
||||
alldebrid: "AllDebrid"
|
||||
realdebrid: "Real-Debrid", megadebrid: "Mega-Debrid", bestdebrid: "BestDebrid", alldebrid: "AllDebrid"
|
||||
};
|
||||
|
||||
const fallbackProviderOptions: Array<{ value: DebridFallbackProvider; label: string }> = [
|
||||
@ -86,21 +59,42 @@ function formatSpeedMbps(speedBps: number): string {
|
||||
return `${mbps.toFixed(2)} MB/s`;
|
||||
}
|
||||
|
||||
function humanSize(bytes: number): string {
|
||||
if (bytes < 1024) { return `${bytes} B`; }
|
||||
if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)} KB`; }
|
||||
if (bytes < 1024 * 1024 * 1024) { return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; }
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
let nextCollectorId = 1;
|
||||
|
||||
export function App(): ReactElement {
|
||||
const [snapshot, setSnapshot] = useState<UiSnapshot>(emptySnapshot);
|
||||
const [tab, setTab] = useState<Tab>("collector");
|
||||
const [linksRaw, setLinksRaw] = useState("");
|
||||
const [statusToast, setStatusToast] = useState("");
|
||||
const [settingsDraft, setSettingsDraft] = useState<AppSettings>(emptySnapshot().settings);
|
||||
const latestStateRef = useRef<UiSnapshot | null>(null);
|
||||
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [editingPackageId, setEditingPackageId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
const [collectorTabs, setCollectorTabs] = useState<CollectorTab[]>([
|
||||
{ id: `tab-${nextCollectorId++}`, name: "Tab 1", text: "" }
|
||||
]);
|
||||
const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id);
|
||||
const activeCollectorTabRef = useRef(activeCollectorTab);
|
||||
const draggedPackageIdRef = useRef<string | null>(null);
|
||||
|
||||
const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0];
|
||||
|
||||
useEffect(() => {
|
||||
activeCollectorTabRef.current = activeCollectorTab;
|
||||
}, [activeCollectorTab]);
|
||||
|
||||
const showToast = (message: string, timeoutMs = 2200): void => {
|
||||
setStatusToast(message);
|
||||
if (toastTimerRef.current) {
|
||||
clearTimeout(toastTimerRef.current);
|
||||
}
|
||||
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
|
||||
toastTimerRef.current = setTimeout(() => {
|
||||
setStatusToast("");
|
||||
toastTimerRef.current = null;
|
||||
@ -109,9 +103,11 @@ export function App(): ReactElement {
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let unsubClipboard: (() => void) | null = null;
|
||||
void window.rd.getSnapshot().then((state) => {
|
||||
setSnapshot(state);
|
||||
setSettingsDraft(state.settings);
|
||||
applyTheme(state.settings.theme);
|
||||
if (state.settings.autoUpdateCheck) {
|
||||
void window.rd.checkUpdates().then((result) => {
|
||||
void handleUpdateResult(result, "startup");
|
||||
@ -122,9 +118,7 @@ export function App(): ReactElement {
|
||||
});
|
||||
unsubscribe = window.rd.onStateUpdate((state) => {
|
||||
latestStateRef.current = state;
|
||||
if (stateFlushTimerRef.current) {
|
||||
return;
|
||||
}
|
||||
if (stateFlushTimerRef.current) { return; }
|
||||
stateFlushTimerRef.current = setTimeout(() => {
|
||||
stateFlushTimerRef.current = null;
|
||||
if (latestStateRef.current) {
|
||||
@ -133,18 +127,20 @@ export function App(): ReactElement {
|
||||
}
|
||||
}, 220);
|
||||
});
|
||||
unsubClipboard = window.rd.onClipboardDetected((links) => {
|
||||
showToast(`Zwischenablage: ${links.length} Link(s) erkannt`, 3000);
|
||||
setCollectorTabs((prev) => {
|
||||
const active = prev.find((t) => t.id === activeCollectorTabRef.current) ?? prev[0];
|
||||
if (!active) { return prev; }
|
||||
const newText = active.text ? `${active.text}\n${links.join("\n")}` : links.join("\n");
|
||||
return prev.map((t) => t.id === active.id ? { ...t, text: newText } : t);
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
if (stateFlushTimerRef.current) {
|
||||
clearTimeout(stateFlushTimerRef.current);
|
||||
stateFlushTimerRef.current = null;
|
||||
}
|
||||
if (toastTimerRef.current) {
|
||||
clearTimeout(toastTimerRef.current);
|
||||
toastTimerRef.current = null;
|
||||
}
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
if (stateFlushTimerRef.current) { clearTimeout(stateFlushTimerRef.current); }
|
||||
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
|
||||
if (unsubscribe) { unsubscribe(); }
|
||||
if (unsubClipboard) { unsubClipboard(); }
|
||||
};
|
||||
}, []);
|
||||
|
||||
@ -154,124 +150,225 @@ export function App(): ReactElement {
|
||||
|
||||
const handleUpdateResult = async (result: UpdateCheckResult, source: "manual" | "startup"): Promise<void> => {
|
||||
if (result.error) {
|
||||
if (source === "manual") {
|
||||
showToast(`Update-Check fehlgeschlagen: ${result.error}`, 2800);
|
||||
}
|
||||
if (source === "manual") { showToast(`Update-Check fehlgeschlagen: ${result.error}`, 2800); }
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.updateAvailable) {
|
||||
if (source === "manual") {
|
||||
showToast(`Kein Update verfügbar (v${result.currentVersion})`, 2000);
|
||||
}
|
||||
if (source === "manual") { showToast(`Kein Update verfügbar (v${result.currentVersion})`, 2000); }
|
||||
return;
|
||||
}
|
||||
|
||||
const approved = window.confirm(
|
||||
`Update verfügbar: ${result.latestTag} (aktuell v${result.currentVersion})\n\nJetzt automatisch herunterladen und installieren?`
|
||||
);
|
||||
if (!approved) {
|
||||
showToast(`Update verfügbar: ${result.latestTag}`, 2600);
|
||||
return;
|
||||
}
|
||||
|
||||
const approved = window.confirm(`Update verfügbar: ${result.latestTag} (aktuell v${result.currentVersion})\n\nJetzt automatisch herunterladen und installieren?`);
|
||||
if (!approved) { showToast(`Update verfügbar: ${result.latestTag}`, 2600); return; }
|
||||
const install = await window.rd.installUpdate();
|
||||
if (install.started) {
|
||||
showToast("Updater gestartet - App wird geschlossen", 2600);
|
||||
return;
|
||||
}
|
||||
|
||||
if (install.started) { showToast("Updater gestartet - App wird geschlossen", 2600); return; }
|
||||
showToast(`Auto-Update fehlgeschlagen: ${install.message}`, 3200);
|
||||
};
|
||||
|
||||
const onSaveSettings = async (): Promise<void> => {
|
||||
try {
|
||||
await window.rd.updateSettings(settingsDraft);
|
||||
const result = await window.rd.updateSettings(settingsDraft);
|
||||
setSettingsDraft(result);
|
||||
applyTheme(result.theme);
|
||||
showToast("Settings gespeichert", 1800);
|
||||
} catch (error) {
|
||||
showToast(`Settings konnten nicht gespeichert werden: ${String(error)}`, 2800);
|
||||
}
|
||||
} catch (error) { showToast(`Settings konnten nicht gespeichert werden: ${String(error)}`, 2800); }
|
||||
};
|
||||
|
||||
const onCheckUpdates = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await window.rd.checkUpdates();
|
||||
await handleUpdateResult(result, "manual");
|
||||
} catch (error) {
|
||||
showToast(`Update-Check fehlgeschlagen: ${String(error)}`, 2800);
|
||||
}
|
||||
} catch (error) { showToast(`Update-Check fehlgeschlagen: ${String(error)}`, 2800); }
|
||||
};
|
||||
|
||||
const onAddLinks = async (): Promise<void> => {
|
||||
try {
|
||||
await window.rd.updateSettings(settingsDraft);
|
||||
const result = await window.rd.addLinks({ rawText: linksRaw, packageName: settingsDraft.packageName });
|
||||
const result = await window.rd.addLinks({ rawText: currentCollectorTab.text, packageName: settingsDraft.packageName });
|
||||
if (result.addedLinks > 0) {
|
||||
showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`);
|
||||
setLinksRaw("");
|
||||
} else {
|
||||
showToast("Keine gültigen Links gefunden");
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(`Fehler beim Hinzufügen: ${String(error)}`, 2600);
|
||||
}
|
||||
setCollectorTabs((prev) => prev.map((t) => t.id === currentCollectorTab.id ? { ...t, text: "" } : t));
|
||||
} else { showToast("Keine gültigen Links gefunden"); }
|
||||
} catch (error) { showToast(`Fehler beim Hinzufügen: ${String(error)}`, 2600); }
|
||||
};
|
||||
|
||||
const onImportDlc = async (): Promise<void> => {
|
||||
try {
|
||||
const files = await window.rd.pickContainers();
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (files.length === 0) { return; }
|
||||
await window.rd.updateSettings(settingsDraft);
|
||||
const result = await window.rd.addContainers(files);
|
||||
showToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
|
||||
} catch (error) {
|
||||
showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600);
|
||||
}
|
||||
} catch (error) { showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600); }
|
||||
};
|
||||
|
||||
const onDrop = async (event: DragEvent<HTMLTextAreaElement>): Promise<void> => {
|
||||
const onDrop = async (event: DragEvent<HTMLElement>): Promise<void> => {
|
||||
event.preventDefault();
|
||||
setDragOver(false);
|
||||
const files = Array.from(event.dataTransfer.files ?? []) as File[];
|
||||
const dlc = files
|
||||
.filter((file) => file.name.toLowerCase().endsWith(".dlc"))
|
||||
.map((file) => (file as unknown as { path?: string }).path)
|
||||
.filter((value): value is string => !!value);
|
||||
if (dlc.length === 0) {
|
||||
return;
|
||||
}
|
||||
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 droppedText = event.dataTransfer.getData("text/plain") || event.dataTransfer.getData("text/uri-list") || "";
|
||||
if (dlc.length > 0) {
|
||||
try {
|
||||
await window.rd.updateSettings(settingsDraft);
|
||||
const result = await window.rd.addContainers(dlc);
|
||||
showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
|
||||
} catch (error) {
|
||||
showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600);
|
||||
} catch (error) { showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600); }
|
||||
} else if (droppedText.trim()) {
|
||||
setCollectorTabs((prev) => prev.map((t) => t.id === currentCollectorTab.id
|
||||
? { ...t, text: t.text ? `${t.text}\n${droppedText}` : droppedText } : t));
|
||||
setTab("collector");
|
||||
showToast("Links per Drag-and-Drop eingefügt");
|
||||
}
|
||||
};
|
||||
|
||||
const setBool = (key: keyof AppSettings, value: boolean): void => {
|
||||
setSettingsDraft((prev: AppSettings) => ({ ...prev, [key]: value }));
|
||||
const onExportQueue = async (): Promise<void> => {
|
||||
try {
|
||||
const json = await window.rd.exportQueue();
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "rd-queue-export.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast("Queue exportiert");
|
||||
} catch (error) { showToast(`Export fehlgeschlagen: ${String(error)}`, 2600); }
|
||||
};
|
||||
|
||||
const setText = (key: keyof AppSettings, value: string): void => {
|
||||
setSettingsDraft((prev: AppSettings) => ({ ...prev, [key]: value }));
|
||||
const onImportQueue = async (): Promise<void> => {
|
||||
try {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".json";
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) { return; }
|
||||
try {
|
||||
const text = await file.text();
|
||||
const result = await window.rd.importQueue(text);
|
||||
showToast(`Importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
|
||||
} catch (error) {
|
||||
showToast(`Import fehlgeschlagen: ${String(error)}`, 2600);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
} catch (error) { showToast(`Import fehlgeschlagen: ${String(error)}`, 2600); }
|
||||
};
|
||||
|
||||
const setNum = (key: keyof AppSettings, value: number): void => {
|
||||
setSettingsDraft((prev: AppSettings) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
const setBool = (key: keyof AppSettings, value: boolean): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); };
|
||||
const setText = (key: keyof AppSettings, value: string): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); };
|
||||
const setNum = (key: keyof AppSettings, value: number): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); };
|
||||
|
||||
const performQuickAction = async (action: () => Promise<unknown>): Promise<void> => {
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
showToast(`Fehler: ${String(error)}`, 2600);
|
||||
}
|
||||
try { await action(); } catch (error) { showToast(`Fehler: ${String(error)}`, 2600); }
|
||||
};
|
||||
|
||||
const movePackage = useCallback((packageId: string, direction: "up" | "down") => {
|
||||
const order = [...snapshot.session.packageOrder];
|
||||
const idx = order.indexOf(packageId);
|
||||
if (idx < 0) { return; }
|
||||
const target = direction === "up" ? idx - 1 : idx + 1;
|
||||
if (target < 0 || target >= order.length) { return; }
|
||||
[order[idx], order[target]] = [order[target], order[idx]];
|
||||
void window.rd.reorderPackages(order);
|
||||
}, [snapshot.session.packageOrder]);
|
||||
|
||||
const reorderPackagesByDrop = useCallback((draggedPackageId: string, targetPackageId: string) => {
|
||||
const order = [...snapshot.session.packageOrder];
|
||||
const fromIndex = order.indexOf(draggedPackageId);
|
||||
const toIndex = order.indexOf(targetPackageId);
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) {
|
||||
return;
|
||||
}
|
||||
const [dragged] = order.splice(fromIndex, 1);
|
||||
const insertIndex = fromIndex < toIndex ? toIndex - 1 : toIndex;
|
||||
order.splice(insertIndex, 0, dragged);
|
||||
void window.rd.reorderPackages(order);
|
||||
}, [snapshot.session.packageOrder]);
|
||||
|
||||
const addCollectorTab = (): void => {
|
||||
const id = `tab-${nextCollectorId++}`;
|
||||
const name = `Tab ${collectorTabs.length + 1}`;
|
||||
setCollectorTabs((prev) => [...prev, { id, name, text: "" }]);
|
||||
setActiveCollectorTab(id);
|
||||
};
|
||||
|
||||
const removeCollectorTab = (id: string): void => {
|
||||
setCollectorTabs((prev) => {
|
||||
if (prev.length <= 1) {
|
||||
return prev;
|
||||
}
|
||||
const index = prev.findIndex((tabEntry) => tabEntry.id === id);
|
||||
if (index < 0) {
|
||||
return prev;
|
||||
}
|
||||
const next = prev.filter((tabEntry) => tabEntry.id !== id);
|
||||
if (activeCollectorTabRef.current === id) {
|
||||
const fallback = next[Math.max(0, index - 1)]?.id ?? next[0]?.id ?? "";
|
||||
setActiveCollectorTab(fallback);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const onPackageDragStart = useCallback((packageId: string) => {
|
||||
draggedPackageIdRef.current = packageId;
|
||||
}, []);
|
||||
|
||||
const onPackageDrop = useCallback((targetPackageId: string) => {
|
||||
const draggedPackageId = draggedPackageIdRef.current;
|
||||
draggedPackageIdRef.current = null;
|
||||
if (!draggedPackageId || draggedPackageId === targetPackageId) {
|
||||
return;
|
||||
}
|
||||
reorderPackagesByDrop(draggedPackageId, targetPackageId);
|
||||
}, [reorderPackagesByDrop]);
|
||||
|
||||
const onPackageDragEnd = useCallback(() => {
|
||||
draggedPackageIdRef.current = null;
|
||||
}, []);
|
||||
|
||||
const schedules = settingsDraft.bandwidthSchedules ?? [];
|
||||
const addSchedule = (): void => {
|
||||
setSettingsDraft((prev) => ({
|
||||
...prev,
|
||||
bandwidthSchedules: [...(prev.bandwidthSchedules ?? []), { startHour: 0, endHour: 8, speedLimitKbps: 0, enabled: true }]
|
||||
}));
|
||||
};
|
||||
const removeSchedule = (idx: number): void => {
|
||||
setSettingsDraft((prev) => ({
|
||||
...prev,
|
||||
bandwidthSchedules: (prev.bandwidthSchedules ?? []).filter((_, i) => i !== idx)
|
||||
}));
|
||||
};
|
||||
const updateSchedule = (idx: number, field: keyof BandwidthScheduleEntry, value: number | boolean): void => {
|
||||
setSettingsDraft((prev) => ({
|
||||
...prev,
|
||||
bandwidthSchedules: (prev.bandwidthSchedules ?? []).map((s, i) => i === idx ? { ...s, [field]: value } : s)
|
||||
}));
|
||||
};
|
||||
|
||||
const applyTheme = (theme: AppTheme): void => {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
};
|
||||
|
||||
const packageSpeedMap = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const item of Object.values(snapshot.session.items)) {
|
||||
if (item.speedBps > 0) {
|
||||
map.set(item.packageId, (map.get(item.packageId) ?? 0) + item.speedBps);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [snapshot]);
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div
|
||||
className={`app-shell${dragOver ? " drag-over" : ""}`}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<header className="top-header">
|
||||
<div className="title-block">
|
||||
<h1>Debrid Download Manager</h1>
|
||||
@ -280,45 +377,31 @@ export function App(): ReactElement {
|
||||
<div className="metrics">
|
||||
<div>{snapshot.speedText}</div>
|
||||
<div>{snapshot.etaText}</div>
|
||||
{snapshot.reconnectSeconds > 0 && (
|
||||
<div className="reconnect-badge">Reconnect: {snapshot.reconnectSeconds}s</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="control-strip">
|
||||
<div className="buttons">
|
||||
<button
|
||||
className="btn accent"
|
||||
disabled={!snapshot.canStart}
|
||||
onClick={async () => {
|
||||
await performQuickAction(async () => {
|
||||
await window.rd.updateSettings(settingsDraft);
|
||||
await window.rd.start();
|
||||
});
|
||||
}}
|
||||
>Start</button>
|
||||
<button className="btn accent" disabled={!snapshot.canStart} onClick={async () => {
|
||||
await performQuickAction(async () => { await window.rd.updateSettings(settingsDraft); await window.rd.start(); });
|
||||
}}>Start</button>
|
||||
<button className="btn" disabled={!snapshot.canPause} onClick={() => { void performQuickAction(() => window.rd.togglePause()); }}>
|
||||
{snapshot.session.paused ? "Resume" : "Pause"}
|
||||
</button>
|
||||
<button className="btn" disabled={!snapshot.canStop} onClick={() => { void performQuickAction(() => window.rd.stop()); }}>Stop</button>
|
||||
<button className="btn" onClick={() => { void performQuickAction(() => window.rd.clearAll()); }}>Alles leeren</button>
|
||||
<button className={`btn${snapshot.clipboardActive ? " btn-active" : ""}`} onClick={() => { void performQuickAction(() => window.rd.toggleClipboard()); }}>
|
||||
Clipboard {snapshot.clipboardActive ? "An" : "Aus"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="speed-config">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settingsDraft.speedLimitEnabled}
|
||||
onChange={(event) => setBool("speedLimitEnabled", event.target.checked)}
|
||||
/>
|
||||
Speed-Limit
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={500000}
|
||||
value={settingsDraft.speedLimitKbps}
|
||||
onChange={(event) => setNum("speedLimitKbps", Number(event.target.value) || 0)}
|
||||
/>
|
||||
<label><input type="checkbox" checked={settingsDraft.speedLimitEnabled} onChange={(e) => setBool("speedLimitEnabled", e.target.checked)} /> Speed-Limit</label>
|
||||
<input type="number" min={0} max={500000} value={settingsDraft.speedLimitKbps} onChange={(e) => setNum("speedLimitKbps", Number(e.target.value) || 0)} />
|
||||
<span>KB/s</span>
|
||||
<select value={settingsDraft.speedLimitMode} onChange={(event) => setText("speedLimitMode", event.target.value)}>
|
||||
<select value={settingsDraft.speedLimitMode} onChange={(e) => setText("speedLimitMode", e.target.value)}>
|
||||
<option value="global">global</option>
|
||||
<option value="per_download">per_download</option>
|
||||
</select>
|
||||
@ -335,32 +418,69 @@ export function App(): ReactElement {
|
||||
{tab === "collector" && (
|
||||
<section className="grid-two">
|
||||
<article className="card wide">
|
||||
<div className="collector-header">
|
||||
<h3>Linksammler</h3>
|
||||
<div className="link-actions">
|
||||
<button className="btn" onClick={onImportDlc}>DLC import</button>
|
||||
<button className="btn accent" onClick={onAddLinks}>Zur Queue hinzufügen</button>
|
||||
<button className="btn" onClick={onExportQueue}>Queue Export</button>
|
||||
<button className="btn" onClick={onImportQueue}>Queue Import</button>
|
||||
<button className="btn accent" onClick={onAddLinks}>Zur Queue hinzufugen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="collector-tabs">
|
||||
{collectorTabs.map((ct) => (
|
||||
<div key={ct.id} className={`collector-tab${ct.id === activeCollectorTab ? " active" : ""}`}>
|
||||
<button onClick={() => setActiveCollectorTab(ct.id)}>{ct.name}</button>
|
||||
{collectorTabs.length > 1 && <button className="close-tab" onClick={() => removeCollectorTab(ct.id)}>x</button>}
|
||||
</div>
|
||||
))}
|
||||
<button className="btn add-tab" onClick={addCollectorTab}>+</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={linksRaw}
|
||||
onChange={(event) => setLinksRaw(event.target.value)}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={onDrop}
|
||||
placeholder="# package: Release-Name\nhttps://...\nhttps://..."
|
||||
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"}
|
||||
/>
|
||||
<p className="hint">.dlc einfach auf das Feld ziehen oder per Button importieren.</p>
|
||||
</article>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === "downloads" && (
|
||||
<section className="downloads-view">
|
||||
{snapshot.reconnectSeconds > 0 && (
|
||||
<div className="reconnect-banner">
|
||||
Reconnect aktiv: {snapshot.reconnectSeconds}s verbleibend
|
||||
{snapshot.session.reconnectReason && <span> ({snapshot.session.reconnectReason})</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="stats-bar">
|
||||
<span>Pakete: {snapshot.stats.totalPackages}</span>
|
||||
<span>Dateien: {snapshot.stats.totalFiles} fertig</span>
|
||||
<span>Gesamt: {humanSize(snapshot.stats.totalDownloaded)}</span>
|
||||
</div>
|
||||
{packages.length === 0 && <div className="empty">Noch keine Pakete in der Queue.</div>}
|
||||
{packages.map((pkg) => (
|
||||
{packages.map((pkg, idx) => (
|
||||
<PackageCard
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
items={pkg.itemIds.map((id) => snapshot.session.items[id]).filter(Boolean)}
|
||||
packageSpeed={packageSpeedMap.get(pkg.id) ?? 0}
|
||||
isFirst={idx === 0}
|
||||
isLast={idx === packages.length - 1}
|
||||
isEditing={editingPackageId === pkg.id}
|
||||
editingName={editingName}
|
||||
onStartEdit={() => { setEditingPackageId(pkg.id); setEditingName(pkg.name); }}
|
||||
onFinishEdit={(name) => { setEditingPackageId(null); if (name.trim()) { void window.rd.renamePackage(pkg.id, name); } }}
|
||||
onEditChange={setEditingName}
|
||||
onCancel={() => { void performQuickAction(() => window.rd.cancelPackage(pkg.id)); }}
|
||||
onMoveUp={() => movePackage(pkg.id, "up")}
|
||||
onMoveDown={() => movePackage(pkg.id, "down")}
|
||||
onToggle={() => { void window.rd.togglePackage(pkg.id); }}
|
||||
onRemoveItem={(itemId) => { void window.rd.removeItem(itemId); }}
|
||||
onDragStart={() => onPackageDragStart(pkg.id)}
|
||||
onDrop={() => onPackageDrop(pkg.id)}
|
||||
onDragEnd={onPackageDragEnd}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
@ -374,7 +494,14 @@ export function App(): ReactElement {
|
||||
<span>Kompakt, schnell auffindbar und direkt speicherbar.</span>
|
||||
</div>
|
||||
<div className="settings-toolbar-actions">
|
||||
<button className="btn" onClick={onCheckUpdates}>Updates prüfen</button>
|
||||
<button className="btn" onClick={onCheckUpdates}>Updates prufen</button>
|
||||
<button className={`btn${settingsDraft.theme === "light" ? " btn-active" : ""}`} onClick={() => {
|
||||
const next = settingsDraft.theme === "dark" ? "light" : "dark";
|
||||
setSettingsDraft((prev) => ({ ...prev, theme: next as AppTheme }));
|
||||
applyTheme(next as AppTheme);
|
||||
}}>
|
||||
{settingsDraft.theme === "dark" ? "Light Mode" : "Dark Mode"}
|
||||
</button>
|
||||
<button className="btn accent" onClick={onSaveSettings}>Settings speichern</button>
|
||||
</div>
|
||||
</article>
|
||||
@ -383,148 +510,100 @@ export function App(): ReactElement {
|
||||
<article className="card settings-card">
|
||||
<h3>Provider & Zugang</h3>
|
||||
<label>Real-Debrid API Token</label>
|
||||
<input type="password" value={settingsDraft.token} onChange={(event) => setText("token", event.target.value)} />
|
||||
<input type="password" value={settingsDraft.token} onChange={(e) => setText("token", e.target.value)} />
|
||||
<label>Mega-Debrid Login</label>
|
||||
<input value={settingsDraft.megaLogin} onChange={(event) => setText("megaLogin", event.target.value)} />
|
||||
<input value={settingsDraft.megaLogin} onChange={(e) => setText("megaLogin", e.target.value)} />
|
||||
<label>Mega-Debrid Passwort</label>
|
||||
<input type="password" value={settingsDraft.megaPassword} onChange={(event) => setText("megaPassword", event.target.value)} />
|
||||
<input type="password" value={settingsDraft.megaPassword} onChange={(e) => setText("megaPassword", e.target.value)} />
|
||||
<label>BestDebrid API Token</label>
|
||||
<input type="password" value={settingsDraft.bestToken} onChange={(event) => setText("bestToken", event.target.value)} />
|
||||
<input type="password" value={settingsDraft.bestToken} onChange={(e) => setText("bestToken", e.target.value)} />
|
||||
<label>AllDebrid API Key</label>
|
||||
<input type="password" value={settingsDraft.allDebridToken} onChange={(event) => setText("allDebridToken", event.target.value)} />
|
||||
|
||||
<input type="password" value={settingsDraft.allDebridToken} onChange={(e) => setText("allDebridToken", e.target.value)} />
|
||||
<div className="field-grid three">
|
||||
<div>
|
||||
<label>Primär</label>
|
||||
<select value={settingsDraft.providerPrimary} onChange={(event) => setText("providerPrimary", event.target.value)}>
|
||||
{Object.entries(providerLabels).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div><label>Primar</label><select value={settingsDraft.providerPrimary} onChange={(e) => setText("providerPrimary", e.target.value)}>
|
||||
{Object.entries(providerLabels).map(([key, label]) => (<option key={key} value={key}>{label}</option>))}
|
||||
</select></div>
|
||||
<div><label>Sekundar</label><select value={settingsDraft.providerSecondary} onChange={(e) => setText("providerSecondary", e.target.value)}>
|
||||
{fallbackProviderOptions.map((o) => (<option key={o.value} value={o.value}>{o.label}</option>))}
|
||||
</select></div>
|
||||
<div><label>Tertiar</label><select value={settingsDraft.providerTertiary} onChange={(e) => setText("providerTertiary", e.target.value)}>
|
||||
{fallbackProviderOptions.map((o) => (<option key={o.value} value={o.value}>{o.label}</option>))}
|
||||
</select></div>
|
||||
</div>
|
||||
<div>
|
||||
<label>Sekundär</label>
|
||||
<select value={settingsDraft.providerSecondary} onChange={(event) => setText("providerSecondary", event.target.value)}>
|
||||
{fallbackProviderOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Tertiär</label>
|
||||
<select value={settingsDraft.providerTertiary} onChange={(event) => setText("providerTertiary", event.target.value)}>
|
||||
{fallbackProviderOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoProviderFallback} onChange={(event) => setBool("autoProviderFallback", event.target.checked)} /> Bei Fehler/Fair-Use automatisch zum nächsten Provider wechseln</label>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.rememberToken} onChange={(event) => setBool("rememberToken", event.target.checked)} /> Zugangsdaten lokal speichern</label>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoProviderFallback} onChange={(e) => setBool("autoProviderFallback", e.target.checked)} /> Bei Fehler/Fair-Use automatisch zum nachsten Provider wechseln</label>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.rememberToken} onChange={(e) => setBool("rememberToken", e.target.checked)} /> Zugangsdaten lokal speichern</label>
|
||||
</article>
|
||||
|
||||
<article className="card settings-card">
|
||||
<h3>Pfade & Paketierung</h3>
|
||||
<label>Download-Ordner</label>
|
||||
<div className="input-row">
|
||||
<input value={settingsDraft.outputDir} onChange={(event) => setText("outputDir", event.target.value)} />
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
void performQuickAction(async () => {
|
||||
const selected = await window.rd.pickFolder();
|
||||
if (selected) {
|
||||
setText("outputDir", selected);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>Wählen</button>
|
||||
<input value={settingsDraft.outputDir} onChange={(e) => setText("outputDir", e.target.value)} />
|
||||
<button className="btn" onClick={() => { void performQuickAction(async () => { const s = await window.rd.pickFolder(); if (s) { setText("outputDir", s); } }); }}>Wahlen</button>
|
||||
</div>
|
||||
<label>Paketname (optional)</label>
|
||||
<input value={settingsDraft.packageName} onChange={(event) => setText("packageName", event.target.value)} />
|
||||
<input value={settingsDraft.packageName} onChange={(e) => setText("packageName", e.target.value)} />
|
||||
<label>Entpacken nach</label>
|
||||
<div className="input-row">
|
||||
<input value={settingsDraft.extractDir} onChange={(event) => setText("extractDir", event.target.value)} />
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
void performQuickAction(async () => {
|
||||
const selected = await window.rd.pickFolder();
|
||||
if (selected) {
|
||||
setText("extractDir", selected);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>Wählen</button>
|
||||
<input value={settingsDraft.extractDir} onChange={(e) => setText("extractDir", e.target.value)} />
|
||||
<button className="btn" onClick={() => { void performQuickAction(async () => { const s = await window.rd.pickFolder(); if (s) { setText("extractDir", s); } }); }}>Wahlen</button>
|
||||
</div>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtract} onChange={(event) => setBool("autoExtract", event.target.checked)} /> Auto-Extract</label>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(event) => setBool("hybridExtract", event.target.checked)} /> Hybrid-Extract</label>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtract} onChange={(e) => setBool("autoExtract", e.target.checked)} /> Auto-Extract</label>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(e) => setBool("hybridExtract", e.target.checked)} /> Hybrid-Extract</label>
|
||||
</article>
|
||||
|
||||
<article className="card settings-card">
|
||||
<h3>Queue, Limits & Reconnect</h3>
|
||||
<div className="field-grid two">
|
||||
<div>
|
||||
<label>Max. Downloads</label>
|
||||
<input type="number" min={1} max={50} value={settingsDraft.maxParallel} onChange={(event) => setNum("maxParallel", Number(event.target.value) || 1)} />
|
||||
<div><label>Max. Downloads</label><input type="number" min={1} max={50} value={settingsDraft.maxParallel} onChange={(e) => setNum("maxParallel", Number(e.target.value) || 1)} /></div>
|
||||
<div><label>Reconnect-Wartezeit (Sek.)</label><input type="number" min={10} max={600} value={settingsDraft.reconnectWaitSeconds} onChange={(e) => setNum("reconnectWaitSeconds", Number(e.target.value) || 45)} /></div>
|
||||
</div>
|
||||
<div>
|
||||
<label>Reconnect-Wartezeit (Sek.)</label>
|
||||
<input type="number" min={10} max={600} value={settingsDraft.reconnectWaitSeconds} onChange={(event) => setNum("reconnectWaitSeconds", Number(event.target.value) || 45)} />
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.speedLimitEnabled} onChange={(e) => setBool("speedLimitEnabled", e.target.checked)} /> Speed-Limit aktivieren</label>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoReconnect} onChange={(e) => setBool("autoReconnect", e.target.checked)} /> Automatischer Reconnect</label>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoResumeOnStart} onChange={(e) => setBool("autoResumeOnStart", e.target.checked)} /> Auto-Resume beim Start</label>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.clipboardWatch} onChange={(e) => setBool("clipboardWatch", e.target.checked)} /> Zwischenablage uberwachen</label>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label>
|
||||
<h4>Bandbreitenplanung</h4>
|
||||
{schedules.map((s, i) => (
|
||||
<div key={i} className="schedule-row">
|
||||
<input type="number" min={0} max={23} value={s.startHour} onChange={(e) => updateSchedule(i, "startHour", Number(e.target.value))} title="Von (Stunde)" />
|
||||
<span>-</span>
|
||||
<input type="number" min={0} max={23} value={s.endHour} onChange={(e) => updateSchedule(i, "endHour", Number(e.target.value))} title="Bis (Stunde)" />
|
||||
<span>Uhr</span>
|
||||
<input type="number" min={0} value={s.speedLimitKbps} onChange={(e) => updateSchedule(i, "speedLimitKbps", Number(e.target.value) || 0)} title="KB/s (0=unbegrenzt)" />
|
||||
<span>KB/s</span>
|
||||
<input type="checkbox" checked={s.enabled} onChange={(e) => updateSchedule(i, "enabled", e.target.checked)} />
|
||||
<button className="btn danger" onClick={() => removeSchedule(i)}>X</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field-grid two">
|
||||
<div>
|
||||
<label>Speed-Limit (KB/s)</label>
|
||||
<input type="number" min={0} max={500000} value={settingsDraft.speedLimitKbps} onChange={(event) => setNum("speedLimitKbps", Number(event.target.value) || 0)} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Speed-Modus</label>
|
||||
<select value={settingsDraft.speedLimitMode} onChange={(event) => setText("speedLimitMode", event.target.value)}>
|
||||
<option value="global">global</option>
|
||||
<option value="per_download">per_download</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.speedLimitEnabled} onChange={(event) => setBool("speedLimitEnabled", event.target.checked)} /> Speed-Limit aktivieren</label>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoReconnect} onChange={(event) => setBool("autoReconnect", event.target.checked)} /> Automatischer Reconnect</label>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoResumeOnStart} onChange={(event) => setBool("autoResumeOnStart", event.target.checked)} /> Auto-Resume beim Start</label>
|
||||
))}
|
||||
<button className="btn" onClick={addSchedule}>Zeitregel hinzufugen</button>
|
||||
</article>
|
||||
|
||||
<article className="card settings-card">
|
||||
<h3>Integrität, Cleanup & Updates</h3>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.enableIntegrityCheck} onChange={(event) => setBool("enableIntegrityCheck", event.target.checked)} /> SFV/CRC/MD5/SHA1 prüfen</label>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeLinkFilesAfterExtract} onChange={(event) => setBool("removeLinkFilesAfterExtract", event.target.checked)} /> Link-Dateien nach Entpacken entfernen</label>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeSamplesAfterExtract} onChange={(event) => setBool("removeSamplesAfterExtract", event.target.checked)} /> Samples nach Entpacken entfernen</label>
|
||||
<h3>Integritat, Cleanup & Updates</h3>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.enableIntegrityCheck} onChange={(e) => setBool("enableIntegrityCheck", e.target.checked)} /> SFV/CRC/MD5/SHA1 prufen</label>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeLinkFilesAfterExtract} onChange={(e) => setBool("removeLinkFilesAfterExtract", e.target.checked)} /> Link-Dateien nach Entpacken entfernen</label>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeSamplesAfterExtract} onChange={(e) => setBool("removeSamplesAfterExtract", e.target.checked)} /> Samples nach Entpacken entfernen</label>
|
||||
<label>Fertiggestellte Downloads entfernen</label>
|
||||
<select value={settingsDraft.completedCleanupPolicy} onChange={(event) => setText("completedCleanupPolicy", event.target.value)}>
|
||||
{Object.entries(cleanupLabels).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
<select value={settingsDraft.completedCleanupPolicy} onChange={(e) => setText("completedCleanupPolicy", e.target.value)}>
|
||||
{Object.entries(cleanupLabels).map(([key, label]) => (<option key={key} value={key}>{label}</option>))}
|
||||
</select>
|
||||
<div className="field-grid two">
|
||||
<div>
|
||||
<label>Cleanup nach Entpacken</label>
|
||||
<select value={settingsDraft.cleanupMode} onChange={(event) => setText("cleanupMode", event.target.value)}>
|
||||
<option value="none">keine Archive löschen</option>
|
||||
<div><label>Cleanup nach Entpacken</label><select value={settingsDraft.cleanupMode} onChange={(e) => setText("cleanupMode", e.target.value)}>
|
||||
<option value="none">keine Archive loschen</option>
|
||||
<option value="trash">Archive in Papierkorb</option>
|
||||
<option value="delete">Archive löschen</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Konfliktmodus</label>
|
||||
<select value={settingsDraft.extractConflictMode} onChange={(event) => setText("extractConflictMode", event.target.value)}>
|
||||
<option value="overwrite">überschreiben</option>
|
||||
<option value="skip">überspringen</option>
|
||||
<option value="delete">Archive loschen</option>
|
||||
</select></div>
|
||||
<div><label>Konfliktmodus</label><select value={settingsDraft.extractConflictMode} onChange={(e) => setText("extractConflictMode", e.target.value)}>
|
||||
<option value="overwrite">uberschreiben</option>
|
||||
<option value="skip">uberspringen</option>
|
||||
<option value="rename">umbenennen</option>
|
||||
<option value="ask">nachfragen</option>
|
||||
</select>
|
||||
</div>
|
||||
</select></div>
|
||||
</div>
|
||||
<label>GitHub Repo</label>
|
||||
<input value={settingsDraft.updateRepo} onChange={(event) => setText("updateRepo", event.target.value)} />
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoUpdateCheck} onChange={(event) => setBool("autoUpdateCheck", event.target.checked)} /> Beim Start auf Updates prüfen</label>
|
||||
<input value={settingsDraft.updateRepo} onChange={(e) => setText("updateRepo", e.target.value)} />
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoUpdateCheck} onChange={(e) => setBool("autoUpdateCheck", e.target.checked)} /> Beim Start auf Updates prufen</label>
|
||||
</article>
|
||||
</section>
|
||||
</section>
|
||||
@ -532,40 +611,85 @@ export function App(): ReactElement {
|
||||
</main>
|
||||
|
||||
{statusToast && <div className="toast">{statusToast}</div>}
|
||||
{dragOver && <div className="drop-overlay">Links oder .dlc Dateien hier ablegen</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PackageCard({ pkg, items, onCancel }: { pkg: PackageEntry; items: DownloadItem[]; onCancel: () => void }): ReactElement {
|
||||
interface PackageCardProps {
|
||||
pkg: PackageEntry;
|
||||
items: DownloadItem[];
|
||||
packageSpeed: number;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
isEditing: boolean;
|
||||
editingName: string;
|
||||
onStartEdit: () => void;
|
||||
onFinishEdit: (name: string) => void;
|
||||
onEditChange: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
onToggle: () => void;
|
||||
onRemoveItem: (itemId: string) => void;
|
||||
onDragStart: () => void;
|
||||
onDrop: () => void;
|
||||
onDragEnd: () => void;
|
||||
}
|
||||
|
||||
function PackageCard({ pkg, items, packageSpeed, isFirst, isLast, isEditing, editingName, onStartEdit, onFinishEdit, onEditChange, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement {
|
||||
const done = items.filter((item) => item.status === "completed").length;
|
||||
const failed = items.filter((item) => item.status === "failed").length;
|
||||
const cancelled = items.filter((item) => item.status === "cancelled").length;
|
||||
const total = Math.max(1, items.length);
|
||||
const progress = Math.floor((done / total) * 100);
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === "Enter") { onFinishEdit(editingName); }
|
||||
if (e.key === "Escape") { onFinishEdit(pkg.name); }
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="package-card">
|
||||
<article
|
||||
className={`package-card${pkg.enabled ? "" : " disabled-pkg"}`}
|
||||
draggable
|
||||
onDragStart={(event) => { event.stopPropagation(); onDragStart(); }}
|
||||
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); }}
|
||||
onDrop={(event) => { event.preventDefault(); event.stopPropagation(); onDrop(); }}
|
||||
onDragEnd={(event) => { event.stopPropagation(); onDragEnd(); }}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<h4>{pkg.name}</h4>
|
||||
<span>{done}/{total} fertig · {failed} Fehler · {cancelled} abgebrochen</span>
|
||||
<div className="pkg-info">
|
||||
<div className="pkg-name-row">
|
||||
<input type="checkbox" checked={pkg.enabled} onChange={onToggle} title={pkg.enabled ? "Paket aktiv" : "Paket deaktiviert"} />
|
||||
{isEditing ? (
|
||||
<input className="rename-input" value={editingName} onChange={(e) => onEditChange(e.target.value)} onBlur={() => onFinishEdit(editingName)} onKeyDown={onKeyDown} autoFocus />
|
||||
) : (
|
||||
<h4 onDoubleClick={onStartEdit} title="Doppelklick zum Umbenennen">{pkg.name}</h4>
|
||||
)}
|
||||
</div>
|
||||
<span>{done}/{total} fertig {failed > 0 && `· ${failed} Fehler `}{cancelled > 0 && `· ${cancelled} abgebrochen `}
|
||||
{packageSpeed > 0 && <span className="pkg-speed">{formatSpeedMbps(packageSpeed)}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pkg-actions">
|
||||
<button className="btn" disabled={isFirst} onClick={onMoveUp} title="Nach oben">▲</button>
|
||||
<button className="btn" disabled={isLast} onClick={onMoveDown} title="Nach unten">▼</button>
|
||||
<button className={`btn${pkg.enabled ? "" : " btn-active"}`} onClick={onToggle}>{pkg.enabled ? "Paket stoppen" : "Paket starten"}</button>
|
||||
<button className="btn danger" onClick={onCancel}>Paket abbrechen</button>
|
||||
</header>
|
||||
<div className="progress">
|
||||
<div style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</header>
|
||||
<div className="progress"><div style={{ width: `${progress}%` }} /></div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<thead><tr>
|
||||
<th className="col-file">Datei</th>
|
||||
<th className="col-provider">Provider</th>
|
||||
<th className="col-status">Status</th>
|
||||
<th className="col-progress">Fortschritt</th>
|
||||
<th className="col-speed">Speed</th>
|
||||
<th className="col-retries">Retries</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<th className="col-actions">Aktion</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
@ -575,6 +699,7 @@ function PackageCard({ pkg, items, onCancel }: { pkg: PackageEntry; items: Downl
|
||||
<td className="col-progress num">{item.progressPercent}%</td>
|
||||
<td className="col-speed num">{formatSpeedMbps(item.speedBps)}</td>
|
||||
<td className="col-retries num">{item.retries}</td>
|
||||
<td className="col-actions"><button className="btn-icon danger" onClick={() => onRemoveItem(item.id)} title="Entfernen">X</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: "Manrope", "Segoe UI Variable", "Segoe UI", sans-serif;
|
||||
--bg: #040912;
|
||||
--bg-glow: #10203b;
|
||||
--surface: #0b1424;
|
||||
--card: #101d31;
|
||||
--field: #081120;
|
||||
@ -10,6 +10,40 @@
|
||||
--muted: #90a4bf;
|
||||
--accent: #38bdf8;
|
||||
--danger: #f43f5e;
|
||||
--button-bg: #0d1a2c;
|
||||
--button-bg-hover: #12243d;
|
||||
--tab-bg: #0b1321;
|
||||
--tab-active: #14253e;
|
||||
--toast-bg: #0f1f33;
|
||||
--toast-border: #2a4f78;
|
||||
--progress-track: #0b1628;
|
||||
--overlay: rgba(8, 13, 23, 0.85);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #eef3fb;
|
||||
--bg-glow: #d7e5ff;
|
||||
--surface: #f7faff;
|
||||
--card: #ffffff;
|
||||
--field: #ffffff;
|
||||
--border: #c7d5ea;
|
||||
--text: #0f223d;
|
||||
--muted: #4e6482;
|
||||
--accent: #1168d9;
|
||||
--danger: #c0392b;
|
||||
--button-bg: #f3f7ff;
|
||||
--button-bg-hover: #e6efff;
|
||||
--tab-bg: #edf3ff;
|
||||
--tab-active: #dce8ff;
|
||||
--toast-bg: #ffffff;
|
||||
--toast-border: #a9c1e8;
|
||||
--progress-track: #dfe8f8;
|
||||
--overlay: rgba(229, 238, 252, 0.88);
|
||||
}
|
||||
|
||||
* {
|
||||
@ -22,7 +56,7 @@ body,
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(circle at 15% 10%, #10203b 0, #050b15 45%, #040912 100%);
|
||||
background: radial-gradient(circle at 15% 10%, var(--bg-glow) 0, var(--surface) 45%, var(--bg) 100%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@ -63,7 +97,7 @@ body,
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: linear-gradient(180deg, rgba(20, 34, 56, 0.95), rgba(9, 16, 28, 0.95));
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--surface) 92%, transparent), color-mix(in srgb, var(--bg) 92%, transparent));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
@ -80,7 +114,7 @@ body,
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: #0d1a2c;
|
||||
background: var(--button-bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9px;
|
||||
@ -99,7 +133,7 @@ body,
|
||||
.btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--accent);
|
||||
background: #12243d;
|
||||
background: var(--button-bg-hover);
|
||||
}
|
||||
|
||||
.btn.accent {
|
||||
@ -113,13 +147,23 @@ body,
|
||||
color: #fda4af;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .btn.danger {
|
||||
border-color: color-mix(in srgb, var(--danger) 60%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.btn.btn-active {
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
background: #0b1321;
|
||||
background: var(--tab-bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
border-radius: 9px;
|
||||
@ -131,8 +175,8 @@ body,
|
||||
|
||||
.tab.active {
|
||||
color: var(--text);
|
||||
background: #14253e;
|
||||
border-color: #2c4e77;
|
||||
background: var(--tab-active);
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
@ -149,7 +193,7 @@ body,
|
||||
.card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, rgba(17, 29, 49, 0.95), rgba(9, 16, 28, 0.95));
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--card) 96%, transparent), color-mix(in srgb, var(--surface) 96%, transparent));
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -212,6 +256,82 @@ body,
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.collector-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.collector-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.collector-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9px;
|
||||
overflow: hidden;
|
||||
background: var(--tab-bg);
|
||||
}
|
||||
|
||||
.collector-tab > button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.collector-tab.active > button {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.collector-tab .close-tab {
|
||||
border: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
width: 26px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.collector-tab .close-tab:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.add-tab {
|
||||
min-width: 32px;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.reconnect-badge {
|
||||
color: color-mix(in srgb, var(--danger) 70%, var(--text));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reconnect-banner {
|
||||
border: 1px solid color-mix(in srgb, var(--danger) 45%, var(--border));
|
||||
background: color-mix(in srgb, var(--danger) 12%, var(--surface));
|
||||
color: var(--text);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-shell {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
@ -246,6 +366,8 @@ body,
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
align-content: start;
|
||||
@ -286,10 +408,22 @@ body,
|
||||
.package-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, rgba(16, 29, 48, 0.95), rgba(7, 13, 22, 0.95));
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--card) 96%, transparent), color-mix(in srgb, var(--surface) 96%, transparent));
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.package-card[draggable="true"] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.package-card[draggable="true"]:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.disabled-pkg {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.package-card header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@ -307,11 +441,43 @@ body,
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pkg-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pkg-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pkg-name-row input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.rename-input {
|
||||
min-width: 220px;
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
.pkg-speed {
|
||||
margin-left: 7px;
|
||||
color: color-mix(in srgb, var(--accent) 70%, var(--text));
|
||||
}
|
||||
|
||||
.pkg-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.progress {
|
||||
margin-top: 8px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: #0b1628;
|
||||
background: var(--progress-track);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@ -379,6 +545,39 @@ td {
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
}
|
||||
|
||||
.schedule-row {
|
||||
display: grid;
|
||||
grid-template-columns: 56px auto 56px auto 92px auto auto auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.schedule-row input[type="number"] {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.schedule-row .btn {
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--button-bg);
|
||||
color: var(--text);
|
||||
border-radius: 7px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-icon.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.col-actions {
|
||||
width: 8%;
|
||||
}
|
||||
|
||||
.empty {
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 12px;
|
||||
@ -391,20 +590,39 @@ td {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 18px;
|
||||
background: #0f1f33;
|
||||
background: var(--toast-bg);
|
||||
color: var(--text);
|
||||
border: 1px solid #2a4f78;
|
||||
border: 1px solid var(--toast-border);
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.drop-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--overlay);
|
||||
border: 2px dashed color-mix(in srgb, var(--accent) 72%, transparent);
|
||||
color: var(--text);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.control-strip {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.settings-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
@ -424,6 +642,24 @@ td {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.schedule-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.schedule-row span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.package-card header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.pkg-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.card.wide,
|
||||
.settings-actions {
|
||||
grid-column: span 1;
|
||||
|
||||
@ -12,7 +12,15 @@ export const IPC_CHANNELS = {
|
||||
STOP: "queue:stop",
|
||||
TOGGLE_PAUSE: "queue:toggle-pause",
|
||||
CANCEL_PACKAGE: "queue:cancel-package",
|
||||
RENAME_PACKAGE: "queue:rename-package",
|
||||
REORDER_PACKAGES: "queue:reorder-packages",
|
||||
REMOVE_ITEM: "queue:remove-item",
|
||||
TOGGLE_PACKAGE: "queue:toggle-package",
|
||||
EXPORT_QUEUE: "queue:export",
|
||||
IMPORT_QUEUE: "queue:import",
|
||||
PICK_FOLDER: "dialog:pick-folder",
|
||||
PICK_CONTAINERS: "dialog:pick-containers",
|
||||
STATE_UPDATE: "state:update"
|
||||
STATE_UPDATE: "state:update",
|
||||
CLIPBOARD_DETECTED: "clipboard:detected",
|
||||
TOGGLE_CLIPBOARD: "clipboard:toggle"
|
||||
} as const;
|
||||
|
||||
@ -14,7 +14,15 @@ export interface ElectronApi {
|
||||
stop: () => Promise<void>;
|
||||
togglePause: () => Promise<boolean>;
|
||||
cancelPackage: (packageId: string) => Promise<void>;
|
||||
renamePackage: (packageId: string, newName: string) => Promise<void>;
|
||||
reorderPackages: (packageIds: string[]) => Promise<void>;
|
||||
removeItem: (itemId: string) => Promise<void>;
|
||||
togglePackage: (packageId: string) => Promise<void>;
|
||||
exportQueue: () => Promise<string>;
|
||||
importQueue: (json: string) => Promise<{ addedPackages: number; addedLinks: number }>;
|
||||
toggleClipboard: () => Promise<boolean>;
|
||||
pickFolder: () => Promise<string | null>;
|
||||
pickContainers: () => Promise<string[]>;
|
||||
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
|
||||
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
|
||||
}
|
||||
|
||||
@ -16,6 +16,21 @@ export type SpeedMode = "global" | "per_download";
|
||||
export type FinishedCleanupPolicy = "never" | "immediate" | "on_start" | "package_done";
|
||||
export type DebridProvider = "realdebrid" | "megadebrid" | "bestdebrid" | "alldebrid";
|
||||
export type DebridFallbackProvider = DebridProvider | "none";
|
||||
export type AppTheme = "dark" | "light";
|
||||
|
||||
export interface BandwidthScheduleEntry {
|
||||
startHour: number;
|
||||
endHour: number;
|
||||
speedLimitKbps: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface DownloadStats {
|
||||
totalDownloaded: number;
|
||||
totalFiles: number;
|
||||
totalPackages: number;
|
||||
sessionStartedAt: number;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
token: string;
|
||||
@ -49,6 +64,10 @@ export interface AppSettings {
|
||||
speedLimitMode: SpeedMode;
|
||||
updateRepo: string;
|
||||
autoUpdateCheck: boolean;
|
||||
clipboardWatch: boolean;
|
||||
minimizeToTray: boolean;
|
||||
theme: AppTheme;
|
||||
bandwidthSchedules: BandwidthScheduleEntry[];
|
||||
}
|
||||
|
||||
export interface DownloadItem {
|
||||
@ -80,6 +99,7 @@ export interface PackageEntry {
|
||||
status: DownloadStatus;
|
||||
itemIds: string[];
|
||||
cancelled: boolean;
|
||||
enabled: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
@ -123,11 +143,14 @@ export interface UiSnapshot {
|
||||
settings: AppSettings;
|
||||
session: SessionState;
|
||||
summary: DownloadSummary | null;
|
||||
stats: DownloadStats;
|
||||
speedText: string;
|
||||
etaText: string;
|
||||
canStart: boolean;
|
||||
canStop: boolean;
|
||||
canPause: boolean;
|
||||
clipboardActive: boolean;
|
||||
reconnectSeconds: number;
|
||||
}
|
||||
|
||||
export interface AddLinksPayload {
|
||||
|
||||
@ -250,4 +250,41 @@ describe("debrid service", () => {
|
||||
await expect(service.unrestrictLink("https://rapidgator.net/file/example.part6.rar.html")).rejects.toThrow();
|
||||
expect(megaWeb).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("resolves rapidgator filename from page when provider returns hash", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
providerPrimary: "realdebrid" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: true
|
||||
};
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
|
||||
return new Response(JSON.stringify({
|
||||
download: "https://cdn.example/file.bin",
|
||||
filename: "6f09df2984fe01378537c7cd8d7fa7ce",
|
||||
filesize: 2048
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
if (url.includes("rapidgator.net/file/6f09df2984fe01378537c7cd8d7fa7ce")) {
|
||||
return new Response("<html><head><title>download file Banshee.S04E01.German.DL.720p.part01.rar - Rapidgator</title></head></html>", {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html" }
|
||||
});
|
||||
}
|
||||
return new Response("not-found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const service = new DebridService(settings);
|
||||
const result = await service.unrestrictLink("https://rapidgator.net/file/6f09df2984fe01378537c7cd8d7fa7ce");
|
||||
expect(result.provider).toBe("realdebrid");
|
||||
expect(result.fileName).toBe("Banshee.S04E01.German.DL.720p.part01.rar");
|
||||
});
|
||||
});
|
||||
|
||||
@ -204,6 +204,79 @@ describe("download manager", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("uses content-disposition filename when provider filename is opaque", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
const binary = Buffer.alloc(96 * 1024, 13);
|
||||
const expectedName = "Banshee.S04E01.German.DL.720p.part01.rar";
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if ((req.url || "") !== "/content-name") {
|
||||
res.statusCode = 404;
|
||||
res.end("not-found");
|
||||
return;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Accept-Ranges", "bytes");
|
||||
res.setHeader("Content-Length", String(binary.length));
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${expectedName}"`);
|
||||
res.end(binary);
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("server address unavailable");
|
||||
}
|
||||
const directUrl = `http://127.0.0.1:${address.port}/content-name`;
|
||||
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("/unrestrict/link")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
download: directUrl,
|
||||
filename: "6f09df2984fe01378537c7cd8d7fa7ce",
|
||||
filesize: binary.length
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
}
|
||||
);
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
|
||||
try {
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
autoExtract: false
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
manager.addPackages([{ name: "content-name", links: ["https://rapidgator.net/file/6f09df2984fe01378537c7cd8d7fa7ce"] }]);
|
||||
manager.start();
|
||||
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
||||
|
||||
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||
expect(item?.status).toBe("completed");
|
||||
expect(item?.fileName).toBe(expectedName);
|
||||
expect(path.basename(item?.targetPath || "")).toBe(expectedName);
|
||||
} finally {
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses stored partial target path when queued item resumes", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
@ -281,6 +354,7 @@ describe("download manager", () => {
|
||||
status: "queued",
|
||||
itemIds: [itemId],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
@ -412,6 +486,7 @@ describe("download manager", () => {
|
||||
status: "queued",
|
||||
itemIds: [itemId],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
@ -484,6 +559,7 @@ describe("download manager", () => {
|
||||
status: "reconnect_wait",
|
||||
itemIds: [itemId],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
@ -650,6 +726,7 @@ describe("download manager", () => {
|
||||
status: "completed",
|
||||
itemIds: [oldItemId],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt: oldNow,
|
||||
updatedAt: oldNow
|
||||
};
|
||||
@ -1025,6 +1102,175 @@ describe("download manager", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("finishes run when remaining packages are disabled", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
const binary = Buffer.alloc(96 * 1024, 8);
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if ((req.url || "") !== "/enabled") {
|
||||
res.statusCode = 404;
|
||||
res.end("not-found");
|
||||
return;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Accept-Ranges", "bytes");
|
||||
res.setHeader("Content-Length", String(binary.length));
|
||||
res.end(binary);
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("server address unavailable");
|
||||
}
|
||||
const directUrl = `http://127.0.0.1:${address.port}/enabled`;
|
||||
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("/unrestrict/link")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
download: directUrl,
|
||||
filename: "enabled.bin",
|
||||
filesize: binary.length
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
}
|
||||
);
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
|
||||
try {
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
autoExtract: false,
|
||||
maxParallel: 1
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
manager.addPackages([
|
||||
{ name: "enabled", links: ["https://dummy/enabled"] },
|
||||
{ name: "disabled", links: ["https://dummy/disabled"] }
|
||||
]);
|
||||
const initial = manager.getSnapshot();
|
||||
const enabledPkgId = initial.session.packageOrder[0];
|
||||
const disabledPkgId = initial.session.packageOrder[1];
|
||||
const enabledItemId = initial.session.packages[enabledPkgId]?.itemIds[0] || "";
|
||||
const disabledItemId = initial.session.packages[disabledPkgId]?.itemIds[0] || "";
|
||||
|
||||
manager.togglePackage(disabledPkgId);
|
||||
manager.start();
|
||||
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
||||
|
||||
const snapshot = manager.getSnapshot();
|
||||
expect(snapshot.session.packages[disabledPkgId]?.enabled).toBe(false);
|
||||
expect(snapshot.session.items[enabledItemId]?.status).toBe("completed");
|
||||
expect(snapshot.session.items[disabledItemId]?.status).toBe("queued");
|
||||
} finally {
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
}
|
||||
});
|
||||
|
||||
it("stops active package and keeps items queued", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
const binary = Buffer.alloc(320 * 1024, 15);
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if ((req.url || "") !== "/toggle") {
|
||||
res.statusCode = 404;
|
||||
res.end("not-found");
|
||||
return;
|
||||
}
|
||||
const half = Math.floor(binary.length / 2);
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Accept-Ranges", "bytes");
|
||||
res.setHeader("Content-Length", String(binary.length));
|
||||
res.write(binary.subarray(0, half));
|
||||
setTimeout(() => {
|
||||
res.end(binary.subarray(half));
|
||||
}, 1200);
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("server address unavailable");
|
||||
}
|
||||
const directUrl = `http://127.0.0.1:${address.port}/toggle`;
|
||||
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("/unrestrict/link")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
download: directUrl,
|
||||
filename: "toggle.bin",
|
||||
filesize: binary.length
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
}
|
||||
);
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
|
||||
try {
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
autoExtract: false,
|
||||
maxParallel: 1
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
manager.addPackages([{ name: "toggle", links: ["https://dummy/toggle"] }]);
|
||||
const initial = manager.getSnapshot();
|
||||
const pkgId = initial.session.packageOrder[0];
|
||||
const itemId = initial.session.packages[pkgId]?.itemIds[0] || "";
|
||||
|
||||
manager.start();
|
||||
await waitFor(() => {
|
||||
const item = manager.getSnapshot().session.items[itemId];
|
||||
return item?.status === "downloading";
|
||||
}, 12000);
|
||||
|
||||
manager.togglePackage(pkgId);
|
||||
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
||||
|
||||
const snapshot = manager.getSnapshot();
|
||||
const item = snapshot.session.items[itemId];
|
||||
expect(snapshot.session.packages[pkgId]?.enabled).toBe(false);
|
||||
expect(item?.status).toBe("queued");
|
||||
expect(item?.fullStatus).toBe("Paket gestoppt");
|
||||
} finally {
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows stable ETA while paused", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
@ -1129,6 +1375,7 @@ describe("download manager", () => {
|
||||
status: "downloading",
|
||||
itemIds: [itemId],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user