release: 5.0.12 — Progress-Logik komplett neu geschrieben

Root Cause (User-Report): bei Part 1/6 Multi-Part-Download mit 869 MB
schon geladen, blieb der Bar auf einer fixen Position weit links — sah
aus als ob ein paar % erreicht waeren obwohl viel mehr lief.

Drei Probleme die das aus 5.0.1-5.0.11 noch ueberlebt haben:

1. downloadVODPart Path A (1-Sek-Heartbeat) emittierte progress=-1
   wenn HLS kein known total hat. UI flippte zwischen 'determinate
   bei letztem streamlink-%' und 'indeterminate-Animation' — User
   sah einen 35%-Bar der zwischen Streamlink-%-Updates kurz aufpoppt.

2. Part-based-Split (download_mode='parts', langes VOD in N Stunden-
   Parts) hat downloadVODPart's onProgress UNGEWRAPPT an die UI
   gegeben. Bar zeigte 'X% von Part i' statt 'gewichtete overall %'.
   Bei Part-Wechsel sprang er von 100% zurueck auf 0%. Bei Part 1
   mit 50% Stream-Progress zeigte der Bar 50% obwohl overall erst
   bei 8.3% (1 von 6 Parts halb fertig).

3. Full-VOD-Download (kein --hls-duration) hatte kein expected total
   fuer den bytes-Estimate -> blieb in indeterminate-Mode.

Fixes:

- downloadVODPart bekommt optionalen  Parameter.
  Path A schaetzt jetzt progress aus downloadedBytes / (duration *
  ~625 KB/s, Twitch ~5 Mbit/s Schaetzung), gecappt bei 95%. Wenn
  streamlink-stdout-% rauskommt, override mit dem (genauer). Nur
  noch progress=-1 wenn weder bytes noch streamlink-% verfuegbar.

- Part-based-Split (downloadVodWithStrategy) wrappt onProgress jetzt
  mit einem Aggregator: pro Part den max-bekannten %-Wert merken,
  overallProgress = avg(allParts). Bei Part-Done aus dem Loop
  partProgresses[i] = 100 setzen. Bar steigt monoton von 0% auf
  100% ueber alle Parts.

- Full-VOD-Download passt totalDuration als expectedTotalSec an
  downloadVODPart, sodass auch hier der bytes-Estimate greift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-20 02:38:16 +02:00
parent a2d9215b22
commit 7bc7ef84a2
3 changed files with 81 additions and 16 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "5.0.11", "version": "5.0.12",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "5.0.11", "version": "5.0.12",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.16.1", "axios": "^1.16.1",

View File

@ -1,6 +1,6 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "5.0.11", "version": "5.0.12",
"description": "Twitch VOD Manager - Download Twitch VODs easily", "description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js", "main": "dist/main.js",
"author": "xRangerDE", "author": "xRangerDE",

View File

