From 781661798b942a216007dff71c61562921b91a4e Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:43:24 +0700 Subject: [PATCH] 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. --- .../spotiflac/SafMetadataReadPolicyTest.kt | 8 + docs/EXTENSION_DEVELOPMENT.md | 4 + go_backend/exports_metadata.go | 17 +- go_backend/extension_resolution_budget.go | 76 ++++++-- .../extension_resolution_budget_test.go | 163 ++++++++++++++++++ go_backend/extension_runtime_chunked.go | 5 +- go_backend/extension_runtime_segments.go | 32 ++-- go_backend/extension_runtime_transfer.go | 3 +- go_backend/metadata_descriptor_test.go | 49 ++++++ 9 files changed, 327 insertions(+), 30 deletions(-) diff --git a/android/app/src/test/kotlin/com/zarz/spotiflac/SafMetadataReadPolicyTest.kt b/android/app/src/test/kotlin/com/zarz/spotiflac/SafMetadataReadPolicyTest.kt index 95e88052..32f27e22 100644 --- a/android/app/src/test/kotlin/com/zarz/spotiflac/SafMetadataReadPolicyTest.kt +++ b/android/app/src/test/kotlin/com/zarz/spotiflac/SafMetadataReadPolicyTest.kt @@ -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({ null }, { throw IllegalArgumentException("malformed metadata") })) assertEquals("next file", readSafMetadataWithFallback({ "next file" }, { error("copy") })) diff --git a/docs/EXTENSION_DEVELOPMENT.md b/docs/EXTENSION_DEVELOPMENT.md index c1e56e53..e5eaf32b 100644 --- a/docs/EXTENSION_DEVELOPMENT.md +++ b/docs/EXTENSION_DEVELOPMENT.md @@ -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 diff --git a/go_backend/exports_metadata.go b/go_backend/exports_metadata.go index 17554d8c..f8c98e00 100644 --- a/go_backend/exports_metadata.go +++ b/go_backend/exports_metadata.go @@ -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) } diff --git a/go_backend/extension_resolution_budget.go b/go_backend/extension_resolution_budget.go index 250e5677..67a1cb45 100644 --- a/go_backend/extension_resolution_budget.go +++ b/go_backend/extension_resolution_budget.go @@ -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) +} diff --git a/go_backend/extension_resolution_budget_test.go b/go_backend/extension_resolution_budget_test.go index e0d2ee6e..751e5275 100644 --- a/go_backend/extension_resolution_budget_test.go +++ b/go_backend/extension_resolution_budget_test.go @@ -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) { diff --git a/go_backend/extension_runtime_chunked.go b/go_backend/extension_runtime_chunked.go index 73872bb5..e83f86d1 100644 --- a/go_backend/extension_runtime_chunked.go +++ b/go_backend/extension_runtime_chunked.go @@ -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 } diff --git a/go_backend/extension_runtime_segments.go b/go_backend/extension_runtime_segments.go index 767942be..f41cd0c4 100644 --- a/go_backend/extension_runtime_segments.go +++ b/go_backend/extension_runtime_segments.go @@ -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)), + ) + }() } } } diff --git a/go_backend/extension_runtime_transfer.go b/go_backend/extension_runtime_transfer.go index a1958163..bd838375 100644 --- a/go_backend/extension_runtime_transfer.go +++ b/go_backend/extension_runtime_transfer.go @@ -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() diff --git a/go_backend/metadata_descriptor_test.go b/go_backend/metadata_descriptor_test.go index abeda797..6ac2c6da 100644 --- a/go_backend/metadata_descriptor_test.go +++ b/go_backend/metadata_descriptor_test.go @@ -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) {