mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-03 00:30:54 +02:00
741 lines
20 KiB
Go
741 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,
|
|
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 := client.Do(req)
|
|
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 waitTransferRetry(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 waitTransferRetry(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 {
|
|
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 waitTransferRetry(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
|
|
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,
|
|
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 {
|
|
_, _ = 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),
|
|
})
|
|
}
|