mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-03-31 00:39:24 +02:00
fix: improve extension download reliability and Qobuz API integration
- Add dedicated long-timeout download client (24h) for extension file downloads, preventing timeouts on large lossless audio files - Skip unnecessary SongLink Deezer prelookup when an extension download provider handles the track, reducing latency and avoiding spurious API failures - Prefer native track ID over Spotify ID when a source/provider is set, ensuring extension providers receive their own IDs correctly - Update Qobuz MusicDL API endpoint and switch payload URL to open.qobuz.com - Extract buildQobuzMusicDLPayload helper and add test coverage
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(`
|
||||
<button data-itemtype="album" data-itemId="0886446451985"></button>
|
||||
|
||||
@@ -3594,6 +3594,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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<DownloadQueueState> {
|
||||
}
|
||||
|
||||
// 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<DownloadQueueState> {
|
||||
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<DownloadQueueState> {
|
||||
|
||||
Map<String, dynamic> result;
|
||||
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final hasActiveExtensions = extensionState.extensions.any(
|
||||
(e) => e.enabled,
|
||||
);
|
||||
|
||||
@@ -958,9 +958,16 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
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<TrackState> {
|
||||
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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user