feat: add Deepbrid, daily scheduling, and notification center

Add encrypted Deepbrid API accounts with account validation, provider routing, fallback, usage tracking, safe error handling, and verified 1Fichier downloads. Restore persistent recurring daily starts with local-calendar deduplication and legacy schedule compatibility. Add durable Discord package, run, remaining-volume, stall, and recovery notifications with privacy-safe telemetry and disk-failure recovery.
This commit is contained in:
Sucukdeluxe
2026-08-24 07:37:55 +02:00
parent 06e5bf4340
commit 1b7caba2eb
83 changed files with 12769 additions and 1098 deletions
+29 -17
View File
@@ -11,8 +11,10 @@ import { VirtualizedDownloadsBody } from "./VirtualizedDownloadsBody";
import "./downloads.css";
const integerFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 });
export interface DownloadsStatusModel {
export type DailyScheduleStartDay = "today" | "tomorrow";
export interface DownloadsStatusModel {
packages: number;
links: number;
session: string;
@@ -37,10 +39,12 @@ export interface DownloadsViewModel extends DownloadsViewModelCore {
reconnectSeconds: number;
reconnectReason: string;
clipboardWatcher: boolean;
scheduleActive: boolean;
scheduleOpen: boolean;
scheduleTime: string;
scheduleLabel: string;
scheduleActive: boolean;
scheduleOpen: boolean;
scheduleTime: string;
scheduleTimeValid: boolean;
scheduleStartDay: DailyScheduleStartDay;
scheduleLabel: string;
packageSpeedBps: Record<string, number>;
editingPackageId: string | null;
editingName: string;
@@ -63,9 +67,10 @@ export interface DownloadsViewActions extends DownloadsTableActions {
onStartDownloads: () => void;
onPauseDownloads: () => void;
onStopDownloads: () => void;
onToggleSchedule: () => void;
onScheduleTimeChange: (value: string) => void;
onActivateSchedule: () => void;
onToggleSchedule: () => void;
onScheduleTimeChange: (value: string) => void;
onScheduleStartDayChange: (value: DailyScheduleStartDay) => void;
onActivateSchedule: () => void;
onCancelSchedule: () => void;
onMoveSelectionUp: () => void;
onMoveSelectionDown: () => void;
@@ -121,18 +126,25 @@ export function DownloadsSidebarStatus({ model }: { model: DownloadsViewModel })
})}<div><span>Geschwindigkeit</span><strong data-status-metric="speed">{speed}</strong></div><div><span>ETA</span><strong data-status-metric="eta">{eta}</strong></div></section>;
}
export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
const hasSelection = model.actionableSelectedIds.length > 0;
const hasSelectedPackage = model.actionableSelectedPackageIds.length > 0;
const onePackage = model.actionableSelectedPackageIds.length === 1 && model.actionableSelectedIds.length === 1;
return (
export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
const hasSelection = model.actionableSelectedIds.length > 0;
const hasSelectedPackage = model.actionableSelectedPackageIds.length > 0;
const onePackage = model.actionableSelectedPackageIds.length === 1 && model.actionableSelectedIds.length === 1;
const scheduleSlotOpen = model.scheduleActive || model.scheduleOpen;
const scheduleSlotClass = `downloads-schedule-slot ${scheduleSlotOpen ? "is-open" : "is-closed"}${model.animationsEnabled ? "" : " is-motion-disabled"}`;
return (
<div className="downloads-toolbar" data-visual-region="downloads-toolbar">
<button disabled={model.actionBusy || !model.canStart} onClick={actions.onStartDownloads} type="button">Start</button>
<button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button>
{model.scheduleActive
? <span className="downloads-schedule-controls"><strong>Geplant: {model.scheduleLabel}</strong><button disabled={false} onClick={actions.onCancelSchedule} type="button">Abbrechen</button></span>
: <><button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button>{model.scheduleOpen ? <span className="downloads-schedule-controls"><input aria-label="Startzeit" onChange={(event) => actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} /><button onClick={actions.onActivateSchedule} type="button">Planen</button></span> : null}</>}
{!model.scheduleActive ? <button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button> : null}
<span className={scheduleSlotClass}>
<span {...(!scheduleSlotOpen ? { inert: "true" } : {})} aria-hidden={!scheduleSlotOpen} className="downloads-schedule-controls">
{model.scheduleActive
? <><strong>Geplant: {model.scheduleLabel}</strong><button disabled={false} onClick={actions.onCancelSchedule} type="button">Abbrechen</button></>
: <><input aria-label="Startzeit" disabled={!scheduleSlotOpen} onChange={(event) => actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} /><select aria-label="Starttag" disabled={!scheduleSlotOpen} onChange={(event) => actions.onScheduleStartDayChange(event.target.value as DailyScheduleStartDay)} value={model.scheduleStartDay}><option value="today">Ab heute</option><option value="tomorrow">Ab morgen</option></select><button disabled={!scheduleSlotOpen || !model.scheduleTimeValid} onClick={actions.onActivateSchedule} type="button">Planen</button></>}
</span>
</span>
<span className="downloads-toolbar-divider" />
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionUp} type="button">Nach oben</button>
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionDown} type="button">Nach unten</button>
+33 -1
View File
@@ -237,13 +237,45 @@
background: var(--ui-border);
}
.downloads-schedule-slot {
display: grid;
grid-template-columns: 0fr;
min-width: 0;
overflow: hidden;
opacity: 0;
pointer-events: none;
transition: grid-template-columns 180ms ease, opacity 140ms ease;
}
.downloads-schedule-slot.is-open {
grid-template-columns: 1fr;
opacity: 1;
pointer-events: auto;
}
.downloads-schedule-controls {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
width: max-content;
overflow: hidden;
white-space: nowrap;
transform: translateX(-10px);
transition: transform 180ms ease;
}
.downloads-schedule-controls input {
.downloads-schedule-slot.is-open .downloads-schedule-controls {
transform: translateX(0);
}
.downloads-schedule-slot.is-motion-disabled,
.downloads-schedule-slot.is-motion-disabled .downloads-schedule-controls {
transition: none !important;
}
.downloads-schedule-controls input,
.downloads-schedule-controls select {
height: 36px;
}
+56 -1
View File
@@ -20,6 +20,7 @@ import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
import { SlidingSelection } from "../../ui/SlidingSelection";
import {
createHistoryTableColumnWidths,
formatHistoryDuration,
getHistoryTableGridTemplate,
getHistoryTableMinWidth,
HISTORY_TABLE_COLUMN_IDS,
@@ -69,6 +70,12 @@ const HISTORY_DISCLOSURE_DURATION_MS = 520;
const useRendererLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
let historyTableResizeSession: { column: HistoryTableColumnId; startX: number; initial: HistoryTableColumnWidths } | null = null;
function operationStatusLabel(status: "completed" | "failed" | "cancelled"): string {
if (status === "completed") return "Abgeschlossen";
if (status === "failed") return "Fehlgeschlagen";
return "Abgebrochen";
}
function loadHistoryTableColumnWidths(): HistoryTableColumnWidths {
try {
const stored = typeof window === "undefined" ? null : window.localStorage.getItem(HISTORY_TABLE_COLUMN_STORAGE_KEY);
@@ -183,11 +190,59 @@ function HistoryRowDetails({
<dl className="history-details-grid">
<div><dt>Provider</dt><dd>{row.providerLabel}</dd></div>
<div><dt>Dateien</dt><dd>{row.fileCount}</dd></div>
<div><dt>Dauer</dt><dd>{row.durationLabel}</dd></div>
{row.hasStructuredLifecycle ? (
<>
<div><dt>Download gestartet</dt><dd>{row.startedLabel}</dd></div>
<div><dt>Download beendet</dt><dd>{row.downloadEndedLabel}</dd></div>
<div><dt>Nachbearbeitung gestartet</dt><dd>{row.postProcessStartedLabel}</dd></div>
<div><dt>Abgeschlossen</dt><dd>{row.completedLabel}</dd></div>
<div><dt>Downloaddauer</dt><dd>{row.downloadDurationLabel}</dd></div>
<div><dt>Entpackdauer</dt><dd>{row.extractionDurationLabel}</dd></div>
<div><dt>Remuxdauer</dt><dd>{row.remuxDurationLabel}</dd></div>
<div><dt>Nachbearbeitungsdauer</dt><dd>{row.postProcessDurationLabel}</dd></div>
<div><dt>Gesamtdauer</dt><dd>{row.totalDurationLabel}</dd></div>
<div><dt>Status</dt><dd>{row.statusLabel}</dd></div>
<div><dt>Erfolgreich / Fehlgeschlagen / Abgebrochen</dt><dd>{row.successfulFiles ?? 0} / {row.failedFiles ?? 0} / {row.cancelledFiles ?? 0}</dd></div>
<div><dt>Archive / Parts / Ausgaben</dt><dd>{row.archiveCount ?? 0} / {row.partCount ?? 0} / {row.outputCount ?? 0}</dd></div>
<div><dt>Fehlerphase</dt><dd>{row.failurePhaseLabel}</dd></div>
</>
) : (
<div><dt>Downloaddauer (Altbestand)</dt><dd>{row.durationLabel}</dd></div>
)}
<div><dt>Durchschnitt</dt><dd>{row.averageSpeedLabel}</dd></div>
<div className="history-detail-wide"><dt>Zielordner</dt><dd className="history-copyable">{row.outputDir || "—"}</dd></div>
<div className="history-detail-wide"><dt>URLs</dt><dd className="history-copyable">{row.urls?.length ? row.urls.join("\n") : "—"}</dd></div>
</dl>
{row.hasStructuredLifecycle ? (
<div className="history-operation-groups">
<section className="history-operation-group">
<h3>Archivvorgänge</h3>
{row.archiveOperations?.length ? (
<ul>
{row.archiveOperations.map((operation) => (
<li key={operation.id}>
<strong>{operation.name}</strong>
<span>{operation.partCount} Parts · {formatHistoryDuration(operation.durationMs / 1000)} · {operationStatusLabel(operation.status)}{operation.errorCategory ? ` · ${operation.errorCategory}` : ""}</span>
</li>
))}
</ul>
) : <p>Keine Archivvorgänge</p>}
</section>
<section className="history-operation-group">
<h3>Remuxvorgänge</h3>
{row.remuxOperations?.length ? (
<ul>
{row.remuxOperations.map((operation) => (
<li key={operation.id}>
<strong>{operation.fileName}</strong>
<span>{formatHistoryDuration(operation.durationMs / 1000)} · {operationStatusLabel(operation.status)}{operation.errorCategory ? ` · ${operation.errorCategory}` : ""}</span>
</li>
))}
</ul>
) : <p>Keine Remuxvorgänge</p>}
</section>
</div>
) : null}
</div>
</div>
</div>
+51 -9
View File
@@ -14,6 +14,15 @@ export interface HistoryRow extends HistoryViewEntry {
durationLabel: string;
averageSpeedLabel: string;
statusLabel: string;
hasStructuredLifecycle: boolean;
downloadEndedLabel: string;
postProcessStartedLabel: string;
downloadDurationLabel: string;
extractionDurationLabel: string;
remuxDurationLabel: string;
postProcessDurationLabel: string;
totalDurationLabel: string;
failurePhaseLabel: string;
}
export interface HistoryFilterCounts {
@@ -108,6 +117,7 @@ const providerLabels: Record<DebridProvider, string> = {
"megadebrid-web": "Mega-Debrid Web",
bestdebrid: "BestDebrid",
alldebrid: "AllDebrid",
deepbrid: "Deepbrid",
ddownload: "DDownload",
onefichier: "1Fichier",
debridlink: "Debrid-Link",
@@ -116,6 +126,8 @@ const providerLabels: Record<DebridProvider, string> = {
const statusLabels: Record<HistoryViewStatus, string> = {
completed: "Abgeschlossen",
partial: "Teilweise",
cancelled: "Abgebrochen",
deleted: "Gelöscht",
failed: "Fehlgeschlagen"
};
@@ -145,7 +157,7 @@ function formatBytes(bytes: number): string {
return `${numberFormatter.format(value)} ${units[unitIndex]}`;
}
function formatDuration(durationSeconds: number): string {
export function formatHistoryDuration(durationSeconds: number): string {
const total = Math.max(0, Math.floor(Number.isFinite(durationSeconds) ? durationSeconds : 0));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
@@ -156,6 +168,19 @@ function formatDuration(durationSeconds: number): string {
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
function formatTimestamp(timestamp: number | undefined): string {
const safe = Math.max(0, Number.isFinite(timestamp) ? Number(timestamp) : 0);
return safe > 0 ? dateFormatter.format(new Date(safe)) : "—";
}
function failurePhaseLabel(entry: HistoryViewEntry): string {
if (entry.failurePhase === "download") return "Download";
if (entry.failurePhase === "extract") return "Entpacken";
if (entry.failurePhase === "remux") return "Remux";
if (entry.failurePhase === "cleanup") return "Aufräumen";
return "—";
}
export function paginateHistoryRows(rows: HistoryRow[], requestedPage: number): HistoryPage {
const totalItems = rows.length;
const totalPages = Math.max(1, Math.ceil(totalItems / HISTORY_PAGE_SIZE));
@@ -232,7 +257,11 @@ export function deriveHistoryHoster(urls: string[] | undefined): string {
return hostnames.length > 0 ? hostnames.join(", ") : "—";
}
export function deriveHistoryStartAt(entry: Pick<HistoryViewEntry, "completedAt" | "durationSeconds">): number {
export function deriveHistoryStartAt(entry: Pick<HistoryViewEntry, "completedAt" | "durationSeconds" | "startedAt">): number {
const startedAt = Math.max(0, Number.isFinite(entry.startedAt) ? Number(entry.startedAt) : 0);
if (startedAt > 0) {
return startedAt;
}
const completedAt = Math.max(0, Number.isFinite(entry.completedAt) ? entry.completedAt : 0);
const durationMs = Math.max(0, Number.isFinite(entry.durationSeconds) ? entry.durationSeconds : 0) * 1000;
return Math.max(0, completedAt - durationMs);
@@ -242,19 +271,32 @@ function toHistoryRow(entry: HistoryViewEntry): HistoryRow {
const hoster = deriveHistoryHoster(entry.urls);
const providerLabel = entry.provider ? providerLabels[entry.provider] : "—";
const startAt = deriveHistoryStartAt(entry);
const durationSeconds = Math.max(0, entry.durationSeconds || 0);
const averageBytesPerSecond = durationSeconds > 0 ? entry.downloadedBytes / durationSeconds : 0;
const downloadDurationSeconds = Math.max(0, entry.downloadDurationSeconds ?? entry.durationSeconds ?? 0);
const averageBytesPerSecond = downloadDurationSeconds > 0 ? entry.downloadedBytes / downloadDurationSeconds : 0;
const hasStructuredLifecycle = entry.startedAt !== undefined
|| entry.downloadEndedAt !== undefined
|| entry.postProcessStartedAt !== undefined
|| entry.totalDurationSeconds !== undefined;
return {
...entry,
hoster,
providerLabel,
startAt,
sizeLabel: `${formatBytes(entry.downloadedBytes)} / ${formatBytes(entry.totalBytes)}`,
startedLabel: dateFormatter.format(new Date(startAt)),
completedLabel: dateFormatter.format(new Date(Math.max(0, entry.completedAt))),
durationLabel: formatDuration(durationSeconds),
averageSpeedLabel: durationSeconds > 0 ? `${formatBytes(averageBytesPerSecond)}/s` : "—",
statusLabel: statusLabels[entry.status]
startedLabel: formatTimestamp(startAt),
completedLabel: formatTimestamp(entry.completedAt),
durationLabel: formatHistoryDuration(downloadDurationSeconds),
averageSpeedLabel: downloadDurationSeconds > 0 ? `${formatBytes(averageBytesPerSecond)}/s` : "—",
statusLabel: statusLabels[entry.status],
hasStructuredLifecycle,
downloadEndedLabel: formatTimestamp(entry.downloadEndedAt),
postProcessStartedLabel: formatTimestamp(entry.postProcessStartedAt),
downloadDurationLabel: formatHistoryDuration(entry.downloadDurationSeconds ?? 0),
extractionDurationLabel: formatHistoryDuration(entry.extractionDurationSeconds ?? 0),
remuxDurationLabel: formatHistoryDuration(entry.remuxDurationSeconds ?? 0),
postProcessDurationLabel: formatHistoryDuration(entry.postProcessDurationSeconds ?? 0),
totalDurationLabel: formatHistoryDuration(entry.totalDurationSeconds ?? 0),
failurePhaseLabel: failurePhaseLabel(entry)
};
}
+72 -10
View File
@@ -339,11 +339,23 @@
color: var(--ui-danger-text);
}
.history-status-failed {
background: color-mix(in srgb, var(--ui-danger) 15%, transparent);
border-color: color-mix(in srgb, var(--ui-danger) 60%, var(--ui-border));
.history-status-failed {
background: color-mix(in srgb, var(--ui-danger) 15%, transparent);
border-color: color-mix(in srgb, var(--ui-danger) 60%, var(--ui-border));
color: var(--ui-danger-text);
}
}
.history-status-partial {
background: color-mix(in srgb, var(--ui-warning) 16%, transparent);
border-color: color-mix(in srgb, var(--ui-warning) 60%, var(--ui-border));
color: var(--ui-warning-text);
}
.history-status-cancelled {
background: color-mix(in srgb, var(--ui-text-muted) 14%, transparent);
border-color: color-mix(in srgb, var(--ui-text-muted) 48%, var(--ui-border));
color: var(--ui-text-secondary);
}
.history-row-size,
.history-row-hoster,
@@ -422,9 +434,55 @@
min-width: 0;
}
.history-details-grid .history-detail-wide {
grid-column: span 2;
}
.history-details-grid .history-detail-wide {
grid-column: span 2;
}
.history-operation-groups {
display: grid;
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 12px;
}
.history-operation-group {
background: color-mix(in srgb, var(--ui-panel) 72%, transparent);
border: 1px solid color-mix(in srgb, var(--ui-border) 82%, transparent);
border-radius: 6px;
min-width: 0;
padding: 10px 11px;
}
.history-operation-group h3 {
color: var(--ui-text-muted);
font-size: 11px;
margin: 0 0 8px;
text-transform: uppercase;
}
.history-operation-group p,
.history-operation-group ul {
color: var(--ui-text-secondary);
margin: 0;
}
.history-operation-group ul {
display: grid;
gap: 8px;
list-style: none;
padding: 0;
}
.history-operation-group li {
display: grid;
gap: 3px;
min-width: 0;
}
.history-operation-group strong,
.history-operation-group span {
overflow-wrap: anywhere;
}
.history-copyable {
overflow-wrap: anywhere;
@@ -494,9 +552,13 @@
overflow: hidden;
}
.history-action {
padding: 0 9px;
}
.history-action {
padding: 0 9px;
}
.history-operation-groups {
grid-template-columns: 1fr;
}
}
+42 -5
View File
@@ -146,6 +146,7 @@ export interface AccountRowSource {
};
dailyLimitBytes?: number;
dailyUsageBytes?: number;
totalUsageBytes?: number;
username: string;
credentialKind: "password" | "api-key" | "protected";
canCheck: boolean;
@@ -270,6 +271,22 @@ export interface SettingsFormProjectionInput {
themeChoice?: "light" | "dark" | "system";
}
const NOTIFICATION_NUMBER_LIMITS = {
notifyRemainingThresholdGb: { min: 1, max: 100000, fallback: 50 },
notifyStallAfterSeconds: { min: 60, max: 3600, fallback: 90 },
notifyStallCooldownMinutes: { min: 5, max: 1440, fallback: 10 }
} as const;
export function normalizeNotificationNumberField(fieldId: string, value: unknown): number | undefined {
const limits = NOTIFICATION_NUMBER_LIMITS[fieldId as keyof typeof NOTIFICATION_NUMBER_LIMITS];
if (!limits) {
return undefined;
}
const parsed = Number(value);
const normalized = Number.isFinite(parsed) ? Math.floor(parsed) : limits.fallback;
return Math.max(limits.min, Math.min(limits.max, normalized));
}
export function buildSettingsFormViewModel({
settings,
section,
@@ -600,7 +617,24 @@ export function buildSettingsFormViewModel({
{ id: "notifyMention", kind: "text", label: "Discord-Erwähnung (optional)", value: settings.notifyMention },
{ id: "notifyOnPackageCompleted", kind: "switch", label: "Melden, wenn ein Paket fertig ist", value: settings.notifyOnPackageCompleted },
{ id: "notifyOnPackageFailed", kind: "switch", label: "Melden, wenn ein Paket fehlschlägt", value: settings.notifyOnPackageFailed },
{ id: "notifyOnRunFinished", kind: "switch", label: "Melden, wenn alles fertig ist", value: settings.notifyOnRunFinished }
{
id: "notifyPackageSuccessMode",
kind: "select",
label: "Erfolgsmeldungen senden",
value: settings.notifyPackageSuccessMode,
disabled: !settings.notifyOnPackageCompleted,
options: [
{ value: "digest", label: "Gesammelt (alle 2 Minuten)" },
{ value: "individual", label: "Jedes Paket einzeln" }
]
},
{ id: "notifyOnRunFinished", kind: "switch", label: "Melden, wenn der gesamte Lauf fertig ist", value: settings.notifyOnRunFinished },
{ id: "notifyOnRemainingBelow", kind: "switch", label: "Melden, wenn die Restmenge unterschritten wird", value: settings.notifyOnRemainingBelow },
{ id: "notifyRemainingThresholdGb", kind: "number", label: "Restmengenschwelle (GB)", value: String(settings.notifyRemainingThresholdGb), min: 1, max: 100000, disabled: !settings.notifyOnRemainingBelow },
{ id: "notifyOnDownloadStall", kind: "switch", label: "Melden, wenn Downloads stillstehen", value: settings.notifyOnDownloadStall },
{ id: "notifyStallAfterSeconds", kind: "number", label: "Stillstand bestätigen nach (Sek.)", value: String(settings.notifyStallAfterSeconds), min: 60, max: 3600, disabled: !settings.notifyOnDownloadStall },
{ id: "notifyStallCooldownMinutes", kind: "number", label: "Frühestens erneut melden nach (Min.)", value: String(settings.notifyStallCooldownMinutes), min: 5, max: 1440, disabled: !settings.notifyOnDownloadStall },
{ id: "notifyOnDownloadRecovery", kind: "switch", label: "Melden, wenn Downloads wieder laufen", value: settings.notifyOnDownloadRecovery, disabled: !settings.notifyOnDownloadStall }
]
}
]
@@ -621,12 +655,15 @@ function formatBytes(bytes: number): string {
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: value >= 100 ? 0 : value >= 10 ? 1 : 2 }).format(value)} ${units[unitIndex]}`;
}
function formatTraffic(limitBytes?: number, usageBytes?: number): string {
function formatTraffic(limitBytes?: number, usageBytes?: number, totalUsageBytes?: number): string {
const total = Number.isFinite(totalUsageBytes) && totalUsageBytes && totalUsageBytes > 0
? ` · Gesamt ${formatBytes(totalUsageBytes)}`
: "";
if (!Number.isFinite(limitBytes) || !limitBytes || limitBytes <= 0) {
return "Unbeschränkt";
return `Unbeschränkt${total}`;
}
const safeUsage = Number.isFinite(usageBytes) && usageBytes && usageBytes > 0 ? usageBytes : 0;
return `${formatBytes(Math.max(0, limitBytes - safeUsage))} von ${formatBytes(limitBytes)} übrig`;
return `${formatBytes(Math.max(0, limitBytes - safeUsage))} von ${formatBytes(limitBytes)} übrig${total}`;
}
function formatExpiry(premiumUntilMs: number | null): string {
@@ -714,7 +751,7 @@ export function projectAccountRows(
enabled: source.enabled,
selected: selected.has(id),
status: { ...status, checkedAgo: formatCheckedAgo(source.status.checkedAt, nowMs) },
traffic: formatTraffic(source.dailyLimitBytes, source.dailyUsageBytes),
traffic: formatTraffic(source.dailyLimitBytes, source.dailyUsageBytes, source.totalUsageBytes),
username: identity.username,
email: identity.email,
expires: formatExpiry(source.status.premiumUntilMs),
@@ -50,6 +50,7 @@ const providerLabels: Record<DebridProvider, string> = {
"megadebrid-web": "Mega-Debrid Web",
bestdebrid: "BestDebrid",
alldebrid: "AllDebrid",
deepbrid: "Deepbrid",
ddownload: "DDownload",
onefichier: "1Fichier",
debridlink: "Debrid-Link",