@ -3043,7 +3043,12 @@ function downloadVODPart(
onProgress: (progress: DownloadProgress) => void, onProgress: (progress: DownloadProgress) => void,
itemId: string, itemId: string,
partNum: number, partNum: number,
totalParts: number totalParts: number,
/** Erwartete Dauer in Sekunden fuer den Progress-Estimate. Wenn endTime
gesetzt ist, ueberschrieben aus dort. Wenn startTime und endTime null
sind (Full-VOD), kann Caller hier die VOD-Gesamtdauer reingeben,
damit der Bar nicht in indeterminate haengt. 0 = unknown. */
expectedTotalSec: number = 0
): Promise<DownloadResult> { ): Promise<DownloadResult> {
return new Promise((resolve) => { return new Promise((resolve) => {
const streamlinkCmd = getStreamlinkCommand(); const streamlinkCmd = getStreamlinkCommand();
@ -3127,9 +3132,31 @@ function downloadVODPart(
} }
} }
// Bytes-basierte Schaetzung statt progress=-1, damit die Bar
// determinate bleibt + kontinuierlich waechst. Wenn streamlink
// spaeter eine echte % rausgibt (Path B), wird die ueber den
// bytes-Estimate gelegt (siehe lastStreamlinkPercent-Logik
// im stdout-handler).
// Quelle: endTime (--hls-duration arg) ODER expectedTotalSec
// Param (fuer Full-VOD wo Caller die Dauer kennt).
const expectedDurationSecForEstimate = parseClockDurationSeconds(endTime) || expectedTotalSec;
const expectedBytes = expectedDurationSecForEstimate > 0 ? expectedDurationSecForEstimate * 625_000 : 0;
let progressEstimate: number;
if (lastStreamlinkPercent > 0) {
// Streamlink hat % rausgegeben — vertrau dem (genauer als bytes).
progressEstimate = lastStreamlinkPercent;
} else if (expectedBytes > 0 && downloadedBytes > 0) {
// Bytes-Fallback: cap bei 95% damit der Bar nicht 100%
// vor dem tatsaechlichen Abschluss hinrennt.
progressEstimate = Math.min(95, (downloadedBytes / expectedBytes) * 100);
} else {
// Keine Info -> echtes Unknown, Bar geht in indeterminate.
progressEstimate = -1;
}
onProgress({ onProgress({
id: itemId, id: itemId,
progress: -1, // Unknown total progress: progressEstimate,
speed: formatSpeed(speed), speed: formatSpeed(speed),
eta: etaStr, eta: etaStr,
status: tBackend('statusBytesDownloaded', { bytes: formatBytes(downloadedBytes) }), status: tBackend('statusBytesDownloaded', { bytes: formatBytes(downloadedBytes) }),
@ -5323,7 +5350,9 @@ async function downloadVOD(
// Check download mode // Check download mode
if (config.download_mode === 'full' || totalDuration <= config.part_minutes * 60) { if (config.download_mode === 'full' || totalDuration <= config.part_minutes * 60) {
// Full download // Full download — totalDuration als expectedTotalSec damit der Bar
// determinate-progress aus bytes/duration schaetzen kann (statt in
// indeterminate-Animation zu haengen).
const filename = ensureUniqueFilename(makeTemplateFilename( const filename = ensureUniqueFilename(makeTemplateFilename(
config.filename_template_vod, config.filename_template_vod,
DEFAULT_FILENAME_TEMPLATE_VOD, DEFAULT_FILENAME_TEMPLATE_VOD,
@ -5331,13 +5360,18 @@ async function downloadVOD(
0, 0,
totalDuration totalDuration
), item.id); ), item.id);
const result = await downloadVODPart(item.url, filename, null, null, onProgress, item.id, 1, 1); const result = await downloadVODPart(item.url, filename, null, null, onProgress, item.id, 1, 1, totalDuration);
return result.success ? { ...result, outputFiles: [filename] } : result; return result.success ? { ...result, outputFiles: [filename] } : result;
} else { } else {
// Part-based download // Part-based download — wrappt onProgress mit einem Aggregator, der
// pro Part den letzten bekannten %-Wert haelt und einen weighted
// overallProgress (0-100%) zurueck an die UI emittiert. Ohne den
// Wrapper sah die UI nur "Part X bei Y%" und der Bar sprang bei
// Part-Wechsel von 100% zurueck auf 0%.
const partDuration = config.part_minutes * 60; const partDuration = config.part_minutes * 60;
const numParts = Math.ceil(totalDuration / partDuration); const numParts = Math.ceil(totalDuration / partDuration);
const downloadedFiles: string[] = []; const downloadedFiles: string[] = [];
const partProgresses: number[] = Array(numParts).fill(0);
for (let i = 0; i < numParts; i++) { for (let i = 0; i < numParts; i++) {
if (cancelledItemIds.has(item.id)) break; if (cancelledItemIds.has(item.id)) break;
@ -5359,16 +5393,32 @@ async function downloadVOD(
partFilename, partFilename,
formatDuration(startSec), formatDuration(startSec),
formatDuration(duration), formatDuration(duration),
onProgress, (progress) => {
// Per-part %-Update — clampen, NaN/negativ filtern
if (Number.isFinite(progress.progress) && progress.progress > 0 && progress.progress <= 100) {
partProgresses[i] = Math.max(partProgresses[i], progress.progress);
}
// Overall: avg ueber alle Parts (parts haben gleiche
// Dauer per Definition, also avg = weighted avg)
const overall = partProgresses.reduce((s, p) => s + p, 0) / numParts;
onProgress({
...progress,
progress: overall,
currentPart: i + 1,
totalParts: numParts
});
},
item.id, item.id,
i + 1, i + 1,
numParts numParts,
duration
); );
if (!result.success) { if (!result.success) {
return result; return result;
} }
partProgresses[i] = 100;
downloadedFiles.push(partFilename); downloadedFiles.push(partFilename);
} }
@ -5445,12 +5495,19 @@ async function processDownloadMergeGroup(
const vodWeight = vodDuration / totalDurationSec; const vodWeight = vodDuration / totalDurationSec;
const priorWeight = mg.items.slice(0, i).reduce((s, v) => s + parseDuration(v.duration_str), 0) / totalDurationSec; const priorWeight = mg.items.slice(0, i).reduce((s, v) => s + parseDuration(v.duration_str), 0) / totalDurationSec;
// Persistente per-part vodProgress, damit Path-A-Ticks (progress=-1, // Geschaetzte Bytes pro Part fuer den Fallback-Progress: Twitch-
// 1s-rhythmus mit unknown-total) den determinate Bar nicht auf // VOD Bitrate ~5 Mbit/s = ~625 KB/s. Wenn streamlink-stdout keine
// priorWeight zuruecksetzen. Wir merken uns die letzte positive // %-Lines emittiert (HLS ohne known total), nutzen wir
// Streamlink-Prozentangabe (Path B) und nutzen sie, bis ein neuer // downloadedBytes / estimatedTotalBytes als rough progress. Cap
// Wert kommt. Ohne das oszilliert die Bar zwischen indeterminate // bei 95% damit der Bar nie 100% vorm tatsaechlichen Done erreicht.
// und priorWeight, optisch eingefroren. const estimatedTotalBytes = Math.max(1, vodDuration * 625_000);
// Persistente per-part vodProgress. Quelle 1: streamlink stdout %
// (genau). Quelle 2: downloadedBytes / estimated (Fallback wenn
// % nicht reportet wird). Ohne den Fallback haengte der Bar auf
// dem indeterminate-Pattern (animierte 35%-Box) waehrend tatsaechlich
// schon ein paar 100 MB unten waren — User sieht das als "fest mittig
// links" weil die Animation schnell ist und nur Snapshots zeigen.
let lastVodProgress = 0; let lastVodProgress = 0;
const result = await downloadVODPart( const result = await downloadVODPart(
vodItem.url, vodItem.url,
@ -5460,6 +5517,14 @@ async function processDownloadMergeGroup(
(progress) => { (progress) => {
if (progress.progress > 0 && progress.progress <= 100) { if (progress.progress > 0 && progress.progress <= 100) {
lastVodProgress = progress.progress; lastVodProgress = progress.progress;
} else if (progress.downloadedBytes && progress.downloadedBytes > 0) {
// Fallback: bytes-basierte Schaetzung. Streamlink-stdout-%
// bleibt bevorzugt; bytes-Fallback wird nur genutzt wenn
// noch nie ein echter % rein kam (lastVodProgress noch 0).
if (lastVodProgress === 0) {
const bytePct = Math.min(95, (progress.downloadedBytes / estimatedTotalBytes) * 100);
lastVodProgress = bytePct;
}
} }
// Weighted progress: download phase = 0-70% // Weighted progress: download phase = 0-70%
const overallProgress = (priorWeight + vodWeight * (lastVodProgress / 100)) * 70; const overallProgress = (priorWeight + vodWeight * (lastVodProgress / 100)) * 70;