Implement full UX upgrade and Rapidgator filename hardening in v1.3.2
This commit is contained in:
@@ -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: []
|
||||
};
|
||||
}
|
||||
|
||||
+110
-25
@@ -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 "";
|
||||
}
|
||||
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"
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
return "";
|
||||
}
|
||||
const html = await response.text();
|
||||
const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
|
||||
const title = decodeHtmlEntities((titleMatch?.[1] || "").trim());
|
||||
if (!title) {
|
||||
return "";
|
||||
}
|
||||
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 "";
|
||||
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",
|
||||
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 fromHtml = extractRapidgatorFilenameFromHtml(html);
|
||||
if (fromHtml) {
|
||||
return fromHtml;
|
||||
}
|
||||
} catch {
|
||||
// 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]
|
||||
};
|
||||
|
||||
+287
-13
@@ -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") {
|
||||
|
||||
+116
-2
@@ -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) {
|
||||
|
||||
+28
-2
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user