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,
);