release: prepare v2.0.43

Add sanitized provider and account runtime diagnostics, complete the Multi-Debrid-Downloader product rename, preserve existing application data during migration, update public release verification, and publish the accompanying English documentation and regression coverage.
This commit is contained in:
Sucukdeluxe
2026-08-16 21:50:01 +02:00
parent 7107a92ae2
commit a8a5ca4b40
36 changed files with 1117 additions and 146 deletions
+126 -1
View File
@@ -1593,7 +1593,7 @@ export function App(): ReactElement {
const [avatarMenuOpen, setAvatarMenuOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [settingsSubTab, setSettingsSubTab] = useState<SettingsSection>("allgemein");
const [accountManagementTab, setAccountManagementTab] = useState<"overview" | "rules">("overview");
const [accountManagementTab, setAccountManagementTab] = useState<"overview" | "rules" | "runtime">("overview");
const [selectedAccountRowKeys, setSelectedAccountRowKeys] = useState<Set<string>>(() => new Set());
const [openSubmenu, setOpenSubmenu] = useState<string | null>(null);
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null);
@@ -5011,6 +5011,130 @@ export function App(): ReactElement {
const visibleAccountRows = useMemo(() => accountStatusSort === "none"
? projectedAccountRows
: sortAccountRows(projectedAccountRows, accountStatusSort), [accountStatusSort, projectedAccountRows]);
const accountRuntimeModel = useMemo<AccountWorkspaceViewModel["runtime"]>(() => {
const runtimeEntries = snapshot.accountRuntime || [];
const runtimeByAccountId = new Map(runtimeEntries.map((entry) => [`${entry.provider}:${entry.accountId}`, entry]));
const runtimeAccountIdCounts = new Map<string, number>();
for (const entry of runtimeEntries) {
runtimeAccountIdCounts.set(entry.accountId, (runtimeAccountIdCounts.get(entry.accountId) || 0) + 1);
}
const uniqueRuntimeByAccountId = new Map(runtimeEntries
.filter((entry) => runtimeAccountIdCounts.get(entry.accountId) === 1)
.map((entry) => [entry.accountId, entry]));
const projectedById = new Map(projectedAccountRows.map((row) => [row.id, row]));
const providerGroups = new Map<string, {
id: string;
label: string;
accountCount: number;
availableAccountCount: number;
activeDownloads: number;
dailyUsageBytes: number;
}>();
const stateLabels = {
ready: "Bereit",
active: "Aktiv",
checking: "Prüfung",
cooldown: "Cooldown",
disabled: "Deaktiviert",
daily_limit: "Tageslimit",
invalid: "Fehler"
} as const;
const stateTones = {
ready: "ok",
active: "active",
checking: "active",
cooldown: "warning",
disabled: "muted",
daily_limit: "warning",
invalid: "danger"
} as const;
const accounts = accountRows.map((row) => {
const viewId = accountRowViewId(row);
const projected = projectedById.get(viewId);
const runtimeId = row.accountId || `svc-${row.entry.provider}`;
const runtimeProvider = row.entry.provider === "megadebrid"
? (row.modeLabel.toLocaleLowerCase("de-DE").includes("web") ? "megadebrid-web" : "megadebrid-api")
: row.entry.provider;
const runtime = runtimeByAccountId.get(`${runtimeProvider}:${runtimeId}`)
?? uniqueRuntimeByAccountId.get(runtimeId);
const fallbackState = row.disabled
? "disabled"
: row.dailyLimitBytes > 0 && row.dailyUsedBytes >= row.dailyLimitBytes
? "daily_limit"
: projected?.problem
? "invalid"
: "ready";
const state = runtime?.state || fallbackState;
const outcomes = (runtime?.successes || 0) + (runtime?.failures || 0);
const successRateText = outcomes > 0
? `${Math.round(((runtime?.successes || 0) / outcomes) * 100)} % (${runtime?.successes || 0}/${outcomes})`
: "—";
const lastUsedAt = runtime?.lastUsedAt || null;
let lastUsedText = "Noch nicht in dieser Sitzung";
if (lastUsedAt) {
const ageSeconds = Math.max(0, Math.floor((runtimeNow - lastUsedAt) / 1000));
lastUsedText = ageSeconds < 60
? "Gerade eben"
: ageSeconds < 3600
? `vor ${Math.floor(ageSeconds / 60)} Min.`
: ageSeconds < 86400
? `vor ${Math.floor(ageSeconds / 3600)} Std.`
: formatDateTime(lastUsedAt);
}
let cooldownText = "—";
if (runtime?.cooldownUntil && runtime.cooldownUntil > runtimeNow) {
const seconds = Math.max(1, Math.ceil((runtime.cooldownUntil - runtimeNow) / 1000));
const duration = seconds < 60
? `${seconds} Sek.`
: seconds < 3600
? `${Math.ceil(seconds / 60)} Min.`
: `${Math.ceil(seconds / 3600)} Std.`;
cooldownText = `${runtime.reason} · ${duration}`;
} else if (state === "disabled" || state === "daily_limit" || state === "invalid") {
cooldownText = runtime?.reason || stateLabels[state];
}
const providerKey = row.hosterLabel.toLocaleLowerCase("de-DE");
const providerGroup = providerGroups.get(providerKey) ?? {
id: providerKey,
label: row.hosterLabel,
accountCount: 0,
availableAccountCount: 0,
activeDownloads: 0,
dailyUsageBytes: 0
};
providerGroup.accountCount += 1;
providerGroup.availableAccountCount += state === "ready" || state === "active" || state === "checking" ? 1 : 0;
providerGroup.activeDownloads += runtime?.activeDownloads || 0;
providerGroup.dailyUsageBytes += runtime?.dailyUsageBytes ?? row.dailyUsedBytes;
providerGroups.set(providerKey, providerGroup);
return {
id: viewId,
providerLabel: row.hosterLabel,
modeLabel: row.modeLabel,
identity: projected?.username !== "—" ? projected?.username || "" : projected?.email || "",
stateLabel: stateLabels[state],
stateTone: stateTones[state],
activeDownloads: runtime?.activeDownloads || 0,
dailyUsageText: humanSize(runtime?.dailyUsageBytes ?? row.dailyUsedBytes),
successRateText,
lastUsedText,
cooldownText
};
});
return {
providers: [...providerGroups.values()]
.sort((left, right) => left.label.localeCompare(right.label, "de-DE", { sensitivity: "base" }))
.map((provider) => ({
id: provider.id,
label: provider.label,
accountCount: provider.accountCount,
availableAccountCount: provider.availableAccountCount,
activeDownloads: provider.activeDownloads,
dailyUsageText: humanSize(provider.dailyUsageBytes)
})),
accounts
};
}, [accountRows, projectedAccountRows, runtimeNow, snapshot.accountRuntime]);
const routingEntries = useMemo(() => Object.entries(settingsDraft.hosterRouting || {}).sort(([left], [right]) => left.localeCompare(right)), [settingsDraft.hosterRouting]);
const usedRoutingHosters = useMemo(() => new Set(routingEntries.map(([hosterId]) => hosterId)), [routingEntries]);
const routingProviderOptions = useMemo(() => configuredProviders.map((provider) => ({
@@ -5023,6 +5147,7 @@ export function App(): ReactElement {
selectedIds: selectedAccountViewIds,
busy: actionBusy || accountCheckBusy,
statusSort: accountStatusSort,
runtime: accountRuntimeModel,
rules: {
providerOrder: activeProviderOrder.map((provider) => providerLabelWithMode(provider, settingsDraft)),
routing: routingEntries.map(([hosterId, provider]) => `${KNOWN_HOSTERS.find((hoster) => hoster.id === hosterId)?.label || hosterId}${providerLabelWithMode(provider, settingsDraft)}`),
+24 -1
View File
@@ -28,7 +28,10 @@ const pairs = [
["Priorität", "Priority"], ["Status", "Status"], ["Aktion", "Action"], ["Alle Services", "All services"], ["Paket, Datei oder Service", "Package, file or service"], ["Alle ein-/ausklappen", "Expand/collapse all"],
["Hoch", "High"], ["Normal", "Normal"], ["Niedrig", "Low"], ["In Warteschlange", "Queued"], ["Abgeschlossen", "Completed"], ["Entpackt", "Extracted"], ["Automatisch entpacken", "Extract automatically"],
["Liste leeren", "Clear list"], ["Sitzung", "Session"], ["Gesamt", "Total"], ["Verbleibend", "Remaining"], ["Bereit", "Ready"], ["Download läuft", "Download running"], ["Wartet", "Waiting"], ["Offline", "Offline"],
["Übersicht", "Overview"], ["Verwendungsregeln", "Usage rules"], ["Accountverwaltung", "Account management"], ["Accounts hinzufügen, prüfen und verwalten.", "Add, check and manage accounts."],
["Übersicht", "Overview"], ["Verwendungsregeln", "Usage rules"], ["Laufzeit", "Runtime"], ["Accountverwaltung", "Account management"], ["Accounts hinzufügen, prüfen und verwalten.", "Add, check and manage accounts."],
["Provider-Laufzeit", "Provider runtime"], ["Account-Laufzeit", "Account runtime"], ["Aktive Downloads", "Active downloads"], ["Erfolgsquote · Diese Sitzung", "Success rate · This session"], ["Zuletzt verwendet", "Last used"], ["Cooldown / Grund", "Cooldown / reason"],
["Noch keine Accounts konfiguriert.", "No accounts configured yet."], ["Noch keine Laufzeitdaten verfügbar.", "No runtime data available yet."], ["Noch nicht in dieser Sitzung", "Not yet in this session"], ["Gerade eben", "Just now"], ["Prüfung", "Checking"], ["Tageslimit", "Daily limit"], ["Cooldown", "Cooldown"],
["aktiver Download", "active download"], ["aktive Downloads", "active downloads"], ["heute", "today"], ["Account deaktiviert", "Account disabled"], ["Tageslimit erreicht", "Daily limit reached"], ["Anmeldung ungültig", "Invalid login"], ["Rate-Limit aktiv", "Rate limit active"], ["Traffic- oder Kontolimit erreicht", "Traffic or account limit reached"], ["Vorübergehender Cooldown", "Temporary cooldown"], ["Provider oder Link vorübergehend nicht verfügbar", "Provider or link temporarily unavailable"],
["Accounts zum Herunterladen verwenden", "Use accounts for downloads"], ["Download-Traffic übrig", "Download traffic remaining"], ["Benutzername", "Username"], ["E-Mail", "Email"], ["Verfallsdatum", "Expiration date"], ["Passwort/Zugang", "Password/access"],
["Account hinzufügen", "Add account"], ["Ausgewählte prüfen", "Check selected"], ["Ausgewählte entfernen", "Remove selected"], ["Aktivieren", "Enable"], ["Deaktivieren", "Disable"], ["Noch nicht geprüft", "Not checked yet"],
["Aktiviert", "Enabled"], ["Aktionen", "Actions"], ["Deaktiviert", "Disabled"], ["Premium aktiv", "Premium active"], ["API-Key aktiv", "API key active"], ["API-Account", "API account"], ["API-Key", "API key"],
@@ -236,6 +239,16 @@ function translateDynamic(value: string, language: AppLanguage): string {
if (pageStatus) return `Page ${pageStatus[1]} of ${pageStatus[2]}`;
const filter = value.match(/^(Alle|Aktiv|Wartend|Pausiert|Fertig|Fehler) (\d+)$/);
if (filter) return `${deToEn.get(filter[1]) ?? filter[1]} ${filter[2]}`;
const runtimeAvailability = value.match(/^(\d+) von (\d+) verfügbar$/);
if (runtimeAvailability) return `${runtimeAvailability[1]} of ${runtimeAvailability[2]} available`;
const runtimeAgo = value.match(/^vor (\d+) (Min|Std)\.$/);
if (runtimeAgo) return `${runtimeAgo[1]} ${runtimeAgo[2] === "Min" ? "min" : "hr"} ago`;
const runtimeCooldown = value.match(/^(.+) · (\d+) (Sek|Min|Std)\.$/);
if (runtimeCooldown) {
const reason = deToEn.get(runtimeCooldown[1]) ?? runtimeCooldown[1];
const unit = runtimeCooldown[3] === "Sek" ? "sec" : runtimeCooldown[3] === "Min" ? "min" : "hr";
return `${reason} · ${runtimeCooldown[2]} ${unit}`;
}
const remaining = value.match(/^(.+) von (.+) übrig$/);
if (remaining) return `${remaining[1]} of ${remaining[2]} remaining`;
const actionsFor = value.match(/^Aktionen für (.+)$/);
@@ -391,6 +404,16 @@ function translateDynamic(value: string, language: AppLanguage): string {
if (perPage) return `${perPage[1]} pro Seite`;
const filter = value.match(/^(All|Active|Queued|Paused|Completed|Errors) (\d+)$/);
if (filter) return `${enToDe.get(filter[1]) ?? filter[1]} ${filter[2]}`;
const runtimeAvailability = value.match(/^(\d+) of (\d+) available$/);
if (runtimeAvailability) return `${runtimeAvailability[1]} von ${runtimeAvailability[2]} verfügbar`;
const runtimeAgo = value.match(/^(\d+) (min|hr) ago$/);
if (runtimeAgo) return `vor ${runtimeAgo[1]} ${runtimeAgo[2] === "min" ? "Min" : "Std"}`;
const runtimeCooldown = value.match(/^(.+) · (\d+) (sec|min|hr)$/);
if (runtimeCooldown) {
const reason = enToDe.get(runtimeCooldown[1]) ?? runtimeCooldown[1];
const unit = runtimeCooldown[3] === "sec" ? "Sek" : runtimeCooldown[3] === "min" ? "Min" : "Std";
return `${reason} · ${runtimeCooldown[2]} ${unit}.`;
}
const remaining = value.match(/^(.+) of (.+) remaining$/);
if (remaining) return `${remaining[1]} von ${remaining[2]} übrig`;
const actionsFor = value.match(/^Actions for (.+)$/);
+110 -19
View File
@@ -29,11 +29,12 @@ import {
type AccountTableColumnWidths
} from "./settings-model";
export type AccountWorkspacePanel = "overview" | "rules";
export type AccountWorkspacePanel = "overview" | "rules" | "runtime";
const ACCOUNT_WORKSPACE_PANELS: readonly { id: AccountWorkspacePanel; label: string }[] = [
{ id: "overview", label: "Übersicht" },
{ id: "rules", label: "Verwendungsregeln" }
{ id: "rules", label: "Verwendungsregeln" },
{ id: "runtime", label: "Laufzeit" }
];
const ACCOUNT_TABLE_COLUMN_STORAGE_KEY = "mdd.account-table-columns.v1";
@@ -79,7 +80,7 @@ function getAccountPanelNavigationIndex(currentIndex: number, key: string): numb
return null;
}
export interface AccountRulesViewModel {
export interface AccountRulesViewModel {
providerOrder: readonly string[];
routing: readonly string[];
autoFallback: boolean;
@@ -91,8 +92,36 @@ export interface AccountRulesViewModel {
provider: string;
providers: readonly { value: string; label: string }[];
}[];
availableRoutingHosters?: readonly { value: string; label: string }[];
}
availableRoutingHosters?: readonly { value: string; label: string }[];
}
export interface AccountRuntimeProviderViewModel {
id: string;
label: string;
accountCount: number;
availableAccountCount: number;
activeDownloads: number;
dailyUsageText: string;
}
export interface AccountRuntimeRowViewModel {
id: string;
providerLabel: string;
modeLabel: string;
identity: string;
stateLabel: string;
stateTone: "ok" | "active" | "warning" | "danger" | "muted";
activeDownloads: number;
dailyUsageText: string;
successRateText: string;
lastUsedText: string;
cooldownText: string;
}
export interface AccountRuntimeViewModel {
providers: readonly AccountRuntimeProviderViewModel[];
accounts: readonly AccountRuntimeRowViewModel[];
}
export interface AccountWorkspaceViewModel {
activePanel: AccountWorkspacePanel;
@@ -101,8 +130,9 @@ export interface AccountWorkspaceViewModel {
busy: boolean;
error?: string;
statusSort?: "none" | "desc" | "asc";
rules: AccountRulesViewModel;
}
rules: AccountRulesViewModel;
runtime: AccountRuntimeViewModel;
}
export interface AccountWorkspaceActions {
onPanelChange: (panel: AccountWorkspacePanel) => void;
@@ -464,7 +494,7 @@ function AccountOverview({ model, actions }: AccountWorkspaceProps): ReactElemen
);
}
function AccountRules({ model, actions }: AccountWorkspaceProps): ReactElement {
function AccountRules({ model, actions }: AccountWorkspaceProps): ReactElement {
return (
<div className="settings-account-rules">
<section className="settings-rule-section">
@@ -578,9 +608,61 @@ function AccountRules({ model, actions }: AccountWorkspaceProps): ReactElement {
</section>
) : null}
</div>
);
}
);
}
function AccountRuntime({ model }: AccountWorkspaceProps): ReactElement {
return (
<div className="settings-account-runtime">
<section aria-label="Provider-Laufzeit" className="settings-runtime-provider-grid">
{model.runtime.providers.length === 0 ? (
<div className="settings-runtime-empty">Noch keine Accounts konfiguriert.</div>
) : model.runtime.providers.map((provider) => (
<article className="settings-runtime-provider-card" key={provider.id}>
<header>
<h3>{provider.label}</h3>
<span>{provider.availableAccountCount} von {provider.accountCount} verfügbar</span>
</header>
<div>
<span><strong>{provider.activeDownloads}</strong>{provider.activeDownloads === 1 ? " aktiver Download" : " aktive Downloads"}</span>
<span><strong>{provider.dailyUsageText}</strong> heute</span>
</div>
</article>
))}
</section>
<section aria-label="Account-Laufzeit" className="settings-runtime-table" role="table">
<div className="settings-runtime-table-scroll">
<div className="settings-runtime-row settings-runtime-header" role="row">
<span role="columnheader">Account</span>
<span role="columnheader">Zustand</span>
<span role="columnheader">Aktive Downloads</span>
<span role="columnheader">Heute</span>
<span role="columnheader">Erfolgsquote · Diese Sitzung</span>
<span role="columnheader">Zuletzt verwendet</span>
<span role="columnheader">Cooldown / Grund</span>
</div>
{model.runtime.accounts.length === 0 ? (
<div className="settings-runtime-empty">Noch keine Laufzeitdaten verfügbar.</div>
) : model.runtime.accounts.map((account) => (
<div className="settings-runtime-row" key={account.id} role="row">
<span className="settings-runtime-account" role="cell">
<strong>{account.providerLabel}</strong>
<small>{account.modeLabel}{account.identity && account.identity !== "—" ? ` · ${account.identity}` : ""}</small>
</span>
<span role="cell"><span className={`settings-runtime-state is-${account.stateTone}`}>{account.stateLabel}</span></span>
<span role="cell">{account.activeDownloads}</span>
<span role="cell">{account.dailyUsageText}</span>
<span role="cell">{account.successRateText}</span>
<span role="cell">{account.lastUsedText}</span>
<span role="cell">{account.cooldownText}</span>
</div>
))}
</div>
</section>
</div>
);
}
export function AccountWorkspace({ model, actions }: AccountWorkspaceProps): ReactElement {
return (
<div className="settings-account-workspace">
@@ -628,18 +710,27 @@ export function AccountWorkspace({ model, actions }: AccountWorkspaceProps): Rea
>
{AccountOverview({ actions, model })}
</div>
<div
aria-labelledby="settings-account-rules-tab"
<div
aria-labelledby="settings-account-rules-tab"
className="settings-account-panel"
hidden={model.activePanel !== "rules"}
id="settings-account-rules"
role="tabpanel"
>
{AccountRules({ actions, model })}
</div>
</div>
);
}
>
{AccountRules({ actions, model })}
</div>
<div
aria-labelledby="settings-account-runtime-tab"
className="settings-account-panel"
hidden={model.activePanel !== "runtime"}
id="settings-account-runtime"
role="tabpanel"
>
{AccountRuntime({ actions, model })}
</div>
</div>
);
}
export function AccountAddDialog({
model,
+149
View File
@@ -850,6 +850,155 @@
overflow-y: auto;
}
.settings-account-runtime {
display: grid;
width: 100%;
min-width: 0;
gap: 16px;
overflow-y: auto;
}
.settings-runtime-provider-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 10px;
}
.settings-runtime-provider-card {
display: grid;
gap: 14px;
padding: 14px 16px;
border: 1px solid var(--ui-border);
border-radius: 8px;
background: var(--ui-panel);
}
.settings-runtime-provider-card header,
.settings-runtime-provider-card > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.settings-runtime-provider-card h3 {
margin: 0;
color: var(--ui-text);
font-size: 14px;
}
.settings-runtime-provider-card header span,
.settings-runtime-provider-card > div span,
.settings-runtime-account small {
color: var(--ui-text-muted);
}
.settings-runtime-provider-card strong {
margin-right: 4px;
color: var(--ui-text);
}
.settings-runtime-table {
min-width: 0;
overflow: hidden;
border: 1px solid var(--ui-border);
border-radius: 8px;
background: var(--ui-panel);
}
.settings-runtime-table-scroll {
overflow-x: auto;
}
.settings-runtime-row {
display: grid;
grid-template-columns: minmax(210px, 1.35fr) 150px 125px 130px 175px 155px minmax(220px, 1fr);
min-width: 1180px;
min-height: 48px;
align-items: center;
border-bottom: 1px solid var(--ui-border);
}
.settings-runtime-row:last-child {
border-bottom: 0;
}
.settings-runtime-row > span {
min-width: 0;
padding: 9px 12px;
color: var(--ui-text-secondary);
}
.settings-runtime-header {
min-height: 40px;
background: var(--ui-table-header);
}
.settings-runtime-header > span {
color: var(--ui-text);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.settings-runtime-account {
display: grid;
gap: 2px;
}
.settings-runtime-account strong {
overflow: hidden;
color: var(--ui-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-runtime-account small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-runtime-state {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 2px 8px;
border-radius: 999px;
background: var(--ui-input);
color: var(--ui-text-secondary);
white-space: nowrap;
}
.settings-runtime-state.is-ok {
background: color-mix(in srgb, var(--ui-success) 18%, transparent);
color: var(--ui-success-text);
}
.settings-runtime-state.is-active {
background: color-mix(in srgb, var(--ui-accent) 18%, transparent);
color: var(--ui-text);
}
.settings-runtime-state.is-warning {
background: color-mix(in srgb, var(--ui-warning) 18%, transparent);
color: var(--ui-warning-text);
}
.settings-runtime-state.is-danger {
background: color-mix(in srgb, var(--ui-danger) 18%, transparent);
color: var(--ui-danger-text);
}
.settings-runtime-state.is-muted {
color: var(--ui-text-muted);
}
.settings-runtime-empty {
padding: 20px;
color: var(--ui-text-muted);
}
.settings-rule-section {
display: grid;
gap: 12px;