From 84df64fcfe63643f6907bc7bbd70c7e516868cff Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 11 Feb 2026 02:02:03 +0700 Subject: [PATCH] perf+security: polling guards, sensitive data redaction, SAF path sanitization Go backend: - Add sensitive data redaction in log buffer (tokens, keys, passwords) - Validate extension auth URLs (HTTPS only, no private IPs, no embedded creds) - Block embedded credentials in extension HTTP requests - Tighten extension storage file permissions (0644 -> 0600) - Sanitize extension ID in store download path - Summarize auth URLs in logs to prevent token leakage Android (Kotlin): - Add sanitizeRelativeDir to prevent path traversal in SAF operations - Apply sanitizeFilename to all user-provided file names in SAF Flutter: - Add sensitive data redaction in Dart logger (mirrors Go patterns) - Mask device ID in log exports - Add in-flight guard to progress polling (download queue + local library) - Remove redundant _downloadedSpotifyIds Set, use _bySpotifyId map - Remove redundant _isrcSet, use _byIsrc map - Expand DownloadQueueLookup with byItemId and itemIds - Lazy search index building in queue tab - Bound embedded cover cache in queue tab (max 180) - Coalesce embedded cover refresh callbacks via postFrameCallback - Cache album track filtering in downloaded album screen - Cache thumbnail sizes by extension ID in home tab - Simplify recent access aggregation (single-pass) - Remove unused _isTyping state in home tab - Cap pre-warm track batch size to 80 - Skip setShowingRecentAccess if value unchanged - Use downloadQueueLookupProvider for granular queue selectors - Move grouped album filtering before content data computation --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 41 +- go_backend/exports.go | 16 +- go_backend/extension_runtime_auth.go | 62 ++- go_backend/extension_runtime_http.go | 3 + go_backend/extension_runtime_storage.go | 2 +- go_backend/logbuffer.go | 16 + go_backend/security_hardening_test.go | 80 ++++ lib/providers/download_queue_provider.dart | 60 +-- lib/providers/local_library_provider.dart | 19 +- lib/providers/track_provider.dart | 353 ++++++++++++------ lib/screens/downloaded_album_screen.dart | 78 ++-- lib/screens/home_tab.dart | 154 ++++---- lib/screens/queue_tab.dart | 151 ++++---- lib/utils/logger.dart | 80 +++- 14 files changed, 785 insertions(+), 330 deletions(-) create mode 100644 go_backend/security_hardening_test.go 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 fc93788..931891e 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -300,8 +300,18 @@ class MainActivity: FlutterFragmentActivity() { return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim() } + private fun sanitizeRelativeDir(relativeDir: String): String { + if (relativeDir.isBlank()) return "" + return relativeDir + .split("/") + .map { sanitizeFilename(it) } + .filter { it.isNotBlank() && it != "." && it != ".." } + .joinToString("/") + } + private fun ensureDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? { - if (relativeDir.isBlank()) { + val safeRelativeDir = sanitizeRelativeDir(relativeDir) + if (safeRelativeDir.isBlank()) { return DocumentFile.fromTreeUri(this, treeUri) } @@ -310,7 +320,7 @@ class MainActivity: FlutterFragmentActivity() { synchronized(safDirLock) { var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null - val parts = relativeDir.split("/").filter { it.isNotBlank() } + val parts = safeRelativeDir.split("/").filter { it.isNotBlank() } for (part in parts) { val existing = current.findFile(part) current = if (existing != null && existing.isDirectory) { @@ -335,9 +345,10 @@ class MainActivity: FlutterFragmentActivity() { private fun findDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? { var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null - if (relativeDir.isBlank()) return current + val safeRelativeDir = sanitizeRelativeDir(relativeDir) + if (safeRelativeDir.isBlank()) return current - val parts = relativeDir.split("/").filter { it.isNotBlank() } + val parts = safeRelativeDir.split("/").filter { it.isNotBlank() } for (part in parts) { val existing = current.findFile(part) if (existing == null || !existing.isDirectory) return null @@ -377,14 +388,21 @@ class MainActivity: FlutterFragmentActivity() { obj.put("relative_dir", "") return obj.toString() } + val safeRelativeDir = sanitizeRelativeDir(relativeDir) + val safeFileName = sanitizeFilename(fileName) + if (safeFileName.isBlank()) { + obj.put("uri", "") + obj.put("relative_dir", "") + return obj.toString() + } val treeUri = Uri.parse(treeUriStr) - val targetDir = findDocumentDir(treeUri, relativeDir) + val targetDir = findDocumentDir(treeUri, safeRelativeDir) if (targetDir != null) { - val direct = targetDir.findFile(fileName) + val direct = targetDir.findFile(safeFileName) if (direct != null && direct.isFile) { obj.put("uri", direct.uri.toString()) - obj.put("relative_dir", relativeDir) + obj.put("relative_dir", safeRelativeDir) return obj.toString() } } @@ -410,7 +428,7 @@ class MainActivity: FlutterFragmentActivity() { val childPath = if (path.isBlank()) childName else "$path/$childName" queue.add(child to childPath) } else if (child.isFile) { - if (child.name == fileName) { + if (child.name == safeFileName) { obj.put("uri", child.uri.toString()) obj.put("relative_dir", path) return obj.toString() @@ -426,7 +444,7 @@ class MainActivity: FlutterFragmentActivity() { private fun buildSafFileName(req: JSONObject, outputExt: String): String { val provided = req.optString("saf_file_name", "") - if (provided.isNotBlank()) return provided + if (provided.isNotBlank()) return sanitizeFilename(provided) val trackName = req.optString("track_name", "track") val artistName = req.optString("artist_name", "") @@ -617,7 +635,7 @@ class MainActivity: FlutterFragmentActivity() { } val treeUri = Uri.parse(treeUriStr) - val relativeDir = req.optString("saf_relative_dir", "") + val relativeDir = sanitizeRelativeDir(req.optString("saf_relative_dir", "")) val outputExt = normalizeExt(req.optString("saf_output_ext", "")) val mimeType = mimeTypeForExt(outputExt) val fileName = buildSafFileName(req, outputExt) @@ -1474,11 +1492,12 @@ class MainActivity: FlutterFragmentActivity() { "safCreateFromPath" -> { val treeUriStr = call.argument("tree_uri") ?: "" val relativeDir = call.argument("relative_dir") ?: "" - val fileName = call.argument("file_name") ?: "" + val fileName = sanitizeFilename(call.argument("file_name") ?: "") val mimeType = call.argument("mime_type") ?: "application/octet-stream" val srcPath = call.argument("src_path") ?: "" val createdUri = withContext(Dispatchers.IO) { if (treeUriStr.isBlank()) return@withContext null + if (fileName.isBlank()) return@withContext null val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null val existing = dir.findFile(fileName) val createdNew = existing == null diff --git a/go_backend/exports.go b/go_backend/exports.go index 0e2c1b5..91f6871 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -2906,14 +2906,26 @@ func GetStoreCategoriesJSON() (string, error) { return string(jsonBytes), nil } +func buildStoreExtensionDestPath(destDir, extensionID string) (string, error) { + if strings.TrimSpace(extensionID) == "" { + return "", fmt.Errorf("invalid extension id") + } + + safeExtensionID := sanitizeFilename(extensionID) + return filepath.Join(destDir, safeExtensionID+".spotiflac-ext"), nil +} + func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) { store := GetExtensionStore() if store == nil { return "", fmt.Errorf("extension store not initialized") } - destPath := fmt.Sprintf("%s/%s.spotiflac-ext", destDir, extensionID) - err := store.DownloadExtension(extensionID, destPath) + destPath, err := buildStoreExtensionDestPath(destDir, extensionID) + if err != nil { + return "", err + } + err = store.DownloadExtension(extensionID, destPath) if err != nil { return "", err } diff --git a/go_backend/extension_runtime_auth.go b/go_backend/extension_runtime_auth.go index eb17eb3..de4ed06 100644 --- a/go_backend/extension_runtime_auth.go +++ b/go_backend/extension_runtime_auth.go @@ -18,6 +18,43 @@ import ( // ==================== Auth API (OAuth Support) ==================== +func validateExtensionAuthURL(urlStr string) error { + parsed, err := url.Parse(urlStr) + if err != nil { + return fmt.Errorf("invalid auth URL: %w", err) + } + + if parsed.Scheme != "https" { + return fmt.Errorf("invalid auth URL: only https is allowed") + } + + host := parsed.Hostname() + if host == "" { + return fmt.Errorf("invalid auth URL: hostname is required") + } + + if parsed.User != nil { + return fmt.Errorf("invalid auth URL: embedded credentials are not allowed") + } + + if isPrivateIP(host) { + return fmt.Errorf("invalid auth URL: private/local network is not allowed") + } + + return nil +} + +func summarizeURLForLog(urlStr string) string { + parsed, err := url.Parse(urlStr) + if err != nil { + return urlStr + } + if parsed.Host == "" { + return parsed.Scheme + "://" + } + return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path) +} + func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue(map[string]interface{}{ @@ -32,6 +69,13 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value { callbackURL = call.Arguments[1].String() } + if err := validateExtensionAuthURL(authURL); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } + pendingAuthRequestsMu.Lock() pendingAuthRequests[r.extensionID] = &PendingAuthRequest{ ExtensionID: r.extensionID, @@ -50,7 +94,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value { state.AuthCode = "" extensionAuthStateMu.Unlock() - GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL) + GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, summarizeURLForLog(authURL)) return r.vm.ToValue(map[string]interface{}{ "success": true, @@ -273,6 +317,12 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V "error": "authUrl, clientId, and redirectUri are required", }) } + if err := validateExtensionAuthURL(authURL); err != nil { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + } scope, _ := config["scope"].(string) extraParams, _ := config["extraParams"].(map[string]interface{}) @@ -331,7 +381,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V } pendingAuthRequestsMu.Unlock() - GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, fullAuthURL) + GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, summarizeURLForLog(fullAuthURL)) return r.vm.ToValue(map[string]interface{}{ "success": true, @@ -441,13 +491,17 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja "error": err.Error(), }) } + bodyPreview := sanitizeSensitiveLogText(string(body)) + if len(bodyPreview) > 1000 { + bodyPreview = bodyPreview[:1000] + "...[truncated]" + } var tokenResp map[string]interface{} if err := json.Unmarshal(body, &tokenResp); err != nil { return r.vm.ToValue(map[string]interface{}{ "success": false, "error": fmt.Sprintf("failed to parse token response: %v", err), - "body": string(body), + "body": bodyPreview, }) } @@ -468,7 +522,7 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja return r.vm.ToValue(map[string]interface{}{ "success": false, "error": "no access_token in response", - "body": string(body), + "body": bodyPreview, }) } diff --git a/go_backend/extension_runtime_http.go b/go_backend/extension_runtime_http.go index a5f4461..dcdd32f 100644 --- a/go_backend/extension_runtime_http.go +++ b/go_backend/extension_runtime_http.go @@ -32,6 +32,9 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error { if parsed.Scheme != "https" { return fmt.Errorf("network access denied: only https is allowed") } + if parsed.User != nil { + return fmt.Errorf("invalid URL: embedded credentials are not allowed") + } domain := parsed.Hostname() if domain == "" { diff --git a/go_backend/extension_runtime_storage.go b/go_backend/extension_runtime_storage.go index 5e9770f..2e85033 100644 --- a/go_backend/extension_runtime_storage.go +++ b/go_backend/extension_runtime_storage.go @@ -46,7 +46,7 @@ func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error { return err } - return os.WriteFile(storagePath, data, 0644) + return os.WriteFile(storagePath, data, 0600) } func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value { diff --git a/go_backend/logbuffer.go b/go_backend/logbuffer.go index 5b11797..9501259 100644 --- a/go_backend/logbuffer.go +++ b/go_backend/logbuffer.go @@ -3,6 +3,7 @@ package gobackend import ( "encoding/json" "fmt" + "regexp" "strings" "sync" "time" @@ -30,8 +31,22 @@ const ( var ( globalLogBuffer *LogBuffer logBufferOnce sync.Once + + authorizationBearerPattern = regexp.MustCompile(`(?i)\bAuthorization\b\s*[:=]\s*Bearer\s+[A-Za-z0-9._~+/\-]+=*`) + genericKeyValuePattern = regexp.MustCompile(`(?i)\b(access[_\s-]?token|refresh[_\s-]?token|id[_\s-]?token|client[_\s-]?secret|authorization|password|api[_\s-]?key)\b(\s*[:=]\s*)([^\s,;]+)`) + queryTokenPattern = regexp.MustCompile(`(?i)([?&](?:access_token|refresh_token|id_token|token|client_secret|api_key|apikey|password)=)[^&\s]+`) + bearerTokenPattern = regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._~+/\-]+=*`) ) +func sanitizeSensitiveLogText(message string) string { + redacted := message + redacted = authorizationBearerPattern.ReplaceAllString(redacted, "Authorization: Bearer [REDACTED]") + redacted = genericKeyValuePattern.ReplaceAllString(redacted, `${1}${2}[REDACTED]`) + redacted = queryTokenPattern.ReplaceAllString(redacted, `${1}[REDACTED]`) + redacted = bearerTokenPattern.ReplaceAllString(redacted, "Bearer [REDACTED]") + return redacted +} + func GetLogBuffer() *LogBuffer { logBufferOnce.Do(func() { globalLogBuffer = &LogBuffer{ @@ -71,6 +86,7 @@ func (lb *LogBuffer) Add(level, tag, message string) { return } + message = sanitizeSensitiveLogText(message) message = truncateLogMessage(message) entry := LogEntry{ diff --git a/go_backend/security_hardening_test.go b/go_backend/security_hardening_test.go new file mode 100644 index 0000000..559ccbd --- /dev/null +++ b/go_backend/security_hardening_test.go @@ -0,0 +1,80 @@ +package gobackend + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestSanitizeSensitiveLogText(t *testing.T) { + input := "access_token=abc123 Authorization:Bearer xyz456 https://api.example.com/cb?refresh_token=zzz" + redacted := sanitizeSensitiveLogText(input) + + if strings.Contains(redacted, "abc123") || strings.Contains(redacted, "xyz456") || strings.Contains(redacted, "zzz") { + t.Fatalf("expected sensitive values to be redacted, got: %s", redacted) + } + if !strings.Contains(redacted, "[REDACTED]") { + t.Fatalf("expected redaction marker in output, got: %s", redacted) + } +} + +func TestValidateExtensionAuthURL(t *testing.T) { + if err := validateExtensionAuthURL("https://accounts.example.com/oauth/authorize"); err != nil { + t.Fatalf("expected valid auth URL, got error: %v", err) + } + + blocked := []string{ + "http://accounts.example.com/oauth/authorize", + "https://user:pass@accounts.example.com/oauth/authorize", + "https://localhost/oauth/authorize", + } + + for _, rawURL := range blocked { + if err := validateExtensionAuthURL(rawURL); err == nil { + t.Fatalf("expected URL to be blocked: %s", rawURL) + } + } +} + +func TestValidateDomainRejectsEmbeddedCredentials(t *testing.T) { + ext := &LoadedExtension{ + ID: "test-ext", + Manifest: &ExtensionManifest{ + Name: "test-ext", + Permissions: ExtensionPermissions{ + Network: []string{"api.example.com"}, + }, + }, + DataDir: t.TempDir(), + } + + runtime := NewExtensionRuntime(ext) + if err := runtime.validateDomain("https://user:pass@api.example.com/resource"); err == nil { + t.Fatal("expected embedded URL credentials to be rejected") + } +} + +func TestBuildStoreExtensionDestPath(t *testing.T) { + baseDir := t.TempDir() + + destPath, err := buildStoreExtensionDestPath(baseDir, "../evil/name") + if err != nil { + t.Fatalf("expected sanitized path to be generated, got error: %v", err) + } + + if !isPathWithinBase(baseDir, destPath) { + t.Fatalf("expected destination path to remain under base dir: %s", destPath) + } + + baseName := filepath.Base(destPath) + if strings.Contains(baseName, "/") || strings.Contains(baseName, `\`) { + t.Fatalf("expected filename to be sanitized, got: %s", baseName) + } + if !strings.HasSuffix(baseName, ".spotiflac-ext") { + t.Fatalf("expected .spotiflac-ext suffix, got: %s", baseName) + } + + if _, err := buildStoreExtensionDestPath(baseDir, " "); err == nil { + t.Fatal("expected empty extension id to be rejected") + } +} diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 9a74729..606bff6 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -208,16 +208,11 @@ class DownloadHistoryItem { class DownloadHistoryState { final List items; - final Set _downloadedSpotifyIds; final Map _bySpotifyId; final Map _byIsrc; DownloadHistoryState({this.items = const []}) - : _downloadedSpotifyIds = items - .where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty) - .map((item) => item.spotifyId!) - .toSet(), - _bySpotifyId = Map.fromEntries( + : _bySpotifyId = Map.fromEntries( items .where( (item) => item.spotifyId != null && item.spotifyId!.isNotEmpty, @@ -230,8 +225,7 @@ class DownloadHistoryState { .map((item) => MapEntry(item.isrc!, item)), ); - bool isDownloaded(String spotifyId) => - _downloadedSpotifyIds.contains(spotifyId); + bool isDownloaded(String spotifyId) => _bySpotifyId.containsKey(spotifyId); DownloadHistoryItem? getBySpotifyId(String spotifyId) => _bySpotifyId[spotifyId]; @@ -682,6 +676,7 @@ class DownloadQueueNotifier extends Notifier { bool _isLoaded = false; final Set _ensuredDirs = {}; int _progressPollingErrorCount = 0; + bool _isProgressPollingInFlight = false; String? _lastServiceTrackName; String? _lastServiceArtistName; int _lastServicePercent = -1; @@ -832,6 +827,8 @@ class DownloadQueueNotifier extends Notifier { void _startMultiProgressPolling() { _progressTimer?.cancel(); _progressTimer = Timer.periodic(_progressPollingInterval, (timer) async { + if (_isProgressPollingInFlight) return; + _isProgressPollingInFlight = true; try { final allProgress = await PlatformBridge.getAllDownloadProgress(); final items = allProgress['items'] as Map? ?? {}; @@ -915,16 +912,18 @@ class DownloadQueueNotifier extends Notifier { bytesReceived: normalizedBytes, ); - final mbReceived = bytesReceived / (1024 * 1024); - final mbTotal = bytesTotal / (1024 * 1024); - if (bytesTotal > 0) { - _log.d( - 'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s', - ); - } else { - _log.d( - 'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s', - ); + if (LogBuffer.loggingEnabled) { + final mbReceived = bytesReceived / (1024 * 1024); + final mbTotal = bytesTotal / (1024 * 1024); + if (bytesTotal > 0) { + _log.d( + 'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s', + ); + } else { + _log.d( + 'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s', + ); + } } } } @@ -1039,6 +1038,8 @@ class DownloadQueueNotifier extends Notifier { if (_progressPollingErrorCount <= 3) { _log.w('Progress polling failed: $e'); } + } finally { + _isProgressPollingInFlight = false; } }); } @@ -1088,6 +1089,7 @@ class DownloadQueueNotifier extends Notifier { _progressTimer?.cancel(); _progressTimer = null; _progressPollingErrorCount = 0; + _isProgressPollingInFlight = false; _lastServiceTrackName = null; _lastServiceArtistName = null; _lastServicePercent = -1; @@ -4011,15 +4013,29 @@ final downloadQueueProvider = class DownloadQueueLookup { final Map byTrackId; + final Map byItemId; + final List itemIds; - DownloadQueueLookup._(this.byTrackId); + DownloadQueueLookup._({ + required this.byTrackId, + required this.byItemId, + required this.itemIds, + }); factory DownloadQueueLookup.fromItems(List items) { - final map = {}; + final byTrackId = {}; + final byItemId = {}; + final itemIds = []; for (final item in items) { - map.putIfAbsent(item.track.id, () => item); + byTrackId.putIfAbsent(item.track.id, () => item); + byItemId[item.id] = item; + itemIds.add(item.id); } - return DownloadQueueLookup._(map); + return DownloadQueueLookup._( + byTrackId: byTrackId, + byItemId: byItemId, + itemIds: itemIds, + ); } } diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 0876405..e02e0d3 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -24,7 +24,6 @@ class LocalLibraryState { final bool scanWasCancelled; final DateTime? lastScannedAt; final int excludedDownloadedCount; - final Set _isrcSet; final Set _trackKeySet; final Map _byIsrc; @@ -39,16 +38,9 @@ class LocalLibraryState { this.scanWasCancelled = false, this.lastScannedAt, this.excludedDownloadedCount = 0, - Set? isrcSet, Set? trackKeySet, Map? byIsrc, - }) : _isrcSet = - isrcSet ?? - items - .where((item) => item.isrc != null && item.isrc!.isNotEmpty) - .map((item) => item.isrc!) - .toSet(), - _trackKeySet = trackKeySet ?? items.map((item) => item.matchKey).toSet(), + }) : _trackKeySet = trackKeySet ?? items.map((item) => item.matchKey).toSet(), _byIsrc = byIsrc ?? Map.fromEntries( @@ -57,7 +49,7 @@ class LocalLibraryState { .map((item) => MapEntry(item.isrc!, item)), ); - bool hasIsrc(String isrc) => _isrcSet.contains(isrc); + bool hasIsrc(String isrc) => _byIsrc.containsKey(isrc); bool hasTrack(String trackName, String artistName) { final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}'; @@ -108,7 +100,6 @@ class LocalLibraryState { lastScannedAt: lastScannedAt ?? this.lastScannedAt, excludedDownloadedCount: excludedDownloadedCount ?? this.excludedDownloadedCount, - isrcSet: keepDerivedIndex ? _isrcSet : null, trackKeySet: keepDerivedIndex ? _trackKeySet : null, byIsrc: keepDerivedIndex ? _byIsrc : null, ); @@ -123,6 +114,7 @@ class LocalLibraryNotifier extends Notifier { bool _isLoaded = false; bool _scanCancelRequested = false; int _progressPollingErrorCount = 0; + bool _isProgressPollingInFlight = false; @override LocalLibraryState build() { @@ -408,6 +400,8 @@ class LocalLibraryNotifier extends Notifier { void _startProgressPolling() { _progressTimer?.cancel(); _progressTimer = Timer.periodic(_progressPollingInterval, (_) async { + if (_isProgressPollingInFlight) return; + _isProgressPollingInFlight = true; try { final progress = await PlatformBridge.getLibraryScanProgress(); final nextProgress = @@ -447,6 +441,8 @@ class LocalLibraryNotifier extends Notifier { if (_progressPollingErrorCount <= 3) { _log.w('Library scan progress polling failed: $e'); } + } finally { + _isProgressPollingInFlight = false; } }); } @@ -455,6 +451,7 @@ class LocalLibraryNotifier extends Notifier { _progressTimer?.cancel(); _progressTimer = null; _progressPollingErrorCount = 0; + _isProgressPollingInFlight = false; } Future cancelScan() async { diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 7101c12..3ecc340 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -26,8 +26,10 @@ class TrackState { final List? searchPlaylists; // For search results (playlists) final bool hasSearchText; // For back button handling final bool isShowingRecentAccess; // For recent access mode - final String? searchExtensionId; // Extension ID used for current search results - final String? selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist") + final String? + searchExtensionId; // Extension ID used for current search results + final String? + selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist") const TrackState({ this.tracks = const [], @@ -52,7 +54,12 @@ class TrackState { this.selectedSearchFilter, }); - bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty) || (searchAlbums != null && searchAlbums!.isNotEmpty) || (searchPlaylists != null && searchPlaylists!.isNotEmpty); + bool get hasContent => + tracks.isNotEmpty || + artistAlbums != null || + (searchArtists != null && searchArtists!.isNotEmpty) || + (searchAlbums != null && searchAlbums!.isNotEmpty) || + (searchPlaylists != null && searchPlaylists!.isNotEmpty); TrackState copyWith({ List? tracks, @@ -95,9 +102,12 @@ class TrackState { searchAlbums: searchAlbums ?? this.searchAlbums, searchPlaylists: searchPlaylists ?? this.searchPlaylists, hasSearchText: hasSearchText ?? this.hasSearchText, - isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess, + isShowingRecentAccess: + isShowingRecentAccess ?? this.isShowingRecentAccess, searchExtensionId: searchExtensionId, - selectedSearchFilter: clearSelectedSearchFilter ? null : (selectedSearchFilter ?? this.selectedSearchFilter), + selectedSearchFilter: clearSelectedSearchFilter + ? null + : (selectedSearchFilter ?? this.selectedSearchFilter), ); } } @@ -178,6 +188,7 @@ class SearchPlaylist { class TrackNotifier extends Notifier { int _currentRequestId = 0; + static const int _maxPreWarmTracksPerRequest = 80; @override TrackState build() { @@ -197,39 +208,42 @@ class TrackNotifier extends Notifier { final extensionHandler = await PlatformBridge.findURLHandler(url); if (extensionHandler != null) { _log.i('Found extension URL handler: $extensionHandler for URL: $url'); - + // Retry logic for extension URL handlers (up to 3 attempts) Map? result; for (int attempt = 1; attempt <= 3; attempt++) { result = await PlatformBridge.handleURLWithExtension(url); if (!_isRequestValid(requestId)) return; - + // Check if we got valid data - if (result != null && result['type'] == 'track' && result['track'] != null) { + if (result != null && + result['type'] == 'track' && + result['track'] != null) { final trackData = result['track'] as Map; final name = trackData['name']?.toString() ?? ''; if (name.isNotEmpty) { break; } - } else if (result != null && (result['type'] == 'album' || result['type'] == 'playlist')) { + } else if (result != null && + (result['type'] == 'album' || result['type'] == 'playlist')) { break; } else if (result != null && result['type'] == 'artist') { break; } - + if (attempt < 3) { await Future.delayed(const Duration(milliseconds: 500)); } } - + if (result != null) { final type = result['type'] as String?; final extensionId = result['extension_id'] as String?; - + if (type == 'track' && result['track'] != null) { final trackData = result['track'] as Map; final track = _parseSearchTrack(trackData, source: extensionId); - + if (track.name.isEmpty) { state = TrackState( isLoading: false, @@ -237,7 +251,7 @@ class TrackNotifier extends Notifier { ); return; } - + state = TrackState( tracks: [track], isLoading: false, @@ -245,15 +259,27 @@ class TrackNotifier extends Notifier { searchExtensionId: extensionId, ); return; - } else if ((type == 'album' || type == 'playlist') && result['tracks'] != null) { + } else if ((type == 'album' || type == 'playlist') && + result['tracks'] != null) { final trackList = result['tracks'] as List; - final tracks = trackList.map((t) => _parseSearchTrack(t as Map, source: extensionId)).toList(); + final tracks = trackList + .map( + (t) => _parseSearchTrack( + t as Map, + source: extensionId, + ), + ) + .toList(); state = TrackState( tracks: tracks, isLoading: false, albumId: result['album']?['id'] as String?, - albumName: result['name'] as String? ?? result['album']?['name'] as String?, - playlistName: type == 'playlist' ? result['name'] as String? : null, + albumName: + result['name'] as String? ?? + result['album']?['name'] as String?, + playlistName: type == 'playlist' + ? result['name'] as String? + : null, coverUrl: result['cover_url'] as String?, searchExtensionId: extensionId, ); @@ -261,17 +287,29 @@ class TrackNotifier extends Notifier { } else if (type == 'artist' && result['artist'] != null) { final artistData = result['artist'] as Map; final albumsList = artistData['albums'] as List? ?? []; - final albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).toList(); - - final topTracksList = artistData['top_tracks'] as List? ?? []; - final topTracks = topTracksList.map((t) => _parseSearchTrack(t as Map, source: extensionId)).toList(); - + final albums = albumsList + .map((a) => _parseArtistAlbum(a as Map)) + .toList(); + + final topTracksList = + artistData['top_tracks'] as List? ?? []; + final topTracks = topTracksList + .map( + (t) => _parseSearchTrack( + t as Map, + source: extensionId, + ), + ) + .toList(); + state = TrackState( tracks: [], isLoading: false, artistId: artistData['id'] as String?, artistName: artistData['name'] as String?, - coverUrl: artistData['image_url'] as String? ?? artistData['images'] as String?, + coverUrl: + artistData['image_url'] as String? ?? + artistData['images'] as String?, headerImageUrl: artistData['header_image'] as String?, monthlyListeners: artistData['listeners'] as int?, artistAlbums: albums, @@ -282,19 +320,19 @@ class TrackNotifier extends Notifier { } } } - + // Step 2: Try Deezer URL parsing if (url.contains('deezer.com') || url.contains('deezer.page.link')) { _log.i('Detected Deezer URL, parsing...'); final parsed = await PlatformBridge.parseDeezerUrl(url); if (!_isRequestValid(requestId)) return; - + final type = parsed['type'] as String; final id = parsed['id'] as String; - + final metadata = await PlatformBridge.getDeezerMetadata(type, id); if (!_isRequestValid(requestId)) return; - + if (type == 'track') { final trackData = metadata['track'] as Map; final track = _parseTrack(trackData); @@ -306,7 +344,9 @@ class TrackNotifier extends Notifier { } else if (type == 'album') { final albumInfo = metadata['album_info'] as Map; final trackList = metadata['track_list'] as List; - final tracks = trackList.map((t) => _parseTrack(t as Map)).toList(); + final tracks = trackList + .map((t) => _parseTrack(t as Map)) + .toList(); state = TrackState( tracks: tracks, isLoading: false, @@ -316,9 +356,12 @@ class TrackNotifier extends Notifier { ); _preWarmCacheForTracks(tracks); } else if (type == 'playlist') { - final playlistInfo = metadata['playlist_info'] as Map; + final playlistInfo = + metadata['playlist_info'] as Map; final trackList = metadata['track_list'] as List; - final tracks = trackList.map((t) => _parseTrack(t as Map)).toList(); + final tracks = trackList + .map((t) => _parseTrack(t as Map)) + .toList(); state = TrackState( tracks: tracks, isLoading: false, @@ -329,7 +372,9 @@ class TrackNotifier extends Notifier { } else if (type == 'artist') { final artistInfo = metadata['artist_info'] as Map; final albumsList = metadata['albums'] as List; - final albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).toList(); + final albums = albumsList + .map((a) => _parseArtistAlbum(a as Map)) + .toList(); state = TrackState( tracks: [], isLoading: false, @@ -341,33 +386,38 @@ class TrackNotifier extends Notifier { } return; } - + // Step 3: Try Tidal URL parsing if (url.contains('tidal.com')) { _log.i('Detected Tidal URL, parsing...'); final parsed = await PlatformBridge.parseTidalUrl(url); if (!_isRequestValid(requestId)) return; - + final type = parsed['type'] as String; final id = parsed['id'] as String; - + _log.i('Tidal URL parsed: type=$type, id=$id'); - + // For track URLs, convert to Spotify/Deezer and fetch metadata from there if (type == 'track') { try { _log.i('Converting Tidal track to Spotify/Deezer via SongLink...'); - final conversion = await PlatformBridge.convertTidalToSpotifyDeezer(url); + final conversion = await PlatformBridge.convertTidalToSpotifyDeezer( + url, + ); if (!_isRequestValid(requestId)) return; - + final spotifyUrl = conversion['spotify_url'] as String?; final deezerUrl = conversion['deezer_url'] as String?; - + if (spotifyUrl != null && spotifyUrl.isNotEmpty) { _log.i('Found Spotify URL: $spotifyUrl, fetching metadata...'); - final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(spotifyUrl); + final metadata = + await PlatformBridge.getSpotifyMetadataWithFallback( + spotifyUrl, + ); if (!_isRequestValid(requestId)) return; - + final trackData = metadata['track'] as Map; final track = _parseTrack(trackData); state = TrackState( @@ -378,10 +428,15 @@ class TrackNotifier extends Notifier { return; } else if (deezerUrl != null && deezerUrl.isNotEmpty) { _log.i('Found Deezer URL: $deezerUrl, fetching metadata...'); - final deezerParsed = await PlatformBridge.parseDeezerUrl(deezerUrl); - final metadata = await PlatformBridge.getDeezerMetadata('track', deezerParsed['id'] as String); + final deezerParsed = await PlatformBridge.parseDeezerUrl( + deezerUrl, + ); + final metadata = await PlatformBridge.getDeezerMetadata( + 'track', + deezerParsed['id'] as String, + ); if (!_isRequestValid(requestId)) return; - + final trackData = metadata['track'] as Map; final track = _parseTrack(trackData); state = TrackState( @@ -395,30 +450,31 @@ class TrackNotifier extends Notifier { _log.w('Failed to convert Tidal URL via SongLink: $e'); } } - + // For album/artist/playlist, not yet supported state = TrackState( isLoading: false, - error: 'Tidal $type links are not fully supported yet. Only track links work via SongLink conversion.', + error: + 'Tidal $type links are not fully supported yet. Only track links work via SongLink conversion.', hasSearchText: state.hasSearchText, ); return; } - + // Step 4: Fall back to Spotify parsing final parsed = await PlatformBridge.parseSpotifyUrl(url); if (!_isRequestValid(requestId)) return; - + final type = parsed['type'] as String; Map metadata; - + try { metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url); } catch (e) { rethrow; } - + if (!_isRequestValid(requestId)) return; if (type == 'track') { @@ -432,7 +488,9 @@ class TrackNotifier extends Notifier { } else if (type == 'album') { final albumInfo = metadata['album_info'] as Map; final trackList = metadata['track_list'] as List; - final tracks = trackList.map((t) => _parseTrack(t as Map)).toList(); + final tracks = trackList + .map((t) => _parseTrack(t as Map)) + .toList(); state = TrackState( tracks: tracks, isLoading: false, @@ -444,7 +502,9 @@ class TrackNotifier extends Notifier { } else if (type == 'playlist') { final playlistInfo = metadata['playlist_info'] as Map; final trackList = metadata['track_list'] as List; - final tracks = trackList.map((t) => _parseTrack(t as Map)).toList(); + final tracks = trackList + .map((t) => _parseTrack(t as Map)) + .toList(); final owner = playlistInfo['owner'] as Map?; state = TrackState( tracks: tracks, @@ -456,7 +516,9 @@ class TrackNotifier extends Notifier { } else if (type == 'artist') { final artistInfo = metadata['artist_info'] as Map; final albumsList = metadata['albums'] as List; - final albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).toList(); + final albums = albumsList + .map((a) => _parseArtistAlbum(a as Map)) + .toList(); state = TrackState( tracks: [], isLoading: false, @@ -468,17 +530,29 @@ class TrackNotifier extends Notifier { } } catch (e) { if (!_isRequestValid(requestId)) return; - state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText); + state = TrackState( + isLoading: false, + error: e.toString(), + hasSearchText: state.hasSearchText, + ); } } - Future search(String query, {String? metadataSource, String? filterOverride}) async { + Future search( + String query, { + String? metadataSource, + String? filterOverride, + }) async { final requestId = ++_currentRequestId; - + // Preserve selected filter during loading final currentFilter = filterOverride ?? state.selectedSearchFilter; - state = TrackState(isLoading: true, hasSearchText: state.hasSearchText, selectedSearchFilter: currentFilter); + state = TrackState( + isLoading: true, + hasSearchText: state.hasSearchText, + selectedSearchFilter: currentFilter, + ); try { final settings = ref.read(settingsProvider); @@ -494,20 +568,23 @@ class TrackNotifier extends Notifier { searchProvider.isNotEmpty; final source = metadataSource ?? 'deezer'; - + _log.i( 'Search started: source=$source, query="$query", useExtensions=$useExtensions, filter=$currentFilter', ); - + Map results; List extensionTracks = []; - + if (useExtensions) { try { _log.d('Calling extension search API...'); - final extResults = await PlatformBridge.searchTracksWithExtensions(query, limit: 20); + final extResults = await PlatformBridge.searchTracksWithExtensions( + query, + limit: 20, + ); _log.i('Extensions returned ${extResults.length} tracks'); - + for (final t in extResults) { try { extensionTracks.add(_parseSearchTrack(t)); @@ -519,37 +596,52 @@ class TrackNotifier extends Notifier { _log.w('Extension search failed, falling back to built-in: $e'); } } - + if (source == 'deezer') { _log.d('Calling Deezer search API...'); - results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 2, filter: currentFilter); - _log.i('Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums'); + results = await PlatformBridge.searchDeezerAll( + query, + trackLimit: 20, + artistLimit: 2, + filter: currentFilter, + ); + _log.i( + 'Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums', + ); } else { _log.d('Calling Spotify search API...'); - results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 2); - _log.i('Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists'); + results = await PlatformBridge.searchSpotifyAll( + query, + trackLimit: 20, + artistLimit: 2, + ); + _log.i( + 'Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists', + ); } - + if (!_isRequestValid(requestId)) { _log.w('Search request cancelled (requestId=$requestId)'); return; } - + final trackList = results['tracks'] as List? ?? []; final artistList = results['artists'] as List? ?? []; final albumList = results['albums'] as List? ?? []; - - _log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists, ${albumList.length} albums'); - + + _log.d( + 'Raw results: ${trackList.length} tracks, ${artistList.length} artists, ${albumList.length} albums', + ); + final tracks = []; - + tracks.addAll(extensionTracks); - + final existingIsrcs = extensionTracks .where((t) => t.isrc != null && t.isrc!.isNotEmpty) .map((t) => t.isrc!) .toSet(); - + for (int i = 0; i < trackList.length; i++) { final t = trackList[i]; try { @@ -566,7 +658,7 @@ class TrackNotifier extends Notifier { _log.e('Failed to parse track[$i]: $e', e); } } - + final artists = []; for (int i = 0; i < artistList.length; i++) { final a = artistList[i]; @@ -580,7 +672,7 @@ class TrackNotifier extends Notifier { _log.e('Failed to parse artist[$i]: $e', e); } } - + final albums = []; for (int i = 0; i < albumList.length; i++) { final a = albumList[i]; @@ -594,7 +686,7 @@ class TrackNotifier extends Notifier { _log.e('Failed to parse album[$i]: $e', e); } } - + final playlistList = results['playlists'] as List? ?? []; final playlists = []; for (int i = 0; i < playlistList.length; i++) { @@ -609,9 +701,11 @@ class TrackNotifier extends Notifier { _log.e('Failed to parse playlist[$i]: $e', e); } } - - _log.i('Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully'); - + + _log.i( + 'Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully', + ); + state = TrackState( tracks: tracks, searchArtists: artists, @@ -624,31 +718,45 @@ class TrackNotifier extends Notifier { } catch (e, stackTrace) { if (!_isRequestValid(requestId)) return; _log.e('Search failed: $e', e, stackTrace); - state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText, selectedSearchFilter: currentFilter); + state = TrackState( + isLoading: false, + error: e.toString(), + hasSearchText: state.hasSearchText, + selectedSearchFilter: currentFilter, + ); } } - Future customSearch(String extensionId, String query, {Map? options}) async { + Future customSearch( + String extensionId, + String query, { + Map? options, + }) async { final requestId = ++_currentRequestId; state = TrackState( isLoading: true, hasSearchText: state.hasSearchText, - selectedSearchFilter: state.selectedSearchFilter, // Preserve filter during loading + selectedSearchFilter: + state.selectedSearchFilter, // Preserve filter during loading ); try { _log.i('Custom search started: extension=$extensionId, query="$query"'); - - final results = await PlatformBridge.customSearchWithExtension(extensionId, query, options: options); - + + final results = await PlatformBridge.customSearchWithExtension( + extensionId, + query, + options: options, + ); + if (!_isRequestValid(requestId)) { _log.w('Custom search request cancelled (requestId=$requestId)'); return; } - + _log.i('Custom search returned ${results.length} tracks'); - + final tracks = []; for (int i = 0; i < results.length; i++) { final t = results[i]; @@ -658,21 +766,28 @@ class TrackNotifier extends Notifier { _log.e('Failed to parse custom search track[$i]: $e', e); } } - - _log.i('Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)'); - + + _log.i( + 'Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)', + ); + state = TrackState( tracks: tracks, searchArtists: [], isLoading: false, hasSearchText: state.hasSearchText, searchExtensionId: extensionId, // Store which extension was used - selectedSearchFilter: state.selectedSearchFilter, // Preserve selected filter + selectedSearchFilter: + state.selectedSearchFilter, // Preserve selected filter ); } catch (e, stackTrace) { if (!_isRequestValid(requestId)) return; _log.e('Custom search failed: $e', e, stackTrace); - state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText); + state = TrackState( + isLoading: false, + error: e.toString(), + hasSearchText: state.hasSearchText, + ); } } @@ -683,7 +798,10 @@ class TrackNotifier extends Notifier { if (track.isrc == null || track.isrc!.isEmpty) return; try { - final availability = await PlatformBridge.checkAvailability(track.id, track.isrc!); + final availability = await PlatformBridge.checkAvailability( + track.id, + track.isrc!, + ); final updatedTrack = Track( id: track.id, name: track.name, @@ -736,11 +854,14 @@ class TrackNotifier extends Notifier { } state = state.copyWith(hasSearchText: hasText); } - + void setShowingRecentAccess(bool showing) { + if (state.isShowingRecentAccess == showing) { + return; + } state = state.copyWith(isShowingRecentAccess: showing); } - + /// Set tracks from a collection (album/playlist) opened from search results void setTracksFromCollection({ required List tracks, @@ -782,9 +903,9 @@ class TrackNotifier extends Notifier { } else if (durationValue is double) { durationMs = durationValue.toInt(); } - + final itemType = data['item_type']?.toString(); - + return Track( id: (data['spotify_id'] ?? data['id'] ?? '').toString(), name: (data['name'] ?? '').toString(), @@ -797,7 +918,10 @@ class TrackNotifier extends Notifier { trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, releaseDate: data['release_date']?.toString(), - source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(), + source: + source ?? + data['source']?.toString() ?? + data['provider_id']?.toString(), albumType: data['album_type']?.toString(), itemType: itemType, ); @@ -849,16 +973,25 @@ class TrackNotifier extends Notifier { } void _preWarmCacheForTracks(List tracks) { - final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList(); - if (tracksWithIsrc.isEmpty) return; - - final cacheRequests = tracksWithIsrc.map((t) => { - 'isrc': t.isrc!, - 'track_name': t.name, - 'artist_name': t.artistName, - 'spotify_id': t.id, // Include Spotify ID for Amazon lookup - 'service': 'tidal', - }).toList(); + if (tracks.isEmpty) return; + final cacheRequests = >[]; + for (final track in tracks) { + final isrc = track.isrc; + if (isrc == null || isrc.isEmpty) { + continue; + } + cacheRequests.add({ + 'isrc': isrc, + 'track_name': track.name, + 'artist_name': track.artistName, + 'spotify_id': track.id, // Include Spotify ID for Amazon lookup + 'service': 'tidal', + }); + if (cacheRequests.length >= _maxPreWarmTracksPerRequest) { + break; + } + } + if (cacheRequests.isEmpty) return; PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {}); } diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index d6950a9..a36d85c 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -34,6 +34,12 @@ class _DownloadedAlbumScreenState extends ConsumerState { final Set _selectedIds = {}; bool _showTitleInAppBar = false; final ScrollController _scrollController = ScrollController(); + bool _embeddedCoverRefreshScheduled = false; + List? _albumTracksSourceCache; + List? _albumTracksCache; + + String get _albumLookupKey => + '${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}'; @override void initState() { @@ -48,6 +54,16 @@ class _DownloadedAlbumScreenState extends ConsumerState { super.dispose(); } + @override + void didUpdateWidget(covariant DownloadedAlbumScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.albumName != widget.albumName || + oldWidget.artistName != widget.artistName) { + _albumTracksSourceCache = null; + _albumTracksCache = null; + } + } + void _onScroll() { final shouldShow = _scrollController.offset > 280; if (shouldShow != _showTitleInAppBar) { @@ -59,28 +75,36 @@ class _DownloadedAlbumScreenState extends ConsumerState { List _getAlbumTracks( List allItems, ) { - return allItems.where((item) { - // Use albumArtist if available and not empty, otherwise artistName - final itemArtist = - (item.albumArtist != null && item.albumArtist!.isNotEmpty) - ? item.albumArtist! - : item.artistName; - // Use lowercase for case-insensitive matching - final itemKey = - '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}'; - final albumKey = - '${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}'; - return itemKey == albumKey; - }).toList()..sort((a, b) { - // Sort by disc number first, then by track number - final aDisc = a.discNumber ?? 1; - final bDisc = b.discNumber ?? 1; - if (aDisc != bDisc) return aDisc.compareTo(bDisc); - final aNum = a.trackNumber ?? 999; - final bNum = b.trackNumber ?? 999; - if (aNum != bNum) return aNum.compareTo(bNum); - return a.trackName.compareTo(b.trackName); - }); + final cached = _albumTracksCache; + if (cached != null && identical(allItems, _albumTracksSourceCache)) { + return cached; + } + + final tracks = + allItems.where((item) { + // Use albumArtist if available and not empty, otherwise artistName + final itemArtist = + (item.albumArtist != null && item.albumArtist!.isNotEmpty) + ? item.albumArtist! + : item.artistName; + // Use lowercase for case-insensitive matching + final itemKey = + '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}'; + return itemKey == _albumLookupKey; + }).toList()..sort((a, b) { + // Sort by disc number first, then by track number + final aDisc = a.discNumber ?? 1; + final bDisc = b.discNumber ?? 1; + if (aDisc != bDisc) return aDisc.compareTo(bDisc); + final aNum = a.trackNumber ?? 999; + final bNum = b.trackNumber ?? 999; + if (aNum != bNum) return aNum.compareTo(bNum); + return a.trackName.compareTo(b.trackName); + }); + + _albumTracksSourceCache = allItems; + _albumTracksCache = tracks; + return tracks; } Map> _groupTracksByDisc( @@ -194,8 +218,14 @@ class _DownloadedAlbumScreenState extends ConsumerState { } void _onEmbeddedCoverChanged() { - if (!mounted) return; - setState(() {}); + if (!mounted || _embeddedCoverRefreshScheduled) return; + _embeddedCoverRefreshScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _embeddedCoverRefreshScheduled = false; + if (mounted) { + setState(() {}); + } + }); } Future _navigateToMetadataScreen(DownloadHistoryItem item) async { diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index aa5c8d7..f60aa69 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -35,18 +35,25 @@ class HomeTab extends ConsumerStatefulWidget { class _RecentAccessView { final List uniqueItems; - final List downloadItems; + final List downloadIds; final Map downloadFilePathByRecentKey; final bool hasHiddenDownloads; const _RecentAccessView({ required this.uniqueItems, - required this.downloadItems, + required this.downloadIds, required this.downloadFilePathByRecentKey, required this.hasHiddenDownloads, }); } +class _RecentAlbumAggregate { + int count; + DownloadHistoryItem mostRecent; + + _RecentAlbumAggregate({required this.count, required this.mostRecent}); +} + class _CsvImportOptions { final bool confirmed; final bool skipDownloaded; @@ -60,7 +67,6 @@ class _CsvImportOptions { class _HomeTabState extends ConsumerState with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin { final _urlController = TextEditingController(); - bool _isTyping = false; final FocusNode _searchFocusNode = FocusNode(); String? _lastSearchQuery; late final ProviderSubscription _trackStateSub; @@ -77,6 +83,9 @@ class _HomeTabState extends ConsumerState List? _recentAccessItemsCache; Set? _recentAccessHiddenIdsCache; _RecentAccessView? _recentAccessViewCache; + bool _embeddedCoverRefreshScheduled = false; + List? _thumbnailSizesExtensionsCache; + Map? _thumbnailSizesCache; double _responsiveScale({ required BuildContext context, @@ -200,6 +209,27 @@ class _HomeTabState extends ConsumerState super.dispose(); } + Map _getThumbnailSizesByExtensionId( + List extensions, + ) { + final cached = _thumbnailSizesCache; + if (cached != null && + identical(extensions, _thumbnailSizesExtensionsCache)) { + return cached; + } + + final map = { + for (final extension in extensions) + if (extension.searchBehavior != null) + extension.id: extension.searchBehavior!.getThumbnailSize( + defaultSize: 56, + ), + }; + _thumbnailSizesExtensionsCache = extensions; + _thumbnailSizesCache = map; + return map; + } + void _onSearchFocusChanged() { if (mounted) { setState(() {}); @@ -217,7 +247,6 @@ class _HomeTabState extends ConsumerState _urlController.text.isNotEmpty && !_searchFocusNode.hasFocus) { _urlController.clear(); - setState(() => _isTyping = false); } } @@ -240,10 +269,7 @@ class _HomeTabState extends ConsumerState ref.read(trackProvider.notifier).setSearchText(text.isNotEmpty); - if (text.isNotEmpty && !_isTyping) { - setState(() => _isTyping = true); - } else if (text.isEmpty && _isTyping) { - setState(() => _isTyping = false); + if (text.isEmpty) { _liveSearchDebounce?.cancel(); return; } @@ -350,7 +376,6 @@ class _HomeTabState extends ConsumerState _urlController.clear(); _searchFocusNode.unfocus(); _lastSearchQuery = null; - setState(() => _isTyping = false); ref.read(trackProvider.notifier).clear(); } @@ -390,7 +415,6 @@ class _HomeTabState extends ConsumerState ); ref.read(trackProvider.notifier).clear(); _urlController.clear(); - setState(() => _isTyping = false); return; } @@ -416,7 +440,6 @@ class _HomeTabState extends ConsumerState ); ref.read(trackProvider.notifier).clear(); _urlController.clear(); - setState(() => _isTyping = false); return; } @@ -438,7 +461,6 @@ class _HomeTabState extends ConsumerState ); ref.read(trackProvider.notifier).clear(); _urlController.clear(); - setState(() => _isTyping = false); return; } } @@ -781,13 +803,9 @@ class _HomeTabState extends ConsumerState ); final showLocalLibraryIndicator = localLibrarySettings.$1 && localLibrarySettings.$2; - final thumbnailSizesByExtensionId = { - for (final extension in extensions) - if (extension.searchBehavior != null) - extension.id: extension.searchBehavior!.getThumbnailSize( - defaultSize: 56, - ), - }; + final thumbnailSizesByExtensionId = _getThumbnailSizesByExtensionId( + extensions, + ); Extension? currentSearchExtension; List searchFilters = []; @@ -1028,8 +1046,14 @@ class _HomeTabState extends ConsumerState } void _onEmbeddedCoverChanged() { - if (!mounted) return; - setState(() {}); + if (!mounted || _embeddedCoverRefreshScheduled) return; + _embeddedCoverRefreshScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _embeddedCoverRefreshScheduled = false; + if (mounted) { + setState(() {}); + } + }); } Widget _buildRecentDownloads( @@ -1148,66 +1172,58 @@ class _HomeTabState extends ConsumerState return cached; } - final albumGroups = >{}; + final albumGroups = {}; for (final h in historyItems) { final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty) ? h.albumArtist! : h.artistName; final albumKey = '${h.albumName}|$artistForKey'; - albumGroups.putIfAbsent(albumKey, () => []).add(h); + final existing = albumGroups[albumKey]; + if (existing == null) { + albumGroups[albumKey] = _RecentAlbumAggregate(count: 1, mostRecent: h); + } else { + existing.count++; + if (h.downloadedAt.isAfter(existing.mostRecent.downloadedAt)) { + existing.mostRecent = h; + } + } } - final downloadItems = []; + final downloadIds = []; + final visibleDownloads = []; final downloadFilePathByRecentKey = {}; - for (final entry in albumGroups.entries) { - final tracks = entry.value; - final mostRecent = tracks.reduce( - (a, b) => a.downloadedAt.isAfter(b.downloadedAt) ? a : b, - ); + for (final aggregate in albumGroups.values) { + final mostRecent = aggregate.mostRecent; final artistForKey = (mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty) ? mostRecent.albumArtist! : mostRecent.artistName; - if (tracks.length == 1) { - final recent = RecentAccessItem( - id: mostRecent.spotifyId ?? mostRecent.id, - name: mostRecent.trackName, - subtitle: mostRecent.artistName, - imageUrl: mostRecent.coverUrl, - type: RecentAccessType.track, - accessedAt: mostRecent.downloadedAt, - providerId: 'download', - ); - downloadItems.add(recent); - downloadFilePathByRecentKey['${recent.type.name}:${recent.id}'] = - mostRecent.filePath; - } else { - final recent = RecentAccessItem( - id: '${mostRecent.albumName}|$artistForKey', - name: mostRecent.albumName, - subtitle: artistForKey, - imageUrl: mostRecent.coverUrl, - type: RecentAccessType.album, - accessedAt: mostRecent.downloadedAt, - providerId: 'download', - ); - downloadItems.add(recent); - downloadFilePathByRecentKey['${recent.type.name}:${recent.id}'] = - mostRecent.filePath; + final isSingleTrack = aggregate.count == 1; + final recentId = isSingleTrack + ? (mostRecent.spotifyId ?? mostRecent.id) + : '${mostRecent.albumName}|$artistForKey'; + final recent = RecentAccessItem( + id: recentId, + name: isSingleTrack ? mostRecent.trackName : mostRecent.albumName, + subtitle: isSingleTrack ? mostRecent.artistName : artistForKey, + imageUrl: mostRecent.coverUrl, + type: isSingleTrack ? RecentAccessType.track : RecentAccessType.album, + accessedAt: mostRecent.downloadedAt, + providerId: 'download', + ); + + downloadIds.add(recentId); + downloadFilePathByRecentKey['${recent.type.name}:${recent.id}'] = + mostRecent.filePath; + if (!hiddenIds.contains(recentId)) { + visibleDownloads.add(recent); } } - downloadItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); - - final visibleDownloads = []; - for (final item in downloadItems) { - if (!hiddenIds.contains(item.id)) { - visibleDownloads.add(item); - if (visibleDownloads.length >= 10) { - break; - } - } + visibleDownloads.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); + if (visibleDownloads.length > 10) { + visibleDownloads.removeRange(10, visibleDownloads.length); } final allItems = [...items, ...visibleDownloads]; @@ -1227,7 +1243,7 @@ class _HomeTabState extends ConsumerState final view = _RecentAccessView( uniqueItems: uniqueItems, - downloadItems: downloadItems, + downloadIds: downloadIds, downloadFilePathByRecentKey: downloadFilePathByRecentKey, hasHiddenDownloads: hiddenIds.isNotEmpty, ); @@ -1641,7 +1657,7 @@ class _HomeTabState extends ConsumerState Widget _buildRecentAccess(_RecentAccessView view, ColorScheme colorScheme) { final uniqueItems = view.uniqueItems; - final downloadItems = view.downloadItems; + final downloadIds = view.downloadIds; final hasHiddenDownloads = view.hasHiddenDownloads; return Padding( @@ -1661,10 +1677,10 @@ class _HomeTabState extends ConsumerState if (uniqueItems.isNotEmpty) TextButton( onPressed: () { - for (final item in downloadItems) { + for (final id in downloadIds) { ref .read(recentAccessProvider.notifier) - .hideDownloadFromRecents(item.id); + .hideDownloadFromRecents(id); } ref.read(recentAccessProvider.notifier).clearHistory(); }, diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 25f4e9a..665873b 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -228,6 +228,7 @@ Map> _filterHistoryInIsolate(Map payload) { final entries = (payload['entries'] as List).cast(); final albumCounts = (payload['albumCounts'] as Map).cast(); final query = (payload['query'] as String?) ?? ''; + final hasQuery = query.isNotEmpty; final allIds = []; final albumIds = []; @@ -236,10 +237,11 @@ Map> _filterHistoryInIsolate(Map payload) { for (final entry in entries) { final id = entry[0] as String; final albumKey = entry[1] as String; - final searchKey = entry[2] as String; - - if (query.isNotEmpty && !searchKey.contains(query)) { - continue; + if (hasQuery) { + final searchKey = entry[2] as String; + if (!searchKey.contains(query)) { + continue; + } } allIds.add(id); @@ -276,6 +278,8 @@ class _QueueTabState extends ConsumerState { final ValueNotifier _alwaysMissingFileNotifier = ValueNotifier(false); final Set _pendingChecks = {}; static const int _maxCacheSize = 500; + static const int _maxSearchIndexCacheSize = 4000; + static const int _maxDownloadedEmbeddedCoverCacheSize = 180; bool _isSelectionMode = false; final Set _selectedIds = {}; @@ -311,8 +315,6 @@ class _QueueTabState extends ConsumerState { final Set _pendingDownloadedCoverExtract = {}; final Set _pendingDownloadedCoverRefresh = {}; final Set _failedDownloadedCoverExtract = {}; - Map _historyItemsById = {}; - List> _historyFilterEntries = const []; Map> _filteredHistoryCache = const {}; List? _filterItemsCache; String _filterQueryCache = ''; @@ -407,32 +409,16 @@ class _QueueTabState extends ConsumerState { _historyItemsCache = items; _localLibraryItemsCache = localItems; _historyStatsCache = _buildHistoryStats(items, localItems); - _searchIndexCache - ..clear() - ..addEntries( - items.map((item) => MapEntry(item.id, _buildSearchKey(item))), - ); + if (historyChanged) { + _searchIndexCache.clear(); + } if (localChanged) { - _localSearchIndexCache - ..clear() - ..addEntries( - localItems.map( - (item) => MapEntry(item.id, _buildLocalSearchKey(item)), - ), - ); + _localSearchIndexCache.clear(); _localFilterItemsCache = null; _localFilterQueryCache = ''; _filteredLocalItemsCache = const []; } _unifiedItemsCache.clear(); - _historyItemsById = {for (final item in items) item.id: item}; - _historyFilterEntries = List>.generate(items.length, (index) { - final item = items[index]; - final searchKey = _searchIndexCache[item.id] ?? _buildSearchKey(item); - final albumKey = - '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; - return [item.id, albumKey, searchKey]; - }, growable: false); if (historyChanged) { final validPaths = items @@ -459,6 +445,30 @@ class _QueueTabState extends ConsumerState { .toLowerCase(); } + String _historySearchKeyForItem(DownloadHistoryItem item) { + final cached = _searchIndexCache[item.id]; + if (cached != null) return cached; + + final searchKey = _buildSearchKey(item); + _searchIndexCache[item.id] = searchKey; + while (_searchIndexCache.length > _maxSearchIndexCacheSize) { + _searchIndexCache.remove(_searchIndexCache.keys.first); + } + return searchKey; + } + + String _localSearchKeyForItem(LocalLibraryItem item) { + final cached = _localSearchIndexCache[item.id]; + if (cached != null) return cached; + + final searchKey = _buildLocalSearchKey(item); + _localSearchIndexCache[item.id] = searchKey; + while (_localSearchIndexCache.length > _maxSearchIndexCacheSize) { + _localSearchIndexCache.remove(_localSearchIndexCache.keys.first); + } + return searchKey; + } + List _filterLocalItems( List items, String query, @@ -471,11 +481,7 @@ class _QueueTabState extends ConsumerState { final filtered = items .where((item) { - final searchKey = - _localSearchIndexCache[item.id] ?? _buildLocalSearchKey(item); - if (!_localSearchIndexCache.containsKey(item.id)) { - _localSearchIndexCache[item.id] = searchKey; - } + final searchKey = _localSearchKeyForItem(item); return searchKey.contains(query); }) .toList(growable: false); @@ -548,15 +554,26 @@ class _QueueTabState extends ConsumerState { } final requestId = ++_filterRequestId; + final includeSearchKey = query.isNotEmpty; + final entries = List>.generate(items.length, (index) { + final item = items[index]; + final albumKey = + '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; + if (!includeSearchKey) { + return [item.id, albumKey]; + } + final searchKey = _historySearchKeyForItem(item); + return [item.id, albumKey, searchKey]; + }, growable: false); final payload = { - 'entries': _historyFilterEntries, + 'entries': entries, 'albumCounts': albumCounts, 'query': query, }; compute(_filterHistoryInIsolate, payload).then((result) { if (!mounted || requestId != _filterRequestId) return; - final itemsById = _historyItemsById; + final itemsById = {for (final item in items) item.id: item}; final filtered = >{}; for (final entry in result.entries) { filtered[entry.key] = entry.value @@ -604,10 +621,7 @@ class _QueueTabState extends ConsumerState { final query = searchQuery; return items .where((item) { - final searchKey = _searchIndexCache[item.id] ?? _buildSearchKey(item); - if (!_searchIndexCache.containsKey(item.id)) { - _searchIndexCache[item.id] = searchKey; - } + final searchKey = _historySearchKeyForItem(item); return searchKey.contains(query); }) .toList(growable: false); @@ -812,6 +826,18 @@ class _QueueTabState extends ConsumerState { _cleanupTempCoverPathSync(cachedPath); } + void _trimDownloadedEmbeddedCoverCache() { + while (_downloadedEmbeddedCoverCache.length > + _maxDownloadedEmbeddedCoverCacheSize) { + final oldestKey = _downloadedEmbeddedCoverCache.keys.first; + final removedPath = _downloadedEmbeddedCoverCache.remove(oldestKey); + _pendingDownloadedCoverExtract.remove(oldestKey); + _pendingDownloadedCoverRefresh.remove(oldestKey); + _failedDownloadedCoverExtract.remove(oldestKey); + _cleanupTempCoverPathSync(removedPath); + } + } + Future _readFileModTimeMillis(String? filePath) async { final cleanPath = _cleanFilePath(filePath); if (cleanPath.isEmpty) return null; @@ -918,6 +944,7 @@ class _QueueTabState extends ConsumerState { final previous = _downloadedEmbeddedCoverCache[cleanPath]; _downloadedEmbeddedCoverCache[cleanPath] = outputPath; _failedDownloadedCoverExtract.remove(cleanPath); + _trimDownloadedEmbeddedCoverCache(); if (previous != null && previous != outputPath) { _cleanupTempCoverPathSync(previous); } @@ -1607,10 +1634,7 @@ class _QueueTabState extends ConsumerState { if (searchQuery.isNotEmpty) { final query = searchQuery; filteredItems = items.where((item) { - final searchKey = _searchIndexCache[item.id] ?? _buildSearchKey(item); - if (!_searchIndexCache.containsKey(item.id)) { - _searchIndexCache[item.id] = searchKey; - } + final searchKey = _historySearchKeyForItem(item); return searchKey.contains(query); }).toList(); } @@ -1797,7 +1821,7 @@ class _QueueTabState extends ConsumerState { _initializePageController(); final hasQueueItems = ref.watch( - downloadQueueProvider.select((s) => s.items.isNotEmpty), + downloadQueueLookupProvider.select((lookup) => lookup.itemIds.isNotEmpty), ); final allHistoryItems = ref.watch( downloadHistoryProvider.select((s) => s.items), @@ -1825,6 +1849,14 @@ class _QueueTabState extends ConsumerState { _buildHistoryStats(allHistoryItems, localLibraryItems); final groupedAlbums = historyStats.groupedAlbums; final groupedLocalAlbums = historyStats.groupedLocalAlbums; + final filteredGroupedAlbums = _filterGroupedAlbums( + groupedAlbums, + _searchQuery, + ); + final filteredGroupedLocalAlbums = _filterGroupedLocalAlbums( + groupedLocalAlbums, + _searchQuery, + ); final albumCount = historyStats.totalAlbumCount; final singleCount = historyStats.totalSingleTracks; final filterDataCache = {}; @@ -1835,8 +1867,8 @@ class _QueueTabState extends ConsumerState { () => _computeFilterContentData( filterMode: filterMode, allHistoryItems: allHistoryItems, - groupedAlbums: groupedAlbums, - groupedLocalAlbums: groupedLocalAlbums, + filteredGroupedAlbums: filteredGroupedAlbums, + filteredGroupedLocalAlbums: filteredGroupedLocalAlbums, albumCounts: historyStats.albumCounts, localAlbumCounts: historyStats.localAlbumCounts, localLibraryItems: localLibraryItems, @@ -2227,8 +2259,8 @@ class _QueueTabState extends ConsumerState { _FilterContentData _computeFilterContentData({ required String filterMode, required List allHistoryItems, - required List<_GroupedAlbum> groupedAlbums, - required List<_GroupedLocalAlbum> groupedLocalAlbums, + required List<_GroupedAlbum> filteredGroupedAlbums, + required List<_GroupedLocalAlbum> filteredGroupedLocalAlbums, required Map albumCounts, required Map localAlbumCounts, required List localLibraryItems, @@ -2243,16 +2275,6 @@ class _QueueTabState extends ConsumerState { filterMode: filterMode, ); - final searchQuery = _searchQuery; - final filteredGroupedAlbums = _filterGroupedAlbums( - groupedAlbums, - searchQuery, - ); - final filteredGroupedLocalAlbums = _filterGroupedLocalAlbums( - groupedLocalAlbums, - searchQuery, - ); - final unifiedItems = _getUnifiedItems( filterMode: filterMode, historyItems: historyItems, @@ -2278,7 +2300,7 @@ class _QueueTabState extends ConsumerState { return Consumer( builder: (context, ref, child) { final queueCount = ref.watch( - downloadQueueProvider.select((s) => s.items.length), + downloadQueueLookupProvider.select((lookup) => lookup.itemIds.length), ); if (queueCount == 0) { return const SliverToBoxAdapter(child: SizedBox.shrink()); @@ -2310,10 +2332,8 @@ class _QueueTabState extends ConsumerState { return Consumer( builder: (context, ref, child) { final queueIdsSnapshot = ref.watch( - downloadQueueProvider.select( - (s) => _QueueItemIdsSnapshot( - s.items.map((item) => item.id).toList(growable: false), - ), + downloadQueueLookupProvider.select( + (lookup) => _QueueItemIdsSnapshot(lookup.itemIds), ), ); if (queueIdsSnapshot.ids.isEmpty) { @@ -4011,14 +4031,7 @@ class _QueueItemSliverRow extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final item = ref.watch( - downloadQueueProvider.select((state) { - for (final current in state.items) { - if (current.id == itemId) { - return current; - } - } - return null; - }), + downloadQueueLookupProvider.select((lookup) => lookup.byItemId[itemId]), ); if (item == null) { return const SizedBox.shrink(); diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart index be1caa1..bc006b1 100644 --- a/lib/utils/logger.dart +++ b/lib/utils/logger.dart @@ -8,6 +8,27 @@ import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; const int _maxLogMessageLength = 500; +const String _redactedValue = '[REDACTED]'; + +final RegExp _authorizationBearerPattern = RegExp( + r'\bAuthorization\b\s*[:=]\s*Bearer\s+[A-Za-z0-9._~+/\-]+=*', + caseSensitive: false, +); + +final RegExp _genericSensitiveKeyValuePattern = RegExp( + r'\b(access[_\s-]?token|refresh[_\s-]?token|id[_\s-]?token|client[_\s-]?secret|authorization|password|api[_\s-]?key)\b(\s*[:=]\s*)([^\s,;]+)', + caseSensitive: false, +); + +final RegExp _sensitiveQueryPattern = RegExp( + r'([?&](?:access_token|refresh_token|id_token|token|client_secret|api_key|apikey|password)=)[^&\s]+', + caseSensitive: false, +); + +final RegExp _bearerTokenPattern = RegExp( + r'\bBearer\s+[A-Za-z0-9._~+/\-]+=*', + caseSensitive: false, +); String _truncateLogText(String value, {int maxLength = _maxLogMessageLength}) { if (value.length <= maxLength) { @@ -16,6 +37,39 @@ String _truncateLogText(String value, {int maxLength = _maxLogMessageLength}) { return '${value.substring(0, maxLength)}...[truncated]'; } +String _redactSensitiveText(String value) { + var redacted = value; + + redacted = redacted.replaceAllMapped(_authorizationBearerPattern, (_) { + return 'Authorization: Bearer $_redactedValue'; + }); + + redacted = redacted.replaceAllMapped(_genericSensitiveKeyValuePattern, ( + match, + ) { + final key = match.group(1) ?? ''; + final delimiter = match.group(2) ?? '='; + return '$key$delimiter$_redactedValue'; + }); + + redacted = redacted.replaceAllMapped(_sensitiveQueryPattern, (match) { + final prefix = match.group(1) ?? ''; + return '$prefix$_redactedValue'; + }); + + redacted = redacted.replaceAllMapped(_bearerTokenPattern, (_) { + return 'Bearer $_redactedValue'; + }); + + return redacted; +} + +String _maskIdentifier(String value) { + if (value.isEmpty) return value; + if (value.length <= 4) return '***'; + return '${value.substring(0, 2)}***${value.substring(value.length - 2)}'; +} + class LogEntry { final DateTime timestamp; final String level; @@ -59,6 +113,7 @@ class LogBuffer extends ChangeNotifier { final Queue _entries = Queue(); Timer? _goLogTimer; int _lastGoLogIndex = 0; + bool _isFetchingGoLogs = false; static bool _loggingEnabled = false; static bool get loggingEnabled => _loggingEnabled; @@ -79,9 +134,11 @@ class LogBuffer extends ChangeNotifier { return; } - final sanitizedMessage = _truncateLogText(entry.message); + final sanitizedMessage = _truncateLogText( + _redactSensitiveText(entry.message), + ); final sanitizedError = entry.error != null - ? _truncateLogText(entry.error!) + ? _truncateLogText(_redactSensitiveText(entry.error!)) : null; final sanitizedEntry = (sanitizedMessage == entry.message && sanitizedError == entry.error) @@ -105,13 +162,20 @@ class LogBuffer extends ChangeNotifier { void startGoLogPolling() { _goLogTimer?.cancel(); _goLogTimer = Timer.periodic(_goLogPollingInterval, (_) async { - await _fetchGoLogs(); + if (_isFetchingGoLogs) return; + _isFetchingGoLogs = true; + try { + await _fetchGoLogs(); + } finally { + _isFetchingGoLogs = false; + } }); } void stopGoLogPolling() { _goLogTimer?.cancel(); _goLogTimer = null; + _isFetchingGoLogs = false; } Future _fetchGoLogs() async { @@ -216,7 +280,7 @@ class LogBuffer extends ChangeNotifier { buffer.writeln( 'Android Version: ${android.version.release} (SDK ${android.version.sdkInt})', ); - buffer.writeln('Device ID: ${android.id}'); + buffer.writeln('Device ID: ${_maskIdentifier(android.id)}'); buffer.writeln('Hardware: ${android.hardware}'); buffer.writeln('Product: ${android.product}'); buffer.writeln('Supported ABIs: ${android.supportedAbis.join(', ')}'); @@ -313,12 +377,14 @@ class BufferedOutput extends LogOutput { void output(OutputEvent event) { if (kDebugMode) { for (final line in event.lines) { - debugPrint(_truncateLogText(line)); + debugPrint(_truncateLogText(_redactSensitiveText(line))); } } final level = _levelToString(event.level); - final message = _truncateLogText(event.lines.join('\n')); + final message = _truncateLogText( + _redactSensitiveText(event.lines.join('\n')), + ); LogBuffer().add( LogEntry( @@ -421,7 +487,7 @@ class AppLogger { _addToBuffer('ERROR', message, error: error.toString()); if (kDebugMode) { debugPrint( - '[$_tag] ERROR: ${_truncateLogText(message)} | ${_truncateLogText(error.toString())}', + '[$_tag] ERROR: ${_truncateLogText(_redactSensitiveText(message))} | ${_truncateLogText(_redactSensitiveText(error.toString()))}', ); if (stackTrace != null) { debugPrint(stackTrace.toString());