fix(extensions): surface swallowed signed-session verification in provider fallback

Two holes let a provider's verification requirement vanish so the fallback
chain failed outright instead of opening the challenge:

- classifyDownloadErrorType did not recognize 'signed session expired'
  (the exact error ensureSignedSession returns), so an expired session
  failed generically.
- Extensions that catch the needsVerification response internally (e.g.
  qobuz-web's availability search) report a plain 'not available', which
  the fallback loop skipped past silently.

The runtime now remembers when a signed-session call inside the current
script invocation required verification; the provider wrapper consumes
that after checkAvailability/download and tags the failure as
verification_required, regardless of how the script handled it. The
source-extension path also classifies thrown errors now.
This commit is contained in:
zarzet
2026-07-14 16:34:43 +07:00
parent 3833a66bf6
commit f09c607aab
6 changed files with 100 additions and 1 deletions
+1
View File
@@ -457,6 +457,7 @@ func classifyDownloadErrorType(msg string) string {
strings.Contains(lowerMsg, "needs verification") ||
strings.Contains(lowerMsg, "session is not authenticated") ||
strings.Contains(lowerMsg, "signed session is not authenticated") ||
strings.Contains(lowerMsg, "signed session expired") ||
strings.Contains(lowerMsg, "unauthorized") ||
strings.Contains(lowerMsg, "precondition required") ||
messageHasHTTPStatusCode(lowerMsg, "401") ||
+5 -1
View File
@@ -695,7 +695,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr)
if strings.EqualFold(lastErrType, "verification_required") {
sourceErrType := lastErrType
if sourceErrType == "" && lastErr != nil {
sourceErrType = classifyDownloadErrorType(lastErr.Error())
}
if strings.EqualFold(sourceErrType, "verification_required") {
GoLog("[DownloadWithExtensionFallback] Source extension %s requires verification, not trying other providers\n", req.Source)
return &DownloadResponse{
Success: false,
+40
View File
@@ -457,6 +457,14 @@ func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, a
script: script,
timeout: DefaultJSTimeout,
itemID: itemID,
beforeRun: func() func() {
// Drop any stale flag so the post-run check below only sees
// verification requested by THIS call.
if p.extension.runtime != nil {
p.extension.runtime.consumeVerificationRequired()
}
return nil
},
}, func(perf *extensionCallPerf, result goja.Value) (*ExtAvailabilityResult, error) {
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return &ExtAvailabilityResult{Available: false, Reason: "not implemented"}, nil
@@ -465,6 +473,19 @@ func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, a
availability := parseExtensionAvailabilityValue(p.vm, result)
perf.recordParse(time.Since(parseStartedAt))
perf.setItems(1)
// A signed-session call inside checkAvailability required
// verification. Extensions often swallow that and report a plain
// "not available", which would silently skip this provider's
// challenge; surface it as an error so the fallback loop pauses and
// opens the challenge instead.
if !availability.Available && p.extension.runtime != nil {
if p.extension.runtime.consumeVerificationRequired() != "" {
return nil, fmt.Errorf(
"VERIFY_REQUIRED: extension '%s' needs signed-session verification",
p.extension.ID,
)
}
}
return &availability, nil
})
}
@@ -533,6 +554,12 @@ func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID
})()
`, trackID, quality, outputPath)
if runtime != nil {
// Drop any stale flag (pooled runtimes survive across downloads) so
// the post-run check only sees verification from THIS call.
runtime.consumeVerificationRequired()
}
jsStartedAt := time.Now()
result, err := RunWithTimeoutAndRecover(vm, script, ExtDownloadTimeout)
perf.recordJS(time.Since(jsStartedAt))
@@ -573,6 +600,19 @@ func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID
downloadResult.DecryptionKey,
)
// A signed-session call inside download() required verification but the
// script reported a generic failure; tag the result so the fallback loop
// pauses and opens this provider's challenge instead of skipping it.
if runtime != nil && !downloadResult.Success {
if runtime.consumeVerificationRequired() != "" &&
!strings.EqualFold(downloadResult.ErrorType, "verification_required") {
downloadResult.ErrorType = "verification_required"
if downloadResult.ErrorMessage == "" {
downloadResult.ErrorMessage = "Verification required"
}
}
}
return &downloadResult, nil
}
+28
View File
@@ -139,6 +139,34 @@ type extensionRuntime struct {
credentialsCache map[string]any
credentialsLoaded bool
storageFlushDelay time.Duration
// Set when a signed-session call inside the current script invocation
// required verification. The provider wrapper consumes it after the
// script returns, so verification surfaces even when the extension
// script swallowed the needsVerification response (issue: fallback
// skipped provider B's challenge and failed outright).
verificationMu sync.Mutex
verificationRequiredURL string
}
func (r *extensionRuntime) noteVerificationRequired(authURL string) {
r.verificationMu.Lock()
if authURL == "" {
authURL = "pending"
}
r.verificationRequiredURL = authURL
r.verificationMu.Unlock()
}
// consumeVerificationRequired returns the noted auth URL (or "pending") and
// clears the flag; "" means no verification was requested since the last
// consume.
func (r *extensionRuntime) consumeVerificationRequired() string {
r.verificationMu.Lock()
url := r.verificationRequiredURL
r.verificationRequiredURL = ""
r.verificationMu.Unlock()
return url
}
type privateIPCacheEntry struct {
@@ -1039,3 +1039,28 @@ func TestExtensionRuntimeUtilityAPIs(t *testing.T) {
t.Fatalf("sanitize wrapper = %q", clean)
}
}
func TestClassifySignedSessionExpiredAsVerification(t *testing.T) {
got := classifyDownloadErrorType("Failed to resolve Deezer download: signed session expired")
if got != "verification_required" {
t.Fatalf("expected verification_required, got %q", got)
}
}
func TestVerificationRequiredNoteConsume(t *testing.T) {
r := &extensionRuntime{}
if got := r.consumeVerificationRequired(); got != "" {
t.Fatalf("expected empty before note, got %q", got)
}
r.noteVerificationRequired("")
if got := r.consumeVerificationRequired(); got != "pending" {
t.Fatalf("expected pending sentinel, got %q", got)
}
if got := r.consumeVerificationRequired(); got != "" {
t.Fatalf("expected cleared after consume, got %q", got)
}
r.noteVerificationRequired("https://x/auth")
if got := r.consumeVerificationRequired(); got != "https://x/auth" {
t.Fatalf("expected auth url, got %q", got)
}
}
+1
View File
@@ -370,6 +370,7 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value
}
func (r *extensionRuntime) signedSessionVerificationRequiredValue(authURL string) goja.Value {
r.noteVerificationRequired(authURL)
return r.vm.ToValue(map[string]any{
"ok": false,
"needsVerification": true,