mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-14 13:59:11 +02:00
fix: restore SAF metadata fallback and preserve active transfers
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.
This commit is contained in:
@@ -6,6 +6,14 @@ import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class SafMetadataReadPolicyTest {
|
||||
@Test fun completeMetadataReadErrorUsesTemporaryCopy() {
|
||||
val metadata = mapOf("lyrics" to "words", "comment" to "notes", "track_number" to 3)
|
||||
assertEquals(metadata, readSafMetadataWithFallback(
|
||||
directRead = { throw Exception("failed to read metadata: permission denied") },
|
||||
fallbackRead = { metadata },
|
||||
))
|
||||
}
|
||||
|
||||
@Test fun fallbackFailureIsIsolatedToOneFile() {
|
||||
assertNull(readSafMetadataWithFallback<String>({ null }, { throw IllegalArgumentException("malformed metadata") }))
|
||||
assertEquals("next file", readSafMetadataWithFallback({ "next file" }, { error("copy") }))
|
||||
|
||||
@@ -177,6 +177,10 @@ requests, URL resolution, retry waits, and refreshes, including HTTP headers
|
||||
and the first audio byte. Native audio reads after the first byte and bounded
|
||||
native FFmpeg conversion pause this clock; transfer stall limits and the
|
||||
overall download timeout still apply. Progress callbacks do not reset it.
|
||||
Within one native segmented or chunked download, a successfully received part
|
||||
establishes the transfer: headers and the first byte of subsequent parts also
|
||||
pause the clock. Retry attempts and waits still consume the allowance, and a
|
||||
new native download call must establish its own transfer.
|
||||
|
||||
`options.resolutionTimeoutMs` reports the initial allowance, while
|
||||
`utils.getResolutionRemainingMs()` reports the remaining allowance. Check for
|
||||
|
||||
@@ -2,6 +2,7 @@ package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -84,6 +85,7 @@ func ReadFileMetadataWithHint(filePath, displayNameHint string) (string, error)
|
||||
"audio_codec": "",
|
||||
}
|
||||
|
||||
var metadataErr error
|
||||
if isFlac {
|
||||
result["format"] = "flac"
|
||||
result["audio_codec"] = "flac"
|
||||
@@ -155,6 +157,7 @@ func ReadFileMetadataWithHint(filePath, displayNameHint string) (string, error)
|
||||
} else if isM4A {
|
||||
result["format"] = "m4a"
|
||||
meta, err := ReadM4ATags(filePath)
|
||||
metadataErr = err
|
||||
if err == nil && meta != nil {
|
||||
applyAudioMetadataToResult(result, meta)
|
||||
}
|
||||
@@ -179,6 +182,7 @@ func ReadFileMetadataWithHint(filePath, displayNameHint string) (string, error)
|
||||
result["format"] = "mp3"
|
||||
result["audio_codec"] = "mp3"
|
||||
meta, err := ReadID3Tags(filePath)
|
||||
metadataErr = err
|
||||
if err == nil && meta != nil {
|
||||
applyAudioMetadataToResult(result, meta)
|
||||
}
|
||||
@@ -195,6 +199,7 @@ func ReadFileMetadataWithHint(filePath, displayNameHint string) (string, error)
|
||||
result["format"] = "opus"
|
||||
result["audio_codec"] = "opus"
|
||||
meta, err := ReadOggVorbisComments(filePath)
|
||||
metadataErr = err
|
||||
if err == nil && meta != nil {
|
||||
applyAudioMetadataToResult(result, meta)
|
||||
}
|
||||
@@ -210,6 +215,7 @@ func ReadFileMetadataWithHint(filePath, displayNameHint string) (string, error)
|
||||
result["format"] = strings.TrimPrefix(lower, ".")
|
||||
result["audio_codec"] = result["format"]
|
||||
apeTag, apeErr := ReadAPETags(filePath)
|
||||
metadataErr = apeErr
|
||||
if apeErr == nil && apeTag != nil {
|
||||
meta := APETagToAudioMetadata(apeTag)
|
||||
if meta != nil {
|
||||
@@ -223,12 +229,12 @@ func ReadFileMetadataWithHint(filePath, displayNameHint string) (string, error)
|
||||
if isAiff {
|
||||
result["format"] = "aiff"
|
||||
result["audio_codec"] = "pcm"
|
||||
meta, _ = ReadAIFFTags(filePath)
|
||||
meta, metadataErr = ReadAIFFTags(filePath)
|
||||
quality, qualityErr = GetAIFFQuality(filePath)
|
||||
} else {
|
||||
result["format"] = "wav"
|
||||
result["audio_codec"] = "pcm"
|
||||
meta, _ = ReadWAVTags(filePath)
|
||||
meta, metadataErr = ReadWAVTags(filePath)
|
||||
quality, qualityErr = GetWAVQuality(filePath)
|
||||
}
|
||||
if meta != nil {
|
||||
@@ -243,6 +249,13 @@ func ReadFileMetadataWithHint(filePath, displayNameHint string) (string, error)
|
||||
return "", fmt.Errorf("unsupported file format: %s", filePath)
|
||||
}
|
||||
|
||||
// A readable audio file can legitimately have no tags. Filesystem errors,
|
||||
// however, must reach the native bridge so an unreadable SAF descriptor
|
||||
// triggers its temporary-file fallback instead of returning empty tags.
|
||||
var pathErr *os.PathError
|
||||
if errors.As(metadataErr, &pathErr) {
|
||||
return "", fmt.Errorf("failed to read metadata: %w", metadataErr)
|
||||
}
|
||||
return marshalJSONString(result)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ type resolutionBudget struct {
|
||||
timer *time.Timer
|
||||
generation uint64
|
||||
pauses int
|
||||
charges int
|
||||
stopped bool
|
||||
}
|
||||
|
||||
@@ -43,7 +44,7 @@ func (b *resolutionBudget) armLocked() {
|
||||
b.timer = time.AfterFunc(b.remaining, func() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.stopped || b.pauses > 0 || b.generation != generation {
|
||||
if b.stopped || (b.pauses > 0 && b.charges == 0) || b.generation != generation {
|
||||
return
|
||||
}
|
||||
b.remaining = 0
|
||||
@@ -57,7 +58,7 @@ func (b *resolutionBudget) pause() func() {
|
||||
b.mu.Unlock()
|
||||
return func() {}
|
||||
}
|
||||
if b.pauses == 0 {
|
||||
if b.pauses == 0 && b.charges == 0 {
|
||||
b.timer.Stop()
|
||||
b.generation++
|
||||
b.remaining -= time.Since(b.started)
|
||||
@@ -76,18 +77,50 @@ func (b *resolutionBudget) pause() func() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.pauses--
|
||||
if b.pauses == 0 && !b.stopped && b.ctx.Err() == nil {
|
||||
if b.pauses == 0 && b.charges == 0 && !b.stopped && b.ctx.Err() == nil {
|
||||
b.armLocked()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Resolver work must keep spending the allowance even while parallel segment
|
||||
// workers are paused in native network reads.
|
||||
func (b *resolutionBudget) charge() func() {
|
||||
b.mu.Lock()
|
||||
if b.stopped || b.ctx.Err() != nil {
|
||||
b.mu.Unlock()
|
||||
return func() {}
|
||||
}
|
||||
if b.charges == 0 && b.pauses > 0 {
|
||||
b.armLocked()
|
||||
}
|
||||
b.charges++
|
||||
b.mu.Unlock()
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.charges--
|
||||
if b.charges == 0 && b.pauses > 0 && !b.stopped && b.ctx.Err() == nil {
|
||||
b.timer.Stop()
|
||||
b.generation++
|
||||
b.remaining -= time.Since(b.started)
|
||||
if b.remaining <= 0 {
|
||||
b.remaining = 0
|
||||
b.cancel(context.DeadlineExceeded)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (b *resolutionBudget) remainingTime() time.Duration {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
remaining := b.remaining
|
||||
if b.pauses == 0 && !b.stopped {
|
||||
if (b.pauses == 0 || b.charges > 0) && !b.stopped {
|
||||
remaining -= time.Since(b.started)
|
||||
}
|
||||
if remaining < 0 || b.ctx.Err() != nil {
|
||||
@@ -133,15 +166,25 @@ func (r *extensionRuntime) getResolutionRemainingMs(goja.FunctionCall) goja.Valu
|
||||
return r.vm.ToValue(extensionResolutionTimeout.Milliseconds())
|
||||
}
|
||||
|
||||
// Wrapping only native audio transfers keeps API/ticket bodies on the resolver
|
||||
// clock. Headers and the first byte still consume the resolution allowance.
|
||||
func (r *extensionRuntime) trackResolutionTransfer(resp *http.Response) {
|
||||
if resp == nil || resp.Body == nil || (resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent) {
|
||||
return
|
||||
}
|
||||
if b := r.currentResolutionBudget(); b != nil {
|
||||
resp.Body = &resolutionTransferBody{ReadCloser: resp.Body, budget: b}
|
||||
// A successful segment/range establishes the current native transfer. Later
|
||||
// parts need no new resolution, so their headers and first byte are transfer
|
||||
// time too. Initial requests and retries still spend the resolver allowance.
|
||||
func (r *extensionRuntime) doResolutionTransfer(client *http.Client, req *http.Request, continuation bool) (*http.Response, error) {
|
||||
b := r.currentResolutionBudget()
|
||||
resp, err := func() (*http.Response, error) {
|
||||
if b != nil {
|
||||
if continuation {
|
||||
defer b.pause()()
|
||||
} else {
|
||||
defer b.charge()()
|
||||
}
|
||||
}
|
||||
return client.Do(req)
|
||||
}()
|
||||
if b != nil && resp != nil && resp.Body != nil && (resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusPartialContent) {
|
||||
resp.Body = &resolutionTransferBody{ReadCloser: resp.Body, budget: b, receivedBytes: continuation}
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
type resolutionTransferBody struct {
|
||||
@@ -155,6 +198,8 @@ func (b *resolutionTransferBody) Read(p []byte) (int, error) {
|
||||
// more resolvers) and retry waits continue spending the same allowance.
|
||||
if b.receivedBytes {
|
||||
defer b.budget.pause()()
|
||||
} else {
|
||||
defer b.budget.charge()()
|
||||
}
|
||||
n, err := b.ReadCloser.Read(p)
|
||||
if n > 0 {
|
||||
@@ -162,3 +207,10 @@ func (b *resolutionTransferBody) Read(p []byte) (int, error) {
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) waitResolutionRetry(ctx context.Context, delay time.Duration) error {
|
||||
if b := r.currentResolutionBudget(); b != nil {
|
||||
defer b.charge()()
|
||||
}
|
||||
return waitTransferRetry(ctx, delay)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package gobackend
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -82,6 +83,168 @@ func (b *resolutionSlowBody) Read(p []byte) (int, error) {
|
||||
}
|
||||
func (*resolutionSlowBody) Close() error { return nil }
|
||||
|
||||
type resolutionDelayedBody struct {
|
||||
io.ReadCloser
|
||||
ctx context.Context
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func (b *resolutionDelayedBody) Read(p []byte) (int, error) {
|
||||
if err := sleepRetry(b.ctx, b.delay); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
b.delay = 0
|
||||
return b.ReadCloser.Read(p)
|
||||
}
|
||||
|
||||
func TestResolutionBudgetAllowsResolvedSegments(t *testing.T) {
|
||||
for _, maxParallel := range []int{1, 3} {
|
||||
for _, phase := range []string{"headers", "first-byte"} {
|
||||
t.Run(fmt.Sprintf("%s/parallel-%d", phase, maxParallel), func(t *testing.T) {
|
||||
r := newFileDownloadTestRuntime(t, func(req *http.Request) (*http.Response, error) {
|
||||
body := &resolutionDelayedBody{ReadCloser: io.NopCloser(strings.NewReader("audio")), ctx: req.Context()}
|
||||
if phase == "headers" {
|
||||
if err := sleepRetry(req.Context(), 90*time.Millisecond); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
body.delay = 90 * time.Millisecond
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: body, ContentLength: 5, Request: req}, nil
|
||||
})
|
||||
r.vm.Set("segments", r.fileDownloadSegments)
|
||||
script := fmt.Sprintf(`
|
||||
var urls = [];
|
||||
for (var i = 0; i < 8; i++) urls.push("https://cdn.example.com/segment/" + i);
|
||||
segments(urls, "audio.flac", {maxParallel: %d, maxAttempts: 1});
|
||||
`, maxParallel)
|
||||
started := time.Now()
|
||||
value, err := runResolutionScript(t, r, context.Background(), 150*time.Millisecond, script)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result := value.Export().(map[string]any); result["success"] != true {
|
||||
t.Fatalf("resolved segments failed: %#v", result)
|
||||
}
|
||||
if time.Since(started) < 250*time.Millisecond {
|
||||
t.Fatal("transfer did not exceed resolution allowance")
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(r.dataDir, "audio.flac"))
|
||||
if err != nil || string(data) != strings.Repeat("audio", 8) {
|
||||
t.Fatalf("output: %q, %v", data, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolutionBudgetAllowsResolvedChunks(t *testing.T) {
|
||||
for _, phase := range []string{"headers", "first-byte"} {
|
||||
t.Run(phase, func(t *testing.T) {
|
||||
r := newFileDownloadTestRuntime(t, func(req *http.Request) (*http.Response, error) {
|
||||
var start, end int
|
||||
if _, err := fmt.Sscanf(req.Header.Get("Range"), "bytes=%d-%d", &start, &end); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := &resolutionDelayedBody{ReadCloser: io.NopCloser(strings.NewReader(strings.Repeat("a", end-start+1))), ctx: req.Context()}
|
||||
if end-start > 1 { // The two-byte capability probe is immediate.
|
||||
if phase == "headers" {
|
||||
if err := sleepRetry(req.Context(), 90*time.Millisecond); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
body.delay = 90 * time.Millisecond
|
||||
}
|
||||
}
|
||||
header := make(http.Header)
|
||||
header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/40", start, end))
|
||||
return &http.Response{StatusCode: 206, Header: header, Body: body, ContentLength: int64(end - start + 1), Request: req}, nil
|
||||
})
|
||||
r.vm.Set("download", r.fileDownload)
|
||||
value, err := runResolutionScript(t, r, context.Background(), 150*time.Millisecond, `download("https://cdn.example.com/audio", "audio.flac", {chunked: 5, maxAttempts: 1})`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result := value.Export().(map[string]any); result["success"] != true {
|
||||
t.Fatalf("resolved chunks failed: %#v", result)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(r.dataDir, "audio.flac"))
|
||||
if err != nil || string(data) != strings.Repeat("a", 40) {
|
||||
t.Fatalf("output: %q, %v", data, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolutionBudgetChargesSegmentCallbacksAndRetryWaits(t *testing.T) {
|
||||
for _, mode := range []string{"callback", "retry-wait", "new-transfer"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
waiting := make(chan struct{})
|
||||
r := newFileDownloadTestRuntime(t, func(req *http.Request) (*http.Response, error) {
|
||||
if strings.HasSuffix(req.URL.Path, "/blocked") {
|
||||
close(waiting)
|
||||
<-req.Context().Done()
|
||||
return nil, req.Context().Err()
|
||||
}
|
||||
header := make(http.Header)
|
||||
status := http.StatusOK
|
||||
if strings.HasSuffix(req.URL.Path, "/retry") {
|
||||
header.Set("Retry-After", "1")
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
return &http.Response{StatusCode: status, Header: header, Body: io.NopCloser(strings.NewReader("audio")), ContentLength: 5, Request: req}, nil
|
||||
})
|
||||
r.vm.Set("segments", r.fileDownloadSegments)
|
||||
r.vm.Set("sleep", r.sleep)
|
||||
r.vm.Set("waitForNextRequest", func() {
|
||||
select {
|
||||
case <-waiting:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("next segment request did not start")
|
||||
}
|
||||
})
|
||||
script := map[string]string{
|
||||
"callback": `segments(["https://cdn.example.com/first", "https://cdn.example.com/blocked"], "audio.flac", {
|
||||
maxParallel: 1, maxAttempts: 1, onProgress: function() { waitForNextRequest(); sleep(1000); }
|
||||
})`,
|
||||
"retry-wait": `segments(["https://cdn.example.com/first", "https://cdn.example.com/second", "https://cdn.example.com/blocked", "https://cdn.example.com/retry"], "audio.flac", {maxParallel: 2, maxAttempts: 2})`,
|
||||
"new-transfer": `segments(["https://cdn.example.com/first"], "first.flac", {maxAttempts: 1});
|
||||
segments(["https://cdn.example.com/blocked"], "audio.flac", {maxAttempts: 1});`,
|
||||
}[mode]
|
||||
started := time.Now()
|
||||
_, err := runResolutionScript(t, r, context.Background(), 150*time.Millisecond, script)
|
||||
if !IsTimeoutError(err) || time.Since(started) > 500*time.Millisecond {
|
||||
t.Fatalf("resolver work escaped allowance: elapsed=%s err=%v", time.Since(started), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolutionBudgetContinuationPreservesStallCancellation(t *testing.T) {
|
||||
r := newFileDownloadTestRuntime(t, func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: &resolutionDelayedBody{
|
||||
ReadCloser: io.NopCloser(strings.NewReader("audio")), ctx: req.Context(), delay: time.Second,
|
||||
}, Request: req}, nil
|
||||
})
|
||||
ctx, finish := r.beginResolutionBudget(context.Background(), 30*time.Millisecond)
|
||||
defer finish()
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://cdn.example.com/audio", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req, watchdog := bindStallWatchdog(req, 90*time.Millisecond)
|
||||
defer watchdog.stop()
|
||||
resp, err := r.doResolutionTransfer(r.httpClient, req, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, err = io.ReadAll(resp.Body)
|
||||
if !errors.Is(err, context.Canceled) || !watchdog.stalled.Load() || ctx.Err() != nil {
|
||||
t.Fatalf("stall cancellation lost: read=%v stalled=%v resolution=%v", err, watchdog.stalled.Load(), ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolutionBudgetAllowsActiveNativeTransfers(t *testing.T) {
|
||||
for _, kind := range []string{"plain", "chunked", "segments"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
|
||||
@@ -230,6 +230,7 @@ func (r *extensionRuntime) fileDownloadChunked(
|
||||
lastCheckpointAt := time.Now()
|
||||
attemptsUsed := probeAttempts
|
||||
fullResponse := false
|
||||
completedChunk := false
|
||||
buffer := make([]byte, 64*1024)
|
||||
for totalSize <= 0 || totalWritten < totalSize {
|
||||
chunkStart := totalWritten
|
||||
@@ -264,8 +265,7 @@ func (r *extensionRuntime) fileDownloadChunked(
|
||||
request.Header.Set("If-Range", validator)
|
||||
}
|
||||
request, watchdog := bindStallWatchdog(request, downloadStallTimeout)
|
||||
response, responseErr := client.Do(request)
|
||||
r.trackResolutionTransfer(response)
|
||||
response, responseErr := r.doResolutionTransfer(client, request, attempt == 1 && completedChunk)
|
||||
if responseErr != nil {
|
||||
stalled := watchdog.stalled.Load()
|
||||
watchdog.stop()
|
||||
@@ -446,6 +446,7 @@ func (r *extensionRuntime) fileDownloadChunked(
|
||||
}
|
||||
if readErr == nil && chunkWritten > 0 {
|
||||
chunkComplete = true
|
||||
completedChunk = true
|
||||
fullResponse = response.StatusCode == http.StatusOK
|
||||
break
|
||||
}
|
||||
|
||||
@@ -169,6 +169,7 @@ func (r *extensionRuntime) fetchSegmentToTemp(
|
||||
tempPath string,
|
||||
policy DownloadTransferPolicy,
|
||||
received *atomic.Int64,
|
||||
completed *atomic.Bool,
|
||||
itemProgressReporter *ItemTransferProgressReporter,
|
||||
) segmentTransferResult {
|
||||
config := transferRetryConfig(policy)
|
||||
@@ -218,8 +219,7 @@ func (r *extensionRuntime) fetchSegmentToTemp(
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
}
|
||||
req, watchdog := bindStallWatchdog(req, downloadStallTimeout)
|
||||
resp, err := client.Do(req)
|
||||
r.trackResolutionTransfer(resp)
|
||||
resp, err := r.doResolutionTransfer(client, req, attempt == 1 && completed.Load())
|
||||
if err != nil {
|
||||
stalled := watchdog.stalled.Load()
|
||||
watchdog.stop()
|
||||
@@ -244,7 +244,7 @@ func (r *extensionRuntime) fetchSegmentToTemp(
|
||||
}
|
||||
return segmentTransferResult{Index: spec.Index, Failure: &lastFailure}
|
||||
}
|
||||
if waitTransferRetry(ctx, retryDelay) != nil {
|
||||
if r.waitResolutionRetry(ctx, retryDelay) != nil {
|
||||
lastFailure.ErrorType = "cancelled"
|
||||
lastFailure.Message = "download cancelled"
|
||||
return segmentTransferResult{Index: spec.Index, Failure: &lastFailure}
|
||||
@@ -273,7 +273,7 @@ func (r *extensionRuntime) fetchSegmentToTemp(
|
||||
if retryAfter > 0 {
|
||||
delay = time.Duration(retryAfter) * time.Second
|
||||
}
|
||||
if waitTransferRetry(ctx, delay) != nil {
|
||||
if r.waitResolutionRetry(ctx, delay) != nil {
|
||||
lastFailure.ErrorType = "cancelled"
|
||||
lastFailure.Message = "download cancelled"
|
||||
return segmentTransferResult{Index: spec.Index, Failure: &lastFailure}
|
||||
@@ -319,6 +319,7 @@ func (r *extensionRuntime) fetchSegmentToTemp(
|
||||
readErr = io.ErrUnexpectedEOF
|
||||
}
|
||||
if readErr == nil && size > 0 {
|
||||
completed.Store(true)
|
||||
return segmentTransferResult{
|
||||
Index: spec.Index,
|
||||
Path: tempPath,
|
||||
@@ -349,7 +350,7 @@ func (r *extensionRuntime) fetchSegmentToTemp(
|
||||
if attempt == policy.MaxAttempts {
|
||||
return segmentTransferResult{Index: spec.Index, Failure: &lastFailure}
|
||||
}
|
||||
if waitTransferRetry(ctx, retryDelay) != nil {
|
||||
if r.waitResolutionRetry(ctx, retryDelay) != nil {
|
||||
lastFailure.ErrorType = "cancelled"
|
||||
lastFailure.Message = "download cancelled"
|
||||
return segmentTransferResult{Index: spec.Index, Failure: &lastFailure}
|
||||
@@ -562,6 +563,7 @@ func (r *extensionRuntime) fileDownloadSegments(call goja.FunctionCall) goja.Val
|
||||
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
|
||||
@@ -578,6 +580,7 @@ func (r *extensionRuntime) fileDownloadSegments(call goja.FunctionCall) goja.Val
|
||||
segmentTempPath(stagedPath, spec.Index),
|
||||
policy,
|
||||
&received,
|
||||
&completed,
|
||||
itemProgressReporter,
|
||||
)
|
||||
select {
|
||||
@@ -679,13 +682,18 @@ func (r *extensionRuntime) fileDownloadSegments(call goja.FunctionCall) goja.Val
|
||||
)
|
||||
}
|
||||
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)),
|
||||
)
|
||||
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)),
|
||||
)
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,8 +394,7 @@ func (r *extensionRuntime) reliableFileDownload(
|
||||
|
||||
retryContext := req.Context()
|
||||
req, watchdog := bindStallWatchdog(req, downloadStallTimeout)
|
||||
resp, requestErr := client.Do(req)
|
||||
r.trackResolutionTransfer(resp)
|
||||
resp, requestErr := r.doResolutionTransfer(client, req, false)
|
||||
if requestErr != nil {
|
||||
stalled := watchdog.stalled.Load()
|
||||
watchdog.stop()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -8,6 +10,53 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompleteMetadataHintReturnsFileAccessErrors(t *testing.T) {
|
||||
for _, format := range []string{"flac", "mp3", "m4a", "mp4", "aac", "opus", "ogg", "wav", "aiff", "aif", "aifc", "ape", "wv", "mpc"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "descriptor")
|
||||
payload, err := ReadFileMetadataWithHint(path, "track."+format)
|
||||
if !errors.Is(err, os.ErrNotExist) || payload != "" {
|
||||
t.Fatalf("missing descriptor returned metadata=%s err=%v", payload, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("inaccessible"), 0000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if file, err := os.Open(path); err == nil {
|
||||
file.Close()
|
||||
t.Skip("host can bypass file permissions; missing-path check passed")
|
||||
}
|
||||
payload, err = ReadFileMetadataWithHint(path, "track."+format)
|
||||
if !errors.Is(err, os.ErrPermission) || payload != "" {
|
||||
t.Fatalf("unreadable descriptor returned metadata=%s err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteMetadataHintAcceptsAudioWithoutTags(t *testing.T) {
|
||||
for _, format := range []string{"wav", "aiff"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "descriptor")
|
||||
if format == "wav" {
|
||||
writeTestWAV(t, path)
|
||||
} else {
|
||||
writeTestAIFF(t, path)
|
||||
}
|
||||
payload, err := ReadFileMetadataWithHint(path, "track."+format)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var metadata map[string]any
|
||||
if err := json.Unmarshal([]byte(payload), &metadata); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if metadata["title"] != "" || metadata["sample_rate"] != float64(44100) {
|
||||
t.Fatalf("unexpected tagless audio metadata: %s", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteMetadataHintMatchesNamedFileAndDescriptor(t *testing.T) {
|
||||
for _, format := range []string{"mp3", "flac", "m4a", "wav"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user