From d725e11abc4f004830e6a8e5017f4ce6c1e4f0e5 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:44:54 +0700 Subject: [PATCH] fix(download): preserve audio quality across provider fallback --- docs/EXTENSION_DEVELOPMENT.md | 18 +++ go_backend/extension_download_quality.go | 109 ++++++++++++++ go_backend/extension_download_quality_test.go | 134 ++++++++++++++++++ go_backend/extension_fallback.go | 39 ++--- go_backend/extension_manifest.go | 12 ++ 5 files changed, 288 insertions(+), 24 deletions(-) create mode 100644 go_backend/extension_download_quality.go create mode 100644 go_backend/extension_download_quality_test.go diff --git a/docs/EXTENSION_DEVELOPMENT.md b/docs/EXTENSION_DEVELOPMENT.md index 1d19cfce..bca5ad59 100644 --- a/docs/EXTENSION_DEVELOPMENT.md +++ b/docs/EXTENSION_DEVELOPMENT.md @@ -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 diff --git a/go_backend/extension_download_quality.go b/go_backend/extension_download_quality.go new file mode 100644 index 00000000..ab79d0ad --- /dev/null +++ b/go_backend/extension_download_quality.go @@ -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 +} diff --git a/go_backend/extension_download_quality_test.go b/go_backend/extension_download_quality_test.go new file mode 100644 index 00000000..ed34d402 --- /dev/null +++ b/go_backend/extension_download_quality_test.go @@ -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) + } +} diff --git a/go_backend/extension_fallback.go b/go_backend/extension_fallback.go index 7a7eeafe..87f27962 100644 --- a/go_backend/extension_fallback.go +++ b/go_backend/extension_fallback.go @@ -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 } diff --git a/go_backend/extension_manifest.go b/go_backend/extension_manifest.go index ebdc95f4..6f2d7d1e 100644 --- a/go_backend/extension_manifest.go +++ b/go_backend/extension_manifest.go @@ -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{