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:
+226
-42
@@ -106,6 +106,7 @@ import {
|
||||
DownloadsSidebar,
|
||||
DownloadsSidebarStatus,
|
||||
DownloadsToolbar,
|
||||
type DailyScheduleStartDay,
|
||||
type DownloadsViewActions,
|
||||
type DownloadsViewModel
|
||||
} from "./views/downloads/DownloadsView";
|
||||
@@ -115,6 +116,7 @@ import {
|
||||
buildTargetedAccountCheck,
|
||||
projectAccountRows,
|
||||
resolveHistoryRetentionSelection,
|
||||
normalizeNotificationNumberField,
|
||||
sortAccountRows,
|
||||
formatAccountContextHeading,
|
||||
type AccountAddOption,
|
||||
@@ -329,7 +331,7 @@ interface RendererSettingsDraft extends RendererSettings {
|
||||
notifyUrl: string;
|
||||
}
|
||||
|
||||
function createSettingsDraft(settings: RendererSettings, current?: RendererSettingsDraft): RendererSettingsDraft {
|
||||
export function createSettingsDraft(settings: RendererSettings, current?: RendererSettingsDraft): RendererSettingsDraft {
|
||||
return {
|
||||
...settings,
|
||||
archivePasswordList: current?.archivePasswordList || "",
|
||||
@@ -503,14 +505,23 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
|
||||
pickerDescription: "Direkter Zugriff über API-Key.",
|
||||
needsToken: true
|
||||
},
|
||||
{
|
||||
kind: "alldebrid-web",
|
||||
{
|
||||
kind: "alldebrid-web",
|
||||
service: "alldebrid",
|
||||
serviceLabel: "AllDebrid",
|
||||
title: "AllDebrid Web-Login",
|
||||
modeLabel: "Web-Login",
|
||||
pickerDescription: "Login über Browserfenster für reCAPTCHA.",
|
||||
},
|
||||
pickerDescription: "Login über Browserfenster für reCAPTCHA.",
|
||||
},
|
||||
{
|
||||
kind: "deepbrid-api",
|
||||
service: "deepbrid",
|
||||
serviceLabel: "Deepbrid",
|
||||
title: "Deepbrid API",
|
||||
modeLabel: "API",
|
||||
pickerDescription: "Direkter Zugriff über API-Key.",
|
||||
needsToken: true
|
||||
},
|
||||
{
|
||||
kind: "ddownload-login",
|
||||
service: "ddownload",
|
||||
@@ -549,7 +560,7 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
|
||||
}
|
||||
];
|
||||
|
||||
const ACCOUNT_SERVICES: AccountService[] = ["realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"];
|
||||
const ACCOUNT_SERVICES: AccountService[] = ["realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "deepbrid", "ddownload", "onefichier", "debridlink", "linksnappy"];
|
||||
const ACCOUNT_LIMIT_BYTES_PER_GIB = 1024 * 1024 * 1024;
|
||||
function findAccountOption(kind: AccountKind): AccountOption {
|
||||
const option = ACCOUNT_OPTIONS.find((entry) => entry.kind === kind);
|
||||
@@ -589,9 +600,10 @@ function getAccountPickerFunctionLabel(option: AccountOption): string {
|
||||
return "Login:Passwort (Web)";
|
||||
case "bestdebrid-web":
|
||||
return "Cookies.txt-Import";
|
||||
case "alldebrid-api":
|
||||
case "onefichier-api":
|
||||
return "API-Key";
|
||||
case "alldebrid-api":
|
||||
case "deepbrid-api":
|
||||
case "onefichier-api":
|
||||
return "API-Key";
|
||||
case "ddownload-login":
|
||||
return "Login + Passwort";
|
||||
default:
|
||||
@@ -613,6 +625,7 @@ function getAccountCredentialLabel(kind: AccountKind): string {
|
||||
case "realdebrid-api":
|
||||
case "bestdebrid-api":
|
||||
case "alldebrid-api":
|
||||
case "deepbrid-api":
|
||||
case "onefichier-api":
|
||||
case "debridlink-api":
|
||||
return "API-Key gespeichert";
|
||||
@@ -637,7 +650,7 @@ function normalizeProviderOrderForSettings(settings: RendererSettings): DebridPr
|
||||
return buildConfiguredProviderOrder(settings.providerOrder || [], configured);
|
||||
}
|
||||
|
||||
function normalizeProviderSelectionForSettings(
|
||||
function normalizeProviderSelectionForSettings(
|
||||
settings: RendererSettings
|
||||
): Pick<RendererSettings, "providerOrder" | "providerPrimary" | "providerSecondary" | "providerTertiary"> {
|
||||
const providerOrder = normalizeProviderOrderForSettings(settings);
|
||||
@@ -646,9 +659,15 @@ function normalizeProviderSelectionForSettings(
|
||||
providerPrimary: providerOrder[0] ?? settings.providerPrimary,
|
||||
providerSecondary: (providerOrder[1] ?? "none") as DebridFallbackProvider,
|
||||
providerTertiary: (providerOrder[2] ?? "none") as DebridFallbackProvider
|
||||
};
|
||||
}
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAccountCreateProviderOrderUpdate(
|
||||
settings: RendererSettings
|
||||
): Pick<RendererSettings, "providerOrder" | "providerPrimary" | "providerSecondary" | "providerTertiary"> {
|
||||
return normalizeProviderSelectionForSettings(settings);
|
||||
}
|
||||
|
||||
function getConfiguredAccountKind(settings: RendererSettings, service: AccountService): AccountKind | null {
|
||||
const configured = new Set(settings.configuredProviders);
|
||||
switch (service) {
|
||||
@@ -665,6 +684,8 @@ function getConfiguredAccountKind(settings: RendererSettings, service: AccountSe
|
||||
case "alldebrid":
|
||||
if (settings.allDebridUseWebLogin) return "alldebrid-web";
|
||||
return configured.has("alldebrid") ? "alldebrid-api" : null;
|
||||
case "deepbrid":
|
||||
return configured.has("deepbrid") ? "deepbrid-api" : null;
|
||||
case "ddownload":
|
||||
return configured.has("ddownload") ? "ddownload-login" : null;
|
||||
case "onefichier":
|
||||
@@ -836,6 +857,8 @@ const emptySnapshot = (): UiSnapshot => ({
|
||||
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
|
||||
theme: "dark", logStorageLocation: "appdata", collapseNewPackages: true, animatePackageDisclosure: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: false, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false,
|
||||
notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
|
||||
notifyPackageSuccessMode: "digest", notifyOnRemainingBelow: false, notifyRemainingThresholdGb: 50,
|
||||
notifyOnDownloadStall: false, notifyStallAfterSeconds: 90, notifyStallCooldownMinutes: 10, notifyOnDownloadRecovery: true,
|
||||
accountListShowDetailedDebridLinkKeys: false,
|
||||
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
|
||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"],
|
||||
@@ -854,10 +877,17 @@ const emptySnapshot = (): UiSnapshot => ({
|
||||
megaDebridWebDisabledAccountIds: [],
|
||||
megaDebridAccountDailyLimitBytes: {},
|
||||
megaDebridAccountDailyUsageBytes: {},
|
||||
megaDebridAccountTotalUsageBytes: {},
|
||||
debridAccountStatuses: {},
|
||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||
scheduledStartEpochMs: 0
|
||||
megaDebridAccountTotalUsageBytes: {},
|
||||
debridAccountStatuses: {},
|
||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||
dailyStartEnabled: false,
|
||||
dailyStartMinuteOfDay: 0,
|
||||
dailyStartFirstLocalDate: "",
|
||||
dailyStartLastHandledLocalDate: "",
|
||||
dailyStartPendingLocalDate: "",
|
||||
dailyStartLastOutcome: "",
|
||||
scheduledStartEpochMs: 0,
|
||||
nextDailyStartEpochMs: 0
|
||||
},
|
||||
accounts: [],
|
||||
session: {
|
||||
@@ -1532,6 +1562,100 @@ export async function runLatestUpdateCheck(
|
||||
await apply(result, generation);
|
||||
}
|
||||
|
||||
interface DailySchedulePersistenceDependencies {
|
||||
updateSettings: (update: RendererSettingsUpdate) => Promise<RendererSettings>;
|
||||
getSnapshot: () => Promise<UiSnapshot>;
|
||||
applySettings: (settings: RendererSettings) => void;
|
||||
applySnapshot: (snapshot: UiSnapshot) => void;
|
||||
showError: (message: string) => void;
|
||||
}
|
||||
|
||||
function formatDailyScheduleLocalDate(date: Date): string {
|
||||
const year = String(date.getFullYear()).padStart(4, "0");
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function formatDailyScheduleTime(minuteOfDay: number): string {
|
||||
const minute = Math.max(0, Math.min(1_439, Math.floor(minuteOfDay)));
|
||||
return `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function resolveDailyScheduleInitialTime(
|
||||
settings: Pick<RendererSettings, "dailyStartMinuteOfDay" | "dailyStartFirstLocalDate">,
|
||||
now = new Date()
|
||||
): string {
|
||||
const minuteOfDay = settings.dailyStartFirstLocalDate
|
||||
? settings.dailyStartMinuteOfDay
|
||||
: now.getHours() * 60 + now.getMinutes();
|
||||
return formatDailyScheduleTime(minuteOfDay);
|
||||
}
|
||||
|
||||
export function buildDailyScheduleSettingsUpdate(
|
||||
time: string,
|
||||
startDay: DailyScheduleStartDay,
|
||||
now = new Date()
|
||||
): RendererSettingsUpdate | null {
|
||||
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(time);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const firstDate = new Date(now);
|
||||
if (startDay === "tomorrow") {
|
||||
firstDate.setDate(firstDate.getDate() + 1);
|
||||
}
|
||||
return {
|
||||
dailyStartEnabled: true,
|
||||
dailyStartMinuteOfDay: Number(match[1]) * 60 + Number(match[2]),
|
||||
dailyStartFirstLocalDate: formatDailyScheduleLocalDate(firstDate)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildScheduleCancellationSettingsUpdate(
|
||||
settings: Pick<RendererSettings, "dailyStartEnabled" | "scheduledStartEpochMs">
|
||||
): RendererSettingsUpdate {
|
||||
return settings.dailyStartEnabled
|
||||
? { dailyStartEnabled: false }
|
||||
: { scheduledStartEpochMs: 0 };
|
||||
}
|
||||
|
||||
export async function activateDailyScheduleSettings(
|
||||
time: string,
|
||||
startDay: DailyScheduleStartDay,
|
||||
persist: (update: RendererSettingsUpdate) => Promise<boolean>,
|
||||
showError: (message: string) => void,
|
||||
now = new Date()
|
||||
): Promise<boolean> {
|
||||
const update = buildDailyScheduleSettingsUpdate(time, startDay, now);
|
||||
if (!update) {
|
||||
showError("Bitte eine gültige Startzeit auswählen.");
|
||||
return false;
|
||||
}
|
||||
return persist(update);
|
||||
}
|
||||
|
||||
export async function persistDailyScheduleSettingsUpdate(
|
||||
update: RendererSettingsUpdate,
|
||||
operation: "activate" | "cancel",
|
||||
dependencies: DailySchedulePersistenceDependencies
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const settings = await dependencies.updateSettings(update);
|
||||
dependencies.applySettings(settings);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const action = operation === "activate" ? "aktiviert" : "abgebrochen";
|
||||
dependencies.showError(`Zeitplan konnte nicht ${action} werden: ${String(error)}`);
|
||||
try {
|
||||
dependencies.applySnapshot(await dependencies.getSnapshot());
|
||||
} catch (snapshotError) {
|
||||
dependencies.showError(`Zeitplan konnte nicht abgeglichen werden: ${String(snapshotError)}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function App(): ReactElement {
|
||||
const [snapshot, setSnapshot] = useState<UiSnapshot>(emptySnapshot);
|
||||
const [appVersion, setAppVersion] = useState("");
|
||||
@@ -1546,9 +1670,10 @@ export function App(): ReactElement {
|
||||
const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({});
|
||||
const [settingsDirty, setSettingsDirty] = useState(false);
|
||||
const [settingsSaveState, setSettingsSaveState] = useState<SettingsSaveState>("clean");
|
||||
const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
|
||||
const [scheduleTimeInput, setScheduleTimeInput] = useState("");
|
||||
const [scheduleCountdown, setScheduleCountdown] = useState("");
|
||||
const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
|
||||
const [scheduleTimeInput, setScheduleTimeInput] = useState("");
|
||||
const [scheduleStartDay, setScheduleStartDay] = useState<DailyScheduleStartDay>("today");
|
||||
const [scheduleCountdown, setScheduleCountdown] = useState("");
|
||||
const [runtimeNow, setRuntimeNow] = useState(() => Date.now());
|
||||
const updateCheckGenerationRef = useRef(0);
|
||||
const dismissedUpdateTagRef = useRef("");
|
||||
@@ -1771,7 +1896,9 @@ export function App(): ReactElement {
|
||||
}, [settingsDraft.speedLimitKbps]);
|
||||
|
||||
useEffect(() => {
|
||||
const schedMs = snapshot.settings.scheduledStartEpochMs || 0;
|
||||
const schedMs = snapshot.settings.dailyStartEnabled
|
||||
? snapshot.settings.nextDailyStartEpochMs
|
||||
: snapshot.settings.scheduledStartEpochMs || 0;
|
||||
if (schedMs <= 0) { setScheduleCountdown(""); return; }
|
||||
const update = (): void => {
|
||||
const remaining = schedMs - Date.now();
|
||||
@@ -1785,7 +1912,7 @@ export function App(): ReactElement {
|
||||
update();
|
||||
const timer = setInterval(update, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [snapshot.settings.scheduledStartEpochMs]);
|
||||
}, [snapshot.settings.dailyStartEnabled, snapshot.settings.nextDailyStartEpochMs, snapshot.settings.scheduledStartEpochMs]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setRuntimeNow(Date.now()), 1000);
|
||||
@@ -2514,7 +2641,9 @@ export function App(): ReactElement {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const serviceAccountId = null;
|
||||
const serviceAccountId = entry.kind === "deepbrid-api"
|
||||
? accountsOfKind(entry.kind, snapshot.accounts)[0]?.accountId || "svc-deepbrid"
|
||||
: null;
|
||||
rows.push({
|
||||
rowKey: `svc-${entry.service}`,
|
||||
entry,
|
||||
@@ -2535,7 +2664,8 @@ export function App(): ReactElement {
|
||||
rowKey: `svc-${entry.service}`,
|
||||
kind: entry.kind as SingleAccountKind,
|
||||
service: entry.service,
|
||||
provider: entry.provider
|
||||
provider: entry.provider,
|
||||
accountId: serviceAccountId || undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2931,8 +3061,9 @@ export function App(): ReactElement {
|
||||
const command = buildAccountCreateCommand(dialogSnapshot);
|
||||
if (!command) throw new Error("Account-Payload ist ungültig");
|
||||
const result = await window.rd.createAccount(command);
|
||||
setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts }));
|
||||
applyPersistedSettings(result.settings);
|
||||
const persistedSettings = await window.rd.updateSettings(buildAccountCreateProviderOrderUpdate(result.settings));
|
||||
setSnapshot((current) => ({ ...current, settings: persistedSettings, accounts: result.accounts }));
|
||||
applyPersistedSettings(persistedSettings);
|
||||
closeAccountDialog();
|
||||
if (quickAction) {
|
||||
await runAccountQuickAction(quickAction, result.accountId);
|
||||
@@ -3091,7 +3222,13 @@ export function App(): ReactElement {
|
||||
...settingsDraft,
|
||||
disabledProviders: nextDisabledProviders
|
||||
};
|
||||
await persistAccountToggle(nextDraft);
|
||||
const enabled = current.includes(provider);
|
||||
await persistAccountToggle(
|
||||
nextDraft,
|
||||
enabled && entry.kind === "deepbrid-api"
|
||||
? () => window.rd.checkAccountCredentials({ kind: "deepbrid-api", accountId: "svc-deepbrid" })
|
||||
: undefined
|
||||
);
|
||||
showToast(
|
||||
nextDisabledProviders.includes(provider)
|
||||
? `${entry.serviceLabel} deaktiviert`
|
||||
@@ -4809,16 +4946,46 @@ export function App(): ReactElement {
|
||||
});
|
||||
}, [setClipboardWatcherActive, showToast]);
|
||||
|
||||
const applyDailyScheduleSettings = useCallback((settings: RendererSettings): void => {
|
||||
const apply = (current: UiSnapshot): UiSnapshot => ({ ...current, settings });
|
||||
const next = apply(snapshotRef.current);
|
||||
snapshotRef.current = next;
|
||||
masterSnapshotRef.current = masterSnapshotRef.current ? apply(masterSnapshotRef.current) : next;
|
||||
if (latestStateRef.current) {
|
||||
latestStateRef.current = apply(latestStateRef.current);
|
||||
}
|
||||
setSnapshot(next);
|
||||
}, []);
|
||||
|
||||
const applyAuthoritativeDailyScheduleSnapshot = useCallback((state: UiSnapshot): void => {
|
||||
masterSnapshotRef.current = state;
|
||||
latestStateRef.current = null;
|
||||
snapshotRef.current = state;
|
||||
setSnapshot(state);
|
||||
}, []);
|
||||
|
||||
const persistDownloadSchedule = useCallback((update: RendererSettingsUpdate, operation: "activate" | "cancel"): Promise<boolean> => (
|
||||
persistDailyScheduleSettingsUpdate(update, operation, {
|
||||
updateSettings: (value) => window.rd.updateSettings(value),
|
||||
getSnapshot: () => window.rd.getSnapshot(),
|
||||
applySettings: applyDailyScheduleSettings,
|
||||
applySnapshot: applyAuthoritativeDailyScheduleSnapshot,
|
||||
showError: (message) => showToast(message, 3200)
|
||||
})
|
||||
), [applyAuthoritativeDailyScheduleSnapshot, applyDailyScheduleSettings, showToast]);
|
||||
|
||||
const activateDownloadSchedule = useCallback((): void => {
|
||||
if (!scheduleTimeInput) return;
|
||||
const [hours, minutes] = scheduleTimeInput.split(":").map(Number);
|
||||
const now = new Date();
|
||||
const target = new Date(now);
|
||||
target.setHours(hours, minutes, 0, 0);
|
||||
if (target.getTime() <= now.getTime()) target.setDate(target.getDate() + 1);
|
||||
void window.rd.updateSettings({ scheduledStartEpochMs: target.getTime() }).catch(() => {});
|
||||
setSchedulePickerOpen(false);
|
||||
}, [scheduleTimeInput]);
|
||||
void activateDailyScheduleSettings(
|
||||
scheduleTimeInput,
|
||||
scheduleStartDay,
|
||||
(update) => persistDownloadSchedule(update, "activate"),
|
||||
(message) => showToast(message, 2800)
|
||||
).then((persisted) => {
|
||||
if (persisted) {
|
||||
setSchedulePickerOpen(false);
|
||||
}
|
||||
});
|
||||
}, [persistDownloadSchedule, scheduleStartDay, scheduleTimeInput, showToast]);
|
||||
|
||||
const removeActionableDownloads = useCallback((): void => {
|
||||
const ids = new Set(downloadsViewCore.actionableSelectedIds);
|
||||
@@ -4849,10 +5016,16 @@ export function App(): ReactElement {
|
||||
reconnectSeconds: snapshot.reconnectSeconds,
|
||||
reconnectReason: snapshot.session.reconnectReason,
|
||||
clipboardWatcher: snapshot.clipboardActive,
|
||||
scheduleActive: snapshot.settings.scheduledStartEpochMs > 0,
|
||||
scheduleActive: snapshot.settings.dailyStartEnabled || snapshot.settings.scheduledStartEpochMs > 0,
|
||||
scheduleOpen: schedulePickerOpen,
|
||||
scheduleTime: scheduleTimeInput,
|
||||
scheduleLabel: scheduleCountdown || (snapshot.settings.scheduledStartEpochMs > 0 ? new Date(snapshot.settings.scheduledStartEpochMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""),
|
||||
scheduleTimeValid: buildDailyScheduleSettingsUpdate(scheduleTimeInput, scheduleStartDay) !== null,
|
||||
scheduleStartDay,
|
||||
scheduleLabel: scheduleCountdown || (snapshot.settings.dailyStartEnabled
|
||||
? formatDailyScheduleTime(snapshot.settings.dailyStartMinuteOfDay)
|
||||
: snapshot.settings.scheduledStartEpochMs > 0
|
||||
? new Date(snapshot.settings.scheduledStartEpochMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
: ""),
|
||||
packageSpeedBps: downloadPackageSpeeds,
|
||||
editingPackageId,
|
||||
editingName,
|
||||
@@ -4876,7 +5049,7 @@ export function App(): ReactElement {
|
||||
speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s",
|
||||
eta: snapshot.etaText
|
||||
}
|
||||
}), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.animatePackageDisclosure, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
|
||||
}), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleStartDay, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.animatePackageDisclosure, snapshot.settings.dailyStartEnabled, snapshot.settings.dailyStartMinuteOfDay, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
|
||||
|
||||
const resetColumnLayout = useCallback((): void => {
|
||||
if (columnDragSettleTimerRef.current !== null) {
|
||||
@@ -4917,12 +5090,17 @@ export function App(): ReactElement {
|
||||
},
|
||||
onStopDownloads: () => { void performQuickAction(() => window.rd.stop()); },
|
||||
onToggleSchedule: () => {
|
||||
setSchedulePickerOpen((current) => !current);
|
||||
setScheduleTimeInput("");
|
||||
if (!schedulePickerOpen) {
|
||||
const now = new Date();
|
||||
setScheduleTimeInput(resolveDailyScheduleInitialTime(snapshot.settings, now));
|
||||
setScheduleStartDay("today");
|
||||
}
|
||||
setSchedulePickerOpen(!schedulePickerOpen);
|
||||
},
|
||||
onScheduleTimeChange: setScheduleTimeInput,
|
||||
onScheduleStartDayChange: setScheduleStartDay,
|
||||
onActivateSchedule: activateDownloadSchedule,
|
||||
onCancelSchedule: () => { void window.rd.updateSettings({ scheduledStartEpochMs: 0 }).catch(() => {}); },
|
||||
onCancelSchedule: () => { void persistDownloadSchedule(buildScheduleCancellationSettingsUpdate(snapshot.settings), "cancel"); },
|
||||
onMoveSelectionUp: () => moveSelectedPackages("up", downloadsViewCore.actionableSelectedIds),
|
||||
onMoveSelectionDown: () => moveSelectedPackages("down", downloadsViewCore.actionableSelectedIds),
|
||||
onRenameSelection: () => {
|
||||
@@ -5150,6 +5328,7 @@ export function App(): ReactElement {
|
||||
},
|
||||
dailyLimitBytes: row.dailyLimitBytes,
|
||||
dailyUsageBytes: row.dailyUsedBytes,
|
||||
totalUsageBytes: row.totalUsedBytes,
|
||||
username: row.username,
|
||||
credentialKind: row.credentialLabel.includes("API") ? "api-key" : row.credentialLabel.includes("•") ? "password" : "protected",
|
||||
canCheck: row.checkable
|
||||
@@ -5451,6 +5630,11 @@ export function App(): ReactElement {
|
||||
setBool(fieldId as keyof RendererSettingsDraft, value);
|
||||
return;
|
||||
}
|
||||
const notificationNumber = normalizeNotificationNumberField(fieldId, value);
|
||||
if (notificationNumber !== undefined) {
|
||||
setNum(fieldId as keyof RendererSettingsDraft, notificationNumber);
|
||||
return;
|
||||
}
|
||||
const numericLimits: Partial<Record<keyof RendererSettingsDraft, [number, number, number]>> = {
|
||||
maxParallel: [1, 50, 1],
|
||||
retryLimit: [0, 99, 0],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AccountDeleteCommand, AccountReplaceCommand, AccountSecretRequest, DebridProvider, RendererAccount, RendererAccountKind } from "../shared/types";
|
||||
|
||||
export type AccountService = "realdebrid" | "megadebrid-api" | "megadebrid-web" | "bestdebrid" | "alldebrid" | "ddownload" | "onefichier" | "debridlink" | "linksnappy";
|
||||
export type AccountService = "realdebrid" | "megadebrid-api" | "megadebrid-web" | "bestdebrid" | "alldebrid" | "deepbrid" | "ddownload" | "onefichier" | "debridlink" | "linksnappy";
|
||||
export type AccountKind = RendererAccountKind;
|
||||
export type SingleAccountKind = Exclude<AccountKind, "megadebrid-api" | "megadebrid-web" | "debridlink-api">;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export const ACCOUNT_SERVICE_ICONS = {
|
||||
"megadebrid-web": "./provider-icons/mega-debrid.png",
|
||||
bestdebrid: "./provider-icons/bestdebrid.ico",
|
||||
alldebrid: "./provider-icons/alldebrid.png",
|
||||
deepbrid: "./provider-icons/deepbrid.png",
|
||||
ddownload: "./provider-icons/ddownload.ico",
|
||||
onefichier: "./provider-icons/onefichier.png",
|
||||
debridlink: "./provider-icons/debrid-link.ico",
|
||||
|
||||
@@ -9,6 +9,7 @@ export const providerLabels: Record<DebridProvider, string> = {
|
||||
"megadebrid-web": "Mega-Debrid Web",
|
||||
bestdebrid: "BestDebrid",
|
||||
alldebrid: "AllDebrid",
|
||||
deepbrid: "Deepbrid",
|
||||
ddownload: "DDownload",
|
||||
onefichier: "1Fichier",
|
||||
debridlink: "Debrid-Link",
|
||||
@@ -56,7 +57,7 @@ export function formatHosterLabel(hoster: string): { compact: string; title: str
|
||||
const normalized = hoster.trim().toLowerCase();
|
||||
if (normalized === "rapidgator") return { compact: "RG", title: "RapidGator", iconSrc: hosterIconSources.rapidgator };
|
||||
if (normalized === "ddownload") return { compact: "DD", title: "DDownload", iconSrc: hosterIconSources.ddownload };
|
||||
if (normalized === "1fichier") return { compact: "1F", title: "1Fichier" };
|
||||
if (normalized === "1fichier") return { compact: "1Fichier", title: "1Fichier", iconSrc: hosterIconSources.onefichier };
|
||||
return { compact: hoster, title: hoster };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const hosterIconSources: Readonly<Record<string, string>> = {
|
||||
rapidgator: "data:image/x-icon;base64,AAABAAIAEBAAAAEAGABoAwAAJgAAACAgAAABAAgAqAgAAI4DAAAoAAAAEAAAACAAAAABABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHVV8LEFQNTU2NTU2NTU2NTU2NTU2NTU2NTU2NTU2MDtDIk9vAAAAAAAAAAAALj5KKFaKHHXZFoP9FoP9FoP9FoP9FoP9FoP9FoP9FoP9GXzrJlqUMzpCmsDaHFd/KFiNGIX/GIX/GIX/GIX/GIX/GIX/LUpqLUpqLUpqIWq7GIX/GIX/Jl+gLT9NLEFPH3jcGob/Gob/Gob/Gob/Gob/Gob/NDQ0NDQ0NDQ0NDQ0JGWuGob/Gob/NTU2MTpAHoLyHIf/HIf/HIf/HIf/HIf/HIf/HIf/HIf/InHJNDQ0NDQ0IXbWHIf/NTU2MTpAIILyHoj/Hoj/Hoj/Hoj/IX3kLVB4NDQ0NDQ0JHLJLkpqNDQ0JHLJHoj/NTU2MTpAIYPyIIn/IIn/IIn/IYPxMT9PNDQ0MEVdL0tqJHjWL0tqNDQ0JXLJIIn/NTU2MTpAI4TyL0tqNDQ0J3PJKmKgNDQ0LlF4Ior/Ior/Ior/L0tqNDQ0J3PJIor/NTU2MTpAJYXyMEtqNDQ0KHTJK2KgNDQ0LV2TJIv/JIv/JIv/MEtqNDQ0KHTJJIv/NTU2MTpAKIbyMUtqNDQ0KnXJKnrWNDQ0NDQ0Ll2TLWOgLWOgMkBPNDQ0KnXJJ4z/NTU2MTpAKojyMUxqNDQ0LHbJKY7/LWquMzpCNDQ0NDQ0NDQ0NDQ0NDQ0LHbJKY7/NTU2MTpALInyMkxqNDQ0MFiFK4//K4//LInxLXfJLXfJLXfJLXfJLXfJLInxK4//NTU2Lz1HLobpL3G7NDQ0NDQ0M0ZdMk1qMk1qMGuuLZD/LZD/LZD/LZD/LZD/LZD/NTU2IFFzMl+TLpD+MGutNDQ0NDQ0NDQ0NDQ0MWWgLpD+LpD+LpD+LpD+LpD+MW60MTpAAAAANDc6MWKYLofrLoHfMHG6MHG6MHG6L3zTLofrLofrLofrLofrMWywM0hgFGKYAAAAo8ffKUZbMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpALz1HE2OZAAAAwAMAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAgAEAACgAAAAgAAAAQAAAAAEACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1NTYAo8ffAClGWwAxOkAALz1HABNjmQAAfdIACnC1ADQ3OgAxYpgALofrAC6B3wAwcboAL3zTADFssAAzSGAAFGKYACBRcwAyX5MALpD+ADBrrQA0NDQAMWWgADFutAAuhukAL3G7ADNGXQAyTWoAMGuuAC2Q/wA1NTYALInyADJMagAwWIUAK4//ACyJ8QAtd8kAKojyADFMagAsdskAKY7/AC1qrgAzOkIAKIbyADFLagAqdckAKnrWAC5dkwAtY6AAMkBPACeM/wAlhfIAMEtqACh0yQArYqAALV2TACSL/wAjhPIAL0tqACdzyQAqYqAALlF4ACKK/wAhg/IAIIn/ACGD8QAxP08AMEVdACR41gAlcskAIILyAB6I/wAhfeQALVB4ACRyyQAuSmoAHoLyAByH/wAicckAIXbWACxBTwAfeNwAGob/ACRlrgAcV38AKFiNABiF/wAtSmoAIWq7ACZfoAAtP00AB3S+AC4+SgAoVooAHHXZABaD/QAZfOsAJlqUAJrA2gAFdsIAHVV8ACxBUAAwO0MAIk9vAAtusQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpZGUeHh4eHh4eHmZnaWlpaWlpaWlpaWlpaWlpaWlpaVxdXl9fX19fX19fYGEqYmlpaWlpaWlpaWlpaWlpaWlUVUBAQEBAQFdXV1hWVllaaWlpaWlpaWlpaWlpaWlpaVBRQEBAQEBAFRUVFVNSUh5paWlpaWlpaWlpaWlpaWlpBEZAQEBAQEBAQEoVFU84HmlpaWlpaWlpaWlpaWlpaWkERkBAQEBISRUVSjQVJzgeaWlpaWlpaWlpaWlpaWlpaQQlQEBAQUIVQzpENBUnOB5paWlpaWlpaWlpaWlpaWlpBCUgFSc2FT04ODg0FSc4HmlpaWlpaWlpaWlpaWlpaWkEJSAVJzYVNzg4ODQVJzgeaWlpaWlpaWlpaWlpaWlpaQQlIBUnLhUVLzAwMRUnEx5paWlpaWlpaWlpaWlpaWlpBCUgFSciKSoVFRUVFScTHmlpaWlpaWlpaWlpaWlpaWkEGCAVISIiIyQkJCQkIxMeaWlpaWlpaWlpaWlpaWlpaQQYGRUVGhsbHBMTExMTEx5paWlpaWlpaWlpaWlpaWlpERITFBUVFRUWExMTExMXA2lpaWlpaWlpaWlpaWlpaWlpCAkKCwwMDA0KCgoKDg8QaWlpaWlpaWlpaWlpaWlpaWkBAgMDAwMDAwMDAwMDBWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaf//////////////////////wAP//4AA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//+AAP//gAH/////////////////////////////////////////////////////////////////",
|
||||
onefichier: "./provider-icons/onefichier.png",
|
||||
ddownload: "./provider-icons/ddownload.ico"
|
||||
};
|
||||
|
||||
@@ -16,6 +16,9 @@ const pairs = [
|
||||
["In den Infobereich minimieren", "Minimize to tray"], ["Vor dem Löschen nachfragen", "Confirm before deleting"], ["Download-Liste mitsichern", "Include download list in backup"],
|
||||
["Ferndiagnose-Einstellungen mitsichern", "Include remote diagnostics settings in backup"], ["Webhook-Adresse", "Webhook address"], ["Discord-Erwähnung (optional)", "Discord mention (optional)"],
|
||||
["Melden, wenn ein Paket fertig ist", "Notify when a package completes"], ["Melden, wenn ein Paket fehlschlägt", "Notify when a package fails"], ["Melden, wenn alles fertig ist", "Notify when everything completes"],
|
||||
["Erfolgsmeldungen senden", "Send success notifications"], ["Gesammelt (alle 2 Minuten)", "Grouped (every 2 minutes)"], ["Jedes Paket einzeln", "Each package individually"],
|
||||
["Melden, wenn der gesamte Lauf fertig ist", "Notify when the entire run completes"], ["Melden, wenn die Restmenge unterschritten wird", "Notify when the remaining amount falls below the threshold"], ["Restmengenschwelle (GB)", "Remaining amount threshold (GB)"],
|
||||
["Melden, wenn Downloads stillstehen", "Notify when downloads stall"], ["Stillstand bestätigen nach (Sek.)", "Confirm stall after (sec.)"], ["Frühestens erneut melden nach (Min.)", "Notify again after at least (min.)"], ["Melden, wenn Downloads wieder laufen", "Notify when downloads resume"],
|
||||
["Quelle und Zeitpunkt der Update-Prüfung.", "Update source and check timing."], ["Aktualisierung", "Update"], ["Beim Start nach Updates suchen", "Check for updates on startup"], ["Update-Quelle", "Update source"],
|
||||
["Jetzt nach einer neuen Version suchen", "Check for a new version now"], ["Nach Updates suchen", "Check for updates"], ["Quelle im Format Benutzer/Repository.", "Source in owner/repository format."],
|
||||
["Update verfügbar", "Update available"], ["Eine neue Version ist bereit. Klicke hier, um sie zu installieren.", "A new version is ready. Click here to install it."], ["Update installieren", "Install update"],
|
||||
@@ -60,7 +63,7 @@ const pairs = [
|
||||
["Abgeschlossene und gelöschte Pakete erscheinen hier.", "Completed and deleted packages appear here."], ["Passe Filter oder Suche an.", "Adjust the filter or search."], ["Öffne die Ansicht erneut, um es noch einmal zu versuchen.", "Open the view again to retry."],
|
||||
["Alle sichtbaren Einträge auswählen", "Select all visible entries"], ["Details anzeigen", "Show details"], ["Details ausblenden", "Hide details"],
|
||||
["Sichtbar:", "Visible:"], ["pro Seite", "per page"],
|
||||
["Verfügbarkeit", "Availability"], ["Hinzugefügt am", "Added on"], ["Ungeprüft", "Unchecked"], ["Paket gestoppt", "Package stopped"], ["Alle anzeigen", "Show all"], ["Planen", "Schedule"], ["Startzeit", "Start time"],
|
||||
["Verfügbarkeit", "Availability"], ["Hinzugefügt am", "Added on"], ["Ungeprüft", "Unchecked"], ["Paket gestoppt", "Package stopped"], ["Alle anzeigen", "Show all"], ["Planen", "Schedule"], ["Startzeit", "Start time"], ["Starttag", "Start day"], ["Ab heute", "Starting today"], ["Ab morgen", "Starting tomorrow"], ["Bitte eine gültige Startzeit auswählen.", "Select a valid start time."],
|
||||
["Keine Downloads", "No downloads"], ["Keine passenden Downloads", "No matching downloads"], ["Füge Links hinzu, um Downloads vorzubereiten.", "Add links to prepare downloads."], ["Passe Filter oder Suche an.", "Adjust the filter or search."],
|
||||
["Keine Links gesammelt", "No links collected"], ["Keine passenden Links", "No matching links"], ["Füge Links oder Text ein, um sie zu sammeln.", "Paste links or text to collect them."], ["Links durchsuchen", "Search links"],
|
||||
["Datenmenge", "Data volume"], ["Sitzungszähler", "Session counter"], ["Sieben Tage", "Seven days"], ["30 Tage", "30 days"], ["Zeitraum", "Period"], ["Erfolgreich", "Successful"],
|
||||
@@ -208,7 +211,7 @@ const prefixedPairs = [
|
||||
["Sicherung laden fehlgeschlagen: ", "Loading backup failed: "], ["Support-Bundle fehlgeschlagen: ", "Support bundle failed: "], ["Support-Trace fehlgeschlagen: ", "Support trace failed: "],
|
||||
["Debug-Setup-Check fehlgeschlagen: ", "Debug setup check failed: "], ["Fehler-Ansicht fehlgeschlagen: ", "Error view failed: "], ["Token-Rotation fehlgeschlagen: ", "Token rotation failed: "],
|
||||
["Ferndiagnose-Status fehlgeschlagen: ", "Remote diagnostics status failed: "], ["Aktivieren fehlgeschlagen: ", "Enabling failed: "], ["Deaktivieren fehlgeschlagen: ", "Disabling failed: "],
|
||||
["Session-Reset fehlgeschlagen: ", "Session reset failed: "], ["Download-Reset fehlgeschlagen: ", "Download reset failed: "]
|
||||
["Session-Reset fehlgeschlagen: ", "Session reset failed: "], ["Download-Reset fehlgeschlagen: ", "Download reset failed: "], ["Zeitplan konnte nicht aktiviert werden: ", "Schedule could not be activated: "], ["Zeitplan konnte nicht abgebrochen werden: ", "Schedule could not be cancelled: "], ["Zeitplan konnte nicht abgeglichen werden: ", "Schedule could not be reconciled: "]
|
||||
] as const;
|
||||
|
||||
export function normalizeLanguage(value: unknown): AppLanguage {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user