@ -119,35 +119,38 @@ const ARCHIVE_SETTLE_MIN_DELAY_MS = 1500;
const ARCHIVE_SETTLE_POLL_MS = 250 ;
const ARCHIVE_SETTLE_POLL_MS = 250 ;
const ARCHIVE_SETTLE_MAX_WAIT_MS = 5000 ;
const ARCHIVE_SETTLE_MAX_WAIT_MS = 5000 ;
const MAX_SAME_DIRECT_URL_ATTEMPTS = 3 ;
const MAX_SAME_DIRECT_URL_ATTEMPTS = 3 ;
const RESUME_REWIND_BYTES = 256 * 1024 ;
const RESUME_REWIND_BYTES = 256 * 1024 ;
const REALDEBRID_TOTAL_MISMATCH_TOLERANCE_BYTES = 64 * 1024 ;
const REALDEBRID_TOTAL_MISMATCH_TOLERANCE_BYTES = 64 * 1024 ;
const PREALLOC_RESUME_MISMATCH_THRESHOLD_BYTES = 1024 * 1024 ;
const PREALLOC_RESUME_MISMATCH_THRESHOLD_BYTES = 1024 * 1024 ;
const LARGE_BINARY_FILE_RE = /\.(?:part\d+\.rar|rar|r\d{2,3}|zip(?:\.\d+)?|7z(?:\.\d+)?|tar|gz|bz2|xz|iso|mkv|mp4|avi|mov|wmv|m4v|ts|m2ts|webm|mp3|flac|aac|wav)$/i ;
const LARGE_BINARY_FILE_RE = /\.(?:part\d+\.rar|rar|r\d{2,3}|zip(?:\.\d+)?|7z(?:\.\d+)?|tar|gz|bz2|xz|iso|mkv|mp4|avi|mov|wmv|m4v|ts|m2ts|webm|mp3|flac|aac|wav)$/i ;
function expectedMinBytes ( totalBytes : number | null | undefined , strict : boolean ) : number {
/** Files that are legitimately tiny (< 5 KB) and should NOT be rejected as suspicious. */
if ( ! totalBytes || totalBytes <= 0 ) {
const KNOWN_SMALL_FILE_RE = /\.(?:sfv|nfo|nzb|md5|sha1|sha256|crc|txt|url|lnk|srr)$/i ;
return 10240 ;
}
function expectedMinBytes ( totalBytes : number | null | undefined , strict : boolean ) : number {
return strict ? totalBytes : Math.max ( 10240 , totalBytes - ALLOCATION_UNIT_SIZE ) ;
if ( ! totalBytes || totalBytes <= 0 ) {
}
return 10240 ;
}
function itemExpectedMinBytes ( item : DownloadItem ) : number {
return strict ? totalBytes : Math.max ( 10240 , totalBytes - ALLOCATION_UNIT_SIZE ) ;
const strict = isLargeBinaryLikePath ( item . targetPath || item . fileName || "" ) ;
}
return expectedMinBytes ( item . totalBytes , strict ) ;
}
function itemExpectedMinBytes ( item : DownloadItem ) : number {
const strict = isLargeBinaryLikePath ( item . targetPath || item . fileName || "" ) ;
function resolvePreallocResumeMismatchThreshold ( pathHint : string ) : number {
return expectedMinBytes ( item . totalBytes , strict ) ;
return isLargeBinaryLikePath ( pathHint )
}
? 0
: PREALLOC_RESUME_MISMATCH_THRESHOLD_BYTES ;
function resolvePreallocResumeMismatchThreshold ( pathHint : string ) : number {
}
return isLargeBinaryLikePath ( pathHint )
? 0
: PREALLOC_RESUME_MISMATCH_THRESHOLD_BYTES ;
}
function resolvePackageItemDiskPath ( pkg : PackageEntry , item : DownloadItem ) : string | null {
function resolvePackageItemDiskPath ( pkg : PackageEntry , item : DownloadItem ) : string | null {
if ( item . targetPath ) {
if ( item . targetPath ) {
@ -349,35 +352,35 @@ function cloneSettings(settings: AppSettings): AppSettings {
} ;
} ;
}
}
type ParsedContentRange = {
type ParsedContentRange = {
start : number ;
start : number ;
end : number ;
end : number ;
total : number | null ;
total : number | null ;
} ;
} ;
function parseContentRange ( contentRange : string | null ) : ParsedContentRange | null {
function parseContentRange ( contentRange : string | null ) : ParsedContentRange | null {
if ( ! contentRange ) {
if ( ! contentRange ) {
return null ;
return null ;
}
}
const match = contentRange . match ( /^bytes\s+(\d+)-(\d+)\/(\d+|\*)$/i ) ;
const match = contentRange . match ( /^bytes\s+(\d+)-(\d+)\/(\d+|\*)$/i ) ;
if ( ! match ) {
if ( ! match ) {
return null ;
return null ;
}
}
const start = Number ( match [ 1 ] ) ;
const start = Number ( match [ 1 ] ) ;
const end = Number ( match [ 2 ] ) ;
const end = Number ( match [ 2 ] ) ;
const total = match [ 3 ] === "*" ? null : Number ( match [ 3 ] ) ;
const total = match [ 3 ] === "*" ? null : Number ( match [ 3 ] ) ;
if ( ! Number . isFinite ( start ) || ! Number . isFinite ( end ) || start < 0 || end < start ) {
if ( ! Number . isFinite ( start ) || ! Number . isFinite ( end ) || start < 0 || end < start ) {
return null ;
return null ;
}
}
if ( total !== null && ( ! Number . isFinite ( total ) || total <= 0 || end >= total ) ) {
if ( total !== null && ( ! Number . isFinite ( total ) || total <= 0 || end >= total ) ) {
return null ;
return null ;
}
}
return { start , end , total } ;
return { start , end , total } ;
}
}
function parseContentRangeTotal ( contentRange : string | null ) : number | null {
function parseContentRangeTotal ( contentRange : string | null ) : number | null {
return parseContentRange ( contentRange ) ? . total ? ? null ;
return parseContentRange ( contentRange ) ? . total ? ? null ;
}
}
function parseContentDispositionFilename ( contentDisposition : string | null ) : string {
function parseContentDispositionFilename ( contentDisposition : string | null ) : string {
if ( ! contentDisposition ) {
if ( ! contentDisposition ) {
@ -437,6 +440,13 @@ function shouldRejectSuspiciousSmallDownload(
const size = Math . max ( 0 , Math . floor ( Number ( fileSizeOnDisk ) || 0 ) ) ;
const size = Math . max ( 0 , Math . floor ( Number ( fileSizeOnDisk ) || 0 ) ) ;
const expected = Number . isFinite ( expectedTotal || NaN ) ? Math . max ( 0 , Math . floor ( expectedTotal || 0 ) ) : 0 ;
const expected = Number . isFinite ( expectedTotal || NaN ) ? Math . max ( 0 , Math . floor ( expectedTotal || 0 ) ) : 0 ;
const binaryLike = isLargeBinaryLikePath ( filePath || fileName ) ;
const binaryLike = isLargeBinaryLikePath ( filePath || fileName ) ;
const name = path . basename ( String ( filePath || fileName || "" ) ) ;
// Known small files (e.g. .sfv, .nfo) are legitimately tiny — never reject them
// as long as they received the expected number of bytes (or we have no expectation).
if ( KNOWN_SMALL_FILE_RE . test ( name ) && ( expected <= 0 || size >= expected ) ) {
return false ;
}
if ( size <= 0 ) {
if ( size <= 0 ) {
return expected > 0 || binaryLike ;
return expected > 0 || binaryLike ;
@ -456,29 +466,29 @@ function shouldRejectSuspiciousSmallDownload(
return binaryLike ;
return binaryLike ;
}
}
function isFetchFailure ( errorText : string ) : boolean {
function isFetchFailure ( errorText : string ) : boolean {
const text = String ( errorText || "" ) . toLowerCase ( ) ;
const text = String ( errorText || "" ) . toLowerCase ( ) ;
return text . includes ( "fetch failed" ) || text . includes ( "socket hang up" ) || text . includes ( "econnreset" ) || text . includes ( "network error" ) ;
return text . includes ( "fetch failed" ) || text . includes ( "socket hang up" ) || text . includes ( "econnreset" ) || text . includes ( "network error" ) ;
}
}
function shouldRewindResumeTail ( errorText : string ) : boolean {
function shouldRewindResumeTail ( errorText : string ) : boolean {
const text = String ( errorText || "" ) . toLowerCase ( ) ;
const text = String ( errorText || "" ) . toLowerCase ( ) ;
if ( ! text ) {
if ( ! text ) {
return false ;
return false ;
}
}
return text . includes ( "terminated" )
return text . includes ( "terminated" )
|| text . includes ( "stall_timeout" )
|| text . includes ( "stall_timeout" )
|| text . includes ( "slow_throughput" )
|| text . includes ( "slow_throughput" )
|| text . includes ( "write_drain_timeout" )
|| text . includes ( "write_drain_timeout" )
|| text . includes ( "premature close" )
|| text . includes ( "premature close" )
|| text . includes ( "unexpected eof" )
|| text . includes ( "unexpected eof" )
|| text . includes ( "download_underflow" )
|| text . includes ( "download_underflow" )
|| isFetchFailure ( text ) ;
|| isFetchFailure ( text ) ;
}
}
function isHttp416Text ( errorText : string ) : boolean {
function isHttp416Text ( errorText : string ) : boolean {
return /(^|\D)416(\D|$)/ . test ( String ( errorText || "" ) ) ;
return /(^|\D)416(\D|$)/ . test ( String ( errorText || "" ) ) ;
}
}
function shouldPreflightFinalizeItemFromDisk ( item : DownloadItem ) : boolean {
function shouldPreflightFinalizeItemFromDisk ( item : DownloadItem ) : boolean {
const text = ` ${ item . fullStatus || "" } ${ item . lastError || "" } ` . toLowerCase ( ) ;
const text = ` ${ item . fullStatus || "" } ${ item . lastError || "" } ` . toLowerCase ( ) ;
@ -5258,35 +5268,35 @@ export class DownloadManager extends EventEmitter {
* knows which files belong to which items . Without this , after restart all paths are
* knows which files belong to which items . Without this , after restart all paths are
* unclaimed and a new download with the same filename would create a "(1)" copy
* unclaimed and a new download with the same filename would create a "(1)" copy
* instead of reusing its own partial file — or worse , overwrite another item ' s file . * /
* instead of reusing its own partial file — or worse , overwrite another item ' s file . * /
private restoreTargetPathReservations ( ) : void {
private restoreTargetPathReservations ( ) : void {
let restored = 0 ;
let restored = 0 ;
let droppedUnsafe = 0 ;
let droppedUnsafe = 0 ;
for ( const item of Object . values ( this . session . items ) ) {
for ( const item of Object . values ( this . session . items ) ) {
const pkg = this . session . packages [ item . packageId ] ;
const pkg = this . session . packages [ item . packageId ] ;
if ( ! pkg ) {
if ( ! pkg ) {
continue ;
continue ;
}
}
const tp = String ( item . targetPath || "" ) . trim ( ) ;
const tp = String ( item . targetPath || "" ) . trim ( ) ;
if ( ! tp ) continue ;
if ( ! tp ) continue ;
if ( ! isPathInsideDir ( tp , pkg . outputDir ) ) {
if ( ! isPathInsideDir ( tp , pkg . outputDir ) ) {
droppedUnsafe += 1 ;
droppedUnsafe += 1 ;
item . targetPath = "" ;
item . targetPath = "" ;
continue ;
continue ;
}
}
const key = pathKey ( tp ) ;
const key = pathKey ( tp ) ;
if ( ! this . reservedTargetPaths . has ( key ) ) {
if ( ! this . reservedTargetPaths . has ( key ) ) {
this . reservedTargetPaths . set ( key , item . id ) ;
this . reservedTargetPaths . set ( key , item . id ) ;
this . claimedTargetPathByItem . set ( item . id , tp ) ;
this . claimedTargetPathByItem . set ( item . id , tp ) ;
restored += 1 ;
restored += 1 ;
}
}
}
}
if ( restored > 0 ) {
if ( restored > 0 ) {
logger . info ( ` restoreTargetPathReservations: ${ restored } Pfade aus Session wiederhergestellt ` ) ;
logger . info ( ` restoreTargetPathReservations: ${ restored } Pfade aus Session wiederhergestellt ` ) ;
}
}
if ( droppedUnsafe > 0 ) {
if ( droppedUnsafe > 0 ) {
logger . warn ( ` restoreTargetPathReservations: ${ droppedUnsafe } unsichere targetPath-Eintraege verworfen ` ) ;
logger . warn ( ` restoreTargetPathReservations: ${ droppedUnsafe } unsichere targetPath-Eintraege verworfen ` ) ;
}
}
this . reconcileDuplicateSuffixSessionItems ( ) ;
this . reconcileDuplicateSuffixSessionItems ( ) ;
// Fix legacy (N) suffix files: rename back to original if original path is free
// Fix legacy (N) suffix files: rename back to original if original path is free
this . fixDuplicateSuffixFiles ( ) ;
this . fixDuplicateSuffixFiles ( ) ;
}
}
@ -5454,7 +5464,7 @@ export class DownloadManager extends EventEmitter {
if ( ! targetPath || ! item . totalBytes || item . totalBytes <= 0 ) continue ;
if ( ! targetPath || ! item . totalBytes || item . totalBytes <= 0 ) continue ;
try {
try {
const stat = fs . statSync ( targetPath ) ;
const stat = fs . statSync ( targetPath ) ;
const expectedMinSize = expectedMinBytes ( item . totalBytes , isLargeBinaryLikePath ( item . fileName || targetPath ) ) ;
const expectedMinSize = expectedMinBytes ( item . totalBytes , isLargeBinaryLikePath ( item . fileName || targetPath ) ) ;
const persistedShortfall = item . downloadedBytes < expectedMinSize && stat . size >= expectedMinSize ;
const persistedShortfall = item . downloadedBytes < expectedMinSize && stat . size >= expectedMinSize ;
if ( stat . size < expectedMinSize ) {
if ( stat . size < expectedMinSize ) {
logger . warn ( ` revalidateCompleted: ${ item . fileName } ist nur ${ humanSize ( stat . size ) } statt ${ humanSize ( item . totalBytes ) } , setze auf queued ` ) ;
logger . warn ( ` revalidateCompleted: ${ item . fileName } ist nur ${ humanSize ( stat . size ) } statt ${ humanSize ( item . totalBytes ) } , setze auf queued ` ) ;
@ -5534,15 +5544,15 @@ export class DownloadManager extends EventEmitter {
|| normalizedError . includes ( "resume_download_underflow" ) ;
|| normalizedError . includes ( "resume_download_underflow" ) ;
const archiveLikeTarget = String ( item . fileName || diskState . diskPath || "" ) . toLowerCase ( ) ;
const archiveLikeTarget = String ( item . fileName || diskState . diskPath || "" ) . toLowerCase ( ) ;
const archiveLike = /(?:\.part\d+\.rar|\.rar|\.r\d{2,3}|\.zip(?:\.\d+)?|\.7z(?:\.\d+)?|\.(?:tar(?:\.(?:gz|bz2|xz))?|tgz|tbz2|txz)|\.\d{3})$/i . test ( archiveLikeTarget ) ;
const archiveLike = /(?:\.part\d+\.rar|\.rar|\.r\d{2,3}|\.zip(?:\.\d+)?|\.7z(?:\.\d+)?|\.(?:tar(?:\.(?:gz|bz2|xz))?|tgz|tbz2|txz)|\.\d{3})$/i . test ( archiveLikeTarget ) ;
const expectedMinSize = expectedMinBytes ( item . totalBytes , isLargeBinaryLikePath ( item . fileName || diskState . diskPath || "" ) ) ;
const expectedMinSize = expectedMinBytes ( item . totalBytes , isLargeBinaryLikePath ( item . fileName || diskState . diskPath || "" ) ) ;
const looksComplete = diskState . exists
const looksComplete = diskState . exists
&& diskState . fullOnDisk
&& diskState . fullOnDisk
&& (
&& (
diskState . reason === "ok"
diskState . reason === "ok"
|| item . progressPercent >= 100
|| item . progressPercent >= 100
|| item . downloadedBytes >= diskState . minBytes
|| item . downloadedBytes >= diskState . minBytes
|| ( item . totalBytes != null && item . totalBytes > 0 && diskState . size >= expectedMinSize )
|| ( item . totalBytes != null && item . totalBytes > 0 && diskState . size >= expectedMinSize )
) ;
) ;
if ( ! looksComplete || ( knownShortfall > 0 && ( underflowIndicated || archiveLike ) ) ) {
if ( ! looksComplete || ( knownShortfall > 0 && ( underflowIndicated || archiveLike ) ) ) {
return false ;
return false ;
}
}
@ -8448,80 +8458,80 @@ export class DownloadManager extends EventEmitter {
const retryDisplayLimit = retryLimitLabel ( configuredRetryLimit ) ;
const retryDisplayLimit = retryLimitLabel ( configuredRetryLimit ) ;
const maxAttemptsBySetting = configuredRetryLimit <= 0 ? Number . MAX_SAFE_INTEGER : configuredRetryLimit + 1 ;
const maxAttemptsBySetting = configuredRetryLimit <= 0 ? Number . MAX_SAFE_INTEGER : configuredRetryLimit + 1 ;
const maxAttempts = Math . max ( 1 , Math . min ( MAX_SAME_DIRECT_URL_ATTEMPTS , maxAttemptsBySetting ) ) ;
const maxAttempts = Math . max ( 1 , Math . min ( MAX_SAME_DIRECT_URL_ATTEMPTS , maxAttemptsBySetting ) ) ;
let lastError = "" ;
let lastError = "" ;
let effectiveTargetPath = targetPath ;
let effectiveTargetPath = targetPath ;
let resumeRewindBytesNextAttempt = 0 ;
let resumeRewindBytesNextAttempt = 0 ;
for ( let attempt = 1 ; attempt <= maxAttempts ; attempt += 1 ) {
for ( let attempt = 1 ; attempt <= maxAttempts ; attempt += 1 ) {
let existingBytes = 0 ;
let existingBytes = 0 ;
try {
try {
const stat = await fs . promises . stat ( effectiveTargetPath ) ;
const stat = await fs . promises . stat ( effectiveTargetPath ) ;
existingBytes = stat . size ;
existingBytes = stat . size ;
} catch {
} catch {
// file does not exist
// file does not exist
}
}
if ( existingBytes > 0 && resumeRewindBytesNextAttempt > 0 ) {
if ( existingBytes > 0 && resumeRewindBytesNextAttempt > 0 ) {
const previousBytes = existingBytes ;
const previousBytes = existingBytes ;
const rewindBytes = Math . min ( existingBytes , resumeRewindBytesNextAttempt ) ;
const rewindBytes = Math . min ( existingBytes , resumeRewindBytesNextAttempt ) ;
const resumeStart = Math . max ( 0 , existingBytes - rewindBytes ) ;
const resumeStart = Math . max ( 0 , existingBytes - rewindBytes ) ;
try {
try {
await fs . promises . truncate ( effectiveTargetPath , resumeStart ) ;
await fs . promises . truncate ( effectiveTargetPath , resumeStart ) ;
existingBytes = resumeStart ;
existingBytes = resumeStart ;
item . downloadedBytes = Math . min ( item . downloadedBytes , existingBytes ) ;
item . downloadedBytes = Math . min ( item . downloadedBytes , existingBytes ) ;
logAttemptEvent ( "WARN" , "Resume-Schutz aktiv: Teil-Datei vor Retry zurueckgespult" , {
logAttemptEvent ( "WARN" , "Resume-Schutz aktiv: Teil-Datei vor Retry zurueckgespult" , {
attempt ,
attempt ,
previousBytes ,
previousBytes ,
rewindBytes ,
rewindBytes ,
resumeStart
resumeStart
} ) ;
} ) ;
} catch ( rewindError ) {
} catch ( rewindError ) {
logAttemptEvent ( "WARN" , "Resume-Schutz: Rueckspulen der Teil-Datei fehlgeschlagen" , {
logAttemptEvent ( "WARN" , "Resume-Schutz: Rueckspulen der Teil-Datei fehlgeschlagen" , {
attempt ,
attempt ,
previousBytes ,
previousBytes ,
rewindBytes ,
rewindBytes ,
error : compactErrorText ( rewindError )
error : compactErrorText ( rewindError )
} ) ;
} ) ;
} finally {
} finally {
resumeRewindBytesNextAttempt = 0 ;
resumeRewindBytesNextAttempt = 0 ;
}
}
} else if ( resumeRewindBytesNextAttempt > 0 ) {
} else if ( resumeRewindBytesNextAttempt > 0 ) {
resumeRewindBytesNextAttempt = 0 ;
resumeRewindBytesNextAttempt = 0 ;
}
}
const persistedBytes = Math . max ( 0 , Math . floor ( Number ( item . downloadedBytes ) || 0 ) ) ;
const persistedBytes = Math . max ( 0 , Math . floor ( Number ( item . downloadedBytes ) || 0 ) ) ;
const preallocMismatchThreshold = resolvePreallocResumeMismatchThreshold ( item . fileName || effectiveTargetPath || "" ) ;
const preallocMismatchThreshold = resolvePreallocResumeMismatchThreshold ( item . fileName || effectiveTargetPath || "" ) ;
// Guard against pre-allocated sparse files from a crashed session:
// Guard against pre-allocated sparse files from a crashed session:
// if file size exceeds persisted downloadedBytes beyond the allowed
// if file size exceeds persisted downloadedBytes beyond the allowed
// mismatch threshold, the file was likely pre-allocated but only
// mismatch threshold, the file was likely pre-allocated but only
// partially written before a hard crash.
// partially written before a hard crash.
// This must also run for persistedBytes=0, otherwise startup-resume can
// This must also run for persistedBytes=0, otherwise startup-resume can
// send Range=full-size and incorrectly accept HTTP 416 as "complete".
// send Range=full-size and incorrectly accept HTTP 416 as "complete".
if ( existingBytes > 0 && existingBytes > persistedBytes + preallocMismatchThreshold ) {
if ( existingBytes > 0 && existingBytes > persistedBytes + preallocMismatchThreshold ) {
try {
try {
const previousBytes = existingBytes ;
const previousBytes = existingBytes ;
await fs . promises . truncate ( effectiveTargetPath , persistedBytes ) ;
await fs . promises . truncate ( effectiveTargetPath , persistedBytes ) ;
existingBytes = persistedBytes ;
existingBytes = persistedBytes ;
logAttemptEvent ( "WARN" , "Pre-alloc-Rest erkannt, Teil-Datei auf persistierte Bytes gekuerzt" , {
logAttemptEvent ( "WARN" , "Pre-alloc-Rest erkannt, Teil-Datei auf persistierte Bytes gekuerzt" , {
attempt ,
attempt ,
previousBytes ,
previousBytes ,
persistedBytes
persistedBytes
} ) ;
} ) ;
} catch {
} catch {
if ( persistedBytes === 0 ) {
if ( persistedBytes === 0 ) {
try {
try {
await fs . promises . rm ( effectiveTargetPath , { force : true } ) ;
await fs . promises . rm ( effectiveTargetPath , { force : true } ) ;
existingBytes = 0 ;
existingBytes = 0 ;
} catch {
} catch {
// ignore
// ignore
}
}
}
}
}
}
}
}
const suspiciousResumeFootprint = existingBytes > 0
const suspiciousResumeFootprint = existingBytes > 0
&& existingBytes > persistedBytes + preallocMismatchThreshold ;
&& existingBytes > persistedBytes + preallocMismatchThreshold ;
const headers : Record < string , string > = { } ;
const headers : Record < string , string > = { } ;
if ( existingBytes > 0 ) {
if ( existingBytes > 0 ) {
headers . Range = ` bytes= ${ existingBytes } - ` ;
headers . Range = ` bytes= ${ existingBytes } - ` ;
}
}
logAttemptEvent ( "INFO" , "HTTP-Download-Versuch vorbereitet" , {
logAttemptEvent ( "INFO" , "HTTP-Download-Versuch vorbereitet" , {
attempt ,
attempt ,
maxAttempts : maxAttempts === Number . MAX_SAFE_INTEGER ? "infinite" : maxAttempts ,
maxAttempts : maxAttempts === Number . MAX_SAFE_INTEGER ? "infinite" : maxAttempts ,
@ -8589,37 +8599,37 @@ export class DownloadManager extends EventEmitter {
if ( response . status === 416 && existingBytes > 0 ) {
if ( response . status === 416 && existingBytes > 0 ) {
await response . arrayBuffer ( ) . catch ( ( ) = > undefined ) ;
await response . arrayBuffer ( ) . catch ( ( ) = > undefined ) ;
const rangeTotal = parseContentRangeTotal ( response . headers . get ( "content-range" ) ) ;
const rangeTotal = parseContentRangeTotal ( response . headers . get ( "content-range" ) ) ;
const expectedTotal = rangeTotal && rangeTotal > 0
const expectedTotal = rangeTotal && rangeTotal > 0
? rangeTotal
? rangeTotal
: ( knownTotal && knownTotal > 0 ? knownTotal : null ) ;
: ( knownTotal && knownTotal > 0 ? knownTotal : null ) ;
const sizeToleranceBytes = isLargeBinaryLikePath ( item . fileName || effectiveTargetPath ) ? 0 : ALLOCATION_UNIT_SIZE ;
const sizeToleranceBytes = isLargeBinaryLikePath ( item . fileName || effectiveTargetPath ) ? 0 : ALLOCATION_UNIT_SIZE ;
const closeEnoughToExpected = expectedTotal != null
const closeEnoughToExpected = expectedTotal != null
&& Math . abs ( existingBytes - expectedTotal ) <= sizeToleranceBytes ;
&& Math . abs ( existingBytes - expectedTotal ) <= sizeToleranceBytes ;
if ( expectedTotal != null && closeEnoughToExpected && ! suspiciousResumeFootprint ) {
if ( expectedTotal != null && closeEnoughToExpected && ! suspiciousResumeFootprint ) {
const finalizedTotal = Math . max ( existingBytes , expectedTotal ) ;
const finalizedTotal = Math . max ( existingBytes , expectedTotal ) ;
item . totalBytes = finalizedTotal ;
item . totalBytes = finalizedTotal ;
item . downloadedBytes = existingBytes ;
item . downloadedBytes = existingBytes ;
item . progressPercent = 100 ;
item . progressPercent = 100 ;
item . speedBps = 0 ;
item . speedBps = 0 ;
item . updatedAt = nowMs ( ) ;
item . updatedAt = nowMs ( ) ;
logAttemptEvent ( "INFO" , "HTTP 416 als vollständig behandelt" , {
logAttemptEvent ( "INFO" , "HTTP 416 als vollständig behandelt" , {
existingBytes ,
existingBytes ,
expectedTotal : finalizedTotal
expectedTotal : finalizedTotal
} ) ;
} ) ;
return { resumable : true } ;
return { resumable : true } ;
}
}
if ( expectedTotal != null && closeEnoughToExpected && suspiciousResumeFootprint ) {
if ( expectedTotal != null && closeEnoughToExpected && suspiciousResumeFootprint ) {
logAttemptEvent ( "WARN" , "HTTP 416 trotz Vollgroesse nicht als fertig gewertet (vermutlich pre-alloc)" , {
logAttemptEvent ( "WARN" , "HTTP 416 trotz Vollgroesse nicht als fertig gewertet (vermutlich pre-alloc)" , {
attempt ,
attempt ,
existingBytes ,
existingBytes ,
persistedBytes ,
persistedBytes ,
expectedTotal
expectedTotal
} ) ;
} ) ;
}
}
try {
try {
await fs . promises . rm ( effectiveTargetPath , { force : true } ) ;
await fs . promises . rm ( effectiveTargetPath , { force : true } ) ;
} catch {
} catch {
// ignore
// ignore
}
}
this . dropItemContribution ( active . itemId ) ;
this . dropItemContribution ( active . itemId ) ;
@ -8698,8 +8708,8 @@ export class DownloadManager extends EventEmitter {
const rawContentLength = Number ( response . headers . get ( "content-length" ) || 0 ) ;
const rawContentLength = Number ( response . headers . get ( "content-length" ) || 0 ) ;
const contentLength = Number . isFinite ( rawContentLength ) && rawContentLength > 0 ? rawContentLength : 0 ;
const contentLength = Number . isFinite ( rawContentLength ) && rawContentLength > 0 ? rawContentLength : 0 ;
const parsedContentRange = parseContentRange ( response . headers . get ( "content-range" ) ) ;
const parsedContentRange = parseContentRange ( response . headers . get ( "content-range" ) ) ;
const totalFromRange = parsedContentRange ? . total ? ? null ;
const totalFromRange = parsedContentRange ? . total ? ? null ;
const serverIgnoredRange = existingBytes > 0 && response . status === 200 ;
const serverIgnoredRange = existingBytes > 0 && response . status === 200 ;
const allowFreshOverwriteAfterResumeReset = serverIgnoredRange
const allowFreshOverwriteAfterResumeReset = serverIgnoredRange
&& active . resumeHardResetUsed
&& active . resumeHardResetUsed
@ -8719,69 +8729,69 @@ export class DownloadManager extends EventEmitter {
}
}
throw new Error ( ` range_ignored_on_resume: ${ existingBytes } / ${ contentLength || 0 } ` ) ;
throw new Error ( ` range_ignored_on_resume: ${ existingBytes } / ${ contentLength || 0 } ` ) ;
}
}
if ( allowFreshOverwriteAfterResumeReset ) {
if ( allowFreshOverwriteAfterResumeReset ) {
logger . warn (
logger . warn (
` Server ignorierte Range-Header nach Resume-Reset, ueberschreibe alte Teil-Datei frisch: ${ item . fileName } `
` Server ignorierte Range-Header nach Resume-Reset, ueberschreibe alte Teil-Datei frisch: ${ item . fileName } `
) ;
) ;
logAttemptEvent ( "WARN" , "Range ignoriert nach Resume-Reset, frischer Vollstart erlaubt" , {
logAttemptEvent ( "WARN" , "Range ignoriert nach Resume-Reset, frischer Vollstart erlaubt" , {
attempt ,
attempt ,
existingBytes ,
existingBytes ,
contentLength ,
contentLength ,
directUrl
directUrl
} ) ;
} ) ;
}
}
if ( existingBytes > 0 && response . status === 206 ) {
if ( existingBytes > 0 && response . status === 206 ) {
if ( ! parsedContentRange ) {
if ( ! parsedContentRange ) {
logAttemptEvent ( "WARN" , "Resume-Range-Header ungueltig oder fehlt" , {
logAttemptEvent ( "WARN" , "Resume-Range-Header ungueltig oder fehlt" , {
attempt ,
attempt ,
existingBytes ,
existingBytes ,
contentRange : response.headers.get ( "content-range" ) || ""
contentRange : response.headers.get ( "content-range" ) || ""
} ) ;
} ) ;
try {
try {
await response . body ? . cancel ( ) ;
await response . body ? . cancel ( ) ;
} catch {
} catch {
// ignore
// ignore
}
}
throw new Error ( ` range_mismatch_on_resume: ${ existingBytes } /invalid ` ) ;
throw new Error ( ` range_mismatch_on_resume: ${ existingBytes } /invalid ` ) ;
}
}
if ( parsedContentRange . start !== existingBytes ) {
if ( parsedContentRange . start !== existingBytes ) {
const sizeToleranceBytes = isLargeBinaryLikePath ( item . fileName || effectiveTargetPath ) ? 0 : ALLOCATION_UNIT_SIZE ;
const sizeToleranceBytes = isLargeBinaryLikePath ( item . fileName || effectiveTargetPath ) ? 0 : ALLOCATION_UNIT_SIZE ;
const canTreatAsAlreadyComplete = contentLength === 0
const canTreatAsAlreadyComplete = contentLength === 0
&& parsedContentRange . start === 0
&& parsedContentRange . start === 0
&& parsedContentRange . total != null
&& parsedContentRange . total != null
&& Math . abs ( existingBytes - parsedContentRange . total ) <= sizeToleranceBytes ;
&& Math . abs ( existingBytes - parsedContentRange . total ) <= sizeToleranceBytes ;
if ( canTreatAsAlreadyComplete ) {
if ( canTreatAsAlreadyComplete ) {
item . totalBytes = parsedContentRange . total ;
item . totalBytes = parsedContentRange . total ;
item . downloadedBytes = existingBytes ;
item . downloadedBytes = existingBytes ;
item . progressPercent = 100 ;
item . progressPercent = 100 ;
item . speedBps = 0 ;
item . speedBps = 0 ;
item . updatedAt = nowMs ( ) ;
item . updatedAt = nowMs ( ) ;
logAttemptEvent ( "WARN" , "Resume-Range-Start abweichend, Datei aber bereits vollstaendig" , {
logAttemptEvent ( "WARN" , "Resume-Range-Start abweichend, Datei aber bereits vollstaendig" , {
attempt ,
attempt ,
existingBytes ,
existingBytes ,
totalFromRange : parsedContentRange.total ,
totalFromRange : parsedContentRange.total ,
contentLength
contentLength
} ) ;
} ) ;
return { resumable : true } ;
return { resumable : true } ;
}
}
logAttemptEvent ( "WARN" , "Resume-Range-Start stimmt nicht mit lokaler Dateigroesse ueberein" , {
logAttemptEvent ( "WARN" , "Resume-Range-Start stimmt nicht mit lokaler Dateigroesse ueberein" , {
attempt ,
attempt ,
expectedStart : existingBytes ,
expectedStart : existingBytes ,
actualStart : parsedContentRange.start ,
actualStart : parsedContentRange.start ,
actualEnd : parsedContentRange.end ,
actualEnd : parsedContentRange.end ,
totalFromRange ,
totalFromRange ,
directUrl
directUrl
} ) ;
} ) ;
try {
try {
await response . body ? . cancel ( ) ;
await response . body ? . cancel ( ) ;
} catch {
} catch {
// ignore
// ignore
}
}
throw new Error ( ` range_mismatch_on_resume: ${ existingBytes } / ${ parsedContentRange . start } ` ) ;
throw new Error ( ` range_mismatch_on_resume: ${ existingBytes } / ${ parsedContentRange . start } ` ) ;
}
}
}
}
const correctedRealDebridTotal = getAuthoritativeRealDebridTotal (
const correctedRealDebridTotal = getAuthoritativeRealDebridTotal (
item . provider ,
item . provider ,
knownTotal || 0 ,
knownTotal || 0 ,
existingBytes ,
existingBytes ,
@ -9340,40 +9350,46 @@ export class DownloadManager extends EventEmitter {
}
}
// Detect tiny error-response files (e.g. hoster returning "Forbidden" with HTTP 200).
// Detect tiny error-response files (e.g. hoster returning "Forbidden" with HTTP 200).
// No legitimate file-hoster download is < 512 bytes.
// No legitimate file-hoster download is < 512 bytes, EXCEPT known small metadata
// files like .sfv (checksum verification), .nfo (release info), etc.
if ( written > 0 && written < 512 ) {
if ( written > 0 && written < 512 ) {
let snippet = "" ;
const knownSmallFile = KNOWN_SMALL_FILE_RE . test ( item . fileName || effectiveTargetPath ) ;
try {
if ( knownSmallFile && ( ( ! item . totalBytes || item . totalBytes <= 0 ) || written >= item . totalBytes ) ) {
snippet = await fs . promises . readFile ( effectiveTargetPath , "utf8" ) ;
logger . info ( ` Kleine Metadaten-Datei akzeptiert ( ${ written } B): ${ item . fileName || effectiveTargetPath } ` ) ;
snippet = snippet . slice ( 0 , 200 ) . replace ( /[\r\n]+/g , " " ) . trim ( ) ;
} catch { /* ignore */ }
const exactTinyBinary = Boolean (
item . totalBytes
&& item . totalBytes > 0
&& written >= item . totalBytes
&& isLargeBinaryLikePath ( item . fileName || effectiveTargetPath )
) ;
const snippetSuggestsError = /<(?:!doctype|html|body)\b|\b(?:forbidden|access denied|error|not found|expired|unavailable)\b/i . test ( snippet ) ;
if ( exactTinyBinary && ! snippetSuggestsError ) {
logger . info ( ` Tiny Binary akzeptiert ( ${ written } B): ${ item . fileName || effectiveTargetPath } ` ) ;
} else {
} else {
logger . warn ( ` Tiny download erkannt ( ${ written } B): " ${ snippet } " ` ) ;
let snippet = "" ;
try {
try {
await fs . promises . rm ( effectiveTargetPath , { force : true } ) ;
snippet = await fs . promises . readFile ( effectiveTargetPath , "utf8" ) ;
} catch { /* ignore */ }
snippet = snippet . slice ( 0 , 200 ) . replace ( /[\r\n]+/g , " " ) . trim ( ) ;
this . releaseTargetPath ( active . itemId ) ;
} catch { /* ignore */ }
this . dropItemContribution ( active . itemId ) ;
const exactTinyBinary = Boolean (
item . downloadedBytes = 0 ;
item . totalBytes
item . progressPercent = 0 ;
&& item . totalBytes > 0
throw new Error ( ` Download zu klein ( ${ written } B) – Hoster-Fehlerseite? ${ snippet ? ` Inhalt: " ${ snippet } " ` : "" } ` ) ;
&& written >= item . totalBytes
&& isLargeBinaryLikePath ( item . fileName || effectiveTargetPath )
) ;
const snippetSuggestsError = /<(?:!doctype|html|body)\b|\b(?:forbidden|access denied|error|not found|expired|unavailable)\b/i . test ( snippet ) ;
if ( exactTinyBinary && ! snippetSuggestsError ) {
logger . info ( ` Tiny Binary akzeptiert ( ${ written } B): ${ item . fileName || effectiveTargetPath } ` ) ;
} else {
logger . warn ( ` Tiny download erkannt ( ${ written } B): " ${ snippet } " ` ) ;
try {
await fs . promises . rm ( effectiveTargetPath , { force : true } ) ;
} catch { /* ignore */ }
this . releaseTargetPath ( active . itemId ) ;
this . dropItemContribution ( active . itemId ) ;
item . downloadedBytes = 0 ;
item . progressPercent = 0 ;
throw new Error ( ` Download zu klein ( ${ written } B) – Hoster-Fehlerseite? ${ snippet ? ` Inhalt: " ${ snippet } " ` : "" } ` ) ;
}
}
}
}
}
const completionValidation = validateDownloadedFileCompletion ( {
const completionValidation = validateDownloadedFileCompletion ( {
actualBytes : written ,
actualBytes : written ,
plan : completionPlan ,
plan : completionPlan ,
toleranceBytes : isLargeBinaryLikePath ( item . fileName || effectiveTargetPath ) ? 0 : ALLOCATION_UNIT_SIZE
toleranceBytes : isLargeBinaryLikePath ( item . fileName || effectiveTargetPath ) ? 0 : ALLOCATION_UNIT_SIZE
} ) ;
} ) ;
if ( ! completionValidation . ok ) {
if ( ! completionValidation . ok ) {
const shortfall = Math . max ( 0 , completionValidation . totalBytes - written ) ;
const shortfall = Math . max ( 0 , completionValidation . totalBytes - written ) ;
if ( preAllocated ) {
if ( preAllocated ) {
@ -9445,27 +9461,27 @@ export class DownloadManager extends EventEmitter {
error : lastError ,
error : lastError ,
targetPath : effectiveTargetPath
targetPath : effectiveTargetPath
} ) ;
} ) ;
if (
if (
normalizedLastError . startsWith ( "range_ignored_on_resume:" )
normalizedLastError . startsWith ( "range_ignored_on_resume:" )
|| normalizedLastError . startsWith ( "range_mismatch_on_resume:" )
|| normalizedLastError . startsWith ( "range_mismatch_on_resume:" )
) {
) {
throw new Error ( ` direct_link_retry_exhausted: ${ normalizedLastError } ` ) ;
throw new Error ( ` direct_link_retry_exhausted: ${ normalizedLastError } ` ) ;
}
}
if ( attempt < maxAttempts && written > existingBytes && shouldRewindResumeTail ( normalizedLastError ) ) {
if ( attempt < maxAttempts && written > existingBytes && shouldRewindResumeTail ( normalizedLastError ) ) {
resumeRewindBytesNextAttempt = Math . max ( resumeRewindBytesNextAttempt , RESUME_REWIND_BYTES ) ;
resumeRewindBytesNextAttempt = Math . max ( resumeRewindBytesNextAttempt , RESUME_REWIND_BYTES ) ;
logAttemptEvent ( "WARN" , "Resume-Schutz vorgemerkt: naechster Retry startet mit Rewind" , {
logAttemptEvent ( "WARN" , "Resume-Schutz vorgemerkt: naechster Retry startet mit Rewind" , {
attempt ,
attempt ,
existingBytes ,
existingBytes ,
written ,
written ,
appendedBytes : Math.max ( 0 , written - existingBytes ) ,
appendedBytes : Math.max ( 0 , written - existingBytes ) ,
rewindBytes : resumeRewindBytesNextAttempt ,
rewindBytes : resumeRewindBytesNextAttempt ,
error : normalizedLastError
error : normalizedLastError
} ) ;
} ) ;
}
}
if ( attempt < maxAttempts ) {
if ( attempt < maxAttempts ) {
item . retries += 1 ;
item . retries += 1 ;
item . fullStatus = ` Downloadfehler, retry ${ attempt } / ${ maxAttempts } (Direktlink) ` ;
item . fullStatus = ` Downloadfehler, retry ${ attempt } / ${ maxAttempts } (Direktlink) ` ;
this . emitState ( ) ;
this . emitState ( ) ;
await sleep ( retryDelayWithJitter ( attempt , 250 ) ) ;
await sleep ( retryDelayWithJitter ( attempt , 250 ) ) ;
continue ;
continue ;
}
}
@ -9943,8 +9959,8 @@ export class DownloadManager extends EventEmitter {
try {
try {
const stat = await fs . promises . stat ( part ) ;
const stat = await fs . promises . stat ( part ) ;
// Find the item that owns this file to get its expected totalBytes
// Find the item that owns this file to get its expected totalBytes
const ownerItem = this . findItemByDiskPath ( pkg , part ) ;
const ownerItem = this . findItemByDiskPath ( pkg , part ) ;
const minBytes = expectedMinBytes ( ownerItem ? . totalBytes ? ? 0 , isLargeBinaryLikePath ( part ) ) ;
const minBytes = expectedMinBytes ( ownerItem ? . totalBytes ? ? 0 , isLargeBinaryLikePath ( part ) ) ;
if ( stat . size < minBytes ) {
if ( stat . size < minBytes ) {
allMissingFullOnDisk = false ;
allMissingFullOnDisk = false ;
break ;
break ;
@ -10478,72 +10494,72 @@ export class DownloadManager extends EventEmitter {
if ( item . status === "downloading" || item . status === "validating" || item . status === "integrity_check" ) {
if ( item . status === "downloading" || item . status === "validating" || item . status === "integrity_check" ) {
continue ;
continue ;
}
}
if ( ! item . targetPath ) {
if ( ! item . targetPath ) {
continue ;
continue ;
}
}
if ( ! isPathInsideDir ( item . targetPath , pkg . outputDir ) ) {
if ( ! isPathInsideDir ( item . targetPath , pkg . outputDir ) ) {
logger . warn ( ` Item-Recovery: Unsicherer targetPath verworfen ( ${ item . fileName } -> ${ item . targetPath } ) ` ) ;
logger . warn ( ` Item-Recovery: Unsicherer targetPath verworfen ( ${ item . fileName } -> ${ item . targetPath } ) ` ) ;
this . releaseTargetPath ( item . id ) ;
this . releaseTargetPath ( item . id ) ;
this . dropItemContribution ( item . id ) ;
this . dropItemContribution ( item . id ) ;
item . targetPath = "" ;
item . targetPath = "" ;
item . status = "queued" ;
item . status = "queued" ;
item . attempts = 0 ;
item . attempts = 0 ;
item . downloadedBytes = 0 ;
item . downloadedBytes = 0 ;
item . progressPercent = 0 ;
item . progressPercent = 0 ;
item . speedBps = 0 ;
item . speedBps = 0 ;
item . fullStatus = "Wartet (ungueltiger Zielpfad)" ;
item . fullStatus = "Wartet (ungueltiger Zielpfad)" ;
item . updatedAt = nowMs ( ) ;
item . updatedAt = nowMs ( ) ;
continue ;
continue ;
}
}
try {
try {
const stat = await fs . promises . stat ( item . targetPath ) ;
const stat = await fs . promises . stat ( item . targetPath ) ;
// Require file to be essentially complete — within one allocation unit of the
// Require file to be essentially complete — within one allocation unit of the
// expected size. The old 50% threshold incorrectly recovered partial downloads
// expected size. The old 50% threshold incorrectly recovered partial downloads
// (e.g. 627 MB of 1001 MB) and triggered hybrid extraction on incomplete archives.
// (e.g. 627 MB of 1001 MB) and triggered hybrid extraction on incomplete archives.
const minSize = expectedMinBytes ( item . totalBytes , isLargeBinaryLikePath ( item . fileName || item . targetPath ) ) ;
const minSize = expectedMinBytes ( item . totalBytes , isLargeBinaryLikePath ( item . fileName || item . targetPath ) ) ;
const persistedBytes = Math . max ( 0 , Math . floor ( Number ( item . downloadedBytes ) || 0 ) ) ;
const persistedBytes = Math . max ( 0 , Math . floor ( Number ( item . downloadedBytes ) || 0 ) ) ;
const preallocMismatchThreshold = resolvePreallocResumeMismatchThreshold ( item . fileName || item . targetPath || "" ) ;
const preallocMismatchThreshold = resolvePreallocResumeMismatchThreshold ( item . fileName || item . targetPath || "" ) ;
const suspiciousPreallocFootprint = item . totalBytes != null
const suspiciousPreallocFootprint = item . totalBytes != null
&& item . totalBytes > 0
&& item . totalBytes > 0
&& stat . size >= minSize
&& stat . size >= minSize
&& stat . size > persistedBytes + preallocMismatchThreshold ;
&& stat . size > persistedBytes + preallocMismatchThreshold ;
if ( stat . size >= minSize ) {
if ( stat . size >= minSize ) {
// Re-check: another task may have started this item during the await
// Re-check: another task may have started this item during the await
const latestItem = this . session . items [ item . id ] ;
const latestItem = this . session . items [ item . id ] ;
if ( ! latestItem || this . activeTasks . has ( item . id ) || latestItem . status === "downloading"
if ( ! latestItem || this . activeTasks . has ( item . id ) || latestItem . status === "downloading"
|| latestItem . status === "validating" || latestItem . status === "integrity_check" ) {
|| latestItem . status === "validating" || latestItem . status === "integrity_check" ) {
continue ;
continue ;
}
}
if ( suspiciousPreallocFootprint ) {
if ( suspiciousPreallocFootprint ) {
logger . warn (
logger . warn (
` Item-Recovery: ${ item . fileName } uebersprungen – pre-alloc-Verdacht ` +
` Item-Recovery: ${ item . fileName } uebersprungen – pre-alloc-Verdacht ` +
` (stat= ${ humanSize ( stat . size ) } , bytes= ${ humanSize ( persistedBytes ) } , total= ${ humanSize ( item . totalBytes ) } ) `
` (stat= ${ humanSize ( stat . size ) } , bytes= ${ humanSize ( persistedBytes ) } , total= ${ humanSize ( item . totalBytes ) } ) `
) ;
) ;
try {
try {
if ( persistedBytes > 0 ) {
if ( persistedBytes > 0 ) {
fs . truncateSync ( item . targetPath , persistedBytes ) ;
fs . truncateSync ( item . targetPath , persistedBytes ) ;
} else {
} else {
fs . rmSync ( item . targetPath , { force : true } ) ;
fs . rmSync ( item . targetPath , { force : true } ) ;
}
}
} catch {
} catch {
// best-effort
// best-effort
}
}
item . status = "queued" ;
item . status = "queued" ;
item . attempts = 0 ;
item . attempts = 0 ;
item . downloadedBytes = persistedBytes ;
item . downloadedBytes = persistedBytes ;
item . progressPercent = item . totalBytes > 0
item . progressPercent = item . totalBytes > 0
? Math . max ( 0 , Math . min ( 99 , Math . floor ( ( persistedBytes / item . totalBytes ) * 100 ) ) )
? Math . max ( 0 , Math . min ( 99 , Math . floor ( ( persistedBytes / item . totalBytes ) * 100 ) ) )
: 0 ;
: 0 ;
item . speedBps = 0 ;
item . speedBps = 0 ;
item . fullStatus = "Wartet (Auto-Recovery: pre-alloc)" ;
item . fullStatus = "Wartet (Auto-Recovery: pre-alloc)" ;
item . updatedAt = nowMs ( ) ;
item . updatedAt = nowMs ( ) ;
continue ;
continue ;
}
}
// Guard against pre-allocated sparse files from a hard crash: file has
// Guard against pre-allocated sparse files from a hard crash: file has
// the full expected size but downloadedBytes is significantly behind.
// the full expected size but downloadedBytes is significantly behind.
if ( item . downloadedBytes > 0 && item . totalBytes && item . totalBytes > 0
if ( item . downloadedBytes > 0 && item . totalBytes && item . totalBytes > 0
&& stat . size >= minSize
&& stat . size >= minSize
&& item . downloadedBytes < item . totalBytes * 0.95 ) {
&& item . downloadedBytes < item . totalBytes * 0.95 ) {
logger . warn ( ` Item-Recovery: ${ item . fileName } uebersprungen – vermutlich pre-alloc (stat= ${ humanSize ( stat . size ) } , bytes= ${ humanSize ( item . downloadedBytes ) } , total= ${ humanSize ( item . totalBytes ) } ) ` ) ;
logger . warn ( ` Item-Recovery: ${ item . fileName } uebersprungen – vermutlich pre-alloc (stat= ${ humanSize ( stat . size ) } , bytes= ${ humanSize ( item . downloadedBytes ) } , total= ${ humanSize ( item . totalBytes ) } ) ` ) ;
continue ;
continue ;
}
}