feat: add per-hoster provider routing (Hoster-Zuordnung)
- New settings field hosterRouting maps file hosters to specific debrid providers - 27 known hosters predefined (Rapidgator, Uploaded, Turbobit, Nitroflare, etc.) - Custom hoster support via prompt dialog - Routing takes priority over default provider chain - Falls back to normal chain on error when autoProviderFallback is enabled - Logs routing decisions: "Hoster-Zuordnung: rapidgator → Debrid-Link" - Full UI section in settings with add/remove/change provider per hoster - Storage validation and normalization for hosterRouting Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9f94404435
commit
c811649b9d
@ -98,6 +98,7 @@ export function defaultSettings(): AppSettings {
|
||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
||||
extractCpuPriority: "high",
|
||||
autoExtractWhenStopped: true,
|
||||
disabledProviders: []
|
||||
disabledProviders: [],
|
||||
hosterRouting: {}
|
||||
};
|
||||
}
|
||||
|
||||
@ -33,6 +33,16 @@ const PROVIDER_LABELS: Record<DebridProvider, string> = {
|
||||
linksnappy: "LinkSnappy"
|
||||
};
|
||||
|
||||
function extractHosterFromUrl(url: string): string {
|
||||
try {
|
||||
const host = new URL(url).hostname.replace(/^www\./, "").toLowerCase();
|
||||
const parts = host.split(".");
|
||||
return parts.length >= 2 ? parts[parts.length - 2] : host;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
interface ProviderUnrestrictedLink extends UnrestrictedLink {
|
||||
provider: DebridProvider;
|
||||
providerLabel: string;
|
||||
@ -1875,6 +1885,42 @@ export class DebridService {
|
||||
public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings): Promise<ProviderUnrestrictedLink> {
|
||||
const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings);
|
||||
|
||||
// Hoster-Zuordnung: prüfe ob für diesen Hoster ein bestimmter Provider konfiguriert ist
|
||||
const routing = settings.hosterRouting || {};
|
||||
const hosterKey = extractHosterFromUrl(link);
|
||||
if (hosterKey && routing[hosterKey]) {
|
||||
const routedProvider = routing[hosterKey];
|
||||
if (this.isProviderConfiguredFor(settings, routedProvider)) {
|
||||
logger.info(`Hoster-Zuordnung: ${hosterKey} → ${PROVIDER_LABELS[routedProvider]}`);
|
||||
try {
|
||||
const result = await this.unrestrictViaProvider(settings, routedProvider, link, signal);
|
||||
let fileName = result.fileName;
|
||||
if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
|
||||
const fromPage = await resolveRapidgatorFilename(link, signal);
|
||||
if (fromPage) fileName = fromPage;
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
fileName,
|
||||
provider: routedProvider,
|
||||
providerLabel: PROVIDER_LABELS[routedProvider] + (result.sourceLabel ? ` (${result.sourceLabel})` : "")
|
||||
};
|
||||
} catch (error) {
|
||||
const errorText = compactErrorText(error);
|
||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
||||
throw error;
|
||||
}
|
||||
if (!settings.autoProviderFallback) {
|
||||
throw new Error(`Hoster-Zuordnung fehlgeschlagen (${hosterKey} → ${PROVIDER_LABELS[routedProvider]}): ${errorText}`);
|
||||
}
|
||||
logger.warn(`Hoster-Zuordnung ${hosterKey} → ${PROVIDER_LABELS[routedProvider]} fehlgeschlagen, Fallback auf Provider-Kette: ${errorText}`);
|
||||
// Fall through to normal provider chain
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Hoster-Zuordnung ${hosterKey} → ${PROVIDER_LABELS[routedProvider]} übersprungen (Provider nicht konfiguriert/deaktiviert)`);
|
||||
}
|
||||
}
|
||||
|
||||
// 1Fichier is a direct file hoster. If the link is a 1fichier.com URL
|
||||
// and the API key is configured, use 1Fichier directly before debrid providers.
|
||||
if (ONEFICHIER_URL_RE.test(link) && this.isProviderConfiguredFor(settings, "onefichier")) {
|
||||
|
||||
@ -91,6 +91,19 @@ function normalizeColumnOrder(raw: unknown): string[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeHosterRouting(raw: unknown): Record<string, DebridProvider> {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||
const result: Record<string, DebridProvider> = {};
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
const hoster = String(key).trim().toLowerCase();
|
||||
const provider = String(value ?? "").trim();
|
||||
if (hoster && VALID_PRIMARY_PROVIDERS.has(provider)) {
|
||||
result[hoster] = provider as DebridProvider;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const DEPRECATED_UPDATE_REPOS = new Set([
|
||||
"sucukdeluxe/real-debrid-downloader"
|
||||
]);
|
||||
@ -164,7 +177,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
||||
columnOrder: normalizeColumnOrder(settings.columnOrder),
|
||||
extractCpuPriority: settings.extractCpuPriority,
|
||||
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[] : []
|
||||
disabledProviders: Array.isArray(settings.disabledProviders) ? settings.disabledProviders.filter((p: unknown) => VALID_PRIMARY_PROVIDERS.has(p as string)) as DebridProvider[] : [],
|
||||
hosterRouting: normalizeHosterRouting(settings.hosterRouting)
|
||||
};
|
||||
|
||||
if (!VALID_PRIMARY_PROVIDERS.has(normalized.providerPrimary)) {
|
||||
|
||||
@ -536,7 +536,9 @@ const emptySnapshot = (): UiSnapshot => ({
|
||||
theme: "dark", collapseNewPackages: true, autoSkipExtracted: false, confirmDeleteSelection: true,
|
||||
bandwidthSchedules: [], totalDownloadedAllTime: 0,
|
||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
||||
autoExtractWhenStopped: true
|
||||
autoExtractWhenStopped: true,
|
||||
disabledProviders: [],
|
||||
hosterRouting: {}
|
||||
},
|
||||
session: {
|
||||
version: 2, packageOrder: [], packages: {}, items: {}, runStartedAt: 0,
|
||||
@ -557,6 +559,36 @@ const providerLabels: Record<DebridProvider, string> = {
|
||||
realdebrid: "Real-Debrid", megadebrid: "Mega-Debrid", bestdebrid: "BestDebrid", alldebrid: "AllDebrid", ddownload: "DDownload", onefichier: "1Fichier", debridlink: "Debrid-Link", linksnappy: "LinkSnappy"
|
||||
};
|
||||
|
||||
const KNOWN_HOSTERS: { id: string; label: string }[] = [
|
||||
{ id: "rapidgator", label: "Rapidgator" },
|
||||
{ id: "uploaded", label: "Uploaded" },
|
||||
{ id: "1fichier", label: "1Fichier" },
|
||||
{ id: "ddownload", label: "DDownload" },
|
||||
{ id: "ddl", label: "DDL.to" },
|
||||
{ id: "turbobit", label: "Turbobit" },
|
||||
{ id: "nitroflare", label: "Nitroflare" },
|
||||
{ id: "filefactory", label: "FileFactory" },
|
||||
{ id: "katfile", label: "Katfile" },
|
||||
{ id: "hitfile", label: "Hitfile" },
|
||||
{ id: "alfafile", label: "Alfafile" },
|
||||
{ id: "k2s", label: "Keep2Share" },
|
||||
{ id: "keep2share", label: "Keep2Share (alt)" },
|
||||
{ id: "tezfiles", label: "Tezfiles" },
|
||||
{ id: "fileboom", label: "Fileboom" },
|
||||
{ id: "mexashare", label: "Mexashare" },
|
||||
{ id: "wdupload", label: "WDUpload" },
|
||||
{ id: "rosefile", label: "Rosefile" },
|
||||
{ id: "filejoker", label: "FileJoker" },
|
||||
{ id: "worldbytez", label: "Worldbytez" },
|
||||
{ id: "fileland", label: "Fileland" },
|
||||
{ id: "depositfiles", label: "DepositFiles" },
|
||||
{ id: "mediafire", label: "MediaFire" },
|
||||
{ id: "mega", label: "Mega.nz" },
|
||||
{ id: "frdl", label: "FreeDownload" },
|
||||
{ id: "hexupload", label: "HexUpload" },
|
||||
{ id: "isra", label: "Isra.cloud" }
|
||||
];
|
||||
|
||||
function providerLabelWithMode(provider: DebridProvider, settings: AppSettings): string {
|
||||
const base = providerLabels[provider];
|
||||
const kind = getConfiguredAccountKind(settings, provider);
|
||||
@ -3716,6 +3748,92 @@ export function App(): ReactElement {
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.rememberToken} onChange={(e) => setBool("rememberToken", e.target.checked)} /> Zugangsdaten lokal speichern</label>
|
||||
</div>
|
||||
|
||||
{configuredProviders.length >= 1 && (
|
||||
<div className="settings-section card">
|
||||
<h3>Hoster-Zuordnung</h3>
|
||||
<div className="hint">Lege fest, welcher Debrid-Provider sich um welchen Filehoster kümmert. Nicht zugeordnete Hoster nutzen die Standard-Reihenfolge oben.</div>
|
||||
{(() => {
|
||||
const routing: Record<string, DebridProvider> = settingsDraft.hosterRouting || {};
|
||||
const routingEntries = Object.entries(routing).sort(([a], [b]) => a.localeCompare(b));
|
||||
const usedHosters = new Set(routingEntries.map(([h]) => h));
|
||||
const availableHosters = KNOWN_HOSTERS.filter((h) => !usedHosters.has(h.id));
|
||||
|
||||
const setRouting = (newRouting: Record<string, DebridProvider>) => {
|
||||
setSettingsDraft((prev) => ({ ...prev, hosterRouting: newRouting }));
|
||||
};
|
||||
|
||||
const addEntry = (hosterId: string) => {
|
||||
if (!hosterId || routing[hosterId]) return;
|
||||
setRouting({ ...routing, [hosterId]: configuredProviders[0] });
|
||||
};
|
||||
|
||||
const removeEntry = (hosterId: string) => {
|
||||
const copy = { ...routing };
|
||||
delete copy[hosterId];
|
||||
setRouting(copy);
|
||||
};
|
||||
|
||||
const changeProvider = (hosterId: string, provider: DebridProvider) => {
|
||||
setRouting({ ...routing, [hosterId]: provider });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{routingEntries.length > 0 && (
|
||||
<div className="hoster-routing-table">
|
||||
<div className="hoster-routing-header">
|
||||
<span>Filehoster</span>
|
||||
<span>Zuständiger Provider</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{routingEntries.map(([hosterId, provider]) => {
|
||||
const hosterLabel = KNOWN_HOSTERS.find((h) => h.id === hosterId)?.label || hosterId;
|
||||
return (
|
||||
<div key={hosterId} className="hoster-routing-row">
|
||||
<span className="hoster-routing-label">{hosterLabel}</span>
|
||||
<select value={provider} onChange={(e) => changeProvider(hosterId, e.target.value as DebridProvider)}>
|
||||
{configuredProviders.map((p) => (
|
||||
<option key={p} value={p}>{providerLabelWithMode(p, settingsDraft)}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn btn-sm btn-danger" onClick={() => removeEntry(hosterId)} title="Zuordnung entfernen">×</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{routingEntries.length === 0 && (
|
||||
<div className="hint" style={{ fontStyle: "italic", opacity: 0.7 }}>Noch keine Zuordnungen. Alle Hoster nutzen die Standard-Reihenfolge.</div>
|
||||
)}
|
||||
<div className="hoster-routing-add">
|
||||
<select
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val === "__custom") {
|
||||
const name = window.prompt("Hoster-Domain eingeben (z.B. rapidgator, turbobit):");
|
||||
const clean = (name || "").trim().toLowerCase().replace(/^www\./, "").split(".")[0];
|
||||
if (clean) addEntry(clean);
|
||||
} else {
|
||||
addEntry(val);
|
||||
}
|
||||
e.target.value = "";
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>Hoster hinzufügen...</option>
|
||||
{availableHosters.map((h) => (
|
||||
<option key={h.id} value={h.id}>{h.label}</option>
|
||||
))}
|
||||
<option value="" disabled>───────────</option>
|
||||
<option value="__custom">Eigener Hoster...</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div hidden>
|
||||
<div className="settings-section card">
|
||||
<h3>Accounts</h3>
|
||||
|
||||
@ -1305,6 +1305,69 @@ body,
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.hoster-routing-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.hoster-routing-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 40px;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: color-mix(in srgb, var(--card) 80%, var(--surface));
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.hoster-routing-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 40px;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 40%, transparent);
|
||||
}
|
||||
|
||||
.hoster-routing-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.hoster-routing-label {
|
||||
font-weight: 500;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.hoster-routing-row select {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.hoster-routing-row .btn-sm {
|
||||
padding: 2px 8px;
|
||||
font-size: 0.9rem;
|
||||
min-width: 28px;
|
||||
line-height: 1.4;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.hoster-routing-add {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.hoster-routing-add select {
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.account-modal {
|
||||
width: min(960px, calc(100vw - 36px));
|
||||
max-height: calc(100vh - 36px);
|
||||
|
||||
@ -96,6 +96,7 @@ export interface AppSettings {
|
||||
extractCpuPriority: ExtractCpuPriority;
|
||||
autoExtractWhenStopped: boolean;
|
||||
disabledProviders: DebridProvider[];
|
||||
hosterRouting: Record<string, DebridProvider>;
|
||||
}
|
||||
|
||||
export interface DownloadItem {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user