feat: add LinkSnappy provider, account deactivation, UI polish
- Add LinkSnappy provider with cookie-based session auth and /api/linkgen - Upgrade LinkSnappy download URLs from http to https (fix 425 errors) - Add account deactivation toggle (disabledProviders in settings) - Show account type (API/Web/Login) in provider dropdowns - Show API key count for Debrid-Link in status label - Fix all missing German umlauts throughout the UI - Wider modal for textarea, compact action buttons in one row - Debrid-Link: log which API key (#1/#2) is used for unrestrict Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a41c99e294
commit
22ed37d67c
@ -53,6 +53,8 @@ export function defaultSettings(): AppSettings {
|
||||
ddownloadPassword: "",
|
||||
oneFichierApiKey: "",
|
||||
debridLinkApiKeys: "",
|
||||
linkSnappyLogin: "",
|
||||
linkSnappyPassword: "",
|
||||
archivePasswordList: "",
|
||||
rememberToken: true,
|
||||
providerPrimary: "realdebrid",
|
||||
@ -95,6 +97,7 @@ export function defaultSettings(): AppSettings {
|
||||
bandwidthSchedules: [],
|
||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
||||
extractCpuPriority: "high",
|
||||
autoExtractWhenStopped: true
|
||||
autoExtractWhenStopped: true,
|
||||
disabledProviders: []
|
||||
};
|
||||
}
|
||||
|
||||
@ -20,6 +20,8 @@ const ONEFICHIER_URL_RE = /^https?:\/\/(?:www\.)?(?:1fichier\.com|alterupload\.c
|
||||
const DEBRID_LINK_API_BASE = "https://debrid-link.com/api/v2";
|
||||
const DEBRID_LINK_QUOTA_ERRORS = new Set(["maxLink", "maxLinkHost", "maxData", "maxDataHost", "maxAttempts", "maxTransfer"]);
|
||||
|
||||
const LINKSNAPPY_API_BASE = "https://linksnappy.com/api";
|
||||
|
||||
const PROVIDER_LABELS: Record<DebridProvider, string> = {
|
||||
realdebrid: "Real-Debrid",
|
||||
megadebrid: "Mega-Debrid",
|
||||
@ -27,7 +29,8 @@ const PROVIDER_LABELS: Record<DebridProvider, string> = {
|
||||
alldebrid: "AllDebrid",
|
||||
ddownload: "DDownload",
|
||||
onefichier: "1Fichier",
|
||||
debridlink: "Debrid-Link"
|
||||
debridlink: "Debrid-Link",
|
||||
linksnappy: "LinkSnappy"
|
||||
};
|
||||
|
||||
interface ProviderUnrestrictedLink extends UnrestrictedLink {
|
||||
@ -1279,7 +1282,7 @@ class DebridLinkClient {
|
||||
|
||||
while (!triedAll) {
|
||||
const apiKey = this.apiKeys[this.currentKeyIndex];
|
||||
const keyLabel = this.apiKeys.length > 1 ? ` (Key ${this.currentKeyIndex + 1}/${this.apiKeys.length})` : "";
|
||||
const keyLabel = this.apiKeys.length > 1 ? ` #${this.currentKeyIndex + 1}` : "";
|
||||
|
||||
let lastError = "";
|
||||
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
||||
@ -1307,7 +1310,7 @@ class DebridLinkClient {
|
||||
}
|
||||
|
||||
if (errorCode === "badToken" || errorCode === "expired_token") {
|
||||
throw new Error(`Debrid-Link${keyLabel}: Ungueltiger oder abgelaufener API-Key`);
|
||||
throw new Error(`Debrid-Link${keyLabel}: Ungültiger oder abgelaufener API-Key`);
|
||||
}
|
||||
if (errorCode === "floodDetected") {
|
||||
await sleep(retryDelay(attempt), signal);
|
||||
@ -1330,19 +1333,21 @@ class DebridLinkClient {
|
||||
const fileName = String(value.name || "") || filenameFromUrl(directUrl) || filenameFromUrl(link);
|
||||
const fileSize = typeof value.size === "number" && value.size > 0 ? value.size : null;
|
||||
|
||||
logger.info(`Debrid-Link${keyLabel}: Unrestrict OK → ${fileName || "?"}`);
|
||||
|
||||
return {
|
||||
fileName,
|
||||
directUrl,
|
||||
fileSize,
|
||||
retriesUsed: attempt - 1,
|
||||
sourceLabel: `API${keyLabel}`
|
||||
sourceLabel: keyLabel ? `#${this.currentKeyIndex + 1}` : "API"
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = compactErrorText(error);
|
||||
if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
|
||||
throw error;
|
||||
}
|
||||
if (/Ungueltig|abgelaufen/i.test(lastError)) {
|
||||
if (/Ungültig|abgelaufen/i.test(lastError)) {
|
||||
throw error;
|
||||
}
|
||||
if (attempt < REQUEST_RETRIES) {
|
||||
@ -1361,6 +1366,150 @@ class DebridLinkClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ── LinkSnappy Client ──
|
||||
|
||||
class LinkSnappyClient {
|
||||
private username: string;
|
||||
private password: string;
|
||||
private sessionCookies: string | null = null;
|
||||
|
||||
public constructor(username: string, password: string) {
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
private async authenticate(signal?: AbortSignal): Promise<void> {
|
||||
const params = new URLSearchParams({ username: this.username, password: this.password });
|
||||
const res = await fetch(`${LINKSNAPPY_API_BASE}/AUTHENTICATE?${params.toString()}`, {
|
||||
signal: withTimeoutSignal(signal, API_TIMEOUT_MS),
|
||||
redirect: "manual"
|
||||
});
|
||||
|
||||
const cookies: string[] = [];
|
||||
const setCookie = res.headers.getSetCookie?.() ?? [];
|
||||
for (const sc of setCookie) {
|
||||
const nameValue = sc.split(";")[0];
|
||||
if (nameValue) cookies.push(nameValue);
|
||||
}
|
||||
|
||||
const json = await res.json() as Record<string, unknown>;
|
||||
if (json.status !== "OK") {
|
||||
throw new Error(`LinkSnappy: Login fehlgeschlagen – ${String(json.error || "Unbekannter Fehler")}`);
|
||||
}
|
||||
|
||||
if (cookies.length > 0) {
|
||||
this.sessionCookies = cookies.join("; ");
|
||||
} else {
|
||||
this.sessionCookies = `username=${encodeURIComponent(this.username)}; Auth=manual`;
|
||||
}
|
||||
|
||||
logger.info("LinkSnappy: Authentifizierung erfolgreich");
|
||||
}
|
||||
|
||||
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
|
||||
if (!this.username || !this.password) {
|
||||
throw new Error("LinkSnappy: Kein Login konfiguriert");
|
||||
}
|
||||
|
||||
let lastError = "";
|
||||
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
||||
if (signal?.aborted) throw new Error("aborted:debrid");
|
||||
try {
|
||||
if (!this.sessionCookies) {
|
||||
await this.authenticate(signal);
|
||||
}
|
||||
|
||||
const genLinks = `{"link":"${encodeURIComponent(link)}","type":"","linkpass":""}`;
|
||||
const url = `${LINKSNAPPY_API_BASE}/linkgen?genLinks=${genLinks}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: { Cookie: this.sessionCookies! },
|
||||
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
|
||||
});
|
||||
|
||||
const json = await res.json() as Record<string, unknown>;
|
||||
|
||||
if (json.status === "ERROR" && json.error) {
|
||||
const errorMsg = String(json.error);
|
||||
if (/not logged in|session expired|unauthorized/i.test(errorMsg)) {
|
||||
this.sessionCookies = null;
|
||||
if (attempt < REQUEST_RETRIES) {
|
||||
continue;
|
||||
}
|
||||
throw new Error(`LinkSnappy: ${errorMsg}`);
|
||||
}
|
||||
throw new Error(`LinkSnappy: ${errorMsg}`);
|
||||
}
|
||||
|
||||
const links = json.links as Array<Record<string, unknown>> | undefined;
|
||||
if (!links || links.length === 0) {
|
||||
throw new Error("LinkSnappy: Keine Antwort-Daten");
|
||||
}
|
||||
|
||||
const entry = links[0];
|
||||
if (entry.status === "ERROR" || (entry.error && entry.status !== "OK")) {
|
||||
const errText = String(entry.error);
|
||||
if (/quota|limit/i.test(errText)) {
|
||||
throw new Error(`LinkSnappy: Quota erreicht – ${errText}`);
|
||||
}
|
||||
throw new Error(`LinkSnappy: ${errText}`);
|
||||
}
|
||||
|
||||
let directUrl = String(entry.generated || "");
|
||||
if (!directUrl) {
|
||||
throw new Error("LinkSnappy: Keine Download-URL in Antwort");
|
||||
}
|
||||
// LinkSnappy liefert http:// URLs – auf https:// upgraden (deren Server unterstützt beides)
|
||||
if (directUrl.startsWith("http://")) {
|
||||
directUrl = directUrl.replace("http://", "https://");
|
||||
}
|
||||
|
||||
const fileName = String(entry.filename || "") || filenameFromUrl(directUrl) || filenameFromUrl(link);
|
||||
const rawSize = entry.filesize;
|
||||
let fileSize: number | null = null;
|
||||
if (typeof rawSize === "number" && rawSize > 0) {
|
||||
fileSize = rawSize;
|
||||
} else if (typeof rawSize === "string") {
|
||||
const parsed = parseFileSizeString(rawSize);
|
||||
if (parsed > 0) fileSize = parsed;
|
||||
}
|
||||
|
||||
logger.info(`LinkSnappy: Unrestrict OK → ${fileName || "?"}`);
|
||||
|
||||
return {
|
||||
fileName,
|
||||
directUrl,
|
||||
fileSize,
|
||||
retriesUsed: attempt - 1,
|
||||
sourceLabel: "API"
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = compactErrorText(error);
|
||||
if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
|
||||
throw error;
|
||||
}
|
||||
if (/fehlgeschlagen/i.test(lastError) && /Login/i.test(lastError)) {
|
||||
throw error;
|
||||
}
|
||||
if (attempt < REQUEST_RETRIES) {
|
||||
await sleep(retryDelay(attempt), signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(String(lastError || "LinkSnappy Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, ""));
|
||||
}
|
||||
}
|
||||
|
||||
function parseFileSizeString(s: string): number {
|
||||
const match = s.trim().match(/^([\d.]+)\s*([KMGT]?)B?$/i);
|
||||
if (!match) return 0;
|
||||
const num = parseFloat(match[1]);
|
||||
const unit = (match[2] || "").toUpperCase();
|
||||
const multipliers: Record<string, number> = { "": 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 };
|
||||
return Math.floor(num * (multipliers[unit] || 1));
|
||||
}
|
||||
|
||||
// ── 1Fichier Client ──
|
||||
|
||||
class OneFichierClient {
|
||||
@ -1620,6 +1769,8 @@ export class DebridService {
|
||||
private cachedDdownloadKey = "";
|
||||
private cachedDebridLinkClient: DebridLinkClient | null = null;
|
||||
private cachedDebridLinkKey = "";
|
||||
private cachedLinkSnappyClient: LinkSnappyClient | null = null;
|
||||
private cachedLinkSnappyKey = "";
|
||||
|
||||
public constructor(settings: AppSettings, options: DebridServiceOptions = {}) {
|
||||
this.settings = cloneSettings(settings);
|
||||
@ -1639,6 +1790,16 @@ export class DebridService {
|
||||
return this.cachedDebridLinkClient;
|
||||
}
|
||||
|
||||
private getLinkSnappyClient(login: string, password: string): LinkSnappyClient {
|
||||
const key = `${login}\0${password}`;
|
||||
if (this.cachedLinkSnappyClient && this.cachedLinkSnappyKey === key) {
|
||||
return this.cachedLinkSnappyClient;
|
||||
}
|
||||
this.cachedLinkSnappyClient = new LinkSnappyClient(login, password);
|
||||
this.cachedLinkSnappyKey = key;
|
||||
return this.cachedLinkSnappyClient;
|
||||
}
|
||||
|
||||
private getDdownloadClient(login: string, password: string): DdownloadClient {
|
||||
const key = `${login}\0${password}`;
|
||||
if (this.cachedDdownloadClient && this.cachedDdownloadKey === key) {
|
||||
@ -1829,6 +1990,7 @@ export class DebridService {
|
||||
}
|
||||
|
||||
private isProviderConfiguredFor(settings: AppSettings, provider: DebridProvider): boolean {
|
||||
if ((settings.disabledProviders || []).includes(provider)) return false;
|
||||
if (provider === "realdebrid") {
|
||||
return Boolean(this.shouldUseRealDebridWeb(settings) || settings.token.trim());
|
||||
}
|
||||
@ -1847,6 +2009,9 @@ export class DebridService {
|
||||
if (provider === "debridlink") {
|
||||
return Boolean(settings.debridLinkApiKeys.trim());
|
||||
}
|
||||
if (provider === "linksnappy") {
|
||||
return Boolean(settings.linkSnappyLogin.trim() && settings.linkSnappyPassword.trim());
|
||||
}
|
||||
return Boolean(this.shouldUseBestDebridWeb(settings) || settings.bestToken.trim());
|
||||
}
|
||||
|
||||
@ -1891,6 +2056,9 @@ export class DebridService {
|
||||
dlResult.sourceLabel = dlResult.sourceLabel || "API";
|
||||
return dlResult;
|
||||
}
|
||||
if (provider === "linksnappy") {
|
||||
return this.getLinkSnappyClient(settings.linkSnappyLogin, settings.linkSnappyPassword).unrestrictLink(link, signal);
|
||||
}
|
||||
if (this.shouldUseBestDebridWeb(settings) && this.options.bestDebridWebUnrestrict) {
|
||||
const bdResult = await this.options.bestDebridWebUnrestrict(link, signal);
|
||||
if (!bdResult) {
|
||||
|
||||
@ -5,8 +5,8 @@ import { AppSettings, BandwidthScheduleEntry, DebridProvider, DownloadItem, Down
|
||||
import { defaultSettings } from "./constants";
|
||||
import { logger } from "./logger";
|
||||
|
||||
const VALID_PRIMARY_PROVIDERS = new Set(["realdebrid", "megadebrid", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"]);
|
||||
const VALID_FALLBACK_PROVIDERS = new Set(["none", "realdebrid", "megadebrid", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"]);
|
||||
const VALID_PRIMARY_PROVIDERS = new Set(["realdebrid", "megadebrid", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"]);
|
||||
const VALID_FALLBACK_PROVIDERS = new Set(["none", "realdebrid", "megadebrid", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"]);
|
||||
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"]);
|
||||
@ -119,6 +119,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
||||
ddownloadPassword: asText(settings.ddownloadPassword),
|
||||
oneFichierApiKey: asText(settings.oneFichierApiKey),
|
||||
debridLinkApiKeys: String(settings.debridLinkApiKeys ?? "").replace(/\r\n|\r/g, "\n").trim(),
|
||||
linkSnappyLogin: asText(settings.linkSnappyLogin),
|
||||
linkSnappyPassword: asText(settings.linkSnappyPassword),
|
||||
archivePasswordList: String(settings.archivePasswordList ?? "").replace(/\r\n|\r/g, "\n"),
|
||||
rememberToken: Boolean(settings.rememberToken),
|
||||
providerPrimary: settings.providerPrimary,
|
||||
@ -161,7 +163,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
||||
bandwidthSchedules: normalizeBandwidthSchedules(settings.bandwidthSchedules),
|
||||
columnOrder: normalizeColumnOrder(settings.columnOrder),
|
||||
extractCpuPriority: settings.extractCpuPriority,
|
||||
autoExtractWhenStopped: settings.autoExtractWhenStopped !== undefined ? Boolean(settings.autoExtractWhenStopped) : defaults.autoExtractWhenStopped
|
||||
autoExtractWhenStopped: settings.autoExtractWhenStopped !== undefined ? Boolean(settings.autoExtractWhenStopped) : defaults.autoExtractWhenStopped,
|
||||
disabledProviders: Array.isArray(settings.disabledProviders) ? settings.disabledProviders.filter((p: unknown) => VALID_PRIMARY_PROVIDERS.has(p as string)) as DebridProvider[] : []
|
||||
};
|
||||
|
||||
if (!VALID_PRIMARY_PROVIDERS.has(normalized.providerPrimary)) {
|
||||
@ -214,7 +217,9 @@ function sanitizeCredentialPersistence(settings: AppSettings): AppSettings {
|
||||
ddownloadLogin: "",
|
||||
ddownloadPassword: "",
|
||||
oneFichierApiKey: "",
|
||||
debridLinkApiKeys: ""
|
||||
debridLinkApiKeys: "",
|
||||
linkSnappyLogin: "",
|
||||
linkSnappyPassword: ""
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -53,7 +53,7 @@ interface LinkPopupState {
|
||||
isPackage: boolean;
|
||||
}
|
||||
|
||||
type AccountService = "realdebrid" | "megadebrid" | "bestdebrid" | "alldebrid" | "ddownload" | "onefichier" | "debridlink";
|
||||
type AccountService = "realdebrid" | "megadebrid" | "bestdebrid" | "alldebrid" | "ddownload" | "onefichier" | "debridlink" | "linksnappy";
|
||||
type AccountKind =
|
||||
| "realdebrid-api"
|
||||
| "realdebrid-web"
|
||||
@ -65,7 +65,8 @@ type AccountKind =
|
||||
| "alldebrid-web"
|
||||
| "ddownload-login"
|
||||
| "onefichier-api"
|
||||
| "debridlink-api";
|
||||
| "debridlink-api"
|
||||
| "linksnappy-login";
|
||||
|
||||
type AccountQuickAction = "realdebrid-login" | "bestdebrid-cookies" | "alldebrid-login" | "alldebrid-status";
|
||||
type AccountColumnKey = "service" | "mode" | "status" | "secret";
|
||||
@ -97,6 +98,7 @@ interface ConfiguredAccountEntry {
|
||||
statusLabel: string;
|
||||
summary: string;
|
||||
note: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
const ACCOUNT_OPTIONS: AccountOption[] = [
|
||||
@ -106,7 +108,7 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
|
||||
serviceLabel: "Real-Debrid",
|
||||
title: "Real-Debrid API",
|
||||
modeLabel: "API",
|
||||
pickerDescription: "Direkter Zugriff ueber API-Token.",
|
||||
pickerDescription: "Direkter Zugriff über API-Token.",
|
||||
needsToken: true
|
||||
},
|
||||
{
|
||||
@ -115,7 +117,7 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
|
||||
serviceLabel: "Real-Debrid",
|
||||
title: "Real-Debrid Web",
|
||||
modeLabel: "Web",
|
||||
pickerDescription: "Login ueber Browserfenster statt Token."
|
||||
pickerDescription: "Login über Browserfenster statt Token."
|
||||
},
|
||||
{
|
||||
kind: "megadebrid-api",
|
||||
@ -123,7 +125,7 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
|
||||
serviceLabel: "Mega-Debrid",
|
||||
title: "Mega-Debrid API",
|
||||
modeLabel: "API",
|
||||
pickerDescription: "Login mit API-Praeferenz und Web-Fallback.",
|
||||
pickerDescription: "Login mit API-Präferenz und Web-Fallback.",
|
||||
needsCredentials: true
|
||||
},
|
||||
{
|
||||
@ -132,7 +134,7 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
|
||||
serviceLabel: "Mega-Debrid",
|
||||
title: "Mega-Debrid Web",
|
||||
modeLabel: "Web",
|
||||
pickerDescription: "Login mit Web-Praeferenz ueber Nutzername und Passwort.",
|
||||
pickerDescription: "Login mit Web-Präferenz über Nutzername und Passwort.",
|
||||
needsCredentials: true
|
||||
},
|
||||
{
|
||||
@ -141,7 +143,7 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
|
||||
serviceLabel: "BestDebrid",
|
||||
title: "BestDebrid API",
|
||||
modeLabel: "API",
|
||||
pickerDescription: "Direkter Zugriff ueber API-Token.",
|
||||
pickerDescription: "Direkter Zugriff über API-Token.",
|
||||
needsToken: true
|
||||
},
|
||||
{
|
||||
@ -158,7 +160,7 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
|
||||
serviceLabel: "AllDebrid",
|
||||
title: "AllDebrid API",
|
||||
modeLabel: "API",
|
||||
pickerDescription: "Direkter Zugriff ueber API-Key.",
|
||||
pickerDescription: "Direkter Zugriff über API-Key.",
|
||||
needsToken: true
|
||||
},
|
||||
{
|
||||
@ -167,7 +169,7 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
|
||||
serviceLabel: "AllDebrid",
|
||||
title: "AllDebrid Web",
|
||||
modeLabel: "Web",
|
||||
pickerDescription: "Login ueber Browserfenster fuer reCAPTCHA.",
|
||||
pickerDescription: "Login über Browserfenster für reCAPTCHA.",
|
||||
},
|
||||
{
|
||||
kind: "ddownload-login",
|
||||
@ -175,7 +177,7 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
|
||||
serviceLabel: "DDownload",
|
||||
title: "DDownload Login",
|
||||
modeLabel: "Login",
|
||||
pickerDescription: "Direkter Login fuer ddownload.com und ddl.to.",
|
||||
pickerDescription: "Direkter Login für ddownload.com und ddl.to.",
|
||||
needsCredentials: true
|
||||
},
|
||||
{
|
||||
@ -184,7 +186,7 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
|
||||
serviceLabel: "1Fichier",
|
||||
title: "1Fichier API",
|
||||
modeLabel: "API",
|
||||
pickerDescription: "API-Key fuer 1fichier.com.",
|
||||
pickerDescription: "API-Key für 1fichier.com.",
|
||||
needsToken: true
|
||||
},
|
||||
{
|
||||
@ -193,12 +195,21 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
|
||||
serviceLabel: "Debrid-Link",
|
||||
title: "Debrid-Link API",
|
||||
modeLabel: "API",
|
||||
pickerDescription: "API-Key(s) fuer debrid-link.com. Mehrere Keys zeilenweise fuer Multi-Account.",
|
||||
pickerDescription: "API-Key(s) für debrid-link.com. Mehrere Keys zeilenweise für Multi-Account.",
|
||||
needsToken: true
|
||||
},
|
||||
{
|
||||
kind: "linksnappy-login",
|
||||
service: "linksnappy",
|
||||
serviceLabel: "LinkSnappy",
|
||||
title: "LinkSnappy Login",
|
||||
modeLabel: "Login",
|
||||
pickerDescription: "Login für linksnappy.com mit Benutzername und Passwort.",
|
||||
needsCredentials: true
|
||||
}
|
||||
];
|
||||
|
||||
const ACCOUNT_SERVICES: AccountService[] = ["realdebrid", "megadebrid", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"];
|
||||
const ACCOUNT_SERVICES: AccountService[] = ["realdebrid", "megadebrid", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"];
|
||||
const ACCOUNT_COLUMN_STORAGE_KEY = "rd-account-column-widths";
|
||||
const ACCOUNT_COLUMN_DEFAULT_WIDTHS: Record<AccountColumnKey, number> = {
|
||||
service: 220,
|
||||
@ -283,11 +294,19 @@ function getConfiguredProvidersFromSettings(settings: AppSettings): DebridProvid
|
||||
if ((settings.debridLinkApiKeys || "").trim()) {
|
||||
list.push("debridlink");
|
||||
}
|
||||
if ((settings.linkSnappyLogin || "").trim() && (settings.linkSnappyPassword || "").trim()) {
|
||||
list.push("linksnappy");
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function getActiveProvidersFromSettings(settings: AppSettings): DebridProvider[] {
|
||||
const disabled = new Set(settings.disabledProviders || []);
|
||||
return getConfiguredProvidersFromSettings(settings).filter((p) => !disabled.has(p));
|
||||
}
|
||||
|
||||
function normalizeProviderSelectionForSettings(settings: AppSettings): Pick<AppSettings, "providerPrimary" | "providerSecondary" | "providerTertiary"> {
|
||||
const configuredProviders = getConfiguredProvidersFromSettings(settings);
|
||||
const configuredProviders = getActiveProvidersFromSettings(settings);
|
||||
const primaryProvider = configuredProviders.includes(settings.providerPrimary)
|
||||
? settings.providerPrimary
|
||||
: (configuredProviders[0] ?? "realdebrid");
|
||||
@ -326,6 +345,8 @@ function getConfiguredAccountKind(settings: AppSettings, service: AccountService
|
||||
return settings.oneFichierApiKey.trim() ? "onefichier-api" : null;
|
||||
case "debridlink":
|
||||
return (settings.debridLinkApiKeys || "").trim() ? "debridlink-api" : null;
|
||||
case "linksnappy":
|
||||
return (settings.linkSnappyLogin || "").trim() && (settings.linkSnappyPassword || "").trim() ? "linksnappy-login" : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@ -366,6 +387,8 @@ function summarizeAccount(kind: AccountKind, settings: AppSettings): string {
|
||||
if (keys.length > 1) return `${keys.length} API-Keys`;
|
||||
return keys.length === 1 ? maskValue(keys[0].trim(), 3, 3) : "Nicht hinterlegt";
|
||||
}
|
||||
case "linksnappy-login":
|
||||
return (settings.linkSnappyLogin || "").trim() ? maskValue((settings.linkSnappyLogin || "").trim(), 2, 4) : "Login + Passwort";
|
||||
default:
|
||||
return "Konfiguriert";
|
||||
}
|
||||
@ -403,6 +426,8 @@ function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | n
|
||||
return { mode, kind, token: settings.oneFichierApiKey, login: "", password: "" };
|
||||
case "debridlink-api":
|
||||
return { mode, kind, token: settings.debridLinkApiKeys || "", login: "", password: "" };
|
||||
case "linksnappy-login":
|
||||
return { mode, kind, token: "", login: settings.linkSnappyLogin || "", password: settings.linkSnappyPassword || "" };
|
||||
default:
|
||||
return { mode, kind, token: "", login: "", password: "" };
|
||||
}
|
||||
@ -438,6 +463,8 @@ function applyAccountDialogToSettings(settings: AppSettings, dialog: AccountDial
|
||||
return { ...settings, oneFichierApiKey: token };
|
||||
case "debridlink-api":
|
||||
return { ...settings, debridLinkApiKeys: token };
|
||||
case "linksnappy-login":
|
||||
return { ...settings, linkSnappyLogin: login, linkSnappyPassword: password };
|
||||
default:
|
||||
return settings;
|
||||
}
|
||||
@ -459,6 +486,8 @@ function clearAccountServiceFromSettings(settings: AppSettings, service: Account
|
||||
return { ...settings, oneFichierApiKey: "" };
|
||||
case "debridlink":
|
||||
return { ...settings, debridLinkApiKeys: "" };
|
||||
case "linksnappy":
|
||||
return { ...settings, linkSnappyLogin: "", linkSnappyPassword: "" };
|
||||
default:
|
||||
return settings;
|
||||
}
|
||||
@ -466,7 +495,7 @@ function clearAccountServiceFromSettings(settings: AppSettings, service: Account
|
||||
|
||||
function validateAccountDialog(dialog: AccountDialogState): string | null {
|
||||
if (!dialog.kind) {
|
||||
return "Bitte zuerst einen Account-Typ auswaehlen.";
|
||||
return "Bitte zuerst einen Account-Typ auswählen.";
|
||||
}
|
||||
const option = findAccountOption(dialog.kind);
|
||||
if (option.needsToken && !dialog.token.trim()) {
|
||||
@ -525,9 +554,17 @@ const cleanupLabels: Record<string, string> = {
|
||||
const AUTO_RENDER_PACKAGE_LIMIT = 260;
|
||||
|
||||
const providerLabels: Record<DebridProvider, string> = {
|
||||
realdebrid: "Real-Debrid", megadebrid: "Mega-Debrid", bestdebrid: "BestDebrid", alldebrid: "AllDebrid", ddownload: "DDownload", onefichier: "1Fichier", debridlink: "Debrid-Link"
|
||||
realdebrid: "Real-Debrid", megadebrid: "Mega-Debrid", bestdebrid: "BestDebrid", alldebrid: "AllDebrid", ddownload: "DDownload", onefichier: "1Fichier", debridlink: "Debrid-Link", linksnappy: "LinkSnappy"
|
||||
};
|
||||
|
||||
function providerLabelWithMode(provider: DebridProvider, settings: AppSettings): string {
|
||||
const base = providerLabels[provider];
|
||||
const kind = getConfiguredAccountKind(settings, provider);
|
||||
if (!kind) return base;
|
||||
const opt = ACCOUNT_OPTIONS.find((o) => o.kind === kind);
|
||||
return opt?.modeLabel ? `${base} (${opt.modeLabel})` : base;
|
||||
}
|
||||
|
||||
function formatDateTime(ts: number): string {
|
||||
if (!ts) return "";
|
||||
const d = new Date(ts);
|
||||
@ -1452,7 +1489,7 @@ export function App(): ReactElement {
|
||||
packages.length > 0 && packages.every((pkg) => collapsedPackages[pkg.id])
|
||||
), [packages, collapsedPackages]);
|
||||
|
||||
const configuredProviders = useMemo(() => getConfiguredProvidersFromSettings(settingsDraft), [settingsDraft]);
|
||||
const configuredProviders = useMemo(() => getActiveProvidersFromSettings(settingsDraft), [settingsDraft]);
|
||||
|
||||
// DDownload is a direct file hoster (not a debrid service) and is used automatically
|
||||
// for ddownload.com/ddl.to URLs. It counts as a configured account but does not
|
||||
@ -1508,9 +1545,9 @@ export function App(): ReactElement {
|
||||
} else if (kind === "megadebrid-web") {
|
||||
note = "Web wird bevorzugt, API bleibt als Fallback aktiv.";
|
||||
} else if (kind === "realdebrid-web") {
|
||||
note = "Login kann bei Bedarf direkt aus der Liste geoeffnet werden.";
|
||||
note = "Login kann bei Bedarf direkt aus der Liste geöffnet werden.";
|
||||
} else if (kind === "bestdebrid-web") {
|
||||
note = "Cookie-Import laesst sich direkt aus der Liste erneut starten.";
|
||||
note = "Cookie-Import lässt sich direkt aus der Liste erneut starten.";
|
||||
} else if (service === "alldebrid") {
|
||||
if (allDebridHostLoading) {
|
||||
statusLabel = "Lade Status";
|
||||
@ -1525,14 +1562,20 @@ export function App(): ReactElement {
|
||||
note = "Status basiert auf den zuletzt gespeicherten AllDebrid-Daten.";
|
||||
}
|
||||
}
|
||||
if (kind === "debridlink-api") {
|
||||
const keyCount = (settingsDraft.debridLinkApiKeys || "").split(/[\n,]+/).filter((k: string) => k.trim()).length;
|
||||
statusLabel = keyCount > 1 ? `${keyCount} API-Keys` : "Konfiguriert";
|
||||
}
|
||||
const isDisabled = (settingsDraft.disabledProviders || []).includes(service as DebridProvider);
|
||||
entries.push({
|
||||
kind,
|
||||
service,
|
||||
serviceLabel: option.serviceLabel,
|
||||
modeLabel: option.modeLabel,
|
||||
statusLabel,
|
||||
statusLabel: isDisabled ? "Deaktiviert" : statusLabel,
|
||||
summary: summarizeAccount(kind, settingsDraft),
|
||||
note
|
||||
note,
|
||||
disabled: isDisabled
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
@ -3586,7 +3629,7 @@ export function App(): ReactElement {
|
||||
const showQuickActionButton = Boolean(quickAction && !(showStatusButton && quickAction.action === "alldebrid-status"));
|
||||
const allDebridStateClass = entry.service === "alldebrid" && allDebridHostInfo ? ` account-status-${allDebridHostInfo.state}` : "";
|
||||
return (
|
||||
<div key={entry.service} className="account-row">
|
||||
<div key={entry.service} className={`account-row${entry.disabled ? " account-row-disabled" : ""}`}>
|
||||
<div className="account-cell account-service-cell">
|
||||
<strong>{entry.serviceLabel}</strong>
|
||||
<span>{option.title}</span>
|
||||
@ -3612,6 +3655,14 @@ export function App(): ReactElement {
|
||||
{quickAction.label}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn" disabled={actionBusy} onClick={() => {
|
||||
const provider = entry.service as DebridProvider;
|
||||
const current = settingsDraft.disabledProviders || [];
|
||||
const next = current.includes(provider) ? current.filter((p) => p !== provider) : [...current, provider];
|
||||
setSettingsDraft((prev) => ({ ...prev, disabledProviders: next }));
|
||||
}}>
|
||||
{entry.disabled ? "Aktivieren" : "Deaktivieren"}
|
||||
</button>
|
||||
<button className="btn" disabled={actionBusy} onClick={() => openEditAccountDialog(entry.kind)}>
|
||||
Bearbeiten
|
||||
</button>
|
||||
@ -3628,18 +3679,18 @@ export function App(): ReactElement {
|
||||
|
||||
<div className="settings-section card">
|
||||
<h3>Hoster-Reihenfolge</h3>
|
||||
<div className="hint">Debrid-Accounts koennen hier priorisiert werden. Direkte Host-Accounts wie DDownload und 1Fichier laufen separat.</div>
|
||||
<div className="hint">Debrid-Accounts können hier priorisiert werden. Direkte Host-Accounts wie DDownload und 1Fichier laufen separat.</div>
|
||||
{configuredProviders.length === 0 && (
|
||||
<div className="account-empty-state compact">
|
||||
<strong>Keine Debrid-Reihenfolge verfuegbar</strong>
|
||||
<span>Fuege mindestens einen Debrid-Account hinzu, dann kannst Du Hauptaccount und Alternativen festlegen.</span>
|
||||
<span>Füge mindestens einen Debrid-Account hinzu, dann kannst Du Hauptaccount und Alternativen festlegen.</span>
|
||||
</div>
|
||||
)}
|
||||
{configuredProviders.length >= 1 && (
|
||||
<div>
|
||||
<label>Hauptaccount</label>
|
||||
<select value={primaryProviderValue} onChange={(e) => setText("providerPrimary", e.target.value)}>
|
||||
{configuredProviders.map((provider) => (<option key={provider} value={provider}>{providerLabels[provider]}</option>))}
|
||||
{configuredProviders.map((provider) => (<option key={provider} value={provider}>{providerLabelWithMode(provider, settingsDraft)}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
@ -3648,7 +3699,7 @@ export function App(): ReactElement {
|
||||
<label>1. Hoster-Alternative</label>
|
||||
<select value={secondaryProviderValue} onChange={(e) => setText("providerSecondary", e.target.value)}>
|
||||
<option value="none">Keine Alternative</option>
|
||||
{secondaryProviderChoices.map((provider) => (<option key={provider} value={provider}>{providerLabels[provider]}</option>))}
|
||||
{secondaryProviderChoices.map((provider) => (<option key={provider} value={provider}>{providerLabelWithMode(provider, settingsDraft)}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
@ -3657,11 +3708,11 @@ export function App(): ReactElement {
|
||||
<label>2. Hoster-Alternative</label>
|
||||
<select value={tertiaryProviderValue} onChange={(e) => setText("providerTertiary", e.target.value)}>
|
||||
<option value="none">Keine Alternative</option>
|
||||
{tertiaryProviderChoices.map((provider) => (<option key={provider} value={provider}>{providerLabels[provider]}</option>))}
|
||||
{tertiaryProviderChoices.map((provider) => (<option key={provider} value={provider}>{providerLabelWithMode(provider, settingsDraft)}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoProviderFallback} onChange={(e) => setBool("autoProviderFallback", e.target.checked)} /> Bei Fehlern oder Fair-Use automatisch zum naechsten Provider wechseln</label>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoProviderFallback} onChange={(e) => setBool("autoProviderFallback", e.target.checked)} /> Bei Fehlern oder Fair-Use automatisch zum nächsten Provider wechseln</label>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.rememberToken} onChange={(e) => setBool("rememberToken", e.target.checked)} /> Zugangsdaten lokal speichern</label>
|
||||
</div>
|
||||
|
||||
@ -3760,19 +3811,19 @@ export function App(): ReactElement {
|
||||
)}
|
||||
{configuredProviders.length >= 1 && (
|
||||
<div><label>Hauptaccount</label><select value={primaryProviderValue} onChange={(e) => setText("providerPrimary", e.target.value)}>
|
||||
{configuredProviders.map((provider) => (<option key={provider} value={provider}>{providerLabels[provider]}</option>))}
|
||||
{configuredProviders.map((provider) => (<option key={provider} value={provider}>{providerLabelWithMode(provider, settingsDraft)}</option>))}
|
||||
</select></div>
|
||||
)}
|
||||
{configuredProviders.length >= 2 && (
|
||||
<div><label>1. Hoster-Alternative</label><select value={secondaryProviderValue} onChange={(e) => setText("providerSecondary", e.target.value)}>
|
||||
<option value="none">Keine Alternative</option>
|
||||
{secondaryProviderChoices.map((provider) => (<option key={provider} value={provider}>{providerLabels[provider]}</option>))}
|
||||
{secondaryProviderChoices.map((provider) => (<option key={provider} value={provider}>{providerLabelWithMode(provider, settingsDraft)}</option>))}
|
||||
</select></div>
|
||||
)}
|
||||
{configuredProviders.length >= 3 && (
|
||||
<div><label>2. Hoster-Alternative</label><select value={tertiaryProviderValue} onChange={(e) => setText("providerTertiary", e.target.value)}>
|
||||
<option value="none">Keine Alternative</option>
|
||||
{tertiaryProviderChoices.map((provider) => (<option key={provider} value={provider}>{providerLabels[provider]}</option>))}
|
||||
{tertiaryProviderChoices.map((provider) => (<option key={provider} value={provider}>{providerLabelWithMode(provider, settingsDraft)}</option>))}
|
||||
</select></div>
|
||||
)}
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoProviderFallback} onChange={(e) => setBool("autoProviderFallback", e.target.checked)} /> Bei Fehler/Fair-Use automatisch zum nächsten Provider wechseln</label>
|
||||
@ -4051,8 +4102,8 @@ export function App(): ReactElement {
|
||||
|
||||
{!accountDialogOption && (
|
||||
<div className="account-empty-state compact">
|
||||
<strong>Oben zuerst einen Account-Typ waehlen</strong>
|
||||
<span>Danach erscheinen hier direkt die passenden Felder fuer Login, Passwort oder API-Token.</span>
|
||||
<strong>Oben zuerst einen Account-Typ wählen</strong>
|
||||
<span>Danach erscheinen hier direkt die passenden Felder für Login, Passwort oder API-Token.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -4066,8 +4117,12 @@ export function App(): ReactElement {
|
||||
<div className="account-modal-fields">
|
||||
{accountDialogOption.needsToken && (
|
||||
<div>
|
||||
<label>{accountDialogOption.service === "alldebrid" || accountDialogOption.service === "onefichier" ? "API-Key" : "Token"}</label>
|
||||
<input type="password" value={accountDialog.token} onChange={(event) => setAccountDialog((prev) => prev ? { ...prev, token: event.target.value } : prev)} />
|
||||
<label>{accountDialogOption.service === "alldebrid" || accountDialogOption.service === "onefichier" || accountDialogOption.service === "debridlink" ? "API-Key(s)" : "Token"}</label>
|
||||
{accountDialogOption.service === "debridlink" ? (
|
||||
<textarea rows={4} placeholder="Ein API-Key pro Zeile" value={accountDialog.token} onChange={(event) => setAccountDialog((prev) => prev ? { ...prev, token: event.target.value } : prev)} style={{ fontFamily: "monospace", resize: "vertical" }} />
|
||||
) : (
|
||||
<input type="password" value={accountDialog.token} onChange={(event) => setAccountDialog((prev) => prev ? { ...prev, token: event.target.value } : prev)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -4085,7 +4140,7 @@ export function App(): ReactElement {
|
||||
)}
|
||||
|
||||
{accountDialog.kind === "realdebrid-web" && (
|
||||
<div className="account-modal-note">Nach dem Speichern kannst Du direkt das Browserfenster fuer den Web-Login oeffnen.</div>
|
||||
<div className="account-modal-note">Nach dem Speichern kannst Du direkt das Browserfenster für den Web-Login öffnen.</div>
|
||||
)}
|
||||
{accountDialog.kind === "bestdebrid-web" && (
|
||||
<div className="account-modal-note">Der Web-Account arbeitet ueber einen Cookies.txt-Import aus dem Browser.</div>
|
||||
|
||||
@ -1141,8 +1141,8 @@ body,
|
||||
minmax(180px, var(--account-col-service))
|
||||
minmax(80px, var(--account-col-mode))
|
||||
minmax(180px, var(--account-col-status))
|
||||
minmax(140px, var(--account-col-secret))
|
||||
minmax(190px, 1fr);
|
||||
minmax(120px, var(--account-col-secret))
|
||||
minmax(260px, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
@ -1287,18 +1287,26 @@ body,
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.account-row-actions .btn {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
padding: 5px 8px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-row-disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.account-row-disabled .account-row-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.account-modal {
|
||||
width: min(880px, calc(100vw - 36px));
|
||||
width: min(960px, calc(100vw - 36px));
|
||||
max-height: calc(100vh - 36px);
|
||||
overflow: hidden;
|
||||
gap: 14px;
|
||||
@ -1401,6 +1409,11 @@ body,
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.account-modal textarea {
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.account-picker-table {
|
||||
display: grid;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 84%, transparent);
|
||||
|
||||
@ -14,7 +14,7 @@ export type CleanupMode = "none" | "trash" | "delete";
|
||||
export type ConflictMode = "overwrite" | "skip" | "rename" | "ask";
|
||||
export type SpeedMode = "global" | "per_download";
|
||||
export type FinishedCleanupPolicy = "never" | "immediate" | "on_start" | "package_done";
|
||||
export type DebridProvider = "realdebrid" | "megadebrid" | "bestdebrid" | "alldebrid" | "ddownload" | "onefichier" | "debridlink";
|
||||
export type DebridProvider = "realdebrid" | "megadebrid" | "bestdebrid" | "alldebrid" | "ddownload" | "onefichier" | "debridlink" | "linksnappy";
|
||||
export type DebridFallbackProvider = DebridProvider | "none";
|
||||
export type AppTheme = "dark" | "light";
|
||||
export type PackagePriority = "high" | "normal" | "low";
|
||||
@ -50,6 +50,8 @@ export interface AppSettings {
|
||||
ddownloadPassword: string;
|
||||
oneFichierApiKey: string;
|
||||
debridLinkApiKeys: string;
|
||||
linkSnappyLogin: string;
|
||||
linkSnappyPassword: string;
|
||||
archivePasswordList: string;
|
||||
rememberToken: boolean;
|
||||
providerPrimary: DebridProvider;
|
||||
@ -93,6 +95,7 @@ export interface AppSettings {
|
||||
columnOrder: string[];
|
||||
extractCpuPriority: ExtractCpuPriority;
|
||||
autoExtractWhenStopped: boolean;
|
||||
disabledProviders: DebridProvider[];
|
||||
}
|
||||
|
||||
export interface DownloadItem {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user