fix: align live speed and split package reset
This commit is contained in:
+12
-1
@@ -155,6 +155,14 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält
|
||||
- Item-Logs melden den Grund `readback_mismatch` beziehungsweise `zero_run_mismatch` mit Range- und Chunkgrenzen. Die bisher irreführende Meldung „Integritätsprüfung bestanden“ bei fehlender `.sfv`-, `.md5`- oder `.sha1`-Prüfsumme wurde in „Integritätsprüfung nicht ausgeführt“ mit Begründung geändert.
|
||||
- Entpack-, Passwort- und Nachbearbeitungslogik wurden nicht verändert. `v2.0.79` wurde nach ausdrücklicher Freigabe auf GitHub und Forgejo veröffentlicht; Installation und produktiver Neustart sind nicht Bestandteil des Releases.
|
||||
|
||||
## Noch unveröffentlicht nach v2.0.79
|
||||
|
||||
- Datei-, Paket-, Kopfzeilen- und Seitenleisten-Geschwindigkeit verwenden nun dieselbe rollierende Ein-Sekunden-Trafficbasis. Dafür werden die bereits für den globalen und paketbezogenen Durchsatz erfassten Bytes zusätzlich pro Item aggregiert; die bisherige kurzzeitige 0,5-Sekunden-Proxyspitze kann nicht mehr als widersprüchlicher Dateiwert stehen bleiben.
|
||||
- Die Geschwindigkeitsprojektion verändert keine internen Session-Items. Nur tatsächlich abweichende Snapshot-Items werden kopiert, damit Restmengen-Benachrichtigungen und große Queues ihren bisherigen Laufzeitzustand beziehungsweise ihre Snapshot-Kosten behalten.
|
||||
- Das Kontextmenü eines Oberpakets trennt jetzt „Nur fehlerhafte Dateien zurücksetzen“ von „Gesamtes Paket zurücksetzen“. Der selektive Weg erfasst fehlgeschlagene Downloads und als Entpackfehler markierte Items, verwendet den vorhandenen Item-Reset und lässt erfolgreiche beziehungsweise bereits entpackte Dateien unverändert.
|
||||
- Der Statistikpunkt „Fehler zurücksetzen“ verwendet dieselbe Fehlerklassifizierung und erfasst dadurch neben Downloadfehlern auch sichtbare Entpackfehler.
|
||||
- Entpack-, Passwort- und Nachbearbeitungslogik wurden nicht verändert. Dieser Stand wurde noch nicht als neue Version veröffentlicht und nicht auf einem produktiven System installiert oder gestartet.
|
||||
|
||||
## Start-, Build- und Testbefehle
|
||||
|
||||
```powershell
|
||||
@@ -186,6 +194,8 @@ npm exec -- tsc --noEmit
|
||||
|
||||
## Verifizierungen vom 31. August 2026
|
||||
|
||||
- Noch unveröffentlichter Anzeige-/Reset-Fix nach `v2.0.79`: 265 von 265 Download-Manager-Tests sowie 372 von 372 Downloadansichts-/Übersetzungstests erfolgreich. Der vollständige Clientlauf erreichte 2.690 erfolgreiche und 4 übersprungene Tests; zusätzlich zu den zwei bekannten Windows-Symlink-Fixtures deckte er eine Snapshot-Seitenwirkung auf. Nach deren Korrektur bestanden die 10 betroffenen Geschwindigkeits-, Restmengen-, Reset-, Shell- und Übersetzungsregressionen vollständig. Der neue Dateisystemtest bestätigt zusätzlich, dass der selektive Paket-Reset erfolgreiche Dateien erhält und nur die beiden ausgewählten Fehlerdateien entfernt und neu einreiht.
|
||||
- Nach dem Anzeige-/Reset-Fix erfolgreich: TypeScript, Main-Build, Renderer-Build, Node-Self-Check und 16 von 16 Backup-API-Tests. Die bekannte Vite-Warnung zum rund 577 KiB großen Renderer-Chunk bleibt bestehen.
|
||||
- Veröffentlichung `v2.0.79`: Der annotierte Tag zeigt auf beiden Git-Remotes exakt auf `04e5ab71d2e77e68256c77b6d0fff48fa86188e8`. GitHub und Forgejo veröffentlichen denselben stabilen Release mit denselben sechs Assets. Alle zwölf erneut heruntergeladenen Dateien stimmen jeweils in Größe und SHA-256 exakt mit den lokalen Originalen überein; GitHub führt `v2.0.79` als Latest Release.
|
||||
- Release-Build `v2.0.79`: Installer und Portable-Datei wurden aus dem versionierten Release-Vorbereitungsstand neu erzeugt. Die Release-Prüfung bestätigte Paket- und Bundle-Version, Update-Metadaten, Artefaktnamen, Dateigröße und SHA-512 des Installers, Icon, Lizenzdateien sowie den entpackten Inhalt beider EXE-Archive. Von den 24 Public-Release-Metadatentests waren 22 erfolgreich; nur die zwei unter Windows ohne Symlink-Berechtigung nicht ausführbaren Fixtures endeten vor ihrer Produktassertion mit `EPERM`.
|
||||
- SHA-256 des `v2.0.79`-Setups: `eccfe029f32aaf198c0597fdbb7874540ead39737616879baf1a17bebdf13450`; SHA-256 der Portable-Datei: `2f32b825e03a36ae842f2aa2b9a73c420657d812046c23bf4cbacf6a63ae2333`.
|
||||
@@ -264,10 +274,11 @@ npm exec -- tsc --noEmit
|
||||
- Der faire rollierende Proxy-Scheduler und der bytebasierte Paketfortschritt sind als `v2.0.77` veröffentlicht. Eine Serverinstallation oder ein produktiver Neustart wurde nicht vorgenommen.
|
||||
- Der 32/40-Proxybereich und der synchrone Live-Takt von „Sitzung“ und „Verbleibend“ sind als `v2.0.78` veröffentlicht. Eine Serverinstallation oder ein produktiver Neustart wurde nicht vorgenommen.
|
||||
- Der Proxy-Readback- und Nullbereich-Hotfix ist als `v2.0.79` auf GitHub und Forgejo veröffentlicht, aber noch nicht auf einem produktiven System installiert oder gestartet.
|
||||
- Die einheitliche Geschwindigkeitsanzeige und die getrennten Paket-Reset-Optionen sind noch unveröffentlicht. Für eine neue Version, Installation oder einen produktiven Neustart ist eine aktuelle ausdrückliche Freigabe erforderlich.
|
||||
|
||||
## Nächste sinnvolle Schritte
|
||||
|
||||
1. Nach Veröffentlichung und einer getrennt freigegebenen Serverinstallation denselben problematischen Download erneut durchführen und Item-Log, SHA-256 sowie `UnRAR t` prüfen.
|
||||
1. Nach ausdrücklicher Release-Freigabe die unveröffentlichte Geschwindigkeits-/Reset-Korrektur versionieren, bauen, veröffentlichen und erst nach getrennt freigegebener Serverinstallation am echten Queue-Fall prüfen.
|
||||
2. Nach einer getrennt freigegebenen Serverinstallation den Ablauf Real-Debrid-Web-Download, Hauptfenster schließen und unmittelbar neu starten am echten Zielsystem verifizieren; vor jedem Eingriff Prozessbaum und Logtail sichern.
|
||||
3. Sicherheitsabhängigkeiten in einem separaten Upgrade-Branch aktualisieren, Electron-/Vite-/Vitest-Major-Wechsel einzeln testen und danach den vollständigen Windows-Paketpfad prüfen.
|
||||
4. Eine nichtdestruktive Strategie zur Bereinigung der divergierenden `main`-Branches abstimmen; kein Force-Push ohne ausdrückliche Freigabe.
|
||||
|
||||
@@ -1874,7 +1874,7 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
private persistTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
private speedEvents: Array<{ at: number; bytes: number; pid: string }> = [];
|
||||
private speedEvents: Array<{ at: number; bytes: number; pid: string; itemId: string }> = [];
|
||||
|
||||
private summary: DownloadSummary | null = null;
|
||||
|
||||
@@ -1885,6 +1885,8 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
private speedBytesLastWindow = 0;
|
||||
|
||||
private speedBytesPerItem = new Map<string, number>();
|
||||
|
||||
private sessionDownloadedBytes = 0;
|
||||
private sessionCompletedFiles = 0;
|
||||
|
||||
@@ -2809,6 +2811,14 @@ export class DownloadManager extends EventEmitter {
|
||||
const reconnectMs = Math.max(0, this.session.reconnectUntil - now);
|
||||
|
||||
const snapshotSession = cloneSession(this.session);
|
||||
for (const [itemId, item] of Object.entries(snapshotSession.items)) {
|
||||
const speedBps = this.session.running && !paused && item.status === "downloading"
|
||||
? Math.floor((this.speedBytesPerItem.get(item.id) ?? 0) / SPEED_WINDOW_SECONDS)
|
||||
: 0;
|
||||
if (item.speedBps !== speedBps) {
|
||||
snapshotSession.items[itemId] = { ...item, speedBps };
|
||||
}
|
||||
}
|
||||
let rendererState: ReturnType<typeof createRendererState>;
|
||||
if (this.settingsSnapshotCache && now - this.settingsSnapshotCacheAt < 400) {
|
||||
rendererState = this.settingsSnapshotCache;
|
||||
@@ -2967,6 +2977,7 @@ export class DownloadManager extends EventEmitter {
|
||||
this.speedEvents = [];
|
||||
this.speedBytesLastWindow = 0;
|
||||
this.speedBytesPerPackage.clear();
|
||||
this.speedBytesPerItem.clear();
|
||||
this.speedEventsHead = 0;
|
||||
changed = true;
|
||||
}
|
||||
@@ -3073,6 +3084,7 @@ export class DownloadManager extends EventEmitter {
|
||||
this.speedEventsHead = 0;
|
||||
this.speedBytesLastWindow = 0;
|
||||
this.speedBytesPerPackage.clear();
|
||||
this.speedBytesPerItem.clear();
|
||||
this.summary = null;
|
||||
this.invalidateStatsCache();
|
||||
this.persistSoon();
|
||||
@@ -3393,6 +3405,7 @@ export class DownloadManager extends EventEmitter {
|
||||
this.speedEventsHead = 0;
|
||||
this.speedBytesLastWindow = 0;
|
||||
this.speedBytesPerPackage.clear();
|
||||
this.speedBytesPerItem.clear();
|
||||
this.packagePostProcessTasks.clear();
|
||||
this.packagePostProcessAbortControllers.clear();
|
||||
this.packageDeferredPostProcessAbortControllers.clear();
|
||||
@@ -6342,6 +6355,7 @@ export class DownloadManager extends EventEmitter {
|
||||
this.speedEvents = [];
|
||||
this.speedBytesLastWindow = 0;
|
||||
this.speedBytesPerPackage.clear();
|
||||
this.speedBytesPerItem.clear();
|
||||
this.speedEventsHead = 0;
|
||||
this.lastGlobalProgressBytes = 0;
|
||||
this.lastGlobalProgressAt = nowMs();
|
||||
@@ -6455,6 +6469,7 @@ export class DownloadManager extends EventEmitter {
|
||||
this.speedEvents = [];
|
||||
this.speedBytesLastWindow = 0;
|
||||
this.speedBytesPerPackage.clear();
|
||||
this.speedBytesPerItem.clear();
|
||||
this.speedEventsHead = 0;
|
||||
this.lastGlobalProgressBytes = 0;
|
||||
this.lastGlobalProgressAt = nowMs();
|
||||
@@ -6572,6 +6587,7 @@ export class DownloadManager extends EventEmitter {
|
||||
this.speedEvents = [];
|
||||
this.speedBytesLastWindow = 0;
|
||||
this.speedBytesPerPackage.clear();
|
||||
this.speedBytesPerItem.clear();
|
||||
this.speedEventsHead = 0;
|
||||
this.lastGlobalProgressBytes = 0;
|
||||
this.lastGlobalProgressAt = nowMs();
|
||||
@@ -6613,6 +6629,7 @@ export class DownloadManager extends EventEmitter {
|
||||
this.speedEvents = [];
|
||||
this.speedBytesLastWindow = 0;
|
||||
this.speedBytesPerPackage.clear();
|
||||
this.speedBytesPerItem.clear();
|
||||
this.speedEventsHead = 0;
|
||||
this.lastGlobalProgressBytes = 0;
|
||||
this.lastGlobalProgressAt = nowMs();
|
||||
@@ -6655,6 +6672,7 @@ export class DownloadManager extends EventEmitter {
|
||||
this.speedEvents = [];
|
||||
this.speedBytesLastWindow = 0;
|
||||
this.speedBytesPerPackage.clear();
|
||||
this.speedBytesPerItem.clear();
|
||||
this.speedEventsHead = 0;
|
||||
if (!keepExtraction) {
|
||||
this.abortPostProcessing("stop");
|
||||
@@ -6777,6 +6795,7 @@ export class DownloadManager extends EventEmitter {
|
||||
this.speedEvents = [];
|
||||
this.speedBytesLastWindow = 0;
|
||||
this.speedBytesPerPackage.clear();
|
||||
this.speedBytesPerItem.clear();
|
||||
this.speedEventsHead = 0;
|
||||
this.runItemIds.clear();
|
||||
this.runPackageIds.clear();
|
||||
@@ -6818,6 +6837,7 @@ export class DownloadManager extends EventEmitter {
|
||||
this.speedEvents = [];
|
||||
this.speedBytesLastWindow = 0;
|
||||
this.speedBytesPerPackage.clear();
|
||||
this.speedBytesPerItem.clear();
|
||||
this.speedEventsHead = 0;
|
||||
}
|
||||
|
||||
@@ -7209,6 +7229,9 @@ export class DownloadManager extends EventEmitter {
|
||||
const pkgBytes = (this.speedBytesPerPackage.get(ev.pid) ?? 0) - ev.bytes;
|
||||
if (pkgBytes <= 0) this.speedBytesPerPackage.delete(ev.pid);
|
||||
else this.speedBytesPerPackage.set(ev.pid, pkgBytes);
|
||||
const itemBytes = (this.speedBytesPerItem.get(ev.itemId) ?? 0) - ev.bytes;
|
||||
if (itemBytes <= 0) this.speedBytesPerItem.delete(ev.itemId);
|
||||
else this.speedBytesPerItem.set(ev.itemId, itemBytes);
|
||||
this.speedEventsHead += 1;
|
||||
}
|
||||
if (this.speedEventsHead > 200) {
|
||||
@@ -7219,7 +7242,7 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
private lastSpeedPruneAt = 0;
|
||||
|
||||
private recordSpeed(bytes: number, packageId: string = ""): void {
|
||||
private recordSpeed(bytes: number, packageId: string = "", itemId: string = ""): void {
|
||||
const now = nowMs();
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return;
|
||||
@@ -7231,13 +7254,14 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
const bucket = now - (now % 120);
|
||||
const last = this.speedEvents[this.speedEvents.length - 1];
|
||||
if (last && last.at === bucket && last.pid === packageId) {
|
||||
if (last && last.at === bucket && last.pid === packageId && last.itemId === itemId) {
|
||||
last.bytes += bytes;
|
||||
} else {
|
||||
this.speedEvents.push({ at: bucket, bytes, pid: packageId });
|
||||
this.speedEvents.push({ at: bucket, bytes, pid: packageId, itemId });
|
||||
}
|
||||
this.speedBytesLastWindow += bytes;
|
||||
this.speedBytesPerPackage.set(packageId, (this.speedBytesPerPackage.get(packageId) ?? 0) + bytes);
|
||||
this.speedBytesPerItem.set(itemId, (this.speedBytesPerItem.get(itemId) ?? 0) + bytes);
|
||||
if (now - this.lastSpeedPruneAt >= 1500) {
|
||||
this.pruneSpeedEvents(now);
|
||||
this.lastSpeedPruneAt = now;
|
||||
@@ -11024,8 +11048,6 @@ export class DownloadManager extends EventEmitter {
|
||||
origin_http_error: "Downloadserver hat die Segmentanfrage abgelehnt",
|
||||
segment_failed: "Proxy-Segmente konnten nicht vollständig geladen werden"
|
||||
};
|
||||
let proxyWindowBytes = 0;
|
||||
let proxyWindowStartedAt = nowMs();
|
||||
let lastProxyUiEmitAt = 0;
|
||||
item.fullStatus = `Proxy-Download startet (Gesamtlimit ${this.settings.proxyConnectionsPerDownload})`;
|
||||
item.updatedAt = nowMs();
|
||||
@@ -11058,14 +11080,13 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
},
|
||||
onTrafficBytes: (bytes) => {
|
||||
proxyWindowBytes += bytes;
|
||||
this.recordProviderDownloadedBytes(
|
||||
item.provider,
|
||||
bytes,
|
||||
item.providerAccountId,
|
||||
item.providerAccountLabel
|
||||
);
|
||||
this.recordSpeed(bytes, item.packageId);
|
||||
this.recordSpeed(bytes, item.packageId, active.itemId);
|
||||
},
|
||||
onProgress: (deltaBytes, downloadedBytes, totalBytes) => {
|
||||
this.session.totalDownloadedBytes = Math.max(0, this.session.totalDownloadedBytes + deltaBytes);
|
||||
@@ -11076,19 +11097,16 @@ export class DownloadManager extends EventEmitter {
|
||||
Math.max(0, (this.itemContributedBytes.get(active.itemId) || 0) + deltaBytes)
|
||||
);
|
||||
const nowTick = nowMs();
|
||||
const elapsed = Math.max((nowTick - proxyWindowStartedAt) / 1000, 0.2);
|
||||
item.status = this.session.paused ? "paused" : "downloading";
|
||||
item.downloadedBytes = downloadedBytes;
|
||||
item.totalBytes = totalBytes;
|
||||
item.progressPercent = Math.max(0, Math.min(100, Math.floor((downloadedBytes / totalBytes) * 100)));
|
||||
item.speedBps = this.session.paused ? 0 : Math.max(0, Math.floor(proxyWindowBytes / elapsed));
|
||||
item.speedBps = this.session.paused
|
||||
? 0
|
||||
: Math.floor((this.speedBytesPerItem.get(active.itemId) ?? 0) / SPEED_WINDOW_SECONDS);
|
||||
item.fullStatus = this.session.paused
|
||||
? "Pausiert"
|
||||
: `Proxy-Download läuft (Gesamtlimit ${this.settings.proxyConnectionsPerDownload})`;
|
||||
if (elapsed >= 0.5) {
|
||||
proxyWindowStartedAt = nowTick;
|
||||
proxyWindowBytes = 0;
|
||||
}
|
||||
if (nowTick - lastProxyUiEmitAt >= DOWNLOAD_LIVE_UPDATE_INTERVAL_MS || deltaBytes < 0) {
|
||||
item.updatedAt = nowTick;
|
||||
this.emitState();
|
||||
@@ -11751,7 +11769,7 @@ export class DownloadManager extends EventEmitter {
|
||||
item.providerAccountLabel
|
||||
);
|
||||
this.itemContributedBytes.set(active.itemId, (this.itemContributedBytes.get(active.itemId) || 0) + buffer.length);
|
||||
this.recordSpeed(buffer.length, item.packageId);
|
||||
this.recordSpeed(buffer.length, item.packageId, active.itemId);
|
||||
throughputWindowBytes += buffer.length;
|
||||
|
||||
if (completionPlan.canFinishEarly && completionPlan.expectedTotal && written >= completionPlan.expectedTotal) {
|
||||
@@ -14630,6 +14648,7 @@ export class DownloadManager extends EventEmitter {
|
||||
this.speedEventsHead = 0;
|
||||
this.speedBytesLastWindow = 0;
|
||||
this.speedBytesPerPackage.clear();
|
||||
this.speedBytesPerItem.clear();
|
||||
this.resetGlobalSpeedLimitState();
|
||||
this.nonResumableActive = 0;
|
||||
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
|
||||
|
||||
+14
-5
@@ -111,7 +111,7 @@ import {
|
||||
StatisticsSidebarStatus,
|
||||
type StatisticsViewActions
|
||||
} from "./views/statistics/StatisticsView";
|
||||
import { buildDownloadsViewModel, formatDownloadEta, formatRemainingDownloadBytes, formatRemainingDownloadTooltip, getDownloadQueueStatusMetrics, getDownloadSpeedBps, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model";
|
||||
import { buildDownloadsViewModel, formatDownloadEta, formatRemainingDownloadBytes, formatRemainingDownloadTooltip, getDownloadQueueStatusMetrics, getDownloadSpeedBps, getPackageErrorItemIds, isDownloadItemError, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model";
|
||||
import { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable";
|
||||
import { DeleteConfirmationDialog } from "./views/downloads/DeleteConfirmationDialog";
|
||||
import { beginDownloadColumnDrag, clearDownloadColumnDrag, commitDownloadColumnDrag, createDownloadColumnOrderPersistence, DOWNLOAD_COLUMN_MOVE_DURATION_MS, updateDownloadColumnDrag, type DownloadColumnDragSession, type DownloadColumnOrderPersistence } from "./views/downloads/column-drag";
|
||||
@@ -5684,7 +5684,7 @@ export function App(): ReactElement {
|
||||
},
|
||||
onResetErrors: () => {
|
||||
const failedIds = Object.values(snapshot.session.items)
|
||||
.filter((item) => item.status === "failed")
|
||||
.filter(isDownloadItemError)
|
||||
.map((item) => item.id);
|
||||
if (failedIds.length === 0) {
|
||||
return;
|
||||
@@ -7119,12 +7119,21 @@ export function App(): ReactElement {
|
||||
else { executeDeleteSelection(ids); }
|
||||
}}>Ausgewählte Dateien entfernen ({selectedItemIds.length})</button>
|
||||
)}
|
||||
{hasPackages && !contextMenu.itemId && (
|
||||
{hasPackages && !contextMenu.itemId && (() => {
|
||||
const errorItemIds = getPackageErrorItemIds(selectedPackageIds, snapshot.session.packages, snapshot.session.items);
|
||||
return (
|
||||
<>
|
||||
<button className="ctx-menu-item" disabled={errorItemIds.length === 0} onClick={() => {
|
||||
if (errorItemIds.length > 0) void window.rd.resetItems(errorItemIds).catch(() => {});
|
||||
setContextMenu(null);
|
||||
}}>Nur fehlerhafte Dateien zurücksetzen</button>
|
||||
<button className="ctx-menu-item" onClick={() => {
|
||||
for (const id of selectedPackageIds) void window.rd.resetPackage(id).catch(() => {});
|
||||
setContextMenu(null);
|
||||
}}>Zurücksetzen{multi ? ` (${selectedPackageIds.length})` : ""}</button>
|
||||
)}
|
||||
}}>{multi ? "Ausgewählte Pakete vollständig zurücksetzen" : "Gesamtes Paket zurücksetzen"}</button>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{contextMenu.itemId && (
|
||||
<button className="ctx-menu-item" onClick={() => {
|
||||
const itemIds = multi ? selectedItemIds : [contextMenu.itemId!];
|
||||
|
||||
@@ -81,7 +81,7 @@ const pairs = [
|
||||
["Sitzungszähler und Ergebnisse der aktuellen Queue werden angezeigt.", "Session counters and results for the current queue are shown."], ["Sitzung zurücksetzen", "Reset session"], ["Gesamt zurücksetzen", "Reset total"], ["Fehler zurücksetzen", "Reset errors"],
|
||||
["Bandbreitenverlauf", "Bandwidth history"], ["Bandbreitenverlauf der letzten 60 Sekunden", "Bandwidth history for the last 60 seconds"], ["Provider", "Provider"], ["Daten", "Data"], ["Ergebnisse", "Results"],
|
||||
["Nie", "Never"], ["Sofort", "Immediately"], ["Beim App-Start", "On app startup"], ["Sobald Paket fertig ist", "When package completes"], ["Überschreiben", "Overwrite"], ["Überspringen", "Skip"], ["Nachfragen", "Ask"],
|
||||
["Abbrechen", "Cancel"], ["Speichern", "Save"], ["Schließen", "Close"], ["Löschen", "Delete"], ["Suchen", "Search"], ["Zurücksetzen", "Reset"], ["Testen", "Test"], ["Öffnen", "Open"],
|
||||
["Abbrechen", "Cancel"], ["Speichern", "Save"], ["Schließen", "Close"], ["Löschen", "Delete"], ["Suchen", "Search"], ["Zurücksetzen", "Reset"], ["Nur fehlerhafte Dateien zurücksetzen", "Reset failed files only"], ["Gesamtes Paket zurücksetzen", "Reset entire package"], ["Ausgewählte Pakete vollständig zurücksetzen", "Reset selected packages completely"], ["Testen", "Test"], ["Öffnen", "Open"],
|
||||
["Noch keine Downloads", "No downloads yet"], ["Füge Links hinzu, um den ersten Download zu starten.", "Add links to start the first download."], ["Keine passenden Downloads", "No matching downloads"], ["Alle anzeigen", "Show all"],
|
||||
["Neue Sammlung", "New collection"], ["Linksammler-Aktionen", "Link collector actions"], ["Linksammler-Filter", "Link collector filters"], ["Links erfassen", "Capture links"], ["DLC importieren", "Import DLC"], ["Datei importieren", "Import file"],
|
||||
["Pakete:", "Packages:"], ["Links:", "Links:"], ["Downloads übergeben", "Transfer downloads"], ["Analyse läuft im Hintergrund", "Analysis is running in the background"], ["Name, URL oder Hoster", "Name, URL, or host"], ["Teilweise online", "Partially online"],
|
||||
|
||||
@@ -193,13 +193,31 @@ function matchesQuery(value: string | undefined, query: string): boolean {
|
||||
return Boolean(value?.toLocaleLowerCase("de-DE").includes(query));
|
||||
}
|
||||
|
||||
function isExtractFailure(item: DownloadItem): boolean {
|
||||
export function isDownloadItemError(item: DownloadItem): boolean {
|
||||
const status = item.fullStatus.trim();
|
||||
return /^(?:Entpack(?:-|\s*)Fehler\b|Entpacken\b.*(?:\bFehler\b|\bError\b|fehlgeschlagen)|Extraction\b.*(?:\bError\b|failed))/i.test(status);
|
||||
return item.status === "failed"
|
||||
|| /^(?:Entpack(?:-|\s*)Fehler\b|Entpacken\b.*(?:\bFehler\b|\bError\b|fehlgeschlagen)|Extraction\b.*(?:\bError\b|failed))/i.test(status);
|
||||
}
|
||||
|
||||
export function getPackageErrorItemIds(
|
||||
packageIds: readonly string[],
|
||||
packages: Record<string, PackageEntry>,
|
||||
items: Record<string, DownloadItem>
|
||||
): string[] {
|
||||
const errorItemIds = new Set<string>();
|
||||
for (const packageId of packageIds) {
|
||||
const pkg = packages[packageId];
|
||||
if (!pkg) continue;
|
||||
for (const itemId of pkg.itemIds) {
|
||||
const item = items[itemId];
|
||||
if (item && isDownloadItemError(item)) errorItemIds.add(itemId);
|
||||
}
|
||||
}
|
||||
return [...errorItemIds];
|
||||
}
|
||||
|
||||
function classifyDownloadItem(item: DownloadItem, pkg: PackageEntry): DownloadSidebarFilter {
|
||||
if (item.status === "failed" || isExtractFailure(item)) return "failed";
|
||||
if (isDownloadItemError(item)) return "failed";
|
||||
if (item.status === "cancelled") return "all";
|
||||
if (isExtracted(item)) return "completed";
|
||||
if ((pkg.status === "extracting" || pkg.status === "integrity_check") && item.status === "completed") return "active";
|
||||
|
||||
@@ -295,6 +295,17 @@ describe("desktop shell", () => {
|
||||
expect(menu).not.toContain("ctx-menu-active");
|
||||
});
|
||||
|
||||
it("offers separate package reset actions for errors and the complete package", () => {
|
||||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||
const menu = source.slice(source.indexOf('ariaLabel="Downloadaktionen"'), source.indexOf('ariaLabel="Priorität"'));
|
||||
|
||||
expect(menu).toContain("Nur fehlerhafte Dateien zurücksetzen");
|
||||
expect(menu).toContain("Gesamtes Paket zurücksetzen");
|
||||
expect(menu).toContain("getPackageErrorItemIds");
|
||||
expect(menu).toContain("window.rd.resetItems(errorItemIds)");
|
||||
expect(menu).toContain("window.rd.resetPackage(id)");
|
||||
});
|
||||
|
||||
it("styles checked columns with a green mark and normal text", () => {
|
||||
const shellCss = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
|
||||
|
||||
|
||||
@@ -49,6 +49,33 @@ describe("runWithLimitedConcurrency", () => {
|
||||
});
|
||||
|
||||
describe("download live update cadence", () => {
|
||||
it("uses the same rolling traffic window for item and package speed", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-speed-window-"));
|
||||
tempDirs.push(root);
|
||||
const manager = new DownloadManager(
|
||||
defaultSettings(),
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
manager.addPackages([{ name: "speed", links: ["https://dummy/speed.bin"] }]);
|
||||
const initial = manager.getSnapshot();
|
||||
const packageId = initial.session.packageOrder[0];
|
||||
const itemId = initial.session.packages[packageId].itemIds[0];
|
||||
const internal = manager as any;
|
||||
internal.session.running = true;
|
||||
internal.session.paused = false;
|
||||
internal.session.packages[packageId].status = "downloading";
|
||||
internal.session.items[itemId].status = "downloading";
|
||||
internal.session.items[itemId].speedBps = 47_870_000;
|
||||
|
||||
internal.recordSpeed(18_550_000, packageId, itemId);
|
||||
|
||||
const snapshot = manager.getSnapshot();
|
||||
expect(snapshot.packageSpeedBps[packageId]).toBe(18_550_000);
|
||||
expect(snapshot.session.items[itemId].speedBps).toBe(snapshot.packageSpeedBps[packageId]);
|
||||
expect(internal.session.items[itemId].speedBps).toBe(47_870_000);
|
||||
});
|
||||
|
||||
it.each([69, 661, 2_470])("emits a running queue snapshot no sooner than 750 ms for %i items", async (itemCount) => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
@@ -8858,6 +8885,76 @@ describe("download manager", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("resets only selected package errors and preserves successful downloaded files", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selective-package-reset-"));
|
||||
tempDirs.push(root);
|
||||
const session = emptySession();
|
||||
const packageId = "selective-reset-package";
|
||||
const outputDir = path.join(root, "downloads", "selective-reset");
|
||||
const createdAt = Date.now();
|
||||
const itemIds = ["successful", "download-error", "extract-error"];
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
session.packageOrder = [packageId];
|
||||
session.packages[packageId] = {
|
||||
id: packageId,
|
||||
name: "selective-reset",
|
||||
outputDir,
|
||||
extractDir: path.join(root, "extract", "selective-reset"),
|
||||
status: "failed",
|
||||
itemIds,
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
for (const itemId of itemIds) {
|
||||
const targetPath = path.join(outputDir, `${itemId}.rar`);
|
||||
fs.writeFileSync(targetPath, itemId);
|
||||
session.items[itemId] = {
|
||||
id: itemId,
|
||||
packageId,
|
||||
url: `https://dummy/${itemId}`,
|
||||
provider: "realdebrid",
|
||||
status: itemId === "download-error" ? "failed" : "completed",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: itemId.length,
|
||||
totalBytes: itemId.length,
|
||||
progressPercent: 100,
|
||||
fileName: `${itemId}.rar`,
|
||||
targetPath,
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: itemId === "successful" ? "" : "Fehler",
|
||||
fullStatus: itemId === "successful" ? "Entpackt" : itemId === "extract-error" ? "Entpack-Fehler: CRCERROR" : "Fehlgeschlagen",
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
}
|
||||
const manager = new DownloadManager(
|
||||
defaultSettings(),
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
await manager.resetItems(["download-error", "extract-error"]);
|
||||
|
||||
const snapshot = manager.getSnapshot().session;
|
||||
expect(snapshot.items.successful.status).toBe("completed");
|
||||
expect(snapshot.items.successful.targetPath).toBe(path.join(outputDir, "successful.rar"));
|
||||
expect(fs.existsSync(snapshot.items.successful.targetPath)).toBe(true);
|
||||
for (const itemId of ["download-error", "extract-error"]) {
|
||||
expect(snapshot.items[itemId]).toEqual(expect.objectContaining({
|
||||
status: "queued",
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
targetPath: "",
|
||||
fullStatus: "Wartet"
|
||||
}));
|
||||
expect(fs.existsSync(path.join(outputDir, `${itemId}.rar`))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not freeze the scheduler when a reset item's old task is parked in a non-abort-observing await", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
getRemainingDownloadBytes,
|
||||
getPendingDownloadItemCount,
|
||||
getDownloadSpeedBps,
|
||||
getPackageErrorItemIds,
|
||||
isDownloadItemError,
|
||||
buildDownloadLogicalRows,
|
||||
type DownloadSidebarFilter,
|
||||
type DownloadsModelInput
|
||||
@@ -888,6 +890,21 @@ describe("downloads model", () => {
|
||||
expect(getDownloadSpeedBps({ stale: -1, invalid: Number.NaN })).toBe(0);
|
||||
});
|
||||
|
||||
it("selects only failed downloads and extraction errors for a package error reset", () => {
|
||||
const packageEntry = pkg("package-a", "Reset", ["successful", "download-error", "extract-error"]);
|
||||
const items = {
|
||||
successful: item("successful", packageEntry.id, "completed", { fullStatus: "Entpackt" }),
|
||||
"download-error": item("download-error", packageEntry.id, "failed", { fullStatus: "Fehlgeschlagen nach 3 Versuchen" }),
|
||||
"extract-error": item("extract-error", packageEntry.id, "completed", { fullStatus: "Entpack-Fehler: CRCERROR" })
|
||||
};
|
||||
|
||||
expect(isDownloadItemError(items.successful)).toBe(false);
|
||||
expect(getPackageErrorItemIds([packageEntry.id], { [packageEntry.id]: packageEntry }, items)).toEqual([
|
||||
"download-error",
|
||||
"extract-error"
|
||||
]);
|
||||
});
|
||||
|
||||
it("exports the sidebar filter contract used by the downloads shell", () => {
|
||||
const filter: DownloadSidebarFilter = "queued";
|
||||
|
||||
|
||||
+4
-1
@@ -185,7 +185,10 @@ describe("renderer localization", () => {
|
||||
it.each([
|
||||
["Entpackte Downloads sind ausgeblendet", "Extracted downloads are hidden"],
|
||||
["Deaktiviere „Entpackte Einträge ausblenden“, um sie wieder anzuzeigen.", "Disable “Hide extracted entries” to show them again."],
|
||||
["Downloads können derzeit nicht gestartet werden", "Downloads cannot be started right now"]
|
||||
["Downloads können derzeit nicht gestartet werden", "Downloads cannot be started right now"],
|
||||
["Nur fehlerhafte Dateien zurücksetzen", "Reset failed files only"],
|
||||
["Gesamtes Paket zurücksetzen", "Reset entire package"],
|
||||
["Ausgewählte Pakete vollständig zurücksetzen", "Reset selected packages completely"]
|
||||
])("translates download state text %s in both directions", (german, english) => {
|
||||
expect(translateUiText(german, "en")).toBe(english);
|
||||
expect(translateUiText(english, "de")).toBe(german);
|
||||
|
||||
Reference in New Issue
Block a user