diff --git a/.gitignore b/.gitignore index b24e937..9150a85 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,7 @@ flutter_*.log # Development tools tool/ .claude/settings.local.json +.playwright-mcp/ # FVM Version Cache .fvm/ diff --git a/go_backend/exports.go b/go_backend/exports.go index 9222eaa..200dd83 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -3212,8 +3212,15 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error if !ext.Manifest.IsMetadataProvider() { 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(` (function() { @@ -3227,7 +3234,7 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error })() `, playlistID, playlistID) - result, err := RunWithTimeoutAndRecover(provider.vm, script, DefaultJSTimeout) + result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout) if err != nil { return "", fmt.Errorf("getPlaylist failed: %w", err) } diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index 8f9e5df..1ac930c 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -1044,13 +1044,14 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) ( return nil, fmt.Errorf("extension not found: %s", extensionID) } - if err := ext.ensureRuntimeReady(); err != nil { - return nil, err - } - if !ext.Enabled { return nil, fmt.Errorf("extension is disabled") } + vm, err := ext.lockReadyVM() + if err != nil { + return nil, err + } + defer ext.VMMu.Unlock() script := fmt.Sprintf(` (function() { @@ -1070,7 +1071,7 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) ( })() `, actionName, actionName, actionName) - result, err := RunWithTimeoutAndRecover(ext.VM, script, DefaultJSTimeout) + result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout) if err != nil { GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err) return nil, fmt.Errorf("action failed: %v", err) diff --git a/go_backend/extension_runtime_http.go b/go_backend/extension_runtime_http.go index 6577583..424a35b 100644 --- a/go_backend/extension_runtime_http.go +++ b/go_backend/extension_runtime_http.go @@ -118,6 +118,7 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value { "statusCode": resp.StatusCode, "status": resp.StatusCode, "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, + "url": resp.Request.URL.String(), "body": string(body), "headers": respHeaders, }) @@ -214,6 +215,7 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value { "statusCode": resp.StatusCode, "status": resp.StatusCode, "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, + "url": resp.Request.URL.String(), "body": string(body), "headers": respHeaders, }) @@ -322,6 +324,7 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value { "statusCode": resp.StatusCode, "status": resp.StatusCode, "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, + "url": resp.Request.URL.String(), "body": string(body), "headers": respHeaders, }) @@ -446,6 +449,7 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC "statusCode": resp.StatusCode, "status": resp.StatusCode, "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, + "url": resp.Request.URL.String(), "body": string(body), "headers": respHeaders, }) diff --git a/go_backend/extension_runtime_polyfills.go b/go_backend/extension_runtime_polyfills.go index 7427010..3068ace 100644 --- a/go_backend/extension_runtime_polyfills.go +++ b/go_backend/extension_runtime_polyfills.go @@ -105,7 +105,7 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value { responseObj.Set("status", resp.StatusCode) responseObj.Set("statusText", http.StatusText(resp.StatusCode)) responseObj.Set("headers", respHeaders) - responseObj.Set("url", urlStr) + responseObj.Set("url", resp.Request.URL.String()) bodyString := string(body) diff --git a/go_backend/extension_timeout.go b/go_backend/extension_timeout.go index 76e51cf..129e05e 100644 --- a/go_backend/extension_timeout.go +++ b/go_backend/extension_timeout.go @@ -20,6 +20,10 @@ func (e *JSExecutionError) Error() string { } 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 { timeout = DefaultJSTimeout } @@ -69,6 +73,11 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj 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 { case res := <-resultCh: if res.err != nil { @@ -78,7 +87,10 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj Message: "execution timeout exceeded", 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{ Message: "execution timeout exceeded (force)", IsTimeout: true, @@ -93,7 +105,9 @@ func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Dura result, err := RunWithTimeout(vm, script, timeout) // Clear any interrupt state so VM can be reused - vm.ClearInterrupt() + if vm != nil { + vm.ClearInterrupt() + } return result, err } diff --git a/go_backend/songlink.go b/go_backend/songlink.go index e464614..d7ba569 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -164,9 +164,9 @@ func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPl } var resolveResp struct { - Success bool `json:"success"` - ISRC string `json:"isrc"` - SongUrls map[string]string `json:"songUrls"` + Success bool `json:"success"` + ISRC string `json:"isrc"` + SongUrls map[string]json.RawMessage `json:"songUrls"` } if err := json.Unmarshal(body, &resolveResp); err != nil { 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) for resolveKey, platformKey := range keyMap { - if u, ok := resolveResp.SongUrls[resolveKey]; ok && strings.TrimSpace(u) != "" { - links[platformKey] = songLinkPlatformLink{URL: strings.TrimSpace(u)} + rawValue, ok := resolveResp.SongUrls[resolveKey] + 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 } +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). func (s *SongLinkClient) songLinkByTargetURL(targetURL string) (map[string]songLinkPlatformLink, error) { songLinkRateLimiter.WaitForSlot() diff --git a/go_backend/songlink_test.go b/go_backend/songlink_test.go index 2bbe8a8..404a47c 100644 --- a/go_backend/songlink_test.go +++ b/go_backend/songlink_test.go @@ -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) { origRetryConfig := songLinkRetryConfig songLinkRetryConfig = func() RetryConfig { diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index d810513..fa5337e 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -757,7 +757,8 @@ class _HomeTabState extends ConsumerState trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl, - recommendedService: trackState.searchSource, + recommendedService: + trackState.searchExtensionId ?? trackState.searchSource, onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier)