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) {