mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-16 06:45:26 +02:00
Propagate filesystem errors from complete metadata reads so Android retries unreadable SAF descriptors through temporary files while accepting valid audio without tags. Keep established segmented and chunked transfers outside the resolution allowance. Continue charging initial requests, retries, and progress callbacks, including during parallel native reads, while preserving stall cancellation. Add metadata, transfer, and native fallback regression tests. Validated with Go tests, race detector, vet, Android tests, targeted Flutter tests, analyzer, and formatting checks.
750 lines
20 KiB
Go
750 lines
20 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/dop251/goja"
|
|
)
|
|
|
|
type segmentTransferSpec struct {
|
|
Index int
|
|
URL string
|
|
Headers map[string]string
|
|
}
|
|
|
|
type segmentTransferResult struct {
|
|
Index int
|
|
Path string
|
|
Size int64
|
|
Attempts int
|
|
Failure *transferFailure
|
|
}
|
|
|
|
type segmentTransferCheckpoint struct {
|
|
Version int `json:"version"`
|
|
Fingerprint string `json:"fingerprint"`
|
|
NextIndex int `json:"next_index"`
|
|
Bytes int64 `json:"bytes"`
|
|
UpdatedAt int64 `json:"updated_at"`
|
|
}
|
|
|
|
func parseStringHeaders(value any) map[string]string {
|
|
raw, ok := value.(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
headers := make(map[string]string, len(raw))
|
|
for key, entry := range raw {
|
|
headers[key] = fmt.Sprintf("%v", entry)
|
|
}
|
|
return headers
|
|
}
|
|
|
|
func mergeStringHeaders(base, override map[string]string) map[string]string {
|
|
if len(base) == 0 && len(override) == 0 {
|
|
return nil
|
|
}
|
|
merged := make(map[string]string, len(base)+len(override))
|
|
for key, value := range base {
|
|
merged[key] = value
|
|
}
|
|
for key, value := range override {
|
|
merged[key] = value
|
|
}
|
|
return merged
|
|
}
|
|
|
|
func parseSegmentTransferSpecs(value any, commonHeaders map[string]string) ([]segmentTransferSpec, error) {
|
|
rawSegments, ok := value.([]any)
|
|
if !ok || len(rawSegments) == 0 {
|
|
return nil, fmt.Errorf("segments must be a non-empty array")
|
|
}
|
|
segments := make([]segmentTransferSpec, 0, len(rawSegments))
|
|
for index, raw := range rawSegments {
|
|
var rawURL string
|
|
var headers map[string]string
|
|
switch typed := raw.(type) {
|
|
case string:
|
|
rawURL = typed
|
|
case map[string]any:
|
|
rawURL, _ = typed["url"].(string)
|
|
headers = parseStringHeaders(typed["headers"])
|
|
default:
|
|
return nil, fmt.Errorf("segment %d must be a URL string or object", index)
|
|
}
|
|
rawURL = strings.TrimSpace(rawURL)
|
|
if rawURL == "" {
|
|
return nil, fmt.Errorf("segment %d URL is empty", index)
|
|
}
|
|
segments = append(segments, segmentTransferSpec{
|
|
Index: index,
|
|
URL: rawURL,
|
|
Headers: mergeStringHeaders(commonHeaders, headers),
|
|
})
|
|
}
|
|
return segments, nil
|
|
}
|
|
|
|
func segmentListFingerprint(segments []segmentTransferSpec) string {
|
|
hash := sha256.New()
|
|
for _, segment := range segments {
|
|
// Segmented checkpoints have no per-segment ETag or Last-Modified
|
|
// validator. Include the complete URL, including its query, so a
|
|
// different media object served from the same CDN path can never reuse
|
|
// already-assembled bytes. Ordinary single-file checkpoints may ignore
|
|
// rotating query credentials because their validator still protects
|
|
// integrity.
|
|
hash.Write([]byte(segment.URL))
|
|
hash.Write([]byte{0})
|
|
}
|
|
return hex.EncodeToString(hash.Sum(nil))
|
|
}
|
|
|
|
func loadSegmentCheckpoint(path, fingerprint string) (segmentTransferCheckpoint, bool) {
|
|
var checkpoint segmentTransferCheckpoint
|
|
data, err := os.ReadFile(path)
|
|
if err != nil || json.Unmarshal(data, &checkpoint) != nil {
|
|
return segmentTransferCheckpoint{}, false
|
|
}
|
|
if checkpoint.Version != transferCheckpointVersion ||
|
|
checkpoint.Fingerprint != fingerprint ||
|
|
checkpoint.NextIndex < 0 || checkpoint.Bytes < 0 ||
|
|
(checkpoint.NextIndex == 0 && checkpoint.Bytes != 0) ||
|
|
(checkpoint.NextIndex > 0 && checkpoint.Bytes == 0) {
|
|
return segmentTransferCheckpoint{}, false
|
|
}
|
|
return checkpoint, true
|
|
}
|
|
|
|
func saveSegmentCheckpoint(path string, checkpoint segmentTransferCheckpoint) error {
|
|
checkpoint.Version = transferCheckpointVersion
|
|
checkpoint.UpdatedAt = time.Now().UnixMilli()
|
|
data, err := json.Marshal(checkpoint)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tempPath := path + ".tmp"
|
|
file, err := os.OpenFile(tempPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err = file.Write(data); err == nil {
|
|
err = file.Sync()
|
|
}
|
|
closeErr := file.Close()
|
|
if err == nil {
|
|
err = closeErr
|
|
}
|
|
if err != nil {
|
|
os.Remove(tempPath)
|
|
return err
|
|
}
|
|
if err := os.Rename(tempPath, path); err != nil {
|
|
os.Remove(tempPath)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func segmentTempPath(stagedPath string, index int) string {
|
|
return fmt.Sprintf("%s.segment.%06d", stagedPath, index)
|
|
}
|
|
|
|
func (r *extensionRuntime) fetchSegmentToTemp(
|
|
ctx context.Context,
|
|
client *http.Client,
|
|
spec segmentTransferSpec,
|
|
tempPath string,
|
|
policy DownloadTransferPolicy,
|
|
received *atomic.Int64,
|
|
completed *atomic.Bool,
|
|
itemProgressReporter *ItemTransferProgressReporter,
|
|
) segmentTransferResult {
|
|
config := transferRetryConfig(policy)
|
|
retryDelay := config.InitialDelay
|
|
var lastFailure transferFailure
|
|
|
|
for attempt := 1; attempt <= policy.MaxAttempts; attempt++ {
|
|
if ctx.Err() != nil {
|
|
return segmentTransferResult{
|
|
Index: spec.Index,
|
|
Failure: &transferFailure{
|
|
ErrorType: "cancelled",
|
|
Message: "download cancelled",
|
|
Attempts: attempt,
|
|
},
|
|
}
|
|
}
|
|
os.Remove(tempPath)
|
|
output, err := os.OpenFile(tempPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
|
|
if err != nil {
|
|
return segmentTransferResult{
|
|
Index: spec.Index,
|
|
Failure: &transferFailure{
|
|
ErrorType: "storage_error",
|
|
Message: fmt.Sprintf("failed to create segment file: %v", err),
|
|
Attempts: attempt,
|
|
},
|
|
}
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", spec.URL, nil)
|
|
if err != nil {
|
|
output.Close()
|
|
return segmentTransferResult{
|
|
Index: spec.Index,
|
|
Failure: &transferFailure{
|
|
ErrorType: "invalid_request",
|
|
Message: err.Error(),
|
|
Attempts: attempt,
|
|
},
|
|
}
|
|
}
|
|
for key, value := range spec.Headers {
|
|
req.Header.Set(key, value)
|
|
}
|
|
if req.Header.Get("User-Agent") == "" {
|
|
req.Header.Set("User-Agent", appUserAgent())
|
|
}
|
|
req, watchdog := bindStallWatchdog(req, downloadStallTimeout)
|
|
resp, err := r.doResolutionTransfer(client, req, attempt == 1 && completed.Load())
|
|
if err != nil {
|
|
stalled := watchdog.stalled.Load()
|
|
watchdog.stop()
|
|
output.Close()
|
|
message := err.Error()
|
|
if stalled {
|
|
message = fmt.Sprintf(
|
|
"segment %d stalled for %ds",
|
|
spec.Index,
|
|
int(downloadStallTimeout.Seconds()),
|
|
)
|
|
}
|
|
lastFailure = transferFailure{
|
|
ErrorType: "transient_network",
|
|
Message: message,
|
|
Attempts: attempt,
|
|
}
|
|
if attempt == policy.MaxAttempts || ctx.Err() != nil {
|
|
if ctx.Err() != nil {
|
|
lastFailure.ErrorType = "cancelled"
|
|
lastFailure.Message = "download cancelled"
|
|
}
|
|
return segmentTransferResult{Index: spec.Index, Failure: &lastFailure}
|
|
}
|
|
if r.waitResolutionRetry(ctx, retryDelay) != nil {
|
|
lastFailure.ErrorType = "cancelled"
|
|
lastFailure.Message = "download cancelled"
|
|
return segmentTransferResult{Index: spec.Index, Failure: &lastFailure}
|
|
}
|
|
retryDelay = calculateNextDelay(retryDelay, config)
|
|
continue
|
|
}
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
retryAfter := retryAfterSeconds(resp)
|
|
io.Copy(io.Discard, io.LimitReader(resp.Body, 32*1024))
|
|
resp.Body.Close()
|
|
watchdog.stop()
|
|
output.Close()
|
|
lastFailure = transferFailure{
|
|
ErrorType: transferErrorTypeForStatus(resp.StatusCode, policy),
|
|
Message: fmt.Sprintf("segment %d HTTP error: %d", spec.Index, resp.StatusCode),
|
|
HTTPStatus: resp.StatusCode,
|
|
RetryAfterSeconds: retryAfter,
|
|
Attempts: attempt,
|
|
}
|
|
if !retryableTransferStatus(resp.StatusCode) || attempt == policy.MaxAttempts {
|
|
return segmentTransferResult{Index: spec.Index, Failure: &lastFailure}
|
|
}
|
|
delay := retryDelay
|
|
if retryAfter > 0 {
|
|
delay = time.Duration(retryAfter) * time.Second
|
|
}
|
|
if r.waitResolutionRetry(ctx, delay) != nil {
|
|
lastFailure.ErrorType = "cancelled"
|
|
lastFailure.Message = "download cancelled"
|
|
return segmentTransferResult{Index: spec.Index, Failure: &lastFailure}
|
|
}
|
|
retryDelay = calculateNextDelay(retryDelay, config)
|
|
continue
|
|
}
|
|
|
|
buffer := make([]byte, 64*1024)
|
|
var size int64
|
|
var readErr error
|
|
for {
|
|
readCount, bodyErr := resp.Body.Read(buffer)
|
|
if readCount > 0 {
|
|
watchdog.reset()
|
|
writeCount, writeErr := output.Write(buffer[:readCount])
|
|
size += int64(writeCount)
|
|
received.Add(int64(writeCount))
|
|
itemProgressReporter.Report(received.Load(), 0)
|
|
if writeErr != nil || writeCount != readCount {
|
|
if writeErr == nil {
|
|
writeErr = io.ErrShortWrite
|
|
}
|
|
readErr = writeErr
|
|
break
|
|
}
|
|
}
|
|
if bodyErr != nil {
|
|
if bodyErr != io.EOF {
|
|
readErr = bodyErr
|
|
}
|
|
break
|
|
}
|
|
}
|
|
resp.Body.Close()
|
|
stalled := watchdog.stalled.Load()
|
|
watchdog.stop()
|
|
closeErr := output.Close()
|
|
if readErr == nil {
|
|
readErr = closeErr
|
|
}
|
|
if readErr == nil && resp.ContentLength > 0 && size != resp.ContentLength {
|
|
readErr = io.ErrUnexpectedEOF
|
|
}
|
|
if readErr == nil && size > 0 {
|
|
completed.Store(true)
|
|
return segmentTransferResult{
|
|
Index: spec.Index,
|
|
Path: tempPath,
|
|
Size: size,
|
|
Attempts: attempt,
|
|
}
|
|
}
|
|
if size > 0 {
|
|
received.Add(-size)
|
|
}
|
|
|
|
message := fmt.Sprintf("failed to read segment %d: %v", spec.Index, readErr)
|
|
if size == 0 && readErr == nil {
|
|
message = fmt.Sprintf("segment %d response was empty", spec.Index)
|
|
}
|
|
if stalled {
|
|
message = fmt.Sprintf(
|
|
"segment %d stalled for %ds",
|
|
spec.Index,
|
|
int(downloadStallTimeout.Seconds()),
|
|
)
|
|
}
|
|
lastFailure = transferFailure{
|
|
ErrorType: "transient_network",
|
|
Message: message,
|
|
Attempts: attempt,
|
|
}
|
|
if attempt == policy.MaxAttempts {
|
|
return segmentTransferResult{Index: spec.Index, Failure: &lastFailure}
|
|
}
|
|
if r.waitResolutionRetry(ctx, retryDelay) != nil {
|
|
lastFailure.ErrorType = "cancelled"
|
|
lastFailure.Message = "download cancelled"
|
|
return segmentTransferResult{Index: spec.Index, Failure: &lastFailure}
|
|
}
|
|
retryDelay = calculateNextDelay(retryDelay, config)
|
|
}
|
|
|
|
return segmentTransferResult{
|
|
Index: spec.Index,
|
|
Failure: &transferFailure{
|
|
ErrorType: "transient_network",
|
|
Message: fmt.Sprintf("segment %d exhausted retry budget", spec.Index),
|
|
Attempts: policy.MaxAttempts,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (r *extensionRuntime) fileDownloadSegments(call goja.FunctionCall) goja.Value {
|
|
if len(call.Arguments) < 2 {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "invalid_request",
|
|
Message: "segments and output path are required",
|
|
})
|
|
}
|
|
|
|
var commonHeaders map[string]string
|
|
var onProgress goja.Callable
|
|
var maxParallelOption int
|
|
var maxAttemptsOption int
|
|
var persistentCheckpointOption *bool
|
|
if len(call.Arguments) > 2 &&
|
|
!goja.IsUndefined(call.Arguments[2]) &&
|
|
!goja.IsNull(call.Arguments[2]) {
|
|
if options, ok := call.Arguments[2].Export().(map[string]any); ok {
|
|
commonHeaders = parseStringHeaders(options["headers"])
|
|
if progressValue, ok := options["onProgress"]; ok {
|
|
if callable, ok := goja.AssertFunction(r.vm.ToValue(progressValue)); ok {
|
|
onProgress = callable
|
|
}
|
|
}
|
|
maxParallelOption = capabilityInt(options["maxParallel"], 0)
|
|
maxAttemptsOption = capabilityInt(options["maxAttempts"], 0)
|
|
if checkpoint, ok := options["persistentCheckpoint"].(bool); ok {
|
|
persistentCheckpointOption = &checkpoint
|
|
}
|
|
}
|
|
}
|
|
|
|
segments, err := parseSegmentTransferSpecs(call.Arguments[0].Export(), commonHeaders)
|
|
if err != nil {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "invalid_request",
|
|
Message: err.Error(),
|
|
})
|
|
}
|
|
for _, segment := range segments {
|
|
if err := r.validateDomain(segment.URL); err != nil {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "permission",
|
|
Message: err.Error(),
|
|
})
|
|
}
|
|
}
|
|
fullPath, err := r.validatePath(call.Arguments[1].String())
|
|
if err != nil {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "permission",
|
|
Message: err.Error(),
|
|
})
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "storage_error",
|
|
Message: fmt.Sprintf("failed to create output directory: %v", err),
|
|
})
|
|
}
|
|
|
|
policy := r.manifest.DownloadTransferPolicy()
|
|
if maxParallelOption > 0 {
|
|
policy.MaxParallelSegments = clampInt(maxParallelOption, 1, maxParallelSegments)
|
|
}
|
|
if maxAttemptsOption > 0 {
|
|
policy.MaxAttempts = clampInt(maxAttemptsOption, 1, 8)
|
|
}
|
|
persistentCheckpoint := policy.PersistentCheckpoint
|
|
if persistentCheckpointOption != nil {
|
|
persistentCheckpoint = *persistentCheckpointOption
|
|
}
|
|
|
|
client := r.downloadClient
|
|
if client == nil {
|
|
client = r.httpClient
|
|
}
|
|
unlock := lockDownloadOutputPath(fullPath)
|
|
defer unlock()
|
|
|
|
stagedPath := stagedDownloadPath(fullPath)
|
|
checkpointPath := transferCheckpointPath(stagedPath) + ".segments"
|
|
fingerprint := segmentListFingerprint(segments)
|
|
checkpoint, checkpointOK := loadSegmentCheckpoint(checkpointPath, fingerprint)
|
|
if !persistentCheckpoint || !checkpointOK || checkpoint.NextIndex > len(segments) {
|
|
checkpoint = segmentTransferCheckpoint{Fingerprint: fingerprint}
|
|
checkpointOK = false
|
|
os.Remove(stagedPath)
|
|
os.Remove(checkpointPath)
|
|
}
|
|
|
|
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 segmented output: %v", err),
|
|
})
|
|
}
|
|
promoted := false
|
|
defer func() {
|
|
output.Close()
|
|
for index := range segments {
|
|
os.Remove(segmentTempPath(stagedPath, index))
|
|
}
|
|
if promoted {
|
|
os.Remove(checkpointPath)
|
|
} else if !persistentCheckpoint {
|
|
os.Remove(stagedPath)
|
|
os.Remove(checkpointPath)
|
|
}
|
|
}()
|
|
|
|
nextIndex := 0
|
|
var totalWritten int64
|
|
if checkpointOK {
|
|
if info, statErr := output.Stat(); statErr == nil && info.Size() >= checkpoint.Bytes {
|
|
totalWritten = checkpoint.Bytes
|
|
nextIndex = checkpoint.NextIndex
|
|
} else {
|
|
// Segment boundaries cannot be reconstructed from a shorter file.
|
|
// Restart instead of skipping segments named by a stale checkpoint.
|
|
checkpointOK = false
|
|
checkpoint = segmentTransferCheckpoint{Fingerprint: fingerprint}
|
|
os.Remove(checkpointPath)
|
|
}
|
|
}
|
|
if err := output.Truncate(totalWritten); err != nil {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "storage_error",
|
|
Message: fmt.Sprintf("failed to restore segmented output: %v", err),
|
|
})
|
|
}
|
|
if _, err := output.Seek(totalWritten, io.SeekStart); err != nil {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "storage_error",
|
|
Message: fmt.Sprintf("failed to seek segmented output: %v", err),
|
|
})
|
|
}
|
|
|
|
activeItemID := r.getActiveDownloadItemID()
|
|
if activeItemID != "" {
|
|
SetItemDownloading(activeItemID)
|
|
SetItemProgress(
|
|
activeItemID,
|
|
float64(nextIndex)/float64(len(segments)),
|
|
totalWritten,
|
|
0,
|
|
)
|
|
}
|
|
if nextIndex == len(segments) && totalWritten > 0 {
|
|
if err := output.Sync(); err != nil {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "storage_error",
|
|
Message: fmt.Sprintf("failed to sync restored segmented output: %v", err),
|
|
})
|
|
}
|
|
if err := output.Close(); err != nil {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "storage_error",
|
|
Message: fmt.Sprintf("failed to close restored segmented output: %v", err),
|
|
})
|
|
}
|
|
if err := os.Rename(stagedPath, fullPath); err != nil {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "storage_error",
|
|
Message: fmt.Sprintf("failed to publish restored segmented output: %v", err),
|
|
})
|
|
}
|
|
promoted = true
|
|
os.Remove(checkpointPath)
|
|
syncDir(filepath.Dir(fullPath))
|
|
if activeItemID != "" {
|
|
SetItemProgress(activeItemID, 1, totalWritten, totalWritten)
|
|
}
|
|
return r.jsSuccess(map[string]any{
|
|
"path": fullPath,
|
|
"size": totalWritten,
|
|
"segments": len(segments),
|
|
"resumed": true,
|
|
})
|
|
}
|
|
|
|
baseRequest, requestErr := http.NewRequest("GET", segments[nextIndex].URL, nil)
|
|
if requestErr != nil {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "invalid_request",
|
|
Message: requestErr.Error(),
|
|
})
|
|
}
|
|
baseRequest = r.bindDownloadCancelContext(baseRequest)
|
|
ctx, cancel := context.WithCancel(baseRequest.Context())
|
|
defer cancel()
|
|
|
|
jobs := make(chan segmentTransferSpec)
|
|
results := make(chan segmentTransferResult, policy.MaxParallelSegments)
|
|
var received atomic.Int64
|
|
var completed atomic.Bool
|
|
received.Store(totalWritten)
|
|
itemProgressReporter := NewItemTransferProgressReporter(activeItemID, totalWritten, 0)
|
|
var workers sync.WaitGroup
|
|
workerCount := min(policy.MaxParallelSegments, len(segments)-nextIndex)
|
|
for workerIndex := 0; workerIndex < workerCount; workerIndex++ {
|
|
workers.Add(1)
|
|
go func() {
|
|
defer workers.Done()
|
|
for spec := range jobs {
|
|
result := r.fetchSegmentToTemp(
|
|
ctx,
|
|
client,
|
|
spec,
|
|
segmentTempPath(stagedPath, spec.Index),
|
|
policy,
|
|
&received,
|
|
&completed,
|
|
itemProgressReporter,
|
|
)
|
|
select {
|
|
case results <- result:
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
if result.Failure != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
go func() {
|
|
defer close(jobs)
|
|
for index := nextIndex; index < len(segments); index++ {
|
|
select {
|
|
case jobs <- segments[index]:
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
go func() {
|
|
workers.Wait()
|
|
close(results)
|
|
}()
|
|
|
|
pending := make(map[int]segmentTransferResult)
|
|
completedSegments := nextIndex
|
|
lastCheckpointBytes := totalWritten
|
|
lastCheckpointAt := time.Now()
|
|
var firstFailure *transferFailure
|
|
for result := range results {
|
|
if result.Failure != nil {
|
|
if firstFailure == nil {
|
|
failureCopy := *result.Failure
|
|
firstFailure = &failureCopy
|
|
cancel()
|
|
}
|
|
continue
|
|
}
|
|
pending[result.Index] = result
|
|
for {
|
|
ready, ok := pending[nextIndex]
|
|
if !ok {
|
|
break
|
|
}
|
|
segmentFile, openErr := os.Open(ready.Path)
|
|
if openErr != nil {
|
|
firstFailure = &transferFailure{
|
|
ErrorType: "storage_error",
|
|
Message: fmt.Sprintf("failed to open downloaded segment %d: %v", nextIndex, openErr),
|
|
Attempts: ready.Attempts,
|
|
}
|
|
cancel()
|
|
break
|
|
}
|
|
copied, copyErr := io.CopyBuffer(output, segmentFile, make([]byte, 128*1024))
|
|
segmentFile.Close()
|
|
if copyErr != nil || copied != ready.Size {
|
|
if copyErr == nil {
|
|
copyErr = io.ErrShortWrite
|
|
}
|
|
firstFailure = &transferFailure{
|
|
ErrorType: "storage_error",
|
|
Message: fmt.Sprintf("failed to append segment %d: %v", nextIndex, copyErr),
|
|
Attempts: ready.Attempts,
|
|
}
|
|
cancel()
|
|
break
|
|
}
|
|
totalWritten += copied
|
|
os.Remove(ready.Path)
|
|
delete(pending, nextIndex)
|
|
nextIndex++
|
|
completedSegments++
|
|
if persistentCheckpoint &&
|
|
(totalWritten-lastCheckpointBytes >= transferCheckpointBytes ||
|
|
time.Since(lastCheckpointAt) >= transferCheckpointPeriod) {
|
|
if output.Sync() == nil && saveSegmentCheckpoint(
|
|
checkpointPath,
|
|
segmentTransferCheckpoint{
|
|
Fingerprint: fingerprint,
|
|
NextIndex: nextIndex,
|
|
Bytes: totalWritten,
|
|
},
|
|
) == nil {
|
|
lastCheckpointBytes = totalWritten
|
|
lastCheckpointAt = time.Now()
|
|
}
|
|
}
|
|
if activeItemID != "" {
|
|
SetItemProgress(
|
|
activeItemID,
|
|
float64(completedSegments)/float64(len(segments)),
|
|
received.Load(),
|
|
0,
|
|
)
|
|
}
|
|
if onProgress != nil {
|
|
func() {
|
|
if b := r.currentResolutionBudget(); b != nil {
|
|
defer b.charge()()
|
|
}
|
|
_, _ = onProgress(
|
|
goja.Undefined(),
|
|
r.vm.ToValue(received.Load()),
|
|
r.vm.ToValue(int64(0)),
|
|
r.vm.ToValue(completedSegments),
|
|
r.vm.ToValue(len(segments)),
|
|
)
|
|
}()
|
|
}
|
|
}
|
|
}
|
|
if firstFailure != nil {
|
|
if persistentCheckpoint && nextIndex > 0 && totalWritten > 0 && output.Sync() == nil {
|
|
_ = saveSegmentCheckpoint(checkpointPath, segmentTransferCheckpoint{
|
|
Fingerprint: fingerprint,
|
|
NextIndex: nextIndex,
|
|
Bytes: totalWritten,
|
|
})
|
|
}
|
|
return r.jsTransferError(*firstFailure)
|
|
}
|
|
if nextIndex != len(segments) || totalWritten <= 0 {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "integrity_failed",
|
|
Message: fmt.Sprintf(
|
|
"segmented transfer incomplete: assembled %d of %d segments",
|
|
nextIndex,
|
|
len(segments),
|
|
),
|
|
})
|
|
}
|
|
if err := output.Sync(); err != nil {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "storage_error",
|
|
Message: fmt.Sprintf("failed to sync segmented output: %v", err),
|
|
})
|
|
}
|
|
if err := output.Close(); err != nil {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "storage_error",
|
|
Message: fmt.Sprintf("failed to close segmented output: %v", err),
|
|
})
|
|
}
|
|
if err := os.Rename(stagedPath, fullPath); err != nil {
|
|
return r.jsTransferError(transferFailure{
|
|
ErrorType: "storage_error",
|
|
Message: fmt.Sprintf("failed to publish segmented output: %v", err),
|
|
})
|
|
}
|
|
promoted = true
|
|
os.Remove(checkpointPath)
|
|
syncDir(filepath.Dir(fullPath))
|
|
if activeItemID != "" {
|
|
SetItemProgress(activeItemID, 1, totalWritten, totalWritten)
|
|
}
|
|
return r.jsSuccess(map[string]any{
|
|
"path": fullPath,
|
|
"size": totalWritten,
|
|
"segments": len(segments),
|
|
})
|
|
}
|