diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index c45b52c..58068a4 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -81,13 +81,14 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex } type ExtensionRuntime struct { - extensionID string - manifest *ExtensionManifest - settings map[string]interface{} - httpClient *http.Client - cookieJar http.CookieJar - dataDir string - vm *goja.Runtime + extensionID string + manifest *ExtensionManifest + settings map[string]interface{} + httpClient *http.Client + downloadClient *http.Client + cookieJar http.CookieJar + dataDir string + vm *goja.Runtime storageMu sync.RWMutex storageCache map[string]interface{} @@ -132,13 +133,20 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { storageFlushDelay: defaultStorageFlushDelay, } + runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second) + runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout) + + return runtime +} + +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. // spotify-web) will redirect http -> https and can end up in 301 loops. // We still reuse sharedTransport so insecure TLS compatibility mode remains effective. client := &http.Client{ Transport: sharedTransport, - Timeout: 30 * time.Second, + Timeout: timeout, Jar: jar, } client.CheckRedirect = func(req *http.Request, via []*http.Request) error { @@ -165,9 +173,7 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { } return nil } - runtime.httpClient = client - - return runtime + return client } type RedirectBlockedError struct { diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go index 414f174..92312c9 100644 --- a/go_backend/extension_runtime_file.go +++ b/go_backend/extension_runtime_file.go @@ -174,7 +174,12 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0") } - resp, err := r.httpClient.Do(req) + client := r.downloadClient + if client == nil { + client = r.httpClient + } + + resp, err := client.Do(req) if err != nil { return r.vm.ToValue(map[string]interface{}{ "success": false, diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index e354f47..8a87cd2 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -49,9 +49,10 @@ const ( qobuzArtistGetBaseURL = "https://www.qobuz.com/api.json/0.2/artist/get?artist_id=" qobuzPlaylistGetBaseURL = "https://www.qobuz.com/api.json/0.2/playlist/get?playlist_id=" qobuzStoreSearchBaseURL = "https://www.qobuz.com/us-en/search/tracks/" + qobuzTrackOpenBaseURL = "https://open.qobuz.com/track/" qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/" qobuzStoreBaseURL = "https://www.qobuz.com/us-en" - qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download" + qobuzDownloadAPIURL = "https://dl.musicdl.me/qobuz/download" qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId=" qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId=" qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/" @@ -1631,19 +1632,23 @@ func fetchQobuzURLWithRetry(provider qobuzAPIProvider, trackID int64, quality st return fetchQobuzURLSingleAttempt(provider, trackID, quality, timeout, "") } +func buildQobuzMusicDLPayload(trackID int64, quality string) ([]byte, error) { + requestQuality := mapQobuzQualityCodeToAPI(quality) + payload := map[string]any{ + "quality": requestQuality, + "upload_to_r2": false, + "url": fmt.Sprintf("%s%d", qobuzTrackOpenBaseURL, trackID), + } + return json.Marshal(payload) +} + func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration, country string) (qobuzDownloadInfo, error) { var lastErr error retryDelay := qobuzRetryDelay var payloadBytes []byte if provider.Kind == qobuzAPIKindMusicDL { - requestQuality := mapQobuzQualityCodeToAPI(quality) - payload := map[string]any{ - "quality": requestQuality, - "upload_to_r2": false, - "url": fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, trackID), - } var err error - payloadBytes, err = json.Marshal(payload) + payloadBytes, err = buildQobuzMusicDLPayload(trackID, quality) if err != nil { return qobuzDownloadInfo{}, fmt.Errorf("failed to encode qobuz request: %w", err) } @@ -1688,7 +1693,6 @@ func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, qualit } if provider.Kind == qobuzAPIKindMusicDL { req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Debug-Key", getQobuzDebugKey()) } resp, err := DoRequestWithUserAgent(client, req) diff --git a/go_backend/qobuz_test.go b/go_backend/qobuz_test.go index 9d15d31..df3b84a 100644 --- a/go_backend/qobuz_test.go +++ b/go_backend/qobuz_test.go @@ -1,6 +1,9 @@ package gobackend -import "testing" +import ( + "encoding/json" + "testing" +) func TestParseQobuzURL(t *testing.T) { tests := []struct { @@ -195,6 +198,28 @@ func TestGetQobuzDebugKey(t *testing.T) { } } +func TestBuildQobuzMusicDLPayloadUsesOpenTrackURL(t *testing.T) { + payloadBytes, err := buildQobuzMusicDLPayload(374610875, "7") + if err != nil { + t.Fatalf("buildQobuzMusicDLPayload returned error: %v", err) + } + + var payload map[string]any + if err := json.Unmarshal(payloadBytes, &payload); err != nil { + t.Fatalf("payload is not valid JSON: %v", err) + } + + if got := payload["url"]; got != "https://open.qobuz.com/track/374610875" { + t.Fatalf("payload url = %v, want open.qobuz.com track URL", got) + } + if got := payload["quality"]; got != "hi-res" { + t.Fatalf("payload quality = %v, want hi-res", got) + } + if got := payload["upload_to_r2"]; got != false { + t.Fatalf("payload upload_to_r2 = %v, want false", got) + } +} + func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) { body := []byte(` diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index bbac0b1..8901c64 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -3594,6 +3594,15 @@ class DownloadQueueNotifier extends Notifier { String? genre; String? label; String? copyright; + final extensionState = ref.read(extensionProvider); + final selectedExtensionDownloadProvider = + settings.useExtensionProviders && + extensionState.extensions.any( + (e) => + e.enabled && + e.hasDownloadProvider && + e.id.toLowerCase() == item.service.toLowerCase(), + ); String? deezerTrackId = trackToDownload.deezerId; if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) { @@ -3628,7 +3637,8 @@ class DownloadQueueNotifier extends Notifier { } // Fallback: Use SongLink to convert Spotify ID to Deezer ID - if (deezerTrackId == null && + if (!selectedExtensionDownloadProvider && + deezerTrackId == null && trackToDownload.id.isNotEmpty && !trackToDownload.id.startsWith('deezer:') && !trackToDownload.id.startsWith('extension:')) { @@ -3729,6 +3739,10 @@ class DownloadQueueNotifier extends Notifier { if (shouldAbortWork('during SongLink availability lookup')) { return; } + } else if (selectedExtensionDownloadProvider && deezerTrackId == null) { + _log.d( + 'Skipping Flutter SongLink Deezer prelookup for extension provider: ${item.service}', + ); } if (deezerTrackId != null && deezerTrackId.isNotEmpty) { @@ -3756,7 +3770,6 @@ class DownloadQueueNotifier extends Notifier { Map result; - final extensionState = ref.read(extensionProvider); final hasActiveExtensions = extensionState.extensions.any( (e) => e.enabled, ); diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index f0552ed..8947dba 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -958,9 +958,16 @@ class TrackNotifier extends Notifier { final durationMs = _extractDurationMs(data); final itemType = data['item_type']?.toString(); + final effectiveSource = + source ?? data['source']?.toString() ?? data['provider_id']?.toString(); + final spotifyId = (data['spotify_id'] ?? '').toString(); + final nativeId = (data['id'] ?? '').toString(); + final preferredId = effectiveSource != null && effectiveSource.isNotEmpty + ? (nativeId.isNotEmpty ? nativeId : spotifyId) + : (spotifyId.isNotEmpty ? spotifyId : nativeId); return Track( - id: (data['spotify_id'] ?? data['id'] ?? '').toString(), + id: preferredId, name: (data['name'] ?? '').toString(), artistName: (data['artists'] ?? data['artist'] ?? '').toString(), albumName: (data['album_name'] ?? data['album'] ?? '').toString(), @@ -974,10 +981,7 @@ class TrackNotifier extends Notifier { discNumber: data['disc_number'] as int?, releaseDate: data['release_date']?.toString(), totalTracks: data['total_tracks'] as int?, - source: - source ?? - data['source']?.toString() ?? - data['provider_id']?.toString(), + source: effectiveSource, albumType: data['album_type']?.toString(), itemType: itemType, );