mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-15 06:15:28 +02:00
fix(download): preserve audio quality across provider fallback
This commit is contained in:
@@ -130,6 +130,24 @@ modes are returned to the extension without automatic replay.
|
||||
Do not use legacy spellings such as `display_name`, `types`,
|
||||
`permissions.network.domains`, or an object for `permissions.network`.
|
||||
|
||||
### Download quality across providers
|
||||
|
||||
Quality IDs belong to the provider that declares them. Add an optional `kind`
|
||||
to each `qualityOptions` entry: `lossless`, `lossy`, or `spatial`.
|
||||
|
||||
```json
|
||||
{"id": "best", "label": "Best FLAC", "kind": "lossless"}
|
||||
```
|
||||
|
||||
On fallback, the host preserves a compatible quality ID or selects the first
|
||||
option of the same kind. A lossless request cannot select Atmos or a lossy
|
||||
tier just because it appears first. Spatial and lossy requests can use
|
||||
lossless when the target has no option of the requested kind. Providers with
|
||||
no compatible option are skipped. The host infers kinds from legacy IDs and
|
||||
labels, with `downloadFallbackTier` helping classify `best` and `default`;
|
||||
explicit kinds avoid ambiguity for custom IDs. Descriptions are not used
|
||||
because they may describe other fallback formats.
|
||||
|
||||
### Permissions
|
||||
|
||||
```json
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Quality IDs belong to their declaring provider. Keep the requested audio
|
||||
// kind when translating an ID instead of treating the first option as best.
|
||||
func extensionQualityKind(option QualityOption, manifest *ExtensionManifest) string {
|
||||
if kind := strings.ToLower(strings.TrimSpace(option.Kind)); kind != "" {
|
||||
switch kind {
|
||||
case "lossless", "lossy", "spatial":
|
||||
return kind
|
||||
}
|
||||
}
|
||||
// Compatibility for installed packages that predate the kind declaration.
|
||||
// Use only the ID and label: descriptions may mention fallback formats.
|
||||
token := strings.ToLower(strings.TrimSpace(option.ID))
|
||||
label := strings.ToLower(option.Label)
|
||||
text := token + " " + label
|
||||
if strings.Contains(text, "atmos") || strings.Contains(text, "dolby") ||
|
||||
strings.Contains(text, "surround") || token == "ac4" || token == "ac-4" ||
|
||||
token == "eac3" || token == "e-ac-3" || token == "ec-3" {
|
||||
return "spatial"
|
||||
}
|
||||
if strings.Contains(text, "lossless") || strings.Contains(text, "flac") ||
|
||||
strings.Contains(text, "alac") || strings.Contains(text, "24-bit") ||
|
||||
strings.Contains(text, "16-bit") || token == "hi_res" {
|
||||
return "lossless"
|
||||
}
|
||||
if token == "high" || token == "low" || strings.Contains(text, "mp3") ||
|
||||
strings.Contains(text, "aac") || strings.Contains(text, "opus") ||
|
||||
strings.Contains(text, "vorbis") {
|
||||
return "lossy"
|
||||
}
|
||||
if token == "best" || token == "default" || token == "" {
|
||||
if manifest != nil {
|
||||
switch strings.ToLower(strings.TrimSpace(fmt.Sprint(manifest.Capabilities["downloadFallbackTier"]))) {
|
||||
case "hi_res", "lossless":
|
||||
return "lossless"
|
||||
case "low_res":
|
||||
return "lossy"
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findExtensionQuality(manifest *ExtensionManifest, requested string) (QualityOption, bool) {
|
||||
if manifest != nil && requested != "" {
|
||||
for _, option := range manifest.QualityOptions {
|
||||
if strings.EqualFold(strings.TrimSpace(option.ID), requested) {
|
||||
return option, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return QualityOption{}, false
|
||||
}
|
||||
|
||||
func resolveExtensionDownloadQuality(requested string, source, target *ExtensionManifest) (string, error) {
|
||||
requested = strings.TrimSpace(requested)
|
||||
if target == nil || len(target.QualityOptions) == 0 {
|
||||
return requested, nil // Legacy providers without a quality declaration.
|
||||
}
|
||||
option, sourceRecognizes := findExtensionQuality(source, requested)
|
||||
if !sourceRecognizes {
|
||||
option = QualityOption{ID: requested}
|
||||
}
|
||||
kind := extensionQualityKind(option, source)
|
||||
if exact, ok := findExtensionQuality(target, requested); ok {
|
||||
targetKind := extensionQualityKind(exact, target)
|
||||
if source == target || (kind != "" && kind == targetKind) ||
|
||||
(kind == "" && targetKind != "spatial") {
|
||||
return strings.TrimSpace(exact.ID), nil
|
||||
}
|
||||
}
|
||||
if kind == "" {
|
||||
// An unknown foreign/default token must never opt into spatial audio.
|
||||
kind = "lossless"
|
||||
}
|
||||
allowedKinds := []string{kind}
|
||||
if kind == "spatial" || kind == "lossy" {
|
||||
allowedKinds = append(allowedKinds, "lossless")
|
||||
}
|
||||
for _, allowed := range allowedKinds {
|
||||
for _, candidate := range target.QualityOptions {
|
||||
id := strings.TrimSpace(candidate.ID)
|
||||
if id != "" && extensionQualityKind(candidate, target) == allowed {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("provider %s has no compatible %s quality for %q", target.Name, kind, requested)
|
||||
}
|
||||
|
||||
func requestedQualityManifest(req DownloadRequest, manager *extensionManager) *ExtensionManifest {
|
||||
if manager == nil {
|
||||
return nil
|
||||
}
|
||||
for _, id := range []string{req.Service, req.Source} {
|
||||
if ext, err := manager.GetExtension(strings.TrimSpace(id)); err == nil && ext.Manifest != nil {
|
||||
if _, recognized := findExtensionQuality(ext.Manifest, strings.TrimSpace(req.Quality)); recognized {
|
||||
return ext.Manifest
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func qualityTestManifest(name string, options ...QualityOption) *ExtensionManifest {
|
||||
return &ExtensionManifest{Name: name, QualityOptions: options}
|
||||
}
|
||||
|
||||
func TestExtensionQualityKeepsAudioKindAcrossProviders(t *testing.T) {
|
||||
amazon := qualityTestManifest("source", QualityOption{ID: "best", Label: "FLAC Best Available"}, QualityOption{ID: "ac4", Label: "Dolby Atmos"})
|
||||
tidal := qualityTestManifest("target",
|
||||
QualityOption{ID: "DOLBY_ATMOS", Label: "Dolby Atmos", Description: "falls back to FLAC"},
|
||||
QualityOption{ID: "HI_RES_LOSSLESS", Label: "HiRes FLAC"},
|
||||
QualityOption{ID: "LOSSLESS", Label: "Lossless"},
|
||||
QualityOption{ID: "HIGH", Label: "High"},
|
||||
)
|
||||
for _, tc := range []struct{ requested, want string }{
|
||||
{"best", "HI_RES_LOSSLESS"}, {"DEFAULT", "HI_RES_LOSSLESS"}, {"", "HI_RES_LOSSLESS"},
|
||||
{"LOSSLESS", "LOSSLESS"}, {"lossless", "LOSSLESS"}, {"HI_RES_LOSSLESS", "HI_RES_LOSSLESS"},
|
||||
{"ac4", "DOLBY_ATMOS"}, {"DOLBY_ATMOS", "DOLBY_ATMOS"}, {"HIGH", "HIGH"},
|
||||
} {
|
||||
t.Run(tc.requested, func(t *testing.T) {
|
||||
got, err := resolveExtensionDownloadQuality(tc.requested, amazon, tidal)
|
||||
if err != nil || got != tc.want {
|
||||
t.Fatalf("quality=%q err=%v; want %q", got, err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if got, err := resolveExtensionDownloadQuality("best", amazon, amazon); err != nil || got != "best" {
|
||||
t.Fatalf("same provider selection changed: %s %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionQualityUsesDeclarationsAndRejectsLosslessDowngrade(t *testing.T) {
|
||||
source := qualityTestManifest("source", QualityOption{ID: "studio", Kind: "lossless"})
|
||||
target := qualityTestManifest("target", QualityOption{ID: "studio", Kind: "spatial"}, QualityOption{ID: "pcm", Kind: "lossless"})
|
||||
if got, err := resolveExtensionDownloadQuality("studio", source, target); err != nil || got != "pcm" {
|
||||
t.Fatalf("foreign ID collision: %s %v", got, err)
|
||||
}
|
||||
for _, target := range []*ExtensionManifest{
|
||||
qualityTestManifest("spatial-only", QualityOption{ID: "DOLBY_ATMOS"}),
|
||||
qualityTestManifest("lossy-only", QualityOption{ID: "mp3_128"}),
|
||||
qualityTestManifest("unknown", QualityOption{ID: "custom"}),
|
||||
} {
|
||||
if _, err := resolveExtensionDownloadQuality("studio", source, target); err == nil {
|
||||
t.Fatalf("accepted incompatible provider %s", target.Name)
|
||||
}
|
||||
}
|
||||
legacy := qualityTestManifest("legacy")
|
||||
if got, err := resolveExtensionDownloadQuality("custom", nil, legacy); err != nil || got != "custom" {
|
||||
t.Fatalf("legacy: %s %v", got, err)
|
||||
}
|
||||
lossless := qualityTestManifest("flac", QualityOption{ID: "flac"})
|
||||
if got, err := resolveExtensionDownloadQuality("DOLBY_ATMOS", nil, lossless); err != nil || got != "flac" {
|
||||
t.Fatalf("explicit Atmos FLAC fallback: %s %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionQualityBestUsesProviderKind(t *testing.T) {
|
||||
source := qualityTestManifest("lossy-source", QualityOption{ID: "best", Label: "Best Audio"})
|
||||
source.Capabilities = map[string]any{"downloadFallbackTier": "low_res"}
|
||||
target := qualityTestManifest("target", QualityOption{ID: "DOLBY_ATMOS"}, QualityOption{ID: "LOSSLESS"}, QualityOption{ID: "HIGH"})
|
||||
if got, err := resolveExtensionDownloadQuality("best", source, target); err != nil || got != "HIGH" {
|
||||
t.Fatalf("lossy best: %s %v", got, err)
|
||||
}
|
||||
if kind := extensionQualityKind(QualityOption{ID: "DOLBY_ATMOS", Description: "best available FLAC fallback"}, target); kind != "spatial" {
|
||||
t.Fatal(kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadFallbackAndVerificationResumePreserveLosslessQuality(t *testing.T) {
|
||||
source := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||
source.ID, source.Manifest.Name = "quality-source", "quality-source"
|
||||
source.Manifest.QualityOptions = []QualityOption{{ID: "best", Label: "FLAC Best Available"}}
|
||||
target := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||
target.ID, target.Manifest.Name = "quality-target", "quality-target"
|
||||
target.Manifest.QualityOptions = []QualityOption{{ID: "DOLBY_ATMOS"}, {ID: "HI_RES_LOSSLESS"}, {ID: "LOSSLESS"}}
|
||||
for ext, script := range map[*loadedExtension]string{
|
||||
source: `registerExtension({checkAvailability:function(){return {available:false};},download:function(){return {success:false,error_type:"not_found",error_message:"source unavailable"};}});`,
|
||||
target: `registerExtension({checkAvailability:function(){return {available:true,track_id:"target-track"};},download:function(id,quality){return {success:false,error_type:"not_found",error_message:"observed-quality:"+quality};}});`,
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(ext.SourceDir, "index.js"), []byte(script), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
manager := getExtensionManager()
|
||||
manager.mu.Lock()
|
||||
previous := manager.extensions
|
||||
manager.extensions = map[string]*loadedExtension{source.ID: source, target.ID: target}
|
||||
manager.mu.Unlock()
|
||||
priority, fallback := GetProviderPriority(), GetExtensionFallbackProviderIDs()
|
||||
SetProviderPriority([]string{source.ID, target.ID})
|
||||
SetExtensionFallbackProviderIDs(nil)
|
||||
t.Cleanup(func() {
|
||||
teardownExtension(source)
|
||||
teardownExtension(target)
|
||||
manager.mu.Lock()
|
||||
manager.extensions = previous
|
||||
manager.mu.Unlock()
|
||||
SetProviderPriority(priority)
|
||||
SetExtensionFallbackProviderIDs(fallback)
|
||||
resetPreparedDownloadRequestCacheForTest()
|
||||
})
|
||||
for _, mode := range []string{"fallback", "direct-source", "verification-resume"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
req := DownloadRequest{Service: source.ID, ItemID: "quality-" + mode, TrackName: "Song", ArtistName: "Artist", AlbumName: "Album", ISRC: "USRC17607839", ReleaseDate: "2026-01-01", OutputDir: t.TempDir(), FilenameFormat: "{title}", Quality: "best", UseFallback: true}
|
||||
if mode == "direct-source" {
|
||||
req.Source = source.ID
|
||||
}
|
||||
if mode == "verification-resume" {
|
||||
cacheUnpreparedDownloadRequest(downloadPreparationKey(req), req)
|
||||
}
|
||||
response, err := DownloadWithExtensionFallback(req)
|
||||
if err != nil || response == nil || !strings.Contains(response.Error, "observed-quality:HI_RES_LOSSLESS") {
|
||||
t.Fatalf("response=%+v err=%v", response, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
// A direct/verified invocation uses the same translation, without relying
|
||||
// on the normal provider-loop branch to repair the foreign token.
|
||||
req := DownloadRequest{Service: source.ID, Quality: "best", OutputDir: t.TempDir(), TrackName: "Direct"}
|
||||
var lastErr error
|
||||
var errorType string
|
||||
var retryAfter int
|
||||
attemptExtensionDownload(req, target, newExtensionProviderWrapper(target), "target-track", req.Quality, target.ID, nil, false, &lastErr, &errorType, &retryAfter)
|
||||
if lastErr == nil || !strings.Contains(lastErr.Error(), "observed-quality:HI_RES_LOSSLESS") {
|
||||
t.Fatal(lastErr)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,20 @@ func attemptExtensionDownload(
|
||||
lastErrType *string,
|
||||
lastRetryAfterSeconds *int,
|
||||
) (resp *DownloadResponse, cancelledOuter bool) {
|
||||
resolvedQuality, qualityErr := resolveExtensionDownloadQuality(
|
||||
quality, requestedQualityManifest(req, getExtensionManager()), ext.Manifest,
|
||||
)
|
||||
if qualityErr != nil {
|
||||
*lastErr = qualityErr
|
||||
*lastErrType = "quality_unavailable"
|
||||
*lastRetryAfterSeconds = 0
|
||||
return nil, false
|
||||
}
|
||||
if resolvedQuality != quality {
|
||||
GoLog("[DownloadWithExtensionFallback] Provider %s maps requested quality %q to %q\n", providerLabel, quality, resolvedQuality)
|
||||
}
|
||||
quality = resolvedQuality
|
||||
req.Quality = resolvedQuality
|
||||
req.DownloadProvider = strings.TrimSpace(providerLabel)
|
||||
req.ProviderTrackID = strings.TrimSpace(trackID)
|
||||
preparedContext = extensionPreparedDownloadContext(req, preparedContext)
|
||||
@@ -700,30 +714,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
|
||||
req.OutputExt = ""
|
||||
|
||||
// Honor the requested quality when this provider recognizes it
|
||||
// (e.g. an explicit user selection). Only when the token is not
|
||||
// one of this provider's own options do we fall back to its
|
||||
// highest quality, since a source provider's token may not map.
|
||||
fallbackQuality := req.Quality
|
||||
if len(ext.Manifest.QualityOptions) > 0 {
|
||||
requested := strings.TrimSpace(req.Quality)
|
||||
recognized := false
|
||||
if requested != "" {
|
||||
for _, opt := range ext.Manifest.QualityOptions {
|
||||
if strings.EqualFold(strings.TrimSpace(opt.ID), requested) {
|
||||
recognized = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !recognized {
|
||||
if best := strings.TrimSpace(ext.Manifest.QualityOptions[0].ID); best != "" {
|
||||
fallbackQuality = best
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp, cancelledOuter := attemptExtensionDownload(req, ext, provider, availability.TrackID, fallbackQuality, providerID, availability.PreparedContext, false, &lastErr, &lastErrType, &lastRetryAfterSeconds)
|
||||
resp, cancelledOuter := attemptExtensionDownload(req, ext, provider, availability.TrackID, req.Quality, providerID, availability.PreparedContext, false, &lastErr, &lastErrType, &lastRetryAfterSeconds)
|
||||
if cancelledOuter {
|
||||
return nil, ErrDownloadCancelled
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ type ExtensionSetting struct {
|
||||
|
||||
type QualityOption struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Settings []QualitySpecificSetting `json:"settings,omitempty"`
|
||||
@@ -246,6 +247,17 @@ func (m *ExtensionManifest) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
for i, quality := range m.QualityOptions {
|
||||
switch quality.Kind {
|
||||
case "", "lossless", "lossy", "spatial":
|
||||
default:
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("qualityOptions[%d].kind", i),
|
||||
Message: "quality kind must be lossless, lossy, or spatial",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, check := range m.ServiceHealth {
|
||||
if strings.TrimSpace(check.ID) == "" {
|
||||
return &ManifestValidationError{
|
||||
|
||||
Reference in New Issue
Block a user