Release v1.4.29 with downloader and API safety hardening
Some checks are pending
Build and Release / build (push) Waiting to run
Some checks are pending
Build and Release / build (push) Waiting to run
This commit is contained in:
parent
84d8f37ba6
commit
eda9754d30
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.4.28",
|
"version": "1.4.29",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.4.28",
|
"version": "1.4.29",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.4.28",
|
"version": "1.4.29",
|
||||||
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@ -5,7 +5,8 @@ import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
|
|||||||
import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils";
|
import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils";
|
||||||
|
|
||||||
const API_TIMEOUT_MS = 30000;
|
const API_TIMEOUT_MS = 30000;
|
||||||
const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.28";
|
const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.29";
|
||||||
|
const RAPIDGATOR_SCAN_MAX_BYTES = 512 * 1024;
|
||||||
|
|
||||||
const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1";
|
const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1";
|
||||||
const ALL_DEBRID_API_BASE = "https://api.alldebrid.com/v4";
|
const ALL_DEBRID_API_BASE = "https://api.alldebrid.com/v4";
|
||||||
@ -28,6 +29,13 @@ interface DebridServiceOptions {
|
|||||||
megaWebUnrestrict?: MegaWebUnrestrictor;
|
megaWebUnrestrict?: MegaWebUnrestrictor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cloneSettings(settings: AppSettings): AppSettings {
|
||||||
|
return {
|
||||||
|
...settings,
|
||||||
|
bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry }))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
type BestDebridRequest = {
|
type BestDebridRequest = {
|
||||||
url: string;
|
url: string;
|
||||||
useAuthHeader: boolean;
|
useAuthHeader: boolean;
|
||||||
@ -50,6 +58,33 @@ function retryDelay(attempt: number): number {
|
|||||||
return Math.min(5000, 400 * 2 ** attempt);
|
return Math.min(5000, 400 * 2 ** attempt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseRetryAfterMs(value: string | null): number {
|
||||||
|
const text = String(value || "").trim();
|
||||||
|
if (!text) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asSeconds = Number(text);
|
||||||
|
if (Number.isFinite(asSeconds) && asSeconds >= 0) {
|
||||||
|
return Math.min(120000, Math.floor(asSeconds * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
const asDate = Date.parse(text);
|
||||||
|
if (Number.isFinite(asDate)) {
|
||||||
|
return Math.min(120000, Math.max(0, asDate - Date.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryDelayForResponse(response: Response, attempt: number): number {
|
||||||
|
if (response.status !== 429) {
|
||||||
|
return retryDelay(attempt);
|
||||||
|
}
|
||||||
|
const fromHeader = parseRetryAfterMs(response.headers.get("retry-after"));
|
||||||
|
return fromHeader > 0 ? fromHeader : retryDelay(attempt);
|
||||||
|
}
|
||||||
|
|
||||||
function readHttpStatusFromErrorText(text: string): number {
|
function readHttpStatusFromErrorText(text: string): number {
|
||||||
const match = String(text || "").match(/HTTP\s+(\d{3})/i);
|
const match = String(text || "").match(/HTTP\s+(\d{3})/i);
|
||||||
return match ? Number(match[1]) : 0;
|
return match ? Number(match[1]) : 0;
|
||||||
@ -226,7 +261,7 @@ export function normalizeResolvedFilename(value: string): string {
|
|||||||
.replace(/^download\s+file\s+/i, "")
|
.replace(/^download\s+file\s+/i, "")
|
||||||
.replace(/\s*[-|]\s*rapidgator.*$/i, "")
|
.replace(/\s*[-|]\s*rapidgator.*$/i, "")
|
||||||
.trim();
|
.trim();
|
||||||
if (!candidate || !looksLikeFileName(candidate) || looksLikeOpaqueFilename(candidate)) {
|
if (!candidate || candidate.length > 260 || !looksLikeFileName(candidate) || looksLikeOpaqueFilename(candidate)) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
return candidate;
|
return candidate;
|
||||||
@ -253,10 +288,9 @@ export function extractRapidgatorFilenameFromHtml(html: string): string {
|
|||||||
const patterns = [
|
const patterns = [
|
||||||
/<meta[^>]+(?:property=["']og:title["'][^>]+content=["']([^"']+)["']|content=["']([^"']+)["'][^>]+property=["']og:title["'])/i,
|
/<meta[^>]+(?:property=["']og:title["'][^>]+content=["']([^"']+)["']|content=["']([^"']+)["'][^>]+property=["']og:title["'])/i,
|
||||||
/<meta[^>]+(?:name=["']title["'][^>]+content=["']([^"']+)["']|content=["']([^"']+)["'][^>]+name=["']title["'])/i,
|
/<meta[^>]+(?:name=["']title["'][^>]+content=["']([^"']+)["']|content=["']([^"']+)["'][^>]+name=["']title["'])/i,
|
||||||
/<title>([^<]+)<\/title>/i,
|
/<title>([^<]{1,260})<\/title>/i,
|
||||||
/(?:Dateiname|File\s*name)\s*[:\-]\s*<[^>]*>\s*([^<]+)\s*</i,
|
/(?:Dateiname|File\s*name)\s*[:\-]\s*<[^>]*>\s*([^<]{1,260})\s*</i,
|
||||||
/(?:Dateiname|File\s*name)\s*[:\-]\s*([^<\r\n]+)/i,
|
/(?:Dateiname|File\s*name)\s*[:\-]\s*([^<\r\n]{1,260})/i
|
||||||
/download\s+file\s+([^<\r\n]+)/i
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
@ -314,6 +348,48 @@ function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number):
|
|||||||
return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
|
return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readResponseTextLimited(response: Response, maxBytes: number, signal?: AbortSignal): Promise<string> {
|
||||||
|
const body = response.body;
|
||||||
|
if (!body) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = body.getReader();
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let readBytes = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (readBytes < maxBytes) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new Error("aborted:debrid");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done || !value || value.byteLength === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = maxBytes - readBytes;
|
||||||
|
const slice = value.byteLength > remaining ? value.subarray(0, remaining) : value;
|
||||||
|
chunks.push(Buffer.from(slice));
|
||||||
|
readBytes += slice.byteLength;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await reader.cancel();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
reader.releaseLock();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.concat(chunks).toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Promise<string> {
|
async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Promise<string> {
|
||||||
if (!isRapidgatorLink(link)) {
|
if (!isRapidgatorLink(link)) {
|
||||||
return "";
|
return "";
|
||||||
@ -340,13 +416,14 @@ async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Pr
|
|||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES + 2) {
|
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES + 2) {
|
||||||
await sleepWithSignal(retryDelay(attempt), signal);
|
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
|
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
|
||||||
|
const contentLength = Number(response.headers.get("content-length") || NaN);
|
||||||
if (contentType
|
if (contentType
|
||||||
&& !contentType.includes("text/html")
|
&& !contentType.includes("text/html")
|
||||||
&& !contentType.includes("application/xhtml")
|
&& !contentType.includes("application/xhtml")
|
||||||
@ -355,8 +432,11 @@ async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Pr
|
|||||||
&& !contentType.includes("application/xml")) {
|
&& !contentType.includes("application/xml")) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
if (!contentType && Number.isFinite(contentLength) && contentLength > RAPIDGATOR_SCAN_MAX_BYTES) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
const html = await response.text();
|
const html = await readResponseTextLimited(response, RAPIDGATOR_SCAN_MAX_BYTES, signal);
|
||||||
const fromHtml = extractRapidgatorFilenameFromHtml(html);
|
const fromHtml = extractRapidgatorFilenameFromHtml(html);
|
||||||
if (fromHtml) {
|
if (fromHtml) {
|
||||||
return fromHtml;
|
return fromHtml;
|
||||||
@ -399,16 +479,22 @@ class MegaDebridClient {
|
|||||||
this.megaWebUnrestrict = megaWebUnrestrict;
|
this.megaWebUnrestrict = megaWebUnrestrict;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async unrestrictLink(link: string): Promise<UnrestrictedLink> {
|
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
|
||||||
if (!this.megaWebUnrestrict) {
|
if (!this.megaWebUnrestrict) {
|
||||||
throw new Error("Mega-Web-Fallback nicht verfügbar");
|
throw new Error("Mega-Web-Fallback nicht verfügbar");
|
||||||
}
|
}
|
||||||
let lastError = "";
|
let lastError = "";
|
||||||
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new Error("aborted:debrid");
|
||||||
|
}
|
||||||
const web = await this.megaWebUnrestrict(link).catch((error) => {
|
const web = await this.megaWebUnrestrict(link).catch((error) => {
|
||||||
lastError = compactErrorText(error);
|
lastError = compactErrorText(error);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new Error("aborted:debrid");
|
||||||
|
}
|
||||||
if (web?.directUrl) {
|
if (web?.directUrl) {
|
||||||
web.retriesUsed = attempt - 1;
|
web.retriesUsed = attempt - 1;
|
||||||
return web;
|
return web;
|
||||||
@ -420,7 +506,7 @@ class MegaDebridClient {
|
|||||||
lastError = web ? "Mega-Web Antwort ohne Download-Link" : "Mega-Web Antwort leer";
|
lastError = web ? "Mega-Web Antwort ohne Download-Link" : "Mega-Web Antwort leer";
|
||||||
}
|
}
|
||||||
if (attempt < REQUEST_RETRIES) {
|
if (attempt < REQUEST_RETRIES) {
|
||||||
await sleep(retryDelay(attempt));
|
await sleepWithSignal(retryDelay(attempt), signal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error(lastError || "Mega-Web Unrestrict fehlgeschlagen");
|
throw new Error(lastError || "Mega-Web Unrestrict fehlgeschlagen");
|
||||||
@ -434,13 +520,13 @@ class BestDebridClient {
|
|||||||
this.token = token;
|
this.token = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async unrestrictLink(link: string): Promise<UnrestrictedLink> {
|
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
|
||||||
const requests = buildBestDebridRequests(link, this.token);
|
const requests = buildBestDebridRequests(link, this.token);
|
||||||
let lastError = "";
|
let lastError = "";
|
||||||
|
|
||||||
for (const request of requests) {
|
for (const request of requests) {
|
||||||
try {
|
try {
|
||||||
return await this.tryRequest(request, link);
|
return await this.tryRequest(request, link, signal);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = compactErrorText(error);
|
lastError = compactErrorText(error);
|
||||||
}
|
}
|
||||||
@ -449,7 +535,7 @@ class BestDebridClient {
|
|||||||
throw new Error(lastError || "BestDebrid Unrestrict fehlgeschlagen");
|
throw new Error(lastError || "BestDebrid Unrestrict fehlgeschlagen");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async tryRequest(request: BestDebridRequest, originalLink: string): Promise<UnrestrictedLink> {
|
private async tryRequest(request: BestDebridRequest, originalLink: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
|
||||||
let lastError = "";
|
let lastError = "";
|
||||||
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
@ -463,7 +549,7 @@ class BestDebridClient {
|
|||||||
const response = await fetch(request.url, {
|
const response = await fetch(request.url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers,
|
headers,
|
||||||
signal: AbortSignal.timeout(API_TIMEOUT_MS)
|
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
|
||||||
});
|
});
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
const parsed = parseJson(text);
|
const parsed = parseJson(text);
|
||||||
@ -472,7 +558,7 @@ class BestDebridClient {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const reason = parseError(response.status, text, payload);
|
const reason = parseError(response.status, text, payload);
|
||||||
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
|
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
|
||||||
await sleep(retryDelay(attempt));
|
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
throw new Error(reason);
|
throw new Error(reason);
|
||||||
@ -480,13 +566,14 @@ class BestDebridClient {
|
|||||||
|
|
||||||
const directUrl = pickString(payload, ["download", "debridLink", "link"]);
|
const directUrl = pickString(payload, ["download", "debridLink", "link"]);
|
||||||
if (directUrl) {
|
if (directUrl) {
|
||||||
|
let parsedDirect: URL;
|
||||||
try {
|
try {
|
||||||
const parsedDirect = new URL(directUrl);
|
parsedDirect = new URL(directUrl);
|
||||||
if (parsedDirect.protocol !== "https:" && parsedDirect.protocol !== "http:") {
|
|
||||||
throw new Error("invalid_protocol");
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("BestDebrid Antwort enthält ungültige Download-URL");
|
throw new Error("BestDebrid Antwort enthält keine gültige Download-URL");
|
||||||
|
}
|
||||||
|
if (parsedDirect.protocol !== "https:" && parsedDirect.protocol !== "http:") {
|
||||||
|
throw new Error(`BestDebrid Antwort enthält ungültiges Download-URL-Protokoll (${parsedDirect.protocol})`);
|
||||||
}
|
}
|
||||||
const fileName = pickString(payload, ["filename", "fileName"]) || filenameFromUrl(originalLink);
|
const fileName = pickString(payload, ["filename", "fileName"]) || filenameFromUrl(originalLink);
|
||||||
const fileSize = pickNumber(payload, ["filesize", "size", "bytes"]);
|
const fileSize = pickNumber(payload, ["filesize", "size", "bytes"]);
|
||||||
@ -506,10 +593,13 @@ class BestDebridClient {
|
|||||||
throw new Error("BestDebrid Antwort ohne Download-Link");
|
throw new Error("BestDebrid Antwort ohne Download-Link");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = compactErrorText(error);
|
lastError = compactErrorText(error);
|
||||||
|
if (signal?.aborted || /aborted/i.test(lastError)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
|
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
await sleep(retryDelay(attempt));
|
await sleepWithSignal(retryDelay(attempt), signal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error(String(lastError || "BestDebrid Request fehlgeschlagen").replace(/^Error:\s*/i, ""));
|
throw new Error(String(lastError || "BestDebrid Request fehlgeschlagen").replace(/^Error:\s*/i, ""));
|
||||||
@ -523,7 +613,7 @@ class AllDebridClient {
|
|||||||
this.token = token;
|
this.token = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getLinkInfos(links: string[]): Promise<Map<string, string>> {
|
public async getLinkInfos(links: string[], signal?: AbortSignal): Promise<Map<string, string>> {
|
||||||
const result = new Map<string, string>();
|
const result = new Map<string, string>();
|
||||||
const canonicalToInput = new Map<string, string>();
|
const canonicalToInput = new Map<string, string>();
|
||||||
const uniqueLinks: string[] = [];
|
const uniqueLinks: string[] = [];
|
||||||
@ -542,6 +632,9 @@ class AllDebridClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (let index = 0; index < uniqueLinks.length; index += 32) {
|
for (let index = 0; index < uniqueLinks.length; index += 32) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new Error("aborted:debrid");
|
||||||
|
}
|
||||||
const chunk = uniqueLinks.slice(index, index + 32);
|
const chunk = uniqueLinks.slice(index, index + 32);
|
||||||
const body = new URLSearchParams();
|
const body = new URLSearchParams();
|
||||||
for (const link of chunk) {
|
for (const link of chunk) {
|
||||||
@ -562,7 +655,7 @@ class AllDebridClient {
|
|||||||
"User-Agent": DEBRID_USER_AGENT
|
"User-Agent": DEBRID_USER_AGENT
|
||||||
},
|
},
|
||||||
body,
|
body,
|
||||||
signal: AbortSignal.timeout(API_TIMEOUT_MS)
|
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
|
||||||
});
|
});
|
||||||
|
|
||||||
text = await response.text();
|
text = await response.text();
|
||||||
@ -570,7 +663,7 @@ class AllDebridClient {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const reason = parseError(response.status, text, payload);
|
const reason = parseError(response.status, text, payload);
|
||||||
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
|
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
|
||||||
await sleep(retryDelay(attempt));
|
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
throw new Error(reason);
|
throw new Error(reason);
|
||||||
@ -594,10 +687,13 @@ class AllDebridClient {
|
|||||||
break;
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorText = compactErrorText(error);
|
const errorText = compactErrorText(error);
|
||||||
|
if (signal?.aborted || /aborted/i.test(errorText)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(errorText)) {
|
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(errorText)) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
await sleep(retryDelay(attempt));
|
await sleepWithSignal(retryDelay(attempt), signal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -607,6 +703,11 @@ class AllDebridClient {
|
|||||||
|
|
||||||
const data = asRecord(payload?.data);
|
const data = asRecord(payload?.data);
|
||||||
const infos = Array.isArray(data?.infos) ? data.infos : [];
|
const infos = Array.isArray(data?.infos) ? data.infos : [];
|
||||||
|
const hasAnyLinkedInfo = infos.some((entry) => {
|
||||||
|
const info = asRecord(entry);
|
||||||
|
return Boolean(pickString(info, ["link"]));
|
||||||
|
});
|
||||||
|
const allowPositionalFallback = infos.length === chunk.length && !hasAnyLinkedInfo;
|
||||||
for (let i = 0; i < infos.length; i += 1) {
|
for (let i = 0; i < infos.length; i += 1) {
|
||||||
const info = asRecord(infos[i]);
|
const info = asRecord(infos[i]);
|
||||||
if (!info) {
|
if (!info) {
|
||||||
@ -621,7 +722,9 @@ class AllDebridClient {
|
|||||||
const byResponse = canonicalToInput.get(canonicalLink(responseLink));
|
const byResponse = canonicalToInput.get(canonicalLink(responseLink));
|
||||||
const byIndex = chunk.length === 1
|
const byIndex = chunk.length === 1
|
||||||
? chunk[0]
|
? chunk[0]
|
||||||
: "";
|
: allowPositionalFallback
|
||||||
|
? chunk[i]
|
||||||
|
: "";
|
||||||
const original = byResponse || byIndex;
|
const original = byResponse || byIndex;
|
||||||
if (!original) {
|
if (!original) {
|
||||||
continue;
|
continue;
|
||||||
@ -633,7 +736,7 @@ class AllDebridClient {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async unrestrictLink(link: string): Promise<UnrestrictedLink> {
|
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
|
||||||
let lastError = "";
|
let lastError = "";
|
||||||
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
@ -645,7 +748,7 @@ class AllDebridClient {
|
|||||||
"User-Agent": DEBRID_USER_AGENT
|
"User-Agent": DEBRID_USER_AGENT
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({ link }),
|
body: new URLSearchParams({ link }),
|
||||||
signal: AbortSignal.timeout(API_TIMEOUT_MS)
|
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
|
||||||
});
|
});
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
const payload = asRecord(parseJson(text));
|
const payload = asRecord(parseJson(text));
|
||||||
@ -653,7 +756,7 @@ class AllDebridClient {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const reason = parseError(response.status, text, payload);
|
const reason = parseError(response.status, text, payload);
|
||||||
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
|
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
|
||||||
await sleep(retryDelay(attempt));
|
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
throw new Error(reason);
|
throw new Error(reason);
|
||||||
@ -687,10 +790,13 @@ class AllDebridClient {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = compactErrorText(error);
|
lastError = compactErrorText(error);
|
||||||
|
if (signal?.aborted || /aborted/i.test(lastError)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
|
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
await sleep(retryDelay(attempt));
|
await sleepWithSignal(retryDelay(attempt), signal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -704,12 +810,12 @@ export class DebridService {
|
|||||||
private options: DebridServiceOptions;
|
private options: DebridServiceOptions;
|
||||||
|
|
||||||
public constructor(settings: AppSettings, options: DebridServiceOptions = {}) {
|
public constructor(settings: AppSettings, options: DebridServiceOptions = {}) {
|
||||||
this.settings = settings;
|
this.settings = cloneSettings(settings);
|
||||||
this.options = options;
|
this.options = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
public setSettings(next: AppSettings): void {
|
public setSettings(next: AppSettings): void {
|
||||||
this.settings = next;
|
this.settings = cloneSettings(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async resolveFilenames(
|
public async resolveFilenames(
|
||||||
@ -717,7 +823,7 @@ export class DebridService {
|
|||||||
onResolved?: (link: string, fileName: string) => void,
|
onResolved?: (link: string, fileName: string) => void,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<Map<string, string>> {
|
): Promise<Map<string, string>> {
|
||||||
const settings = { ...this.settings };
|
const settings = cloneSettings(this.settings);
|
||||||
const allDebridClient = new AllDebridClient(settings.allDebridToken);
|
const allDebridClient = new AllDebridClient(settings.allDebridToken);
|
||||||
const unresolved = links.filter((link) => looksLikeOpaqueFilename(filenameFromUrl(link)));
|
const unresolved = links.filter((link) => looksLikeOpaqueFilename(filenameFromUrl(link)));
|
||||||
if (unresolved.length === 0) {
|
if (unresolved.length === 0) {
|
||||||
@ -740,7 +846,7 @@ export class DebridService {
|
|||||||
const token = settings.allDebridToken.trim();
|
const token = settings.allDebridToken.trim();
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
const infos = await allDebridClient.getLinkInfos(unresolved);
|
const infos = await allDebridClient.getLinkInfos(unresolved, signal);
|
||||||
for (const [link, fileName] of infos.entries()) {
|
for (const [link, fileName] of infos.entries()) {
|
||||||
reportResolved(link, fileName);
|
reportResolved(link, fileName);
|
||||||
}
|
}
|
||||||
@ -755,21 +861,11 @@ export class DebridService {
|
|||||||
reportResolved(link, fromPage);
|
reportResolved(link, fromPage);
|
||||||
});
|
});
|
||||||
|
|
||||||
const stillUnresolved = unresolved.filter((link) => !clean.has(link) && !isRapidgatorLink(link));
|
|
||||||
await runWithConcurrency(stillUnresolved, 4, async (link) => {
|
|
||||||
try {
|
|
||||||
const unrestricted = await this.unrestrictLink(link, signal, settings);
|
|
||||||
reportResolved(link, unrestricted.fileName || "");
|
|
||||||
} catch {
|
|
||||||
// ignore final fallback errors
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return clean;
|
return clean;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings): Promise<ProviderUnrestrictedLink> {
|
public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings): Promise<ProviderUnrestrictedLink> {
|
||||||
const settings = settingsSnapshot ? { ...settingsSnapshot } : { ...this.settings };
|
const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings);
|
||||||
const order = toProviderOrder(
|
const order = toProviderOrder(
|
||||||
settings.providerPrimary,
|
settings.providerPrimary,
|
||||||
settings.providerSecondary,
|
settings.providerSecondary,
|
||||||
@ -855,11 +951,11 @@ export class DebridService {
|
|||||||
return new RealDebridClient(settings.token).unrestrictLink(link, signal);
|
return new RealDebridClient(settings.token).unrestrictLink(link, signal);
|
||||||
}
|
}
|
||||||
if (provider === "megadebrid") {
|
if (provider === "megadebrid") {
|
||||||
return new MegaDebridClient(this.options.megaWebUnrestrict).unrestrictLink(link);
|
return new MegaDebridClient(this.options.megaWebUnrestrict).unrestrictLink(link, signal);
|
||||||
}
|
}
|
||||||
if (provider === "alldebrid") {
|
if (provider === "alldebrid") {
|
||||||
return new AllDebridClient(settings.allDebridToken).unrestrictLink(link);
|
return new AllDebridClient(settings.allDebridToken).unrestrictLink(link, signal);
|
||||||
}
|
}
|
||||||
return new BestDebridClient(settings.bestToken).unrestrictLink(link);
|
return new BestDebridClient(settings.bestToken).unrestrictLink(link, signal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -153,7 +153,7 @@ function parseContentDispositionFilename(contentDisposition: string | null): str
|
|||||||
|
|
||||||
function isArchiveLikePath(filePath: string): boolean {
|
function isArchiveLikePath(filePath: string): boolean {
|
||||||
const lower = path.basename(filePath).toLowerCase();
|
const lower = path.basename(filePath).toLowerCase();
|
||||||
return /\.(?:part\d+\.rar|rar|r\d{2,3}|zip|z\d{2}|7z|7z\.\d{3})$/i.test(lower);
|
return /\.(?:part\d+\.rar|rar|r\d{2,3}|zip(?:\.\d+)?|z\d{1,3}|7z(?:\.\d+)?)$/i.test(lower);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFetchFailure(errorText: string): boolean {
|
function isFetchFailure(errorText: string): boolean {
|
||||||
@ -543,6 +543,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
delete this.session.items[itemId];
|
delete this.session.items[itemId];
|
||||||
this.itemCount = Math.max(0, this.itemCount - 1);
|
this.itemCount = Math.max(0, this.itemCount - 1);
|
||||||
this.retryAfterByItem.delete(itemId);
|
this.retryAfterByItem.delete(itemId);
|
||||||
|
this.dropItemContribution(itemId);
|
||||||
if (!hasActiveTask) {
|
if (!hasActiveTask) {
|
||||||
this.releaseTargetPath(itemId);
|
this.releaseTargetPath(itemId);
|
||||||
}
|
}
|
||||||
@ -742,7 +743,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
totalBytes: null,
|
totalBytes: null,
|
||||||
progressPercent: 0,
|
progressPercent: 0,
|
||||||
fileName,
|
fileName,
|
||||||
targetPath: path.join(outputDir, fileName),
|
targetPath: "",
|
||||||
resumable: true,
|
resumable: true,
|
||||||
attempts: 0,
|
attempts: 0,
|
||||||
lastError: "",
|
lastError: "",
|
||||||
@ -750,6 +751,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
createdAt: nowMs(),
|
createdAt: nowMs(),
|
||||||
updatedAt: nowMs()
|
updatedAt: nowMs()
|
||||||
};
|
};
|
||||||
|
this.assignItemTargetPath(item, path.join(outputDir, fileName));
|
||||||
packageEntry.itemIds.push(itemId);
|
packageEntry.itemIds.push(itemId);
|
||||||
this.session.items[itemId] = item;
|
this.session.items[itemId] = item;
|
||||||
this.itemCount += 1;
|
this.itemCount += 1;
|
||||||
@ -901,7 +903,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
item.lastError = "";
|
item.lastError = "";
|
||||||
item.fullStatus = "Wartet";
|
item.fullStatus = "Wartet";
|
||||||
item.updatedAt = nowMs();
|
item.updatedAt = nowMs();
|
||||||
item.targetPath = path.join(pkg.outputDir, sanitizeFilename(item.fileName || filenameFromUrl(item.url)));
|
this.assignItemTargetPath(item, path.join(pkg.outputDir, sanitizeFilename(item.fileName || filenameFromUrl(item.url))));
|
||||||
this.runOutcomes.delete(itemId);
|
this.runOutcomes.delete(itemId);
|
||||||
this.itemContributedBytes.delete(itemId);
|
this.itemContributedBytes.delete(itemId);
|
||||||
this.retryAfterByItem.delete(itemId);
|
this.retryAfterByItem.delete(itemId);
|
||||||
@ -974,7 +976,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
item.fileName = normalized;
|
item.fileName = normalized;
|
||||||
item.targetPath = path.join(this.session.packages[item.packageId]?.outputDir || this.settings.outputDir, normalized);
|
this.assignItemTargetPath(item, path.join(this.session.packages[item.packageId]?.outputDir || this.settings.outputDir, normalized));
|
||||||
item.updatedAt = nowMs();
|
item.updatedAt = nowMs();
|
||||||
changed = true;
|
changed = true;
|
||||||
changedForLink = true;
|
changedForLink = true;
|
||||||
@ -1551,11 +1553,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const hasPending = items.some((item) => (
|
const hasPending = items.some((item) => (
|
||||||
item.status === "queued"
|
item.status === "queued"
|
||||||
|| item.status === "reconnect_wait"
|
|| item.status === "reconnect_wait"
|
||||||
|| item.status === "validating"
|
|
||||||
|| item.status === "downloading"
|
|
||||||
|| item.status === "paused"
|
|
||||||
|| item.status === "extracting"
|
|
||||||
|| item.status === "integrity_check"
|
|
||||||
));
|
));
|
||||||
if (hasPending) {
|
if (hasPending) {
|
||||||
pkg.status = pkg.enabled ? "queued" : "paused";
|
pkg.status = pkg.enabled ? "queued" : "paused";
|
||||||
@ -1716,18 +1713,30 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.runOutcomes.set(itemId, status);
|
this.runOutcomes.set(itemId, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private dropItemContribution(itemId: string): void {
|
||||||
|
const contributed = this.itemContributedBytes.get(itemId) || 0;
|
||||||
|
if (contributed > 0) {
|
||||||
|
this.session.totalDownloadedBytes = Math.max(0, this.session.totalDownloadedBytes - contributed);
|
||||||
|
}
|
||||||
|
this.itemContributedBytes.delete(itemId);
|
||||||
|
}
|
||||||
|
|
||||||
private claimTargetPath(itemId: string, preferredPath: string, allowExistingFile = false): string {
|
private claimTargetPath(itemId: string, preferredPath: string, allowExistingFile = false): string {
|
||||||
|
const preferredKey = pathKey(preferredPath);
|
||||||
const existingClaim = this.claimedTargetPathByItem.get(itemId);
|
const existingClaim = this.claimedTargetPathByItem.get(itemId);
|
||||||
if (existingClaim) {
|
if (existingClaim) {
|
||||||
const owner = this.reservedTargetPaths.get(pathKey(existingClaim));
|
const existingKey = pathKey(existingClaim);
|
||||||
|
const owner = this.reservedTargetPaths.get(existingKey);
|
||||||
if (owner === itemId) {
|
if (owner === itemId) {
|
||||||
return existingClaim;
|
if (existingKey === preferredKey) {
|
||||||
|
return existingClaim;
|
||||||
|
}
|
||||||
|
this.reservedTargetPaths.delete(existingKey);
|
||||||
}
|
}
|
||||||
this.claimedTargetPathByItem.delete(itemId);
|
this.claimedTargetPathByItem.delete(itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = path.parse(preferredPath);
|
const parsed = path.parse(preferredPath);
|
||||||
const preferredKey = pathKey(preferredPath);
|
|
||||||
const maxIndex = 10000;
|
const maxIndex = 10000;
|
||||||
for (let index = 0; index <= maxIndex; index += 1) {
|
for (let index = 0; index <= maxIndex; index += 1) {
|
||||||
const candidate = index === 0
|
const candidate = index === 0
|
||||||
@ -1764,6 +1773,19 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.claimedTargetPathByItem.delete(itemId);
|
this.claimedTargetPathByItem.delete(itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private assignItemTargetPath(item: DownloadItem, targetPath: string): string {
|
||||||
|
const rawTargetPath = String(targetPath || "").trim();
|
||||||
|
if (!rawTargetPath) {
|
||||||
|
this.releaseTargetPath(item.id);
|
||||||
|
item.targetPath = "";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const normalizedTargetPath = path.resolve(rawTargetPath);
|
||||||
|
const claimed = this.claimTargetPath(item.id, normalizedTargetPath);
|
||||||
|
item.targetPath = claimed;
|
||||||
|
return claimed;
|
||||||
|
}
|
||||||
|
|
||||||
private abortPostProcessing(reason: string): void {
|
private abortPostProcessing(reason: string): void {
|
||||||
for (const [packageId, controller] of this.packagePostProcessAbortControllers.entries()) {
|
for (const [packageId, controller] of this.packagePostProcessAbortControllers.entries()) {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
@ -1943,6 +1965,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.packagePostProcessTasks.delete(packageId);
|
this.packagePostProcessTasks.delete(packageId);
|
||||||
for (const itemId of itemIds) {
|
for (const itemId of itemIds) {
|
||||||
this.retryAfterByItem.delete(itemId);
|
this.retryAfterByItem.delete(itemId);
|
||||||
|
this.releaseTargetPath(itemId);
|
||||||
|
this.dropItemContribution(itemId);
|
||||||
delete this.session.items[itemId];
|
delete this.session.items[itemId];
|
||||||
this.itemCount = Math.max(0, this.itemCount - 1);
|
this.itemCount = Math.max(0, this.itemCount - 1);
|
||||||
}
|
}
|
||||||
@ -2215,6 +2239,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
item.attempts = 0;
|
item.attempts = 0;
|
||||||
active.abortController = new AbortController();
|
active.abortController = new AbortController();
|
||||||
active.abortReason = "none";
|
active.abortReason = "none";
|
||||||
|
// Caller returns immediately after this; startItem().finally releases the active slot,
|
||||||
|
// so the retry backoff never blocks a worker.
|
||||||
this.retryAfterByItem.set(item.id, nowMs() + waitMs);
|
this.retryAfterByItem.set(item.id, nowMs() + waitMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2306,6 +2332,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
let done = false;
|
let done = false;
|
||||||
while (!done && item.attempts < maxAttempts) {
|
while (!done && item.attempts < maxAttempts) {
|
||||||
item.attempts += 1;
|
item.attempts += 1;
|
||||||
|
if (item.status !== "downloading") {
|
||||||
|
item.status = "downloading";
|
||||||
|
item.fullStatus = `Download läuft (${providerLabel(item.provider)})`;
|
||||||
|
item.updatedAt = nowMs();
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
const result = await this.downloadToFile(active, unrestricted.directUrl, item.targetPath, item.totalBytes);
|
const result = await this.downloadToFile(active, unrestricted.directUrl, item.targetPath, item.totalBytes);
|
||||||
active.resumable = result.resumable;
|
active.resumable = result.resumable;
|
||||||
if (!active.resumable && !active.nonResumableCounted) {
|
if (!active.resumable && !active.nonResumableCounted) {
|
||||||
@ -2416,6 +2448,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
item.downloadedBytes = 0;
|
item.downloadedBytes = 0;
|
||||||
item.progressPercent = 0;
|
item.progressPercent = 0;
|
||||||
item.totalBytes = null;
|
item.totalBytes = null;
|
||||||
|
this.dropItemContribution(item.id);
|
||||||
} else if (reason === "stop") {
|
} else if (reason === "stop") {
|
||||||
item.status = "cancelled";
|
item.status = "cancelled";
|
||||||
item.fullStatus = "Gestoppt";
|
item.fullStatus = "Gestoppt";
|
||||||
@ -2424,6 +2457,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
item.downloadedBytes = 0;
|
item.downloadedBytes = 0;
|
||||||
item.progressPercent = 0;
|
item.progressPercent = 0;
|
||||||
item.totalBytes = null;
|
item.totalBytes = null;
|
||||||
|
this.dropItemContribution(item.id);
|
||||||
}
|
}
|
||||||
} else if (reason === "shutdown") {
|
} else if (reason === "shutdown") {
|
||||||
item.status = "queued";
|
item.status = "queued";
|
||||||
@ -2466,6 +2500,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
item.downloadedBytes = 0;
|
item.downloadedBytes = 0;
|
||||||
item.totalBytes = null;
|
item.totalBytes = null;
|
||||||
item.progressPercent = 0;
|
item.progressPercent = 0;
|
||||||
|
this.dropItemContribution(item.id);
|
||||||
item.status = "failed";
|
item.status = "failed";
|
||||||
this.recordRunOutcome(item.id, "failed");
|
this.recordRunOutcome(item.id, "failed");
|
||||||
item.lastError = errorText;
|
item.lastError = errorText;
|
||||||
@ -2997,6 +3032,11 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (item.status === "completed" && hasZeroByteArchive) {
|
if (item.status === "completed" && hasZeroByteArchive) {
|
||||||
|
const maxCompletedZeroByteAutoRetries = Math.max(2, REQUEST_RETRIES);
|
||||||
|
if (item.retries >= maxCompletedZeroByteAutoRetries) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
item.retries += 1;
|
||||||
this.queueItemForRetry(item, {
|
this.queueItemForRetry(item, {
|
||||||
hardReset: true,
|
hardReset: true,
|
||||||
reason: "Wartet (Auto-Retry: 0B-Datei)"
|
reason: "Wartet (Auto-Retry: 0B-Datei)"
|
||||||
@ -3033,6 +3073,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
item.downloadedBytes = 0;
|
item.downloadedBytes = 0;
|
||||||
item.totalBytes = null;
|
item.totalBytes = null;
|
||||||
item.progressPercent = 0;
|
item.progressPercent = 0;
|
||||||
|
this.dropItemContribution(item.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
item.status = "queued";
|
item.status = "queued";
|
||||||
@ -3327,12 +3368,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (/\.zip\.001$/i.test(entryPointName)) {
|
if (/\.zip\.001$/i.test(entryPointName)) {
|
||||||
const stem = entryPointName.replace(/\.zip\.001$/i, "").toLowerCase();
|
const stem = entryPointName.replace(/\.zip\.001$/i, "").toLowerCase();
|
||||||
const escaped = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
const escaped = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
return new RegExp(`^${escaped}\\.zip(\\.\\d{3})?$`, "i").test(fileName);
|
return new RegExp(`^${escaped}\\.zip(\\.\\d+)?$`, "i").test(fileName);
|
||||||
}
|
}
|
||||||
if (/\.7z\.001$/i.test(entryPointName)) {
|
if (/\.7z\.001$/i.test(entryPointName)) {
|
||||||
const stem = entryPointName.replace(/\.7z\.001$/i, "").toLowerCase();
|
const stem = entryPointName.replace(/\.7z\.001$/i, "").toLowerCase();
|
||||||
const escaped = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
const escaped = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
return new RegExp(`^${escaped}\\.7z(\\.\\d{3})?$`, "i").test(fileName);
|
return new RegExp(`^${escaped}\\.7z(\\.\\d+)?$`, "i").test(fileName);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -3651,7 +3692,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applyPackageDoneCleanup(packageId: string): void {
|
private applyPackageDoneCleanup(packageId: string): void {
|
||||||
if (this.settings.completedCleanupPolicy !== "package_done") {
|
const policy = this.settings.completedCleanupPolicy;
|
||||||
|
if (policy !== "package_done" && policy !== "immediate") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3660,6 +3702,13 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (policy === "immediate") {
|
||||||
|
for (const itemId of [...pkg.itemIds]) {
|
||||||
|
this.applyCompletedCleanupPolicy(packageId, itemId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const allCompleted = pkg.itemIds.every((itemId) => {
|
const allCompleted = pkg.itemIds.every((itemId) => {
|
||||||
const item = this.session.items[itemId];
|
const item = this.session.items[itemId];
|
||||||
return !item || item.status === "completed";
|
return !item || item.status === "completed";
|
||||||
@ -3691,6 +3740,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId);
|
pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId);
|
||||||
|
this.releaseTargetPath(itemId);
|
||||||
|
this.dropItemContribution(itemId);
|
||||||
delete this.session.items[itemId];
|
delete this.session.items[itemId];
|
||||||
this.itemCount = Math.max(0, this.itemCount - 1);
|
this.itemCount = Math.max(0, this.itemCount - 1);
|
||||||
this.retryAfterByItem.delete(itemId);
|
this.retryAfterByItem.delete(itemId);
|
||||||
|
|||||||
@ -6,7 +6,9 @@ export function mergePackageInputs(packages: ParsedPackageInput[]): ParsedPackag
|
|||||||
for (const pkg of packages) {
|
for (const pkg of packages) {
|
||||||
const name = sanitizeFilename(pkg.name || inferPackageNameFromLinks(pkg.links));
|
const name = sanitizeFilename(pkg.name || inferPackageNameFromLinks(pkg.links));
|
||||||
const list = grouped.get(name) ?? [];
|
const list = grouped.get(name) ?? [];
|
||||||
list.push(...pkg.links);
|
for (const link of pkg.links) {
|
||||||
|
list.push(link);
|
||||||
|
}
|
||||||
grouped.set(name, list);
|
grouped.set(name, list);
|
||||||
}
|
}
|
||||||
return Array.from(grouped.entries()).map(([name, links]) => ({
|
return Array.from(grouped.entries()).map(([name, links]) => ({
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { API_BASE_URL, REQUEST_RETRIES } from "./constants";
|
import { API_BASE_URL, REQUEST_RETRIES } from "./constants";
|
||||||
import { compactErrorText, sleep } from "./utils";
|
import { compactErrorText, sleep } from "./utils";
|
||||||
|
|
||||||
const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.28";
|
const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.29";
|
||||||
|
|
||||||
export interface UnrestrictedLink {
|
export interface UnrestrictedLink {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@ -18,6 +18,33 @@ function retryDelay(attempt: number): number {
|
|||||||
return Math.min(5000, 400 * 2 ** attempt);
|
return Math.min(5000, 400 * 2 ** attempt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseRetryAfterMs(value: string | null): number {
|
||||||
|
const text = String(value || "").trim();
|
||||||
|
if (!text) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asSeconds = Number(text);
|
||||||
|
if (Number.isFinite(asSeconds) && asSeconds >= 0) {
|
||||||
|
return Math.min(120000, Math.floor(asSeconds * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
const asDate = Date.parse(text);
|
||||||
|
if (Number.isFinite(asDate)) {
|
||||||
|
return Math.min(120000, Math.max(0, asDate - Date.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryDelayForResponse(response: Response, attempt: number): number {
|
||||||
|
if (response.status !== 429) {
|
||||||
|
return retryDelay(attempt);
|
||||||
|
}
|
||||||
|
const fromHeader = parseRetryAfterMs(response.headers.get("retry-after"));
|
||||||
|
return fromHeader > 0 ? fromHeader : retryDelay(attempt);
|
||||||
|
}
|
||||||
|
|
||||||
function readHttpStatusFromErrorText(text: string): number {
|
function readHttpStatusFromErrorText(text: string): number {
|
||||||
const match = String(text || "").match(/HTTP\s+(\d{3})/i);
|
const match = String(text || "").match(/HTTP\s+(\d{3})/i);
|
||||||
return match ? Number(match[1]) : 0;
|
return match ? Number(match[1]) : 0;
|
||||||
@ -89,7 +116,7 @@ export class RealDebridClient {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const parsed = parseErrorBody(response.status, text, contentType);
|
const parsed = parseErrorBody(response.status, text, contentType);
|
||||||
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
|
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
|
||||||
await sleep(retryDelay(attempt));
|
await sleep(retryDelayForResponse(response, attempt));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
throw new Error(parsed);
|
throw new Error(parsed);
|
||||||
|
|||||||
@ -123,6 +123,7 @@ export function App(): ReactElement {
|
|||||||
const [settingsDraft, setSettingsDraft] = useState<AppSettings>(emptySnapshot().settings);
|
const [settingsDraft, setSettingsDraft] = useState<AppSettings>(emptySnapshot().settings);
|
||||||
const [settingsDirty, setSettingsDirty] = useState(false);
|
const [settingsDirty, setSettingsDirty] = useState(false);
|
||||||
const settingsDirtyRef = useRef(false);
|
const settingsDirtyRef = useRef(false);
|
||||||
|
const settingsDraftRevisionRef = useRef(0);
|
||||||
const latestStateRef = useRef<UiSnapshot | null>(null);
|
const latestStateRef = useRef<UiSnapshot | null>(null);
|
||||||
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@ -334,17 +335,22 @@ export function App(): ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCollapsedPackages((prev) => {
|
setCollapsedPackages((prev) => {
|
||||||
|
let changed = false;
|
||||||
const next: Record<string, boolean> = { ...prev };
|
const next: Record<string, boolean> = { ...prev };
|
||||||
const defaultCollapsed = totalPackageCount >= 24;
|
const defaultCollapsed = totalPackageCount >= 24;
|
||||||
for (const packageId of snapshot.session.packageOrder) {
|
for (const packageId of snapshot.session.packageOrder) {
|
||||||
next[packageId] = prev[packageId] ?? defaultCollapsed;
|
if (!(packageId in prev)) {
|
||||||
|
next[packageId] = defaultCollapsed;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const packageId of Object.keys(next)) {
|
for (const packageId of Object.keys(next)) {
|
||||||
if (!snapshot.session.packages[packageId]) {
|
if (!snapshot.session.packages[packageId]) {
|
||||||
delete next[packageId];
|
delete next[packageId];
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return next;
|
return changed ? next : prev;
|
||||||
});
|
});
|
||||||
}, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]);
|
}, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]);
|
||||||
|
|
||||||
@ -472,10 +478,13 @@ export function App(): ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const persistDraftSettings = async (): Promise<AppSettings> => {
|
const persistDraftSettings = async (): Promise<AppSettings> => {
|
||||||
|
const revisionAtStart = settingsDraftRevisionRef.current;
|
||||||
const result = await window.rd.updateSettings(normalizedSettingsDraft);
|
const result = await window.rd.updateSettings(normalizedSettingsDraft);
|
||||||
setSettingsDraft(result);
|
if (settingsDraftRevisionRef.current === revisionAtStart) {
|
||||||
settingsDirtyRef.current = false;
|
setSettingsDraft(result);
|
||||||
setSettingsDirty(false);
|
settingsDirtyRef.current = false;
|
||||||
|
setSettingsDirty(false);
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -663,6 +672,7 @@ export function App(): ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
input.onchange = async () => {
|
input.onchange = async () => {
|
||||||
|
window.removeEventListener("focus", onWindowFocus);
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
if (!file) {
|
if (!file) {
|
||||||
releasePickerBusy();
|
releasePickerBusy();
|
||||||
@ -683,22 +693,26 @@ export function App(): ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setBool = (key: keyof AppSettings, value: boolean): void => {
|
const setBool = (key: keyof AppSettings, value: boolean): void => {
|
||||||
|
settingsDraftRevisionRef.current += 1;
|
||||||
settingsDirtyRef.current = true;
|
settingsDirtyRef.current = true;
|
||||||
setSettingsDirty(true);
|
setSettingsDirty(true);
|
||||||
setSettingsDraft((prev) => ({ ...prev, [key]: value }));
|
setSettingsDraft((prev) => ({ ...prev, [key]: value }));
|
||||||
};
|
};
|
||||||
const setText = (key: keyof AppSettings, value: string): void => {
|
const setText = (key: keyof AppSettings, value: string): void => {
|
||||||
|
settingsDraftRevisionRef.current += 1;
|
||||||
settingsDirtyRef.current = true;
|
settingsDirtyRef.current = true;
|
||||||
setSettingsDirty(true);
|
setSettingsDirty(true);
|
||||||
setSettingsDraft((prev) => ({ ...prev, [key]: value }));
|
setSettingsDraft((prev) => ({ ...prev, [key]: value }));
|
||||||
};
|
};
|
||||||
const setNum = (key: keyof AppSettings, value: number): void => {
|
const setNum = (key: keyof AppSettings, value: number): void => {
|
||||||
|
settingsDraftRevisionRef.current += 1;
|
||||||
settingsDirtyRef.current = true;
|
settingsDirtyRef.current = true;
|
||||||
setSettingsDirty(true);
|
setSettingsDirty(true);
|
||||||
setSettingsDraft((prev) => ({ ...prev, [key]: value }));
|
setSettingsDraft((prev) => ({ ...prev, [key]: value }));
|
||||||
};
|
};
|
||||||
const setSpeedLimitMbps = (value: number): void => {
|
const setSpeedLimitMbps = (value: number): void => {
|
||||||
const mbps = Number.isFinite(value) ? Math.max(0, value) : 0;
|
const mbps = Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||||
|
settingsDraftRevisionRef.current += 1;
|
||||||
settingsDirtyRef.current = true;
|
settingsDirtyRef.current = true;
|
||||||
setSettingsDirty(true);
|
setSettingsDirty(true);
|
||||||
setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: Math.floor(mbps * 1024) }));
|
setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: Math.floor(mbps * 1024) }));
|
||||||
@ -747,8 +761,10 @@ export function App(): ReactElement {
|
|||||||
[order[idx], order[target]] = [order[target], order[idx]];
|
[order[idx], order[target]] = [order[target], order[idx]];
|
||||||
setDownloadsSortDescending(false);
|
setDownloadsSortDescending(false);
|
||||||
packageOrderRef.current = order;
|
packageOrderRef.current = order;
|
||||||
void window.rd.reorderPackages(order);
|
void window.rd.reorderPackages(order).catch((error) => {
|
||||||
}, []);
|
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
|
||||||
|
});
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
const reorderPackagesByDrop = useCallback((draggedPackageId: string, targetPackageId: string) => {
|
const reorderPackagesByDrop = useCallback((draggedPackageId: string, targetPackageId: string) => {
|
||||||
const currentOrder = packageOrderRef.current;
|
const currentOrder = packageOrderRef.current;
|
||||||
@ -760,8 +776,10 @@ export function App(): ReactElement {
|
|||||||
}
|
}
|
||||||
setDownloadsSortDescending(false);
|
setDownloadsSortDescending(false);
|
||||||
packageOrderRef.current = nextOrder;
|
packageOrderRef.current = nextOrder;
|
||||||
void window.rd.reorderPackages(nextOrder);
|
void window.rd.reorderPackages(nextOrder).catch((error) => {
|
||||||
}, []);
|
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
|
||||||
|
});
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
const addCollectorTab = (): void => {
|
const addCollectorTab = (): void => {
|
||||||
const id = `tab-${nextCollectorId++}`;
|
const id = `tab-${nextCollectorId++}`;
|
||||||
@ -810,8 +828,54 @@ export function App(): ReactElement {
|
|||||||
draggedPackageIdRef.current = null;
|
draggedPackageIdRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const onPackageStartEdit = useCallback((packageId: string, packageName: string): void => {
|
||||||
|
setEditingPackageId(packageId);
|
||||||
|
setEditingName(packageName);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onPackageFinishEdit = useCallback((packageId: string, currentName: string, nextName: string): void => {
|
||||||
|
setEditingPackageId(null);
|
||||||
|
const normalized = nextName.trim();
|
||||||
|
if (normalized && normalized !== currentName.trim()) {
|
||||||
|
void window.rd.renamePackage(packageId, normalized).catch((error) => {
|
||||||
|
showToast(`Umbenennen fehlgeschlagen: ${String(error)}`, 2400);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
const onPackageToggleCollapse = useCallback((packageId: string): void => {
|
||||||
|
setCollapsedPackages((prev) => ({ ...prev, [packageId]: !(prev[packageId] ?? false) }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onPackageCancel = useCallback((packageId: string): void => {
|
||||||
|
void window.rd.cancelPackage(packageId).catch((error) => {
|
||||||
|
showToast(`Paket-Löschung fehlgeschlagen: ${String(error)}`, 2400);
|
||||||
|
});
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
const onPackageMoveUp = useCallback((packageId: string): void => {
|
||||||
|
movePackage(packageId, "up");
|
||||||
|
}, [movePackage]);
|
||||||
|
|
||||||
|
const onPackageMoveDown = useCallback((packageId: string): void => {
|
||||||
|
movePackage(packageId, "down");
|
||||||
|
}, [movePackage]);
|
||||||
|
|
||||||
|
const onPackageToggle = useCallback((packageId: string): void => {
|
||||||
|
void window.rd.togglePackage(packageId).catch((error) => {
|
||||||
|
showToast(`Paket-Umschalten fehlgeschlagen: ${String(error)}`, 2400);
|
||||||
|
});
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
const onPackageRemoveItem = useCallback((itemId: string): void => {
|
||||||
|
void window.rd.removeItem(itemId).catch((error) => {
|
||||||
|
showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 2400);
|
||||||
|
});
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
const schedules = settingsDraft.bandwidthSchedules ?? [];
|
const schedules = settingsDraft.bandwidthSchedules ?? [];
|
||||||
const addSchedule = (): void => {
|
const addSchedule = (): void => {
|
||||||
|
settingsDraftRevisionRef.current += 1;
|
||||||
settingsDirtyRef.current = true;
|
settingsDirtyRef.current = true;
|
||||||
setSettingsDirty(true);
|
setSettingsDirty(true);
|
||||||
setSettingsDraft((prev) => ({
|
setSettingsDraft((prev) => ({
|
||||||
@ -820,6 +884,7 @@ export function App(): ReactElement {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
const removeSchedule = (idx: number): void => {
|
const removeSchedule = (idx: number): void => {
|
||||||
|
settingsDraftRevisionRef.current += 1;
|
||||||
settingsDirtyRef.current = true;
|
settingsDirtyRef.current = true;
|
||||||
setSettingsDirty(true);
|
setSettingsDirty(true);
|
||||||
setSettingsDraft((prev) => ({
|
setSettingsDraft((prev) => ({
|
||||||
@ -828,6 +893,7 @@ export function App(): ReactElement {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
const updateSchedule = (idx: number, field: keyof BandwidthScheduleEntry, value: number | boolean): void => {
|
const updateSchedule = (idx: number, field: keyof BandwidthScheduleEntry, value: number | boolean): void => {
|
||||||
|
settingsDraftRevisionRef.current += 1;
|
||||||
settingsDirtyRef.current = true;
|
settingsDirtyRef.current = true;
|
||||||
setSettingsDirty(true);
|
setSettingsDirty(true);
|
||||||
setSettingsDraft((prev) => ({
|
setSettingsDraft((prev) => ({
|
||||||
@ -1038,25 +1104,17 @@ export function App(): ReactElement {
|
|||||||
isEditing={editingPackageId === pkg.id}
|
isEditing={editingPackageId === pkg.id}
|
||||||
editingName={editingName}
|
editingName={editingName}
|
||||||
collapsed={collapsedPackages[pkg.id] ?? false}
|
collapsed={collapsedPackages[pkg.id] ?? false}
|
||||||
onStartEdit={() => { setEditingPackageId(pkg.id); setEditingName(pkg.name); }}
|
onStartEdit={onPackageStartEdit}
|
||||||
onFinishEdit={(name) => {
|
onFinishEdit={onPackageFinishEdit}
|
||||||
setEditingPackageId(null);
|
|
||||||
const nextName = name.trim();
|
|
||||||
if (nextName && nextName !== pkg.name.trim()) {
|
|
||||||
void window.rd.renamePackage(pkg.id, nextName);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onEditChange={setEditingName}
|
onEditChange={setEditingName}
|
||||||
onToggleCollapse={() => {
|
onToggleCollapse={onPackageToggleCollapse}
|
||||||
setCollapsedPackages((prev) => ({ ...prev, [pkg.id]: !(prev[pkg.id] ?? false) }));
|
onCancel={onPackageCancel}
|
||||||
}}
|
onMoveUp={onPackageMoveUp}
|
||||||
onCancel={() => { void window.rd.cancelPackage(pkg.id); }}
|
onMoveDown={onPackageMoveDown}
|
||||||
onMoveUp={() => movePackage(pkg.id, "up")}
|
onToggle={onPackageToggle}
|
||||||
onMoveDown={() => movePackage(pkg.id, "down")}
|
onRemoveItem={onPackageRemoveItem}
|
||||||
onToggle={() => { void window.rd.togglePackage(pkg.id); }}
|
onDragStart={onPackageDragStart}
|
||||||
onRemoveItem={(itemId) => { void window.rd.removeItem(itemId); }}
|
onDrop={onPackageDrop}
|
||||||
onDragStart={() => onPackageDragStart(pkg.id)}
|
|
||||||
onDrop={() => onPackageDrop(pkg.id)}
|
|
||||||
onDragEnd={onPackageDragEnd}
|
onDragEnd={onPackageDragEnd}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -1309,17 +1367,17 @@ interface PackageCardProps {
|
|||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
editingName: string;
|
editingName: string;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
onStartEdit: () => void;
|
onStartEdit: (packageId: string, packageName: string) => void;
|
||||||
onFinishEdit: (name: string) => void;
|
onFinishEdit: (packageId: string, currentName: string, nextName: string) => void;
|
||||||
onEditChange: (name: string) => void;
|
onEditChange: (name: string) => void;
|
||||||
onToggleCollapse: () => void;
|
onToggleCollapse: (packageId: string) => void;
|
||||||
onCancel: () => void;
|
onCancel: (packageId: string) => void;
|
||||||
onMoveUp: () => void;
|
onMoveUp: (packageId: string) => void;
|
||||||
onMoveDown: () => void;
|
onMoveDown: (packageId: string) => void;
|
||||||
onToggle: () => void;
|
onToggle: (packageId: string) => void;
|
||||||
onRemoveItem: (itemId: string) => void;
|
onRemoveItem: (itemId: string) => void;
|
||||||
onDragStart: () => void;
|
onDragStart: (packageId: string) => void;
|
||||||
onDrop: () => void;
|
onDrop: (packageId: string) => void;
|
||||||
onDragEnd: () => void;
|
onDragEnd: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1331,27 +1389,27 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs
|
|||||||
const progress = Math.floor((done / total) * 100);
|
const progress = Math.floor((done / total) * 100);
|
||||||
|
|
||||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||||
if (e.key === "Enter") { onFinishEdit(editingName); }
|
if (e.key === "Enter") { onFinishEdit(pkg.id, pkg.name, editingName); }
|
||||||
if (e.key === "Escape") { onFinishEdit(pkg.name); }
|
if (e.key === "Escape") { onFinishEdit(pkg.id, pkg.name, pkg.name); }
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
className={`package-card${pkg.enabled ? "" : " disabled-pkg"}`}
|
className={`package-card${pkg.enabled ? "" : " disabled-pkg"}`}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(event) => { event.stopPropagation(); onDragStart(); }}
|
onDragStart={(event) => { event.stopPropagation(); onDragStart(pkg.id); }}
|
||||||
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); }}
|
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); }}
|
||||||
onDrop={(event) => { event.preventDefault(); event.stopPropagation(); onDrop(); }}
|
onDrop={(event) => { event.preventDefault(); event.stopPropagation(); onDrop(pkg.id); }}
|
||||||
onDragEnd={(event) => { event.stopPropagation(); onDragEnd(); }}
|
onDragEnd={(event) => { event.stopPropagation(); onDragEnd(); }}
|
||||||
>
|
>
|
||||||
<header>
|
<header>
|
||||||
<div className="pkg-info">
|
<div className="pkg-info">
|
||||||
<div className="pkg-name-row">
|
<div className="pkg-name-row">
|
||||||
<input type="checkbox" checked={pkg.enabled} onChange={onToggle} title={pkg.enabled ? "Paket aktiv" : "Paket deaktiviert"} />
|
<input type="checkbox" checked={pkg.enabled} onChange={() => onToggle(pkg.id)} title={pkg.enabled ? "Paket aktiv" : "Paket deaktiviert"} />
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<input className="rename-input" value={editingName} onChange={(e) => onEditChange(e.target.value)} onBlur={() => onFinishEdit(editingName)} onKeyDown={onKeyDown} autoFocus />
|
<input className="rename-input" value={editingName} onChange={(e) => onEditChange(e.target.value)} onBlur={() => onFinishEdit(pkg.id, pkg.name, editingName)} onKeyDown={onKeyDown} autoFocus />
|
||||||
) : (
|
) : (
|
||||||
<h4 onDoubleClick={onStartEdit} title="Doppelklick zum Umbenennen">{pkg.name}</h4>
|
<h4 onDoubleClick={() => onStartEdit(pkg.id, pkg.name)} title="Doppelklick zum Umbenennen">{pkg.name}</h4>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span>{done}/{total} fertig {failed > 0 && `· ${failed} Fehler `}{cancelled > 0 && `· ${cancelled} abgebrochen `}
|
<span>{done}/{total} fertig {failed > 0 && `· ${failed} Fehler `}{cancelled > 0 && `· ${cancelled} abgebrochen `}
|
||||||
@ -1359,11 +1417,11 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="pkg-actions">
|
<div className="pkg-actions">
|
||||||
<button className="btn" onClick={onToggleCollapse}>{collapsed ? "Ausklappen" : "Einklappen"}</button>
|
<button className="btn" onClick={() => onToggleCollapse(pkg.id)}>{collapsed ? "Ausklappen" : "Einklappen"}</button>
|
||||||
<button className="btn" disabled={isFirst} onClick={onMoveUp} title="Nach oben">▲</button>
|
<button className="btn" disabled={isFirst} onClick={() => onMoveUp(pkg.id)} title="Nach oben">▲</button>
|
||||||
<button className="btn" disabled={isLast} onClick={onMoveDown} title="Nach unten">▼</button>
|
<button className="btn" disabled={isLast} onClick={() => onMoveDown(pkg.id)} title="Nach unten">▼</button>
|
||||||
<button className={`btn${pkg.enabled ? "" : " btn-active"}`} onClick={onToggle}>{pkg.enabled ? "Paket stoppen" : "Paket starten"}</button>
|
<button className={`btn${pkg.enabled ? "" : " btn-active"}`} onClick={() => onToggle(pkg.id)}>{pkg.enabled ? "Paket stoppen" : "Paket starten"}</button>
|
||||||
<button className="btn danger" onClick={onCancel}>Paket löschen</button>
|
<button className="btn danger" onClick={() => onCancel(pkg.id)}>Paket löschen</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="progress"><div style={{ width: `${progress}%` }} /></div>
|
<div className="progress"><div style={{ width: `${progress}%` }} /></div>
|
||||||
|
|||||||
@ -442,7 +442,7 @@ describe("debrid service", () => {
|
|||||||
expect(resolved.get(link)).toBe("Bulletproof.S01E01.German.DL.DD20.Synced.720p.AmazonHD.h264-GDR.part01.rar");
|
expect(resolved.get(link)).toBe("Bulletproof.S01E01.German.DL.DD20.Synced.720p.AmazonHD.h264-GDR.part01.rar");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to provider unrestrict for unresolved filename scan", async () => {
|
it("does not unrestrict non-rapidgator links during filename scan", async () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
token: "rd-token",
|
token: "rd-token",
|
||||||
@ -455,6 +455,7 @@ describe("debrid service", () => {
|
|||||||
|
|
||||||
const linkFromPage = "https://rapidgator.net/file/11111111111111111111111111111111";
|
const linkFromPage = "https://rapidgator.net/file/11111111111111111111111111111111";
|
||||||
const linkFromProvider = "https://hoster.example/file/22222222222222222222222222222222";
|
const linkFromProvider = "https://hoster.example/file/22222222222222222222222222222222";
|
||||||
|
let unrestrictCalls = 0;
|
||||||
|
|
||||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
@ -467,6 +468,7 @@ describe("debrid service", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
|
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
|
||||||
|
unrestrictCalls += 1;
|
||||||
const body = init?.body;
|
const body = init?.body;
|
||||||
const bodyText = body instanceof URLSearchParams ? body.toString() : String(body || "");
|
const bodyText = body instanceof URLSearchParams ? body.toString() : String(body || "");
|
||||||
const linkValue = new URLSearchParams(bodyText).get("link") || "";
|
const linkValue = new URLSearchParams(bodyText).get("link") || "";
|
||||||
@ -492,10 +494,10 @@ describe("debrid service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(resolved.get(linkFromPage)).toBe("from-page.part1.rar");
|
expect(resolved.get(linkFromPage)).toBe("from-page.part1.rar");
|
||||||
expect(resolved.get(linkFromProvider)).toBe("from-provider.part2.rar");
|
expect(resolved.has(linkFromProvider)).toBe(false);
|
||||||
|
expect(unrestrictCalls).toBe(0);
|
||||||
expect(events).toEqual(expect.arrayContaining([
|
expect(events).toEqual(expect.arrayContaining([
|
||||||
{ link: linkFromPage, fileName: "from-page.part1.rar" },
|
{ link: linkFromPage, fileName: "from-page.part1.rar" }
|
||||||
{ link: linkFromProvider, fileName: "from-provider.part2.rar" }
|
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -533,7 +535,7 @@ describe("debrid service", () => {
|
|||||||
expect(unrestrictCalls).toBe(0);
|
expect(unrestrictCalls).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not map AllDebrid filename infos by index when response link is missing", async () => {
|
it("maps AllDebrid filename infos by index when response link is missing", async () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
token: "",
|
token: "",
|
||||||
@ -572,7 +574,9 @@ describe("debrid service", () => {
|
|||||||
|
|
||||||
const service = new DebridService(settings);
|
const service = new DebridService(settings);
|
||||||
const resolved = await service.resolveFilenames([linkA, linkB]);
|
const resolved = await service.resolveFilenames([linkA, linkB]);
|
||||||
expect(resolved.size).toBe(0);
|
expect(resolved.get(linkA)).toBe("wrong-a.mkv");
|
||||||
|
expect(resolved.get(linkB)).toBe("wrong-b.mkv");
|
||||||
|
expect(resolved.size).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retries AllDebrid filename infos after transient server error", async () => {
|
it("retries AllDebrid filename infos after transient server error", async () => {
|
||||||
@ -738,6 +742,11 @@ describe("extractRapidgatorFilenameFromHtml", () => {
|
|||||||
expect(extractRapidgatorFilenameFromHtml("")).toBe("");
|
expect(extractRapidgatorFilenameFromHtml("")).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ignores broad body text that is not a labeled filename", () => {
|
||||||
|
const html = "<html><body>Please download file now from mirror.mkv</body></html>";
|
||||||
|
expect(extractRapidgatorFilenameFromHtml(html)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
it("extracts from File name label in page body", () => {
|
it("extracts from File name label in page body", () => {
|
||||||
const html = '<html><body>File name: <b>Show.S02E03.720p.part01.rar</b></body></html>';
|
const html = '<html><body>File name: <b>Show.S02E03.720p.part01.rar</b></body></html>';
|
||||||
expect(extractRapidgatorFilenameFromHtml(html)).toBe("Show.S02E03.720p.part01.rar");
|
expect(extractRapidgatorFilenameFromHtml(html)).toBe("Show.S02E03.720p.part01.rar");
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user