From bede5ae8d751ca48ae895916a69bca6d04583e4a Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 28 Jun 2026 22:16:36 +0700 Subject: [PATCH] feat(go): verification early-abort in fallback + album metadata from tracks - DownloadWithExtensionFallback now immediately surfaces verification_required when any provider needs verification (availability + download stages), instead of letting later providers mask it - classifyDownloadErrorType treats 'session is not authenticated' as verification_required (Go + Dart side) - parseExtensionAlbumValue.withTrackFallbacks() derives album artist, release date, and audio traits from tracks when album-level missing - albumAudioTraitsFromTracks detects dolby_atmos/hi_res_lossless/lossless from per-track audio_quality/audio_modes fields - parseBitDepthSampleRate parses '24bit/96kHz' style quality labels --- go_backend/exports.go | 2 + go_backend/extension_providers.go | 161 ++++++++++++++++++++++++- lib/utils/extension_auth_launcher.dart | 1 + 3 files changed, 163 insertions(+), 1 deletion(-) diff --git a/go_backend/exports.go b/go_backend/exports.go index e60a6688..558ce01b 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -2523,6 +2523,8 @@ func classifyDownloadErrorType(msg string) string { strings.Contains(lowerMsg, "verification_required") || strings.Contains(lowerMsg, "verification required") || strings.Contains(lowerMsg, "needs verification") || + strings.Contains(lowerMsg, "session is not authenticated") || + strings.Contains(lowerMsg, "signed session is not authenticated") || strings.Contains(lowerMsg, "unauthorized") || strings.Contains(lowerMsg, "precondition required") || messageHasHTTPStatusCode(lowerMsg, "401") || diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 201b7fd4..76440842 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -873,7 +873,139 @@ func parseExtensionAlbumValue(vm *goja.Runtime, value goja.Value) (ExtAlbumMetad AudioTraits: gojaObjectStringSlice(obj, "audio_traits", "audioTraits"), Tracks: tracks, ProviderID: gojaObjectString(obj, "provider_id", "providerId"), - }, nil + }.withTrackFallbacks(), nil +} + +// withTrackFallbacks fills the album-level artist and release date from the +// album's tracks when the extension did not provide them at the album level. +// This is a generic mechanism so any extension benefits, without per-extension +// special-casing in the app. +func (a ExtAlbumMetadata) withTrackFallbacks() ExtAlbumMetadata { + if strings.TrimSpace(a.Artists) == "" { + a.Artists = albumArtistFromTracks(a.Tracks) + } + if strings.TrimSpace(a.ReleaseDate) == "" { + a.ReleaseDate = albumReleaseDateFromTracks(a.Tracks) + } + if len(a.AudioTraits) == 0 { + a.AudioTraits = albumAudioTraitsFromTracks(a.Tracks) + } + return a +} + +// albumArtistFromTracks prefers an explicit per-track album artist, then falls +// back to the most common track artist across the album. +func albumArtistFromTracks(tracks []ExtTrackMetadata) string { + for _, t := range tracks { + if s := strings.TrimSpace(t.AlbumArtist); s != "" { + return s + } + } + counts := map[string]int{} + order := []string{} + for _, t := range tracks { + artist := strings.TrimSpace(t.Artists) + if artist == "" { + continue + } + if _, ok := counts[artist]; !ok { + order = append(order, artist) + } + counts[artist]++ + } + best := "" + bestCount := 0 + for _, artist := range order { + if counts[artist] > bestCount { + best = artist + bestCount = counts[artist] + } + } + return best +} + +// albumReleaseDateFromTracks returns the first non-empty track release date. +func albumReleaseDateFromTracks(tracks []ExtTrackMetadata) string { + for _, t := range tracks { + if s := strings.TrimSpace(t.ReleaseDate); s != "" { + return s + } + } + return "" +} + +// albumAudioTraitsFromTracks derives album-level audio badges (Dolby Atmos, +// Hi-Res Lossless, Lossless) from the per-track audio quality/mode fields that +// extensions like Tidal and Qobuz already provide. Tokens match what the album +// header understands ("dolby_atmos", "hi_res_lossless", "lossless"). +func albumAudioTraitsFromTracks(tracks []ExtTrackMetadata) []string { + atmos := false + hiRes := false + lossless := false + + for _, t := range tracks { + modes := strings.ToUpper(t.AudioModes) + quality := strings.ToUpper(t.AudioQuality) + if strings.Contains(modes, "ATMOS") || strings.Contains(quality, "ATMOS") { + atmos = true + } + if strings.Contains(quality, "HI_RES") || + strings.Contains(quality, "HIRES") || + strings.Contains(quality, "MASTER") || + strings.Contains(quality, "MQA") { + hiRes = true + } + if strings.Contains(quality, "LOSSLESS") || + strings.Contains(quality, "FLAC") { + lossless = true + } + if bd, sr := parseBitDepthSampleRate(quality); bd > 0 { + if bd > 16 || sr > 48 { + hiRes = true + } else { + lossless = true + } + } + } + + traits := []string{} + if atmos { + traits = append(traits, "dolby_atmos") + } + if hiRes { + traits = append(traits, "hi_res_lossless") + } else if lossless { + traits = append(traits, "lossless") + } + return traits +} + +// parseBitDepthSampleRate extracts a bit depth and sample rate (in kHz) from +// labels such as "24bit/96kHz", "16bit/44.1kHz" or "24bit". +func parseBitDepthSampleRate(quality string) (int, float64) { + lower := strings.ToLower(quality) + bitDepth := 0 + sampleRate := 0.0 + + if idx := strings.Index(lower, "bit"); idx > 0 { + j := idx + for j > 0 && lower[j-1] >= '0' && lower[j-1] <= '9' { + j-- + } + if n, err := strconv.Atoi(lower[j:idx]); err == nil { + bitDepth = n + } + } + if idx := strings.Index(lower, "khz"); idx > 0 { + j := idx + for j > 0 && ((lower[j-1] >= '0' && lower[j-1] <= '9') || lower[j-1] == '.') { + j-- + } + if f, err := strconv.ParseFloat(lower[j:idx], 64); err == nil { + sampleRate = f + } + } + return bitDepth, sampleRate } func parseExtensionAlbumArray(vm *goja.Runtime, value goja.Value) ([]ExtAlbumMetadata, error) { @@ -2586,6 +2718,15 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID) if err != nil { lastErr = err + if strings.EqualFold(classifyDownloadErrorType(err.Error()), "verification_required") { + GoLog("[DownloadWithExtensionFallback] %s requires verification (availability); pausing fallback to open the challenge\n", providerID) + return &DownloadResponse{ + Success: false, + Error: "Download failed: " + err.Error(), + ErrorType: "verification_required", + Service: providerID, + }, nil + } } if terminalAvailability { GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after availability check\n", providerID) @@ -2707,6 +2848,24 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro lastRetryAfterSeconds = result.RetryAfterSeconds } GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, lastErr) + + if lastErr != nil { + effType := lastErrType + if effType == "" { + effType = classifyDownloadErrorType(lastErr.Error()) + } + if strings.EqualFold(effType, "verification_required") { + GoLog("[DownloadWithExtensionFallback] %s requires verification; pausing fallback to open the challenge\n", providerID) + return &DownloadResponse{ + Success: false, + Error: "Download failed: " + lastErr.Error(), + ErrorType: "verification_required", + RetryAfterSeconds: lastRetryAfterSeconds, + Service: providerID, + }, nil + } + } + if terminalAvailability { GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after download failure\n", providerID) return buildExtensionFallbackStoppedResponse(providerID, availability, lastErr), nil diff --git a/lib/utils/extension_auth_launcher.dart b/lib/utils/extension_auth_launcher.dart index 58a46288..ca737406 100644 --- a/lib/utils/extension_auth_launcher.dart +++ b/lib/utils/extension_auth_launcher.dart @@ -11,6 +11,7 @@ bool isExtensionVerificationRequired(Object error) { message.contains('verification required') || message.contains('needsverification') || message.contains('needs verification') || + message.contains('session is not authenticated') || message.contains('unauthorized') || message.contains('precondition required') || _containsHttpStatusCode(message, '401') ||