mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-02 16:20:57 +02:00
585 lines
17 KiB
Go
585 lines
17 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/dop251/goja"
|
|
)
|
|
|
|
func chunkedTransferTotal(resp *http.Response) int64 {
|
|
if resp == nil {
|
|
return 0
|
|
}
|
|
if contentRange := resp.Header.Get("Content-Range"); contentRange != "" {
|
|
if slash := strings.LastIndex(contentRange, "/"); slash >= 0 {
|
|
var total int64
|
|
if _, err := fmt.Sscanf(contentRange[slash+1:], "%d", &total); err == nil {
|
|
return total
|
|
}
|
|
}
|
|
}
|
|
if resp.StatusCode == http.StatusOK {
|
|
return resp.ContentLength
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (r *extensionRuntime) chunkedTransferCancelled(activeItemID string) bool {
|
|
return activeItemID != "" && isDownloadCancelled(activeItemID)
|
|
}
|
|
|
|
// fileDownloadChunked downloads sequential byte ranges. The same transfer
|
|
// policy used by ordinary and segmented downloads controls retries and
|
|
// checkpoints here, so specialized CDN downloads do not lose the reliability
|
|
// guarantees of the generic file API.
|
|
func (r *extensionRuntime) fileDownloadChunked(
|
|
client *http.Client,
|
|
urlStr, fullPath string,
|
|
headers map[string]string,
|
|
userAgent string,
|
|
chunkSize int64,
|
|
onProgress goja.Callable,
|
|
trackItemBytes bool,
|
|
persistentCheckpoint bool,
|
|
policy DownloadTransferPolicy,
|
|
) goja.Value {
|
|
unlock := lockDownloadOutputPath(fullPath)
|
|
defer unlock()
|
|
|
|
activeItemID := r.getActiveDownloadItemID()
|
|
if activeItemID != "" {
|
|
SetItemDownloading(activeItemID)
|
|
}
|
|
config := transferRetryConfig(policy)
|
|
var probeResp *http.Response
|
|
var probeFailure transferFailure
|
|
probeDelay := config.InitialDelay
|
|
probeAttempts := 0
|
|
for attempt := 1; attempt <= policy.MaxAttempts; attempt++ {
|
|
probeAttempts = attempt
|
|
request, err := http.NewRequest("GET", urlStr, nil)
|
|
if err != nil {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "invalid_request",
|
|
Message: fmt.Sprintf("chunked probe request: %v", err),
|
|
Attempts: attempt,
|
|
})
|
|
}
|
|
request = r.bindDownloadCancelContext(request)
|
|
retryContext := request.Context()
|
|
for key, value := range headers {
|
|
if !strings.EqualFold(key, "Range") {
|
|
request.Header.Set(key, value)
|
|
}
|
|
}
|
|
request.Header.Set("User-Agent", userAgent)
|
|
request.Header.Set("Range", "bytes=0-1")
|
|
request, watchdog := bindStallWatchdog(request, downloadStallTimeout)
|
|
response, requestErr := client.Do(request)
|
|
if requestErr != nil {
|
|
stalled := watchdog.stalled.Load()
|
|
watchdog.stop()
|
|
if r.chunkedTransferCancelled(activeItemID) {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "cancelled",
|
|
Message: "download cancelled",
|
|
Attempts: attempt,
|
|
})
|
|
}
|
|
message := fmt.Sprintf("chunked probe failed: %v", requestErr)
|
|
if stalled {
|
|
message = fmt.Sprintf(
|
|
"chunked probe stalled for %ds",
|
|
int(downloadStallTimeout.Seconds()),
|
|
)
|
|
}
|
|
probeFailure = transferFailure{
|
|
ErrorType: "transient_network",
|
|
Message: message,
|
|
Attempts: attempt,
|
|
}
|
|
if attempt == policy.MaxAttempts {
|
|
return r.jsTransferError(probeFailure)
|
|
}
|
|
if waitTransferRetry(retryContext, probeDelay) != nil {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "cancelled",
|
|
Message: "download cancelled",
|
|
Attempts: attempt,
|
|
})
|
|
}
|
|
probeDelay = calculateNextDelay(probeDelay, config)
|
|
continue
|
|
}
|
|
watchdog.stop()
|
|
if response.StatusCode == http.StatusPartialContent ||
|
|
response.StatusCode == http.StatusOK {
|
|
io.Copy(io.Discard, io.LimitReader(response.Body, 32*1024))
|
|
response.Body.Close()
|
|
probeResp = response
|
|
break
|
|
}
|
|
|
|
retryAfter := retryAfterSeconds(response)
|
|
io.Copy(io.Discard, io.LimitReader(response.Body, 32*1024))
|
|
response.Body.Close()
|
|
probeFailure = transferFailure{
|
|
ErrorType: transferErrorTypeForStatus(response.StatusCode, policy),
|
|
Message: fmt.Sprintf("chunked probe HTTP %d", response.StatusCode),
|
|
HTTPStatus: response.StatusCode,
|
|
RetryAfterSeconds: retryAfter,
|
|
Attempts: attempt,
|
|
}
|
|
if !retryableTransferStatus(response.StatusCode) || attempt == policy.MaxAttempts {
|
|
return r.jsTransferError(probeFailure)
|
|
}
|
|
delay := probeDelay
|
|
if retryAfter > 0 {
|
|
delay = time.Duration(retryAfter) * time.Second
|
|
}
|
|
if waitTransferRetry(retryContext, delay) != nil {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "cancelled",
|
|
Message: "download cancelled",
|
|
Attempts: attempt,
|
|
})
|
|
}
|
|
probeDelay = calculateNextDelay(probeDelay, config)
|
|
}
|
|
if probeResp == nil {
|
|
return r.jsTransferError(probeFailure)
|
|
}
|
|
|
|
totalSize := chunkedTransferTotal(probeResp)
|
|
validator := transferResponseValidator(probeResp.Header)
|
|
fingerprint := transferURLFingerprint(urlStr)
|
|
stagedPath := stagedDownloadPath(fullPath)
|
|
checkpointPath := transferCheckpointPath(stagedPath)
|
|
keepPartial := persistentCheckpoint && validator != "" && fingerprint != ""
|
|
checkpoint, checkpointOK := loadTransferCheckpoint(checkpointPath, fingerprint)
|
|
if checkpointOK && (checkpoint.Validator != validator ||
|
|
(checkpoint.Total > 0 && totalSize > 0 && checkpoint.Total != totalSize)) {
|
|
checkpointOK = false
|
|
}
|
|
if !keepPartial || !checkpointOK {
|
|
os.Remove(stagedPath)
|
|
os.Remove(checkpointPath)
|
|
checkpoint = transferCheckpoint{}
|
|
}
|
|
|
|
output, err := os.OpenFile(stagedPath, os.O_CREATE|os.O_RDWR, 0600)
|
|
if err != nil {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "storage_error",
|
|
Message: fmt.Sprintf("failed to create chunked staged file: %v", err),
|
|
})
|
|
}
|
|
promoted := false
|
|
defer func() {
|
|
output.Close()
|
|
if promoted {
|
|
os.Remove(checkpointPath)
|
|
} else if !keepPartial {
|
|
os.Remove(stagedPath)
|
|
os.Remove(checkpointPath)
|
|
}
|
|
}()
|
|
|
|
var totalWritten int64
|
|
if checkpointOK {
|
|
if info, statErr := output.Stat(); statErr == nil {
|
|
totalWritten = min(checkpoint.Bytes, info.Size())
|
|
}
|
|
}
|
|
if err := output.Truncate(totalWritten); err != nil {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "storage_error",
|
|
Message: fmt.Sprintf("failed to restore chunked partial: %v", err),
|
|
})
|
|
}
|
|
if _, err := output.Seek(totalWritten, io.SeekStart); err != nil {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "storage_error",
|
|
Message: fmt.Sprintf("failed to seek chunked partial: %v", err),
|
|
})
|
|
}
|
|
|
|
shouldTrackBytes := activeItemID != "" && trackItemBytes
|
|
itemProgressReporter := NewItemTransferProgressReporter(activeItemID, totalWritten, totalSize)
|
|
if shouldTrackBytes {
|
|
if totalSize > 0 {
|
|
SetItemProgress(
|
|
activeItemID,
|
|
float64(totalWritten)/float64(totalSize),
|
|
totalWritten,
|
|
totalSize,
|
|
)
|
|
} else if totalWritten > 0 {
|
|
SetItemBytesReceived(activeItemID, totalWritten)
|
|
}
|
|
}
|
|
|
|
lastProgressNotify := totalWritten
|
|
lastCheckpointBytes := totalWritten
|
|
lastCheckpointAt := time.Now()
|
|
attemptsUsed := probeAttempts
|
|
fullResponse := false
|
|
buffer := make([]byte, 64*1024)
|
|
for totalSize <= 0 || totalWritten < totalSize {
|
|
chunkStart := totalWritten
|
|
chunkEnd := chunkStart + chunkSize - 1
|
|
if totalSize > 0 && chunkEnd >= totalSize {
|
|
chunkEnd = totalSize - 1
|
|
}
|
|
retryDelay := config.InitialDelay
|
|
var chunkComplete bool
|
|
var lastFailure transferFailure
|
|
|
|
for attempt := 1; attempt <= policy.MaxAttempts; attempt++ {
|
|
attemptsUsed++
|
|
request, requestErr := http.NewRequest("GET", urlStr, nil)
|
|
if requestErr != nil {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "invalid_request",
|
|
Message: fmt.Sprintf("chunked request at %d: %v", chunkStart, requestErr),
|
|
Attempts: attemptsUsed,
|
|
})
|
|
}
|
|
request = r.bindDownloadCancelContext(request)
|
|
retryContext := request.Context()
|
|
for key, value := range headers {
|
|
if !strings.EqualFold(key, "Range") {
|
|
request.Header.Set(key, value)
|
|
}
|
|
}
|
|
request.Header.Set("User-Agent", userAgent)
|
|
request.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", chunkStart, chunkEnd))
|
|
if validator != "" {
|
|
request.Header.Set("If-Range", validator)
|
|
}
|
|
request, watchdog := bindStallWatchdog(request, downloadStallTimeout)
|
|
response, responseErr := client.Do(request)
|
|
if responseErr != nil {
|
|
stalled := watchdog.stalled.Load()
|
|
watchdog.stop()
|
|
if r.chunkedTransferCancelled(activeItemID) {
|
|
lastFailure = transferFailure{
|
|
ErrorType: "cancelled",
|
|
Message: "download cancelled",
|
|
Attempts: attemptsUsed,
|
|
}
|
|
return r.jsTransferError(lastFailure)
|
|
}
|
|
message := fmt.Sprintf("chunked request at %d failed: %v", chunkStart, responseErr)
|
|
if stalled {
|
|
message = fmt.Sprintf(
|
|
"chunked request at %d stalled for %ds",
|
|
chunkStart,
|
|
int(downloadStallTimeout.Seconds()),
|
|
)
|
|
}
|
|
lastFailure = transferFailure{
|
|
ErrorType: "transient_network",
|
|
Message: message,
|
|
Attempts: attemptsUsed,
|
|
}
|
|
if attempt == policy.MaxAttempts {
|
|
break
|
|
}
|
|
if waitTransferRetry(retryContext, retryDelay) != nil {
|
|
lastFailure.ErrorType = "cancelled"
|
|
lastFailure.Message = "download cancelled"
|
|
return r.jsTransferError(lastFailure)
|
|
}
|
|
retryDelay = calculateNextDelay(retryDelay, config)
|
|
continue
|
|
}
|
|
|
|
if response.StatusCode != http.StatusPartialContent &&
|
|
response.StatusCode != http.StatusOK {
|
|
retryAfter := retryAfterSeconds(response)
|
|
io.Copy(io.Discard, io.LimitReader(response.Body, 32*1024))
|
|
response.Body.Close()
|
|
watchdog.stop()
|
|
lastFailure = transferFailure{
|
|
ErrorType: transferErrorTypeForStatus(response.StatusCode, policy),
|
|
Message: fmt.Sprintf("chunked HTTP %d at offset %d", response.StatusCode, chunkStart),
|
|
HTTPStatus: response.StatusCode,
|
|
RetryAfterSeconds: retryAfter,
|
|
Attempts: attemptsUsed,
|
|
}
|
|
if !retryableTransferStatus(response.StatusCode) || attempt == policy.MaxAttempts {
|
|
break
|
|
}
|
|
delay := retryDelay
|
|
if retryAfter > 0 {
|
|
delay = time.Duration(retryAfter) * time.Second
|
|
}
|
|
if waitTransferRetry(retryContext, delay) != nil {
|
|
lastFailure.ErrorType = "cancelled"
|
|
lastFailure.Message = "download cancelled"
|
|
return r.jsTransferError(lastFailure)
|
|
}
|
|
retryDelay = calculateNextDelay(retryDelay, config)
|
|
continue
|
|
}
|
|
|
|
if response.StatusCode == http.StatusPartialContent &&
|
|
!strings.HasPrefix(
|
|
response.Header.Get("Content-Range"),
|
|
fmt.Sprintf("bytes %d-", chunkStart),
|
|
) {
|
|
contentRange := response.Header.Get("Content-Range")
|
|
response.Body.Close()
|
|
watchdog.stop()
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "integrity_failed",
|
|
Message: fmt.Sprintf(
|
|
"chunked response has unexpected Content-Range %q at %d",
|
|
contentRange,
|
|
chunkStart,
|
|
),
|
|
HTTPStatus: response.StatusCode,
|
|
Attempts: attemptsUsed,
|
|
})
|
|
}
|
|
if nextValidator := transferResponseValidator(response.Header); nextValidator != "" &&
|
|
validator != "" && nextValidator != validator {
|
|
response.Body.Close()
|
|
watchdog.stop()
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "integrity_failed",
|
|
Message: "chunked response validator changed during transfer",
|
|
Attempts: attemptsUsed,
|
|
})
|
|
}
|
|
|
|
if response.StatusCode == http.StatusOK && chunkStart > 0 {
|
|
if err := output.Truncate(0); err != nil {
|
|
response.Body.Close()
|
|
watchdog.stop()
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "storage_error",
|
|
Message: fmt.Sprintf("failed to restart ignored range: %v", err),
|
|
Attempts: attemptsUsed,
|
|
})
|
|
}
|
|
if _, err := output.Seek(0, io.SeekStart); err != nil {
|
|
response.Body.Close()
|
|
watchdog.stop()
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "storage_error",
|
|
Message: fmt.Sprintf("failed to seek restarted range: %v", err),
|
|
Attempts: attemptsUsed,
|
|
})
|
|
}
|
|
chunkStart = 0
|
|
totalWritten = 0
|
|
lastCheckpointBytes = 0
|
|
os.Remove(checkpointPath)
|
|
if response.ContentLength > 0 {
|
|
totalSize = response.ContentLength
|
|
}
|
|
}
|
|
|
|
chunkWritten := int64(0)
|
|
var readErr error
|
|
for {
|
|
readCount, bodyErr := response.Body.Read(buffer)
|
|
if readCount > 0 {
|
|
watchdog.reset()
|
|
if r.chunkedTransferCancelled(activeItemID) {
|
|
readErr = ErrDownloadCancelled
|
|
break
|
|
}
|
|
writeCount, writeErr := output.Write(buffer[:readCount])
|
|
chunkWritten += int64(writeCount)
|
|
totalWritten += int64(writeCount)
|
|
if writeErr != nil || writeCount != readCount {
|
|
response.Body.Close()
|
|
watchdog.stop()
|
|
if writeErr == nil {
|
|
writeErr = io.ErrShortWrite
|
|
}
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "storage_error",
|
|
Message: fmt.Sprintf("failed to write chunked output: %v", writeErr),
|
|
Attempts: attemptsUsed,
|
|
})
|
|
}
|
|
if shouldTrackBytes {
|
|
itemProgressReporter.Report(totalWritten, totalSize)
|
|
}
|
|
if onProgress != nil && totalSize > 0 &&
|
|
(totalWritten-lastProgressNotify >= progressUpdateThreshold || totalWritten >= totalSize) {
|
|
lastProgressNotify = totalWritten
|
|
_, _ = onProgress(
|
|
goja.Undefined(),
|
|
r.vm.ToValue(totalWritten),
|
|
r.vm.ToValue(totalSize),
|
|
)
|
|
}
|
|
}
|
|
if bodyErr != nil {
|
|
if bodyErr != io.EOF {
|
|
readErr = bodyErr
|
|
}
|
|
break
|
|
}
|
|
}
|
|
response.Body.Close()
|
|
stalled := watchdog.stalled.Load()
|
|
watchdog.stop()
|
|
expectedBytes := response.ContentLength
|
|
if response.StatusCode == http.StatusPartialContent && expectedBytes <= 0 {
|
|
expectedBytes = chunkEnd - chunkStart + 1
|
|
}
|
|
if readErr == nil && expectedBytes > 0 && chunkWritten != expectedBytes {
|
|
readErr = io.ErrUnexpectedEOF
|
|
}
|
|
if readErr == nil && chunkWritten > 0 {
|
|
chunkComplete = true
|
|
fullResponse = response.StatusCode == http.StatusOK
|
|
break
|
|
}
|
|
|
|
if err := output.Truncate(chunkStart); err != nil {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "storage_error",
|
|
Message: fmt.Sprintf("failed to roll back incomplete chunk: %v", err),
|
|
Attempts: attemptsUsed,
|
|
})
|
|
}
|
|
if _, err := output.Seek(chunkStart, io.SeekStart); err != nil {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "storage_error",
|
|
Message: fmt.Sprintf("failed to seek rolled-back chunk: %v", err),
|
|
Attempts: attemptsUsed,
|
|
})
|
|
}
|
|
totalWritten = chunkStart
|
|
if shouldTrackBytes && totalSize > 0 {
|
|
SetItemProgress(
|
|
activeItemID,
|
|
float64(totalWritten)/float64(totalSize),
|
|
totalWritten,
|
|
totalSize,
|
|
)
|
|
}
|
|
message := fmt.Sprintf("failed to read chunk at %d: %v", chunkStart, readErr)
|
|
if chunkWritten == 0 && readErr == nil {
|
|
message = fmt.Sprintf("chunk at %d was empty", chunkStart)
|
|
}
|
|
if stalled {
|
|
message = fmt.Sprintf(
|
|
"chunk at %d stalled for %ds",
|
|
chunkStart,
|
|
int(downloadStallTimeout.Seconds()),
|
|
)
|
|
}
|
|
lastFailure = transferFailure{
|
|
ErrorType: "transient_network",
|
|
Message: message,
|
|
Attempts: attemptsUsed,
|
|
}
|
|
if attempt == policy.MaxAttempts || r.chunkedTransferCancelled(activeItemID) {
|
|
if r.chunkedTransferCancelled(activeItemID) {
|
|
lastFailure.ErrorType = "cancelled"
|
|
lastFailure.Message = "download cancelled"
|
|
}
|
|
break
|
|
}
|
|
if waitTransferRetry(retryContext, retryDelay) != nil {
|
|
lastFailure.ErrorType = "cancelled"
|
|
lastFailure.Message = "download cancelled"
|
|
return r.jsTransferError(lastFailure)
|
|
}
|
|
retryDelay = calculateNextDelay(retryDelay, config)
|
|
}
|
|
|
|
if !chunkComplete {
|
|
if keepPartial && totalWritten > 0 {
|
|
_ = output.Sync()
|
|
_ = saveTransferCheckpoint(checkpointPath, transferCheckpoint{
|
|
Fingerprint: fingerprint,
|
|
Validator: validator,
|
|
Bytes: totalWritten,
|
|
Total: totalSize,
|
|
})
|
|
}
|
|
return r.jsTransferError(lastFailure)
|
|
}
|
|
if keepPartial && validator != "" &&
|
|
(totalWritten-lastCheckpointBytes >= transferCheckpointBytes ||
|
|
time.Since(lastCheckpointAt) >= transferCheckpointPeriod) {
|
|
if syncErr := output.Sync(); syncErr == nil {
|
|
if saveTransferCheckpoint(checkpointPath, transferCheckpoint{
|
|
Fingerprint: fingerprint,
|
|
Validator: validator,
|
|
Bytes: totalWritten,
|
|
Total: totalSize,
|
|
}) == nil {
|
|
lastCheckpointBytes = totalWritten
|
|
lastCheckpointAt = time.Now()
|
|
}
|
|
}
|
|
}
|
|
if fullResponse {
|
|
break
|
|
}
|
|
if totalSize <= 0 && totalWritten-chunkStart < chunkSize {
|
|
break
|
|
}
|
|
}
|
|
|
|
if totalWritten <= 0 || (totalSize > 0 && totalWritten != totalSize) {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "integrity_failed",
|
|
Message: fmt.Sprintf(
|
|
"chunked transfer size mismatch: expected %d bytes, wrote %d",
|
|
totalSize,
|
|
totalWritten,
|
|
),
|
|
Attempts: attemptsUsed,
|
|
})
|
|
}
|
|
if err := output.Sync(); err != nil {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "storage_error",
|
|
Message: fmt.Sprintf("failed to sync chunked output: %v", err),
|
|
Attempts: attemptsUsed,
|
|
})
|
|
}
|
|
if err := output.Close(); err != nil {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "storage_error",
|
|
Message: fmt.Sprintf("failed to close chunked output: %v", err),
|
|
Attempts: attemptsUsed,
|
|
})
|
|
}
|
|
if err := os.Rename(stagedPath, fullPath); err != nil {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "storage_error",
|
|
Message: fmt.Sprintf("failed to publish chunked output: %v", err),
|
|
Attempts: attemptsUsed,
|
|
})
|
|
}
|
|
promoted = true
|
|
os.Remove(checkpointPath)
|
|
syncDir(filepath.Dir(fullPath))
|
|
if shouldTrackBytes {
|
|
SetItemProgress(activeItemID, 1, totalWritten, totalWritten)
|
|
}
|
|
return r.jsSuccess(map[string]any{
|
|
"path": fullPath,
|
|
"size": totalWritten,
|
|
"attempts": attemptsUsed,
|
|
})
|
|
}
|