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:
Sucukdeluxe 2026-03-06 19:14:16 +01:00
parent 9f94404435
commit c811649b9d
6 changed files with 246 additions and 3 deletions

View File

@ -98,6 +98,7 @@ export function defaultSettings(): AppSettings {
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
extractCpuPriority: "high",
autoExtractWhenStopped: true,
disabledProviders: []
disabledProviders: [],
hosterRouting: {}
};
}

View File

@ -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")) {

View File

@ -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)) {

View File

@ -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">&times;</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>

View File

@ -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);

View File

@ -96,6 +96,7 @@ export interface AppSettings {
extractCpuPriority: ExtractCpuPriority;
autoExtractWhenStopped: boolean;
disabledProviders: DebridProvider[];
hosterRouting: Record<string, DebridProvider>;
}
export interface DownloadItem {