From 9c054b9e3a65459376ee57a6c4c07d12607426bd Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 26 Jun 2026 07:12:12 +0700 Subject: [PATCH] feat: handle extension verification and retry-after on download Classify verification-required and rate-limit errors from extensions, propagate Retry-After seconds through the download fallback, report session-grant success/failure, and add a completeGrant action fallback in the runtime. --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 11 +- go_backend/exports.go | 21 ++- go_backend/exports_supplement_test.go | 13 ++ go_backend/extension_manager.go | 34 ++-- go_backend/extension_providers.go | 111 ++++++----- go_backend/extension_signed_session.go | 38 +++- lib/providers/download_queue_provider.dart | 175 +++++++++++++++++- lib/utils/extension_auth_launcher.dart | 32 +++- 8 files changed, 353 insertions(+), 82 deletions(-) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 341ce349..d3ce0957 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -2112,19 +2112,24 @@ class MainActivity: FlutterFragmentActivity() { android.util.Log.i("SpotiFLAC", "Extension callback complete for $extId: $json") if (isSessionGrant) { withContext(Dispatchers.Main) { - notifySessionGrantCompleted(extId) + notifySessionGrantCompleted(extId, true) } } } catch (e: Exception) { android.util.Log.w("SpotiFLAC", "Extension callback failed: ${e.message}") + if (isSessionGrant) { + withContext(Dispatchers.Main) { + notifySessionGrantCompleted(extId, false) + } + } } } } - private fun notifySessionGrantCompleted(extensionId: String) { + private fun notifySessionGrantCompleted(extensionId: String, success: Boolean) { val payload = mapOf( "extension_id" to extensionId, - "success" to true, + "success" to success, ) val channel = backendChannel if (channel == null) { diff --git a/go_backend/exports.go b/go_backend/exports.go index ea9038c5..da418acd 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -310,6 +310,7 @@ type DownloadResponse struct { FilePath string `json:"file_path,omitempty"` Error string `json:"error,omitempty"` ErrorType string `json:"error_type,omitempty"` + RetryAfterSeconds int `json:"retry_after_seconds,omitempty"` AlreadyExists bool `json:"already_exists,omitempty"` ActualBitDepth int `json:"actual_bit_depth,omitempty"` ActualSampleRate int `json:"actual_sample_rate,omitempty"` @@ -2512,8 +2513,17 @@ func classifyDownloadErrorType(msg string) string { return "isp_blocked" } else if strings.Contains(lowerMsg, "cancel") { return "cancelled" + } else if strings.Contains(lowerMsg, "verify_required") || + strings.Contains(lowerMsg, "verification_required") || + strings.Contains(lowerMsg, "verification required") || + strings.Contains(lowerMsg, "needs verification") || + strings.Contains(lowerMsg, "unauthorized") || + strings.Contains(lowerMsg, "precondition required") || + messageHasHTTPStatusCode(lowerMsg, "401") || + messageHasHTTPStatusCode(lowerMsg, "428") { + return "verification_required" } else if strings.Contains(lowerMsg, "rate limit") || - strings.Contains(lowerMsg, "429") || + messageHasHTTPStatusCode(lowerMsg, "429") || strings.Contains(lowerMsg, "too many requests") { return "rate_limit" } else if strings.Contains(lowerMsg, "permission") || @@ -2538,6 +2548,15 @@ func classifyDownloadErrorType(msg string) string { return "unknown" } +func messageHasHTTPStatusCode(lowerMsg, code string) bool { + return strings.Contains(lowerMsg, "http "+code) || + strings.Contains(lowerMsg, "http status "+code) || + strings.Contains(lowerMsg, "status "+code) || + strings.Contains(lowerMsg, code+" for ") || + strings.Contains(lowerMsg, code+":") || + strings.Contains(lowerMsg, code+";") +} + func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error { if coverURL == "" { return fmt.Errorf("no cover URL provided") diff --git a/go_backend/exports_supplement_test.go b/go_backend/exports_supplement_test.go index cfe13ea3..78ed8918 100644 --- a/go_backend/exports_supplement_test.go +++ b/go_backend/exports_supplement_test.go @@ -31,6 +31,19 @@ func TestDownloadErrorClassificationPrioritizesRateLimit(t *testing.T) { } } +func TestDownloadErrorClassificationDetectsVerificationRequired(t *testing.T) { + cases := []string{ + "HTTP 401 for /tickets", + "HTTP status 428: precondition required", + "Verification required", + } + for _, tc := range cases { + if got := classifyDownloadErrorType(tc); got != "verification_required" { + t.Fatalf("classifyDownloadErrorType(%q) = %q, want verification_required", tc, got) + } + } +} + func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) { dir := t.TempDir() dataDir := filepath.Join(dir, "data") diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index 86347fda..f8c678c5 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -1175,14 +1175,16 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) ( // Merge extension return values onto the top-level JSON object so Flutter can read // message, open_auth_url, setting_updates without unwrapping a nested "result" key. + actionNameLiteral := strconv.Quote(actionName) script := fmt.Sprintf(` - (function() { - if (typeof extension !== 'undefined' && typeof extension.%s === 'function') { - try { - var result = extension.%s(); - if (result && typeof result.then === 'function') { - return { success: true, pending: true, message: 'Action started' }; - } + (function() { + var actionName = %s; + function runAction(fn) { + try { + var result = fn(); + if (result && typeof result.then === 'function') { + return { success: true, pending: true, message: 'Action started' }; + } if (result !== null && result !== undefined && typeof result === 'object') { var isArr = false; if (typeof Array !== 'undefined' && Array.isArray) { @@ -1197,13 +1199,19 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) ( } } return { success: true, result: result }; - } catch (e) { - return { success: false, error: e.toString() }; + } catch (e) { + return { success: false, error: e.toString() }; + } } - } - return { success: false, error: 'Action function not found: %s' }; - })() - `, actionName, actionName, actionName) + if (typeof extension !== 'undefined' && extension && typeof extension[actionName] === 'function') { + return runAction(function() { return extension[actionName](); }); + } + if (actionName === 'completeGrant' && typeof session !== 'undefined' && session && typeof session.completeGrant === 'function') { + return runAction(function() { return session.completeGrant(); }); + } + return { success: false, error: 'Action function not found: ' + actionName }; + })() + `, actionNameLiteral) result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout) if err != nil { diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index b8842b89..100b443e 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -473,6 +473,18 @@ func shouldAbortCancelledFallback(itemID string, err error) bool { return itemID != "" && isDownloadCancelled(itemID) } +func normalizeExtensionDownloadErrorType(errorType, message string) string { + normalized := strings.TrimSpace(errorType) + classified := classifyDownloadErrorType(message) + if classified != "" && classified != "unknown" { + switch strings.ToLower(normalized) { + case "", "unknown", "runtime_error", "api_error", "download_error", "extension_error": + return classified + } + } + return normalized +} + type DownloadDecryptionInfo struct { Strategy string `json:"strategy,omitempty"` Key string `json:"key,omitempty"` @@ -483,14 +495,15 @@ type DownloadDecryptionInfo struct { } type ExtDownloadResult struct { - Success bool `json:"success"` - FilePath string `json:"file_path,omitempty"` - AlreadyExists bool `json:"already_exists,omitempty"` - BitDepth int `json:"bit_depth,omitempty"` - SampleRate int `json:"sample_rate,omitempty"` - AudioCodec string `json:"audio_codec,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` - ErrorType string `json:"error_type,omitempty"` + Success bool `json:"success"` + FilePath string `json:"file_path,omitempty"` + AlreadyExists bool `json:"already_exists,omitempty"` + BitDepth int `json:"bit_depth,omitempty"` + SampleRate int `json:"sample_rate,omitempty"` + AudioCodec string `json:"audio_codec,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + ErrorType string `json:"error_type,omitempty"` + RetryAfterSeconds int `json:"retry_after_seconds,omitempty"` Title string `json:"title,omitempty"` Artist string `json:"artist,omitempty"` @@ -942,35 +955,36 @@ func parseExtensionDownloadDecryptionValue(vm *goja.Runtime, value goja.Value) * func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDownloadResult { obj := value.ToObject(vm) return ExtDownloadResult{ - Success: gojaObjectBool(obj, "success"), - FilePath: gojaObjectString(obj, "file_path", "filePath", "path"), - AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"), - BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"), - SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"), - AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"), - ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"), - ErrorType: gojaObjectString(obj, "error_type", "errorType"), - Title: gojaObjectString(obj, "title"), - Artist: gojaObjectString(obj, "artist"), - Album: gojaObjectString(obj, "album"), - AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"), - TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"), - DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"), - TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"), - TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"), - ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"), - CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"), - ISRC: gojaObjectString(obj, "isrc"), - Genre: gojaObjectString(obj, "genre"), - Label: gojaObjectString(obj, "label"), - Copyright: gojaObjectString(obj, "copyright"), - Composer: gojaObjectString(obj, "composer"), - LyricsLRC: gojaObjectString(obj, "lyrics_lrc", "lyricsLrc"), - DecryptionKey: gojaObjectString(obj, "decryption_key", "decryptionKey"), - Decryption: parseExtensionDownloadDecryptionValue(vm, gojaObjectValue(obj, "decryption")), - ActualExtension: gojaObjectString(obj, "actual_extension", "actualExtension"), - OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"), - ActualContainer: gojaObjectString(obj, "actual_container", "actualContainer", "container"), + Success: gojaObjectBool(obj, "success"), + FilePath: gojaObjectString(obj, "file_path", "filePath", "path"), + AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"), + BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"), + SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"), + AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"), + ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"), + ErrorType: gojaObjectString(obj, "error_type", "errorType"), + RetryAfterSeconds: gojaObjectInt(obj, "retry_after_seconds", "retryAfterSeconds"), + Title: gojaObjectString(obj, "title"), + Artist: gojaObjectString(obj, "artist"), + Album: gojaObjectString(obj, "album"), + AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"), + TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"), + DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"), + TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"), + TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"), + ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"), + CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"), + ISRC: gojaObjectString(obj, "isrc"), + Genre: gojaObjectString(obj, "genre"), + Label: gojaObjectString(obj, "label"), + Copyright: gojaObjectString(obj, "copyright"), + Composer: gojaObjectString(obj, "composer"), + LyricsLRC: gojaObjectString(obj, "lyrics_lrc", "lyricsLrc"), + DecryptionKey: gojaObjectString(obj, "decryption_key", "decryptionKey"), + Decryption: parseExtensionDownloadDecryptionValue(vm, gojaObjectValue(obj, "decryption")), + ActualExtension: gojaObjectString(obj, "actual_extension", "actualExtension"), + OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"), + ActualContainer: gojaObjectString(obj, "actual_container", "actualContainer", "container"), RequiresContainerConversion: gojaObjectBool( obj, "requires_container_conversion", @@ -2136,6 +2150,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro var lastErr error var lastErrType string + var lastRetryAfterSeconds int var stopProviderFallback bool var sourceExtensionLocked bool var sourceExtensionAvailability *ExtAvailabilityResult @@ -2453,7 +2468,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro lastErrType = "" } else if result.ErrorMessage != "" { lastErr = fmt.Errorf("%s", result.ErrorMessage) - lastErrType = strings.TrimSpace(result.ErrorType) + lastErrType = normalizeExtensionDownloadErrorType(result.ErrorType, result.ErrorMessage) + lastRetryAfterSeconds = result.RetryAfterSeconds } GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr) @@ -2474,10 +2490,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } GoLog("[DownloadWithExtensionFallback] stopProviderFallback is true, not trying other providers\n") return &DownloadResponse{ - Success: false, - Error: "Download failed: " + lastErr.Error(), - ErrorType: firstNonEmptyString(lastErrType, "extension_error"), - Service: req.Source, + Success: false, + Error: "Download failed: " + lastErr.Error(), + ErrorType: firstNonEmptyString(lastErrType, "extension_error"), + RetryAfterSeconds: lastRetryAfterSeconds, + Service: req.Source, }, nil } } else { @@ -2648,7 +2665,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro lastErrType = "" } else if result.ErrorMessage != "" { lastErr = fmt.Errorf("%s", result.ErrorMessage) - lastErrType = strings.TrimSpace(result.ErrorType) + lastErrType = normalizeExtensionDownloadErrorType(result.ErrorType, result.ErrorMessage) + lastRetryAfterSeconds = result.RetryAfterSeconds } GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, lastErr) if terminalAvailability { @@ -2664,9 +2682,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro errorType = "not_found" } return &DownloadResponse{ - Success: false, - Error: "All providers failed. Last error: " + lastErr.Error(), - ErrorType: errorType, + Success: false, + Error: "All providers failed. Last error: " + lastErr.Error(), + ErrorType: errorType, + RetryAfterSeconds: lastRetryAfterSeconds, }, nil } diff --git a/go_backend/extension_signed_session.go b/go_backend/extension_signed_session.go index edad1914..2796f45d 100644 --- a/go_backend/extension_signed_session.go +++ b/go_backend/extension_signed_session.go @@ -14,6 +14,7 @@ import ( "net/url" "os" "path/filepath" + "strconv" "strings" "sync" "time" @@ -322,12 +323,13 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value } } return r.vm.ToValue(map[string]interface{}{ - "statusCode": resp.StatusCode, - "status": resp.StatusCode, - "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, - "url": resp.Request.URL.String(), - "body": string(respBody), - "headers": respHeaders, + "statusCode": resp.StatusCode, + "status": resp.StatusCode, + "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, + "url": resp.Request.URL.String(), + "body": string(respBody), + "headers": respHeaders, + "retryAfterSeconds": signedSessionRetryAfterSeconds(resp), }) } @@ -577,6 +579,30 @@ func (r *extensionRuntime) doSignedSessionRequest( return resp, respBody, headers, nil } +func signedSessionRetryAfterSeconds(resp *http.Response) int { + if resp == nil { + return 0 + } + value := strings.TrimSpace(resp.Header.Get("Retry-After")) + if value == "" { + return 0 + } + if seconds, err := strconv.Atoi(value); err == nil { + if seconds < 0 { + return 0 + } + return seconds + } + if retryAt, err := http.ParseTime(value); err == nil { + seconds := int(time.Until(retryAt).Seconds()) + if seconds < 0 { + return 0 + } + return seconds + } + return 0 +} + func hmacSHA256Bytes(key, message []byte) []byte { mac := hmac.New(sha256.New, key) mac.Write(message) diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 44058f77..be5f90bb 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1906,6 +1906,8 @@ class DownloadQueueNotifier extends Notifier { int _lastNotifQueueCount = -1; final Set _locallyCancelledItemIds = {}; final Set _pausePendingItemIds = {}; + final Set _verificationRetriedItemIds = {}; + final Set _rateLimitRetriedItemIds = {}; String? _activeNativeWorkerRunId; // Album ReplayGain accumulator: keyed by album identifier. @@ -2048,6 +2050,143 @@ class DownloadQueueNotifier extends Notifier { } } + Future _openVerificationAndWait(String extensionId) async { + final normalizedExtensionId = extensionId.trim(); + if (normalizedExtensionId.isEmpty) return false; + + final grantEventFuture = PlatformBridge.extensionSessionGrantEvents() + .where((event) => event.extensionId == normalizedExtensionId) + .first + .timeout( + const Duration(minutes: 5), + onTimeout: () => ExtensionSessionGrantEvent( + extensionId: normalizedExtensionId, + success: false, + ), + ); + + final opened = await openPendingExtensionVerification( + normalizedExtensionId, + ); + if (!opened) return false; + + final event = await grantEventFuture; + return event.success; + } + + Future _handleVerificationRequiredDownload( + DownloadItem item, + String errorMsg, + ) async { + if (_verificationRetriedItemIds.contains(item.id)) { + _log.e( + 'Verification was already completed once for ${item.track.name}; not opening another challenge', + ); + updateItemStatus( + item.id, + DownloadStatus.failed, + error: errorMsg, + errorType: DownloadErrorType.verificationRequired, + ); + _failedInSession++; + return true; + } + _verificationRetriedItemIds.add(item.id); + + _log.i( + 'Download for ${item.track.name} requires verification; waiting for ${item.service} grant', + ); + updateItemStatus( + item.id, + DownloadStatus.downloading, + error: 'Waiting for verification', + errorType: DownloadErrorType.verificationRequired, + ); + + final verified = await _openVerificationAndWait(item.service); + final current = _findItemById(item.id); + if (current == null || _isLocallyCancelled(item.id, item: current)) { + _log.i('Verification completed after item was removed or cancelled'); + return true; + } + + if (verified) { + _log.i( + 'Verification complete for ${item.service}; retrying ${item.track.name}', + ); + updateItemStatus( + item.id, + DownloadStatus.queued, + progress: 0, + speedMBps: 0, + error: 'Retrying after verification', + errorType: DownloadErrorType.verificationRequired, + ); + _saveQueueToStorage(); + return true; + } + + _log.e('Verification did not complete for ${item.service}'); + updateItemStatus( + item.id, + DownloadStatus.failed, + error: errorMsg, + errorType: DownloadErrorType.verificationRequired, + ); + _failedInSession++; + return true; + } + + Duration _rateLimitBackoffDelay(String errorMsg) { + final lower = errorMsg.toLowerCase(); + final retryAfterMatch = RegExp( + r'retry[- ]?after(?: seconds)?[:= ]+(\d+)', + caseSensitive: false, + ).firstMatch(lower); + final parsedSeconds = retryAfterMatch == null + ? null + : int.tryParse(retryAfterMatch.group(1) ?? ''); + final seconds = (parsedSeconds ?? 30).clamp(5, 300).toInt(); + return Duration(seconds: seconds); + } + + Future _handleRateLimitedDownload( + DownloadItem item, + String errorMsg, + ) async { + if (_rateLimitRetriedItemIds.contains(item.id)) { + return false; + } + _rateLimitRetriedItemIds.add(item.id); + + final delay = _rateLimitBackoffDelay(errorMsg); + _log.i( + 'Rate limited while downloading ${item.track.name}; retrying after ${delay.inSeconds}s', + ); + updateItemStatus( + item.id, + DownloadStatus.downloading, + error: 'Rate limited, retrying after ${delay.inSeconds}s', + errorType: DownloadErrorType.rateLimit, + ); + + await Future.delayed(delay); + final current = _findItemById(item.id); + if (current == null || _isLocallyCancelled(item.id, item: current)) { + return true; + } + updateItemStatus( + item.id, + DownloadStatus.queued, + progress: 0, + speedMBps: 0, + error: 'Retrying after rate limit', + errorType: DownloadErrorType.rateLimit, + ); + _saveQueueToStorage(); + return true; + } + void _saveQueueToStorage() { _queuePersistDebounce?.cancel(); _queuePersistDebounce = Timer(_queuePersistDebounceDuration, () { @@ -3978,6 +4117,8 @@ class DownloadQueueNotifier extends Notifier { _log.i('Retrying item: ${item.track.name} (id: $id)'); _locallyCancelledItemIds.remove(id); + _verificationRetriedItemIds.remove(id); + _rateLimitRetriedItemIds.remove(id); // Purge stale ReplayGain entry for this track so a re-scan doesn't // produce duplicate entries that bias album gain. @@ -6974,6 +7115,8 @@ class DownloadQueueNotifier extends Notifier { final remainingIds = state.items.map((item) => item.id).toSet(); _locallyCancelledItemIds.removeWhere((id) => !remainingIds.contains(id)); _pausePendingItemIds.removeWhere((id) => !remainingIds.contains(id)); + _verificationRetriedItemIds.removeWhere((id) => !remainingIds.contains(id)); + _rateLimitRetriedItemIds.removeWhere((id) => !remainingIds.contains(id)); } Future _downloadSingleItem(DownloadItem item) async { @@ -8845,8 +8988,14 @@ class DownloadQueueNotifier extends Notifier { return; } - final errorMsg = result['error'] as String? ?? 'Download failed'; + var errorMsg = result['error'] as String? ?? 'Download failed'; final errorTypeStr = result['error_type'] as String? ?? 'unknown'; + final retryAfterSeconds = readPositiveInt( + result['retry_after_seconds'], + ); + if (retryAfterSeconds != null && retryAfterSeconds > 0) { + errorMsg = '$errorMsg retry-after: $retryAfterSeconds'; + } if (errorTypeStr == 'cancelled') { if (_isPausePending(item.id)) { pausedDuringThisRun = true; @@ -8882,6 +9031,15 @@ class DownloadQueueNotifier extends Notifier { errorType = _downloadErrorTypeFromMessage(errorMsg); } + if (errorType == DownloadErrorType.verificationRequired) { + await _handleVerificationRequiredDownload(item, errorMsg); + return; + } + if (errorType == DownloadErrorType.rateLimit && + await _handleRateLimitedDownload(item, errorMsg)) { + return; + } + _log.e('Download failed: $errorMsg (type: $errorTypeStr)'); updateItemStatus( item.id, @@ -8889,9 +9047,6 @@ class DownloadQueueNotifier extends Notifier { error: errorMsg, errorType: errorType, ); - if (errorType == DownloadErrorType.verificationRequired) { - unawaited(openPendingExtensionVerification(item.service)); - } _failedInSession++; try { @@ -8940,15 +9095,21 @@ class DownloadQueueNotifier extends Notifier { errorType = _downloadErrorTypeFromMessage(errorMsg); } + if (errorType == DownloadErrorType.verificationRequired) { + await _handleVerificationRequiredDownload(item, errorMsg); + return; + } + if (errorType == DownloadErrorType.rateLimit && + await _handleRateLimitedDownload(item, errorMsg)) { + return; + } + updateItemStatus( item.id, DownloadStatus.failed, error: errorMsg, errorType: errorType, ); - if (errorType == DownloadErrorType.verificationRequired) { - unawaited(openPendingExtensionVerification(item.service)); - } _failedInSession++; try { diff --git a/lib/utils/extension_auth_launcher.dart b/lib/utils/extension_auth_launcher.dart index ed7b0ba8..58a46288 100644 --- a/lib/utils/extension_auth_launcher.dart +++ b/lib/utils/extension_auth_launcher.dart @@ -8,25 +8,43 @@ bool isExtensionVerificationRequired(Object error) { final message = error.toString().toLowerCase(); return message.contains('verify_required') || message.contains('verification_required') || + message.contains('verification required') || message.contains('needsverification') || - message.contains('needs verification'); + message.contains('needs verification') || + message.contains('unauthorized') || + message.contains('precondition required') || + _containsHttpStatusCode(message, '401') || + _containsHttpStatusCode(message, '428'); } -Future openPendingExtensionVerification(String extensionId) async { +bool _containsHttpStatusCode(String message, String code) { + return message.contains('http $code') || + message.contains('http status $code') || + message.contains('status $code') || + message.contains('$code for ') || + message.contains('$code:') || + message.contains('$code;'); +} + +Future openPendingExtensionVerification(String extensionId) async { final normalizedExtensionId = extensionId.trim(); - if (normalizedExtensionId.isEmpty) return; + if (normalizedExtensionId.isEmpty) return false; try { final pending = await PlatformBridge.getExtensionPendingAuth( normalizedExtensionId, ); final authUrl = pending?['auth_url']?.toString().trim() ?? ''; - if (authUrl.isEmpty) return; + if (authUrl.isEmpty) return false; final uri = Uri.tryParse(authUrl); - if (uri == null) return; + if (uri == null) return false; + + var launched = await launchUrl(uri, mode: LaunchMode.inAppBrowserView); + if (!launched) { + launched = await launchUrl(uri, mode: LaunchMode.externalApplication); + } - final launched = await launchUrl(uri, mode: LaunchMode.externalApplication); if (launched) { _log.i('Opened verification challenge for $normalizedExtensionId'); } else { @@ -34,9 +52,11 @@ Future openPendingExtensionVerification(String extensionId) async { 'Could not open verification challenge for $normalizedExtensionId', ); } + return launched; } catch (e) { _log.w( 'Failed to open verification challenge for $normalizedExtensionId: $e', ); + return false; } }