mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-23 01:50:44 +02:00
fix: improve extension runtime safety, HTTP response URL, SongLink parsing, and recommended service for extensions
- Add 'url' field (final URL after redirects) to all extension HTTP responses and fix fetch polyfill to return final URL instead of original request URL - Fix RunWithTimeout race condition: increase force-timeout from 1s to 60s to prevent concurrent VM access crashes, add nil guards - Use lockReadyVM() for thread-safe VM access in GetPlaylistWithExtensionJSON and InvokeAction - Handle mixed JSON types (string, null, array) in SongLink resolve API SongUrls field - Fix recommended download service not showing for extension-based searches in download picker
This commit is contained in:
@@ -77,6 +77,7 @@ flutter_*.log
|
|||||||
# Development tools
|
# Development tools
|
||||||
tool/
|
tool/
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
.playwright-mcp/
|
||||||
|
|
||||||
# FVM Version Cache
|
# FVM Version Cache
|
||||||
.fvm/
|
.fvm/
|
||||||
|
|||||||
@@ -3212,8 +3212,15 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
|
|||||||
if !ext.Manifest.IsMetadataProvider() {
|
if !ext.Manifest.IsMetadataProvider() {
|
||||||
return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID)
|
return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID)
|
||||||
}
|
}
|
||||||
|
if !ext.Enabled {
|
||||||
|
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
|
||||||
|
}
|
||||||
|
|
||||||
provider := NewExtensionProviderWrapper(ext)
|
vm, err := ext.lockReadyVM()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer ext.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
@@ -3227,7 +3234,7 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
|
|||||||
})()
|
})()
|
||||||
`, playlistID, playlistID)
|
`, playlistID, playlistID)
|
||||||
|
|
||||||
result, err := RunWithTimeoutAndRecover(provider.vm, script, DefaultJSTimeout)
|
result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("getPlaylist failed: %w", err)
|
return "", fmt.Errorf("getPlaylist failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1044,13 +1044,14 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
|
|||||||
return nil, fmt.Errorf("extension not found: %s", extensionID)
|
return nil, fmt.Errorf("extension not found: %s", extensionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ext.ensureRuntimeReady(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !ext.Enabled {
|
if !ext.Enabled {
|
||||||
return nil, fmt.Errorf("extension is disabled")
|
return nil, fmt.Errorf("extension is disabled")
|
||||||
}
|
}
|
||||||
|
vm, err := ext.lockReadyVM()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer ext.VMMu.Unlock()
|
||||||
|
|
||||||
// Merge extension return values onto the top-level JSON object so Flutter can read
|
// Merge extension return values onto the top-level JSON object so Flutter can read
|
||||||
// message, open_auth_url, setting_updates without unwrapping a nested "result" key.
|
// message, open_auth_url, setting_updates without unwrapping a nested "result" key.
|
||||||
@@ -1084,7 +1085,7 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
|
|||||||
})()
|
})()
|
||||||
`, actionName, actionName, actionName)
|
`, actionName, actionName, actionName)
|
||||||
|
|
||||||
result, err := RunWithTimeoutAndRecover(ext.VM, script, DefaultJSTimeout)
|
result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err)
|
GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err)
|
||||||
return nil, fmt.Errorf("action failed: %v", err)
|
return nil, fmt.Errorf("action failed: %v", err)
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
|||||||
"statusCode": resp.StatusCode,
|
"statusCode": resp.StatusCode,
|
||||||
"status": resp.StatusCode,
|
"status": resp.StatusCode,
|
||||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
"url": resp.Request.URL.String(),
|
||||||
"body": string(body),
|
"body": string(body),
|
||||||
"headers": respHeaders,
|
"headers": respHeaders,
|
||||||
})
|
})
|
||||||
@@ -214,6 +215,7 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
|||||||
"statusCode": resp.StatusCode,
|
"statusCode": resp.StatusCode,
|
||||||
"status": resp.StatusCode,
|
"status": resp.StatusCode,
|
||||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
"url": resp.Request.URL.String(),
|
||||||
"body": string(body),
|
"body": string(body),
|
||||||
"headers": respHeaders,
|
"headers": respHeaders,
|
||||||
})
|
})
|
||||||
@@ -322,6 +324,7 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
|||||||
"statusCode": resp.StatusCode,
|
"statusCode": resp.StatusCode,
|
||||||
"status": resp.StatusCode,
|
"status": resp.StatusCode,
|
||||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
"url": resp.Request.URL.String(),
|
||||||
"body": string(body),
|
"body": string(body),
|
||||||
"headers": respHeaders,
|
"headers": respHeaders,
|
||||||
})
|
})
|
||||||
@@ -446,6 +449,7 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
|||||||
"statusCode": resp.StatusCode,
|
"statusCode": resp.StatusCode,
|
||||||
"status": resp.StatusCode,
|
"status": resp.StatusCode,
|
||||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
"url": resp.Request.URL.String(),
|
||||||
"body": string(body),
|
"body": string(body),
|
||||||
"headers": respHeaders,
|
"headers": respHeaders,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
responseObj.Set("status", resp.StatusCode)
|
responseObj.Set("status", resp.StatusCode)
|
||||||
responseObj.Set("statusText", http.StatusText(resp.StatusCode))
|
responseObj.Set("statusText", http.StatusText(resp.StatusCode))
|
||||||
responseObj.Set("headers", respHeaders)
|
responseObj.Set("headers", respHeaders)
|
||||||
responseObj.Set("url", urlStr)
|
responseObj.Set("url", resp.Request.URL.String())
|
||||||
|
|
||||||
bodyString := string(body)
|
bodyString := string(body)
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ func (e *JSExecutionError) Error() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||||
|
if vm == nil {
|
||||||
|
return nil, fmt.Errorf("extension runtime unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
timeout = DefaultJSTimeout
|
timeout = DefaultJSTimeout
|
||||||
}
|
}
|
||||||
@@ -69,6 +73,11 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
|
|
||||||
vm.Interrupt("execution timeout")
|
vm.Interrupt("execution timeout")
|
||||||
|
|
||||||
|
// MUST wait for the goroutine to finish before returning.
|
||||||
|
// The Goja VM is NOT thread-safe — if we return while the goroutine
|
||||||
|
// is still executing JS (e.g. blocked on an HTTP call), the next
|
||||||
|
// caller will access the VM concurrently and crash with a nil
|
||||||
|
// pointer dereference.
|
||||||
select {
|
select {
|
||||||
case res := <-resultCh:
|
case res := <-resultCh:
|
||||||
if res.err != nil {
|
if res.err != nil {
|
||||||
@@ -78,7 +87,10 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
Message: "execution timeout exceeded",
|
Message: "execution timeout exceeded",
|
||||||
IsTimeout: true,
|
IsTimeout: true,
|
||||||
}
|
}
|
||||||
case <-time.After(1 * time.Second):
|
case <-time.After(60 * time.Second):
|
||||||
|
// Goroutine is truly stuck (e.g. HTTP read with no timeout).
|
||||||
|
// Log a warning — the VM should NOT be reused after this.
|
||||||
|
GoLog("[ExtensionRuntime] WARNING: JS goroutine did not exit within 60s after interrupt, VM may be unsafe\n")
|
||||||
return nil, &JSExecutionError{
|
return nil, &JSExecutionError{
|
||||||
Message: "execution timeout exceeded (force)",
|
Message: "execution timeout exceeded (force)",
|
||||||
IsTimeout: true,
|
IsTimeout: true,
|
||||||
@@ -93,7 +105,9 @@ func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Dura
|
|||||||
result, err := RunWithTimeout(vm, script, timeout)
|
result, err := RunWithTimeout(vm, script, timeout)
|
||||||
|
|
||||||
// Clear any interrupt state so VM can be reused
|
// Clear any interrupt state so VM can be reused
|
||||||
vm.ClearInterrupt()
|
if vm != nil {
|
||||||
|
vm.ClearInterrupt()
|
||||||
|
}
|
||||||
|
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-5
@@ -164,9 +164,9 @@ func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPl
|
|||||||
}
|
}
|
||||||
|
|
||||||
var resolveResp struct {
|
var resolveResp struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
SongUrls map[string]string `json:"songUrls"`
|
SongUrls map[string]json.RawMessage `json:"songUrls"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &resolveResp); err != nil {
|
if err := json.Unmarshal(body, &resolveResp); err != nil {
|
||||||
return nil, fmt.Errorf("failed to decode resolve response: %w", err)
|
return nil, fmt.Errorf("failed to decode resolve response: %w", err)
|
||||||
@@ -189,8 +189,12 @@ func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPl
|
|||||||
|
|
||||||
links := make(map[string]songLinkPlatformLink)
|
links := make(map[string]songLinkPlatformLink)
|
||||||
for resolveKey, platformKey := range keyMap {
|
for resolveKey, platformKey := range keyMap {
|
||||||
if u, ok := resolveResp.SongUrls[resolveKey]; ok && strings.TrimSpace(u) != "" {
|
rawValue, ok := resolveResp.SongUrls[resolveKey]
|
||||||
links[platformKey] = songLinkPlatformLink{URL: strings.TrimSpace(u)}
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if u := extractResolveURLValue(rawValue); u != "" {
|
||||||
|
links[platformKey] = songLinkPlatformLink{URL: u}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +205,29 @@ func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPl
|
|||||||
return links, nil
|
return links, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractResolveURLValue(raw json.RawMessage) string {
|
||||||
|
trimmed := bytes.TrimSpace(raw)
|
||||||
|
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var direct string
|
||||||
|
if err := json.Unmarshal(trimmed, &direct); err == nil {
|
||||||
|
return strings.TrimSpace(direct)
|
||||||
|
}
|
||||||
|
|
||||||
|
var list []string
|
||||||
|
if err := json.Unmarshal(trimmed, &list); err == nil {
|
||||||
|
for _, candidate := range list {
|
||||||
|
if cleaned := strings.TrimSpace(candidate); cleaned != "" {
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// songLinkByTargetURL calls the SongLink API with a target URL (for non-Spotify URLs).
|
// songLinkByTargetURL calls the SongLink API with a target URL (for non-Spotify URLs).
|
||||||
func (s *SongLinkClient) songLinkByTargetURL(targetURL string) (map[string]songLinkPlatformLink, error) {
|
func (s *SongLinkClient) songLinkByTargetURL(targetURL string) (map[string]songLinkPlatformLink, error) {
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|||||||
@@ -114,6 +114,47 @@ func TestCheckTrackAvailabilityFromSpotifyResolveAPIFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCheckTrackAvailabilityFromSpotifyViaResolveAPIMixedSongURLShapes(t *testing.T) {
|
||||||
|
origRetryConfig := songLinkRetryConfig
|
||||||
|
defer func() { songLinkRetryConfig = origRetryConfig }()
|
||||||
|
|
||||||
|
client := &SongLinkClient{
|
||||||
|
client: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.URL.Host == "api.zarz.moe" && req.URL.Path == "/v1/resolve" && req.Method == "POST" {
|
||||||
|
body := `{"success":true,"isrc":"TCAHA2367688","songUrls":{"Spotify":"https://open.spotify.com/track/5glgyj6zH0irbNGfukHacv","Deezer":"https://www.deezer.com/track/2248583177","Tidal":"https://tidal.com/browse/track/290565315","AppleMusic":"https://geo.music.apple.com/us/album/example?i=1","YouTubeMusic":null,"YouTube":"https://www.youtube.com/watch?v=wD_e59XUNdQ","AmazonMusic":"https://music.amazon.com/tracks/B0C35TG38Y/?ref=dm_ff_amazonmusic_3p","Beatport":null,"BeatSource":null,"SoundCloud":null,"Qobuz":null,"Other":[]}}`
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: 200,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
|
||||||
|
return nil, nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
availability, err := client.CheckTrackAvailability("5glgyj6zH0irbNGfukHacv", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckTrackAvailability() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if availability.SpotifyID != "5glgyj6zH0irbNGfukHacv" {
|
||||||
|
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "5glgyj6zH0irbNGfukHacv")
|
||||||
|
}
|
||||||
|
if !availability.Deezer || availability.DeezerID != "2248583177" {
|
||||||
|
t.Fatalf("Deezer availability = %+v, want DeezerID 2248583177", availability)
|
||||||
|
}
|
||||||
|
if !availability.Tidal || availability.TidalID != "290565315" {
|
||||||
|
t.Fatalf("Tidal availability = %+v, want TidalID 290565315", availability)
|
||||||
|
}
|
||||||
|
if availability.Qobuz {
|
||||||
|
t.Fatalf("Qobuz should remain false when resolve response contains null, got %+v", availability)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCheckAvailabilityFromDeezerUsesSongLink(t *testing.T) {
|
func TestCheckAvailabilityFromDeezerUsesSongLink(t *testing.T) {
|
||||||
origRetryConfig := songLinkRetryConfig
|
origRetryConfig := songLinkRetryConfig
|
||||||
songLinkRetryConfig = func() RetryConfig {
|
songLinkRetryConfig = func() RetryConfig {
|
||||||
|
|||||||
@@ -757,7 +757,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||||||
trackName: track.name,
|
trackName: track.name,
|
||||||
artistName: track.artistName,
|
artistName: track.artistName,
|
||||||
coverUrl: track.coverUrl,
|
coverUrl: track.coverUrl,
|
||||||
recommendedService: trackState.searchSource,
|
recommendedService:
|
||||||
|
trackState.searchExtensionId ?? trackState.searchSource,
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
|
|||||||
Reference in New Issue
Block a user