fix(download): preserve audio quality across provider fallback

This commit is contained in:
zarzet
2026-09-06 14:44:54 +07:00
parent 34dd2f0ae9
commit d725e11abc
5 changed files with 288 additions and 24 deletions
+18
View File
@@ -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
+109
View File
@@ -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)
}
}
+15 -24
View File
@@ -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
}
+12
View File
@@ -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{