feat: propagate download cancel to extension HTTP requests and fix SAF filename extension mismatch

- Bind cancel context to all extension HTTP calls (fetch, httpGet, httpPost,
  httpRequest, fileDownload, authExchangeCodeWithPKCE) so in-flight requests
  are aborted when user cancels a download
- Make initDownloadCancel idempotent: return existing context if entry already
  exists and preserve pre-cancelled state
- Force SAF output filename to match actual file extension when extension
  returns a different format than requested (e.g. FLAC requested but M4A produced)
- Map ALAC/AAC quality to .m4a instead of falling through to default .flac
This commit is contained in:
zarzet
2026-04-13 23:35:03 +07:00
parent 7405855e01
commit dbba4d6630
10 changed files with 141 additions and 3 deletions
+14
View File
@@ -10,6 +10,7 @@ import (
var ErrDownloadCancelled = errors.New("download cancelled")
type cancelEntry struct {
ctx context.Context
cancel context.CancelFunc
canceled bool
}
@@ -27,8 +28,21 @@ func initDownloadCancel(itemID string) context.Context {
cancelMu.Lock()
defer cancelMu.Unlock()
if entry, ok := cancelMap[itemID]; ok {
if entry.ctx == nil {
ctx, cancel := context.WithCancel(context.Background())
entry.ctx = ctx
entry.cancel = cancel
if entry.canceled && entry.cancel != nil {
entry.cancel()
}
}
return entry.ctx
}
ctx, cancel := context.WithCancel(context.Background())
cancelMap[itemID] = &cancelEntry{
ctx: ctx,
cancel: cancel,
canceled: false,
}
+4
View File
@@ -615,6 +615,10 @@ func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID
p.extension.runtime.setActiveDownloadItemID(itemID)
defer p.extension.runtime.clearActiveDownloadItemID()
}
if itemID != "" {
initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
+13
View File
@@ -160,6 +160,19 @@ func (r *extensionRuntime) getActiveDownloadItemID() string {
return r.activeDownloadItemID
}
func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Request {
if req == nil {
return nil
}
itemID := r.getActiveDownloadItemID()
if itemID == "" {
return req
}
return req.WithContext(initDownloadCancel(itemID))
}
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
// Extension sandbox enforces HTTPS-only domains. Do not apply global
// allow_http scheme downgrade here, because some extension APIs (e.g.
+1
View File
@@ -458,6 +458,7 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
+1
View File
@@ -166,6 +166,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
+4
View File
@@ -81,6 +81,7 @@ func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
@@ -175,6 +176,7 @@ func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
@@ -284,6 +286,7 @@ func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
@@ -410,6 +413,7 @@ func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
"error": err.Error(),
})
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
@@ -69,6 +69,7 @@ func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
if err != nil {
return r.createFetchError(err.Error())
}
req = r.bindDownloadCancelContext(req)
for k, v := range headers {
req.Header.Set(k, v)
+72
View File
@@ -1,8 +1,10 @@
package gobackend
import (
"net/http"
"path/filepath"
"testing"
"time"
"github.com/dop251/goja"
)
@@ -290,6 +292,76 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
}
}
func TestExtensionRuntime_BindDownloadCancelContext(t *testing.T) {
ext := &loadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
},
DataDir: t.TempDir(),
}
runtime := newExtensionRuntime(ext)
runtime.setActiveDownloadItemID("test-item")
t.Cleanup(func() {
clearDownloadCancel("test-item")
runtime.clearActiveDownloadItemID()
})
req, err := http.NewRequest("GET", "https://api.example.com/test", nil)
if err != nil {
t.Fatalf("NewRequest failed: %v", err)
}
req = runtime.bindDownloadCancelContext(req)
cancelDownload("test-item")
select {
case <-req.Context().Done():
case <-time.After(500 * time.Millisecond):
t.Fatal("Expected bound request context to be cancelled")
}
if req.Context().Err() == nil {
t.Fatal("Expected request context error after cancellation")
}
}
func TestExtensionRuntime_BindDownloadCancelContextPreservesPreCancelledState(t *testing.T) {
ext := &loadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
},
DataDir: t.TempDir(),
}
runtime := newExtensionRuntime(ext)
runtime.setActiveDownloadItemID("test-item")
cancelDownload("test-item")
t.Cleanup(func() {
clearDownloadCancel("test-item")
runtime.clearActiveDownloadItemID()
})
req, err := http.NewRequest("GET", "https://api.example.com/test", nil)
if err != nil {
t.Fatalf("NewRequest failed: %v", err)
}
req = runtime.bindDownloadCancelContext(req)
select {
case <-req.Context().Done():
case <-time.After(500 * time.Millisecond):
t.Fatal("Expected pre-cancelled request context to stay cancelled")
}
if req.Context().Err() == nil {
t.Fatal("Expected request context error for pre-cancelled item")
}
}
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
// Create extension with limited network permissions
ext := &loadedExtension{