mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-06 20:57:57 +02:00
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.
This commit is contained in:
@@ -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) {
|
||||
|
||||
+20
-1
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1906,6 +1906,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
int _lastNotifQueueCount = -1;
|
||||
final Set<String> _locallyCancelledItemIds = {};
|
||||
final Set<String> _pausePendingItemIds = {};
|
||||
final Set<String> _verificationRetriedItemIds = {};
|
||||
final Set<String> _rateLimitRetriedItemIds = {};
|
||||
String? _activeNativeWorkerRunId;
|
||||
|
||||
// Album ReplayGain accumulator: keyed by album identifier.
|
||||
@@ -2048,6 +2050,143 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _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<bool> _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<bool> _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<void>.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<DownloadQueueState> {
|
||||
|
||||
_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<DownloadQueueState> {
|
||||
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<void> _downloadSingleItem(DownloadItem item) async {
|
||||
@@ -8845,8 +8988,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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<DownloadQueueState> {
|
||||
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<DownloadQueueState> {
|
||||
error: errorMsg,
|
||||
errorType: errorType,
|
||||
);
|
||||
if (errorType == DownloadErrorType.verificationRequired) {
|
||||
unawaited(openPendingExtensionVerification(item.service));
|
||||
}
|
||||
_failedInSession++;
|
||||
|
||||
try {
|
||||
@@ -8940,15 +9095,21 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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 {
|
||||
|
||||
@@ -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<void> 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<bool> 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<void> 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user