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
This commit is contained in:
zarzet
2026-02-11 02:02:03 +07:00
parent a9150b85b9
commit 84df64fcfe
14 changed files with 785 additions and 330 deletions
@@ -300,8 +300,18 @@ class MainActivity: FlutterFragmentActivity() {
return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim() 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? { private fun ensureDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? {
if (relativeDir.isBlank()) { val safeRelativeDir = sanitizeRelativeDir(relativeDir)
if (safeRelativeDir.isBlank()) {
return DocumentFile.fromTreeUri(this, treeUri) return DocumentFile.fromTreeUri(this, treeUri)
} }
@@ -310,7 +320,7 @@ class MainActivity: FlutterFragmentActivity() {
synchronized(safDirLock) { synchronized(safDirLock) {
var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null 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) { for (part in parts) {
val existing = current.findFile(part) val existing = current.findFile(part)
current = if (existing != null && existing.isDirectory) { current = if (existing != null && existing.isDirectory) {
@@ -335,9 +345,10 @@ class MainActivity: FlutterFragmentActivity() {
private fun findDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? { private fun findDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? {
var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null 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) { for (part in parts) {
val existing = current.findFile(part) val existing = current.findFile(part)
if (existing == null || !existing.isDirectory) return null if (existing == null || !existing.isDirectory) return null
@@ -377,14 +388,21 @@ class MainActivity: FlutterFragmentActivity() {
obj.put("relative_dir", "") obj.put("relative_dir", "")
return obj.toString() 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 treeUri = Uri.parse(treeUriStr)
val targetDir = findDocumentDir(treeUri, relativeDir) val targetDir = findDocumentDir(treeUri, safeRelativeDir)
if (targetDir != null) { if (targetDir != null) {
val direct = targetDir.findFile(fileName) val direct = targetDir.findFile(safeFileName)
if (direct != null && direct.isFile) { if (direct != null && direct.isFile) {
obj.put("uri", direct.uri.toString()) obj.put("uri", direct.uri.toString())
obj.put("relative_dir", relativeDir) obj.put("relative_dir", safeRelativeDir)
return obj.toString() return obj.toString()
} }
} }
@@ -410,7 +428,7 @@ class MainActivity: FlutterFragmentActivity() {
val childPath = if (path.isBlank()) childName else "$path/$childName" val childPath = if (path.isBlank()) childName else "$path/$childName"
queue.add(child to childPath) queue.add(child to childPath)
} else if (child.isFile) { } else if (child.isFile) {
if (child.name == fileName) { if (child.name == safeFileName) {
obj.put("uri", child.uri.toString()) obj.put("uri", child.uri.toString())
obj.put("relative_dir", path) obj.put("relative_dir", path)
return obj.toString() return obj.toString()
@@ -426,7 +444,7 @@ class MainActivity: FlutterFragmentActivity() {
private fun buildSafFileName(req: JSONObject, outputExt: String): String { private fun buildSafFileName(req: JSONObject, outputExt: String): String {
val provided = req.optString("saf_file_name", "") 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 trackName = req.optString("track_name", "track")
val artistName = req.optString("artist_name", "") val artistName = req.optString("artist_name", "")
@@ -617,7 +635,7 @@ class MainActivity: FlutterFragmentActivity() {
} }
val treeUri = Uri.parse(treeUriStr) 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 outputExt = normalizeExt(req.optString("saf_output_ext", ""))
val mimeType = mimeTypeForExt(outputExt) val mimeType = mimeTypeForExt(outputExt)
val fileName = buildSafFileName(req, outputExt) val fileName = buildSafFileName(req, outputExt)
@@ -1474,11 +1492,12 @@ class MainActivity: FlutterFragmentActivity() {
"safCreateFromPath" -> { "safCreateFromPath" -> {
val treeUriStr = call.argument<String>("tree_uri") ?: "" val treeUriStr = call.argument<String>("tree_uri") ?: ""
val relativeDir = call.argument<String>("relative_dir") ?: "" val relativeDir = call.argument<String>("relative_dir") ?: ""
val fileName = call.argument<String>("file_name") ?: "" val fileName = sanitizeFilename(call.argument<String>("file_name") ?: "")
val mimeType = call.argument<String>("mime_type") ?: "application/octet-stream" val mimeType = call.argument<String>("mime_type") ?: "application/octet-stream"
val srcPath = call.argument<String>("src_path") ?: "" val srcPath = call.argument<String>("src_path") ?: ""
val createdUri = withContext(Dispatchers.IO) { val createdUri = withContext(Dispatchers.IO) {
if (treeUriStr.isBlank()) return@withContext null if (treeUriStr.isBlank()) return@withContext null
if (fileName.isBlank()) return@withContext null
val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null
val existing = dir.findFile(fileName) val existing = dir.findFile(fileName)
val createdNew = existing == null val createdNew = existing == null
+14 -2
View File
@@ -2906,14 +2906,26 @@ func GetStoreCategoriesJSON() (string, error) {
return string(jsonBytes), nil 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) { func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
store := GetExtensionStore() store := GetExtensionStore()
if store == nil { if store == nil {
return "", fmt.Errorf("extension store not initialized") return "", fmt.Errorf("extension store not initialized")
} }
destPath := fmt.Sprintf("%s/%s.spotiflac-ext", destDir, extensionID) destPath, err := buildStoreExtensionDestPath(destDir, extensionID)
err := store.DownloadExtension(extensionID, destPath) if err != nil {
return "", err
}
err = store.DownloadExtension(extensionID, destPath)
if err != nil { if err != nil {
return "", err return "", err
} }
+58 -4
View File
@@ -18,6 +18,43 @@ import (
// ==================== Auth API (OAuth Support) ==================== // ==================== 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 { func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{ 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() 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() pendingAuthRequestsMu.Lock()
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{ pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
ExtensionID: r.extensionID, ExtensionID: r.extensionID,
@@ -50,7 +94,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
state.AuthCode = "" state.AuthCode = ""
extensionAuthStateMu.Unlock() 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{}{ return r.vm.ToValue(map[string]interface{}{
"success": true, "success": true,
@@ -273,6 +317,12 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
"error": "authUrl, clientId, and redirectUri are required", "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) scope, _ := config["scope"].(string)
extraParams, _ := config["extraParams"].(map[string]interface{}) extraParams, _ := config["extraParams"].(map[string]interface{})
@@ -331,7 +381,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
} }
pendingAuthRequestsMu.Unlock() 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{}{ return r.vm.ToValue(map[string]interface{}{
"success": true, "success": true,
@@ -441,13 +491,17 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
"error": err.Error(), "error": err.Error(),
}) })
} }
bodyPreview := sanitizeSensitiveLogText(string(body))
if len(bodyPreview) > 1000 {
bodyPreview = bodyPreview[:1000] + "...[truncated]"
}
var tokenResp map[string]interface{} var tokenResp map[string]interface{}
if err := json.Unmarshal(body, &tokenResp); err != nil { if err := json.Unmarshal(body, &tokenResp); err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
"error": fmt.Sprintf("failed to parse token response: %v", err), "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{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
"error": "no access_token in response", "error": "no access_token in response",
"body": string(body), "body": bodyPreview,
}) })
} }
+3
View File
@@ -32,6 +32,9 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
if parsed.Scheme != "https" { if parsed.Scheme != "https" {
return fmt.Errorf("network access denied: only https is allowed") 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() domain := parsed.Hostname()
if domain == "" { if domain == "" {
+1 -1
View File
@@ -46,7 +46,7 @@ func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
return err return err
} }
return os.WriteFile(storagePath, data, 0644) return os.WriteFile(storagePath, data, 0600)
} }
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value { func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
+16
View File
@@ -3,6 +3,7 @@ package gobackend
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -30,8 +31,22 @@ const (
var ( var (
globalLogBuffer *LogBuffer globalLogBuffer *LogBuffer
logBufferOnce sync.Once 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 { func GetLogBuffer() *LogBuffer {
logBufferOnce.Do(func() { logBufferOnce.Do(func() {
globalLogBuffer = &LogBuffer{ globalLogBuffer = &LogBuffer{
@@ -71,6 +86,7 @@ func (lb *LogBuffer) Add(level, tag, message string) {
return return
} }
message = sanitizeSensitiveLogText(message)
message = truncateLogMessage(message) message = truncateLogMessage(message)
entry := LogEntry{ entry := LogEntry{
+80
View File
@@ -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")
}
}
+38 -22
View File
@@ -208,16 +208,11 @@ class DownloadHistoryItem {
class DownloadHistoryState { class DownloadHistoryState {
final List<DownloadHistoryItem> items; final List<DownloadHistoryItem> items;
final Set<String> _downloadedSpotifyIds;
final Map<String, DownloadHistoryItem> _bySpotifyId; final Map<String, DownloadHistoryItem> _bySpotifyId;
final Map<String, DownloadHistoryItem> _byIsrc; final Map<String, DownloadHistoryItem> _byIsrc;
DownloadHistoryState({this.items = const []}) DownloadHistoryState({this.items = const []})
: _downloadedSpotifyIds = items : _bySpotifyId = Map.fromEntries(
.where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty)
.map((item) => item.spotifyId!)
.toSet(),
_bySpotifyId = Map.fromEntries(
items items
.where( .where(
(item) => item.spotifyId != null && item.spotifyId!.isNotEmpty, (item) => item.spotifyId != null && item.spotifyId!.isNotEmpty,
@@ -230,8 +225,7 @@ class DownloadHistoryState {
.map((item) => MapEntry(item.isrc!, item)), .map((item) => MapEntry(item.isrc!, item)),
); );
bool isDownloaded(String spotifyId) => bool isDownloaded(String spotifyId) => _bySpotifyId.containsKey(spotifyId);
_downloadedSpotifyIds.contains(spotifyId);
DownloadHistoryItem? getBySpotifyId(String spotifyId) => DownloadHistoryItem? getBySpotifyId(String spotifyId) =>
_bySpotifyId[spotifyId]; _bySpotifyId[spotifyId];
@@ -682,6 +676,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
bool _isLoaded = false; bool _isLoaded = false;
final Set<String> _ensuredDirs = {}; final Set<String> _ensuredDirs = {};
int _progressPollingErrorCount = 0; int _progressPollingErrorCount = 0;
bool _isProgressPollingInFlight = false;
String? _lastServiceTrackName; String? _lastServiceTrackName;
String? _lastServiceArtistName; String? _lastServiceArtistName;
int _lastServicePercent = -1; int _lastServicePercent = -1;
@@ -832,6 +827,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
void _startMultiProgressPolling() { void _startMultiProgressPolling() {
_progressTimer?.cancel(); _progressTimer?.cancel();
_progressTimer = Timer.periodic(_progressPollingInterval, (timer) async { _progressTimer = Timer.periodic(_progressPollingInterval, (timer) async {
if (_isProgressPollingInFlight) return;
_isProgressPollingInFlight = true;
try { try {
final allProgress = await PlatformBridge.getAllDownloadProgress(); final allProgress = await PlatformBridge.getAllDownloadProgress();
final items = allProgress['items'] as Map<String, dynamic>? ?? {}; final items = allProgress['items'] as Map<String, dynamic>? ?? {};
@@ -915,16 +912,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
bytesReceived: normalizedBytes, bytesReceived: normalizedBytes,
); );
final mbReceived = bytesReceived / (1024 * 1024); if (LogBuffer.loggingEnabled) {
final mbTotal = bytesTotal / (1024 * 1024); final mbReceived = bytesReceived / (1024 * 1024);
if (bytesTotal > 0) { final mbTotal = bytesTotal / (1024 * 1024);
_log.d( if (bytesTotal > 0) {
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s', _log.d(
); 'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s',
} else { );
_log.d( } else {
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s', _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<DownloadQueueState> {
if (_progressPollingErrorCount <= 3) { if (_progressPollingErrorCount <= 3) {
_log.w('Progress polling failed: $e'); _log.w('Progress polling failed: $e');
} }
} finally {
_isProgressPollingInFlight = false;
} }
}); });
} }
@@ -1088,6 +1089,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_progressTimer?.cancel(); _progressTimer?.cancel();
_progressTimer = null; _progressTimer = null;
_progressPollingErrorCount = 0; _progressPollingErrorCount = 0;
_isProgressPollingInFlight = false;
_lastServiceTrackName = null; _lastServiceTrackName = null;
_lastServiceArtistName = null; _lastServiceArtistName = null;
_lastServicePercent = -1; _lastServicePercent = -1;
@@ -4011,15 +4013,29 @@ final downloadQueueProvider =
class DownloadQueueLookup { class DownloadQueueLookup {
final Map<String, DownloadItem> byTrackId; final Map<String, DownloadItem> byTrackId;
final Map<String, DownloadItem> byItemId;
final List<String> itemIds;
DownloadQueueLookup._(this.byTrackId); DownloadQueueLookup._({
required this.byTrackId,
required this.byItemId,
required this.itemIds,
});
factory DownloadQueueLookup.fromItems(List<DownloadItem> items) { factory DownloadQueueLookup.fromItems(List<DownloadItem> items) {
final map = <String, DownloadItem>{}; final byTrackId = <String, DownloadItem>{};
final byItemId = <String, DownloadItem>{};
final itemIds = <String>[];
for (final item in items) { 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,
);
} }
} }
+8 -11
View File
@@ -24,7 +24,6 @@ class LocalLibraryState {
final bool scanWasCancelled; final bool scanWasCancelled;
final DateTime? lastScannedAt; final DateTime? lastScannedAt;
final int excludedDownloadedCount; final int excludedDownloadedCount;
final Set<String> _isrcSet;
final Set<String> _trackKeySet; final Set<String> _trackKeySet;
final Map<String, LocalLibraryItem> _byIsrc; final Map<String, LocalLibraryItem> _byIsrc;
@@ -39,16 +38,9 @@ class LocalLibraryState {
this.scanWasCancelled = false, this.scanWasCancelled = false,
this.lastScannedAt, this.lastScannedAt,
this.excludedDownloadedCount = 0, this.excludedDownloadedCount = 0,
Set<String>? isrcSet,
Set<String>? trackKeySet, Set<String>? trackKeySet,
Map<String, LocalLibraryItem>? byIsrc, Map<String, LocalLibraryItem>? byIsrc,
}) : _isrcSet = }) : _trackKeySet = trackKeySet ?? items.map((item) => item.matchKey).toSet(),
isrcSet ??
items
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => item.isrc!)
.toSet(),
_trackKeySet = trackKeySet ?? items.map((item) => item.matchKey).toSet(),
_byIsrc = _byIsrc =
byIsrc ?? byIsrc ??
Map.fromEntries( Map.fromEntries(
@@ -57,7 +49,7 @@ class LocalLibraryState {
.map((item) => MapEntry(item.isrc!, item)), .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) { bool hasTrack(String trackName, String artistName) {
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}'; final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
@@ -108,7 +100,6 @@ class LocalLibraryState {
lastScannedAt: lastScannedAt ?? this.lastScannedAt, lastScannedAt: lastScannedAt ?? this.lastScannedAt,
excludedDownloadedCount: excludedDownloadedCount:
excludedDownloadedCount ?? this.excludedDownloadedCount, excludedDownloadedCount ?? this.excludedDownloadedCount,
isrcSet: keepDerivedIndex ? _isrcSet : null,
trackKeySet: keepDerivedIndex ? _trackKeySet : null, trackKeySet: keepDerivedIndex ? _trackKeySet : null,
byIsrc: keepDerivedIndex ? _byIsrc : null, byIsrc: keepDerivedIndex ? _byIsrc : null,
); );
@@ -123,6 +114,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
bool _isLoaded = false; bool _isLoaded = false;
bool _scanCancelRequested = false; bool _scanCancelRequested = false;
int _progressPollingErrorCount = 0; int _progressPollingErrorCount = 0;
bool _isProgressPollingInFlight = false;
@override @override
LocalLibraryState build() { LocalLibraryState build() {
@@ -408,6 +400,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
void _startProgressPolling() { void _startProgressPolling() {
_progressTimer?.cancel(); _progressTimer?.cancel();
_progressTimer = Timer.periodic(_progressPollingInterval, (_) async { _progressTimer = Timer.periodic(_progressPollingInterval, (_) async {
if (_isProgressPollingInFlight) return;
_isProgressPollingInFlight = true;
try { try {
final progress = await PlatformBridge.getLibraryScanProgress(); final progress = await PlatformBridge.getLibraryScanProgress();
final nextProgress = final nextProgress =
@@ -447,6 +441,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (_progressPollingErrorCount <= 3) { if (_progressPollingErrorCount <= 3) {
_log.w('Library scan progress polling failed: $e'); _log.w('Library scan progress polling failed: $e');
} }
} finally {
_isProgressPollingInFlight = false;
} }
}); });
} }
@@ -455,6 +451,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_progressTimer?.cancel(); _progressTimer?.cancel();
_progressTimer = null; _progressTimer = null;
_progressPollingErrorCount = 0; _progressPollingErrorCount = 0;
_isProgressPollingInFlight = false;
} }
Future<void> cancelScan() async { Future<void> cancelScan() async {
+189 -56
View File
@@ -26,8 +26,10 @@ class TrackState {
final List<SearchPlaylist>? searchPlaylists; // For search results (playlists) final List<SearchPlaylist>? searchPlaylists; // For search results (playlists)
final bool hasSearchText; // For back button handling final bool hasSearchText; // For back button handling
final bool isShowingRecentAccess; // For recent access mode final bool isShowingRecentAccess; // For recent access mode
final String? searchExtensionId; // Extension ID used for current search results final String?
final String? selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist") searchExtensionId; // Extension ID used for current search results
final String?
selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
const TrackState({ const TrackState({
this.tracks = const [], this.tracks = const [],
@@ -52,7 +54,12 @@ class TrackState {
this.selectedSearchFilter, 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({ TrackState copyWith({
List<Track>? tracks, List<Track>? tracks,
@@ -95,9 +102,12 @@ class TrackState {
searchAlbums: searchAlbums ?? this.searchAlbums, searchAlbums: searchAlbums ?? this.searchAlbums,
searchPlaylists: searchPlaylists ?? this.searchPlaylists, searchPlaylists: searchPlaylists ?? this.searchPlaylists,
hasSearchText: hasSearchText ?? this.hasSearchText, hasSearchText: hasSearchText ?? this.hasSearchText,
isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess, isShowingRecentAccess:
isShowingRecentAccess ?? this.isShowingRecentAccess,
searchExtensionId: searchExtensionId, searchExtensionId: searchExtensionId,
selectedSearchFilter: clearSelectedSearchFilter ? null : (selectedSearchFilter ?? this.selectedSearchFilter), selectedSearchFilter: clearSelectedSearchFilter
? null
: (selectedSearchFilter ?? this.selectedSearchFilter),
); );
} }
} }
@@ -178,6 +188,7 @@ class SearchPlaylist {
class TrackNotifier extends Notifier<TrackState> { class TrackNotifier extends Notifier<TrackState> {
int _currentRequestId = 0; int _currentRequestId = 0;
static const int _maxPreWarmTracksPerRequest = 80;
@override @override
TrackState build() { TrackState build() {
@@ -205,13 +216,16 @@ class TrackNotifier extends Notifier<TrackState> {
if (!_isRequestValid(requestId)) return; if (!_isRequestValid(requestId)) return;
// Check if we got valid data // 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<String, dynamic>; final trackData = result['track'] as Map<String, dynamic>;
final name = trackData['name']?.toString() ?? ''; final name = trackData['name']?.toString() ?? '';
if (name.isNotEmpty) { if (name.isNotEmpty) {
break; break;
} }
} else if (result != null && (result['type'] == 'album' || result['type'] == 'playlist')) { } else if (result != null &&
(result['type'] == 'album' || result['type'] == 'playlist')) {
break; break;
} else if (result != null && result['type'] == 'artist') { } else if (result != null && result['type'] == 'artist') {
break; break;
@@ -245,15 +259,27 @@ class TrackNotifier extends Notifier<TrackState> {
searchExtensionId: extensionId, searchExtensionId: extensionId,
); );
return; 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<dynamic>; final trackList = result['tracks'] as List<dynamic>;
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList(); final tracks = trackList
.map(
(t) => _parseSearchTrack(
t as Map<String, dynamic>,
source: extensionId,
),
)
.toList();
state = TrackState( state = TrackState(
tracks: tracks, tracks: tracks,
isLoading: false, isLoading: false,
albumId: result['album']?['id'] as String?, albumId: result['album']?['id'] as String?,
albumName: result['name'] as String? ?? result['album']?['name'] as String?, albumName:
playlistName: type == 'playlist' ? result['name'] as String? : null, result['name'] as String? ??
result['album']?['name'] as String?,
playlistName: type == 'playlist'
? result['name'] as String?
: null,
coverUrl: result['cover_url'] as String?, coverUrl: result['cover_url'] as String?,
searchExtensionId: extensionId, searchExtensionId: extensionId,
); );
@@ -261,17 +287,29 @@ class TrackNotifier extends Notifier<TrackState> {
} else if (type == 'artist' && result['artist'] != null) { } else if (type == 'artist' && result['artist'] != null) {
final artistData = result['artist'] as Map<String, dynamic>; final artistData = result['artist'] as Map<String, dynamic>;
final albumsList = artistData['albums'] as List<dynamic>? ?? []; final albumsList = artistData['albums'] as List<dynamic>? ?? [];
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList(); final albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? []; final topTracksList =
final topTracks = topTracksList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList(); artistData['top_tracks'] as List<dynamic>? ?? [];
final topTracks = topTracksList
.map(
(t) => _parseSearchTrack(
t as Map<String, dynamic>,
source: extensionId,
),
)
.toList();
state = TrackState( state = TrackState(
tracks: [], tracks: [],
isLoading: false, isLoading: false,
artistId: artistData['id'] as String?, artistId: artistData['id'] as String?,
artistName: artistData['name'] 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?, headerImageUrl: artistData['header_image'] as String?,
monthlyListeners: artistData['listeners'] as int?, monthlyListeners: artistData['listeners'] as int?,
artistAlbums: albums, artistAlbums: albums,
@@ -306,7 +344,9 @@ class TrackNotifier extends Notifier<TrackState> {
} else if (type == 'album') { } else if (type == 'album') {
final albumInfo = metadata['album_info'] as Map<String, dynamic>; final albumInfo = metadata['album_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>; final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList(); final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
state = TrackState( state = TrackState(
tracks: tracks, tracks: tracks,
isLoading: false, isLoading: false,
@@ -316,9 +356,12 @@ class TrackNotifier extends Notifier<TrackState> {
); );
_preWarmCacheForTracks(tracks); _preWarmCacheForTracks(tracks);
} else if (type == 'playlist') { } else if (type == 'playlist') {
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>; final playlistInfo =
metadata['playlist_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>; final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList(); final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
state = TrackState( state = TrackState(
tracks: tracks, tracks: tracks,
isLoading: false, isLoading: false,
@@ -329,7 +372,9 @@ class TrackNotifier extends Notifier<TrackState> {
} else if (type == 'artist') { } else if (type == 'artist') {
final artistInfo = metadata['artist_info'] as Map<String, dynamic>; final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
final albumsList = metadata['albums'] as List<dynamic>; final albumsList = metadata['albums'] as List<dynamic>;
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList(); final albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
state = TrackState( state = TrackState(
tracks: [], tracks: [],
isLoading: false, isLoading: false,
@@ -357,7 +402,9 @@ class TrackNotifier extends Notifier<TrackState> {
if (type == 'track') { if (type == 'track') {
try { try {
_log.i('Converting Tidal track to Spotify/Deezer via SongLink...'); _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; if (!_isRequestValid(requestId)) return;
final spotifyUrl = conversion['spotify_url'] as String?; final spotifyUrl = conversion['spotify_url'] as String?;
@@ -365,7 +412,10 @@ class TrackNotifier extends Notifier<TrackState> {
if (spotifyUrl != null && spotifyUrl.isNotEmpty) { if (spotifyUrl != null && spotifyUrl.isNotEmpty) {
_log.i('Found Spotify URL: $spotifyUrl, fetching metadata...'); _log.i('Found Spotify URL: $spotifyUrl, fetching metadata...');
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(spotifyUrl); final metadata =
await PlatformBridge.getSpotifyMetadataWithFallback(
spotifyUrl,
);
if (!_isRequestValid(requestId)) return; if (!_isRequestValid(requestId)) return;
final trackData = metadata['track'] as Map<String, dynamic>; final trackData = metadata['track'] as Map<String, dynamic>;
@@ -378,8 +428,13 @@ class TrackNotifier extends Notifier<TrackState> {
return; return;
} else if (deezerUrl != null && deezerUrl.isNotEmpty) { } else if (deezerUrl != null && deezerUrl.isNotEmpty) {
_log.i('Found Deezer URL: $deezerUrl, fetching metadata...'); _log.i('Found Deezer URL: $deezerUrl, fetching metadata...');
final deezerParsed = await PlatformBridge.parseDeezerUrl(deezerUrl); final deezerParsed = await PlatformBridge.parseDeezerUrl(
final metadata = await PlatformBridge.getDeezerMetadata('track', deezerParsed['id'] as String); deezerUrl,
);
final metadata = await PlatformBridge.getDeezerMetadata(
'track',
deezerParsed['id'] as String,
);
if (!_isRequestValid(requestId)) return; if (!_isRequestValid(requestId)) return;
final trackData = metadata['track'] as Map<String, dynamic>; final trackData = metadata['track'] as Map<String, dynamic>;
@@ -399,7 +454,8 @@ class TrackNotifier extends Notifier<TrackState> {
// For album/artist/playlist, not yet supported // For album/artist/playlist, not yet supported
state = TrackState( state = TrackState(
isLoading: false, 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, hasSearchText: state.hasSearchText,
); );
return; return;
@@ -432,7 +488,9 @@ class TrackNotifier extends Notifier<TrackState> {
} else if (type == 'album') { } else if (type == 'album') {
final albumInfo = metadata['album_info'] as Map<String, dynamic>; final albumInfo = metadata['album_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>; final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList(); final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
state = TrackState( state = TrackState(
tracks: tracks, tracks: tracks,
isLoading: false, isLoading: false,
@@ -444,7 +502,9 @@ class TrackNotifier extends Notifier<TrackState> {
} else if (type == 'playlist') { } else if (type == 'playlist') {
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>; final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>; final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList(); final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final owner = playlistInfo['owner'] as Map<String, dynamic>?; final owner = playlistInfo['owner'] as Map<String, dynamic>?;
state = TrackState( state = TrackState(
tracks: tracks, tracks: tracks,
@@ -456,7 +516,9 @@ class TrackNotifier extends Notifier<TrackState> {
} else if (type == 'artist') { } else if (type == 'artist') {
final artistInfo = metadata['artist_info'] as Map<String, dynamic>; final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
final albumsList = metadata['albums'] as List<dynamic>; final albumsList = metadata['albums'] as List<dynamic>;
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList(); final albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
state = TrackState( state = TrackState(
tracks: [], tracks: [],
isLoading: false, isLoading: false,
@@ -468,17 +530,29 @@ class TrackNotifier extends Notifier<TrackState> {
} }
} catch (e) { } catch (e) {
if (!_isRequestValid(requestId)) return; 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<void> search(String query, {String? metadataSource, String? filterOverride}) async { Future<void> search(
String query, {
String? metadataSource,
String? filterOverride,
}) async {
final requestId = ++_currentRequestId; final requestId = ++_currentRequestId;
// Preserve selected filter during loading // Preserve selected filter during loading
final currentFilter = filterOverride ?? state.selectedSearchFilter; final currentFilter = filterOverride ?? state.selectedSearchFilter;
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText, selectedSearchFilter: currentFilter); state = TrackState(
isLoading: true,
hasSearchText: state.hasSearchText,
selectedSearchFilter: currentFilter,
);
try { try {
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
@@ -505,7 +579,10 @@ class TrackNotifier extends Notifier<TrackState> {
if (useExtensions) { if (useExtensions) {
try { try {
_log.d('Calling extension search API...'); _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'); _log.i('Extensions returned ${extResults.length} tracks');
for (final t in extResults) { for (final t in extResults) {
@@ -522,12 +599,25 @@ class TrackNotifier extends Notifier<TrackState> {
if (source == 'deezer') { if (source == 'deezer') {
_log.d('Calling Deezer search API...'); _log.d('Calling Deezer search API...');
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 2, filter: currentFilter); results = await PlatformBridge.searchDeezerAll(
_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'); 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 { } else {
_log.d('Calling Spotify search API...'); _log.d('Calling Spotify search API...');
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 2); results = await PlatformBridge.searchSpotifyAll(
_log.i('Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists'); 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)) { if (!_isRequestValid(requestId)) {
@@ -539,7 +629,9 @@ class TrackNotifier extends Notifier<TrackState> {
final artistList = results['artists'] as List<dynamic>? ?? []; final artistList = results['artists'] as List<dynamic>? ?? [];
final albumList = results['albums'] as List<dynamic>? ?? []; final albumList = results['albums'] as List<dynamic>? ?? [];
_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 = <Track>[]; final tracks = <Track>[];
@@ -610,7 +702,9 @@ class TrackNotifier extends Notifier<TrackState> {
} }
} }
_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( state = TrackState(
tracks: tracks, tracks: tracks,
@@ -624,23 +718,37 @@ class TrackNotifier extends Notifier<TrackState> {
} catch (e, stackTrace) { } catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return; if (!_isRequestValid(requestId)) return;
_log.e('Search failed: $e', e, stackTrace); _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<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async { Future<void> customSearch(
String extensionId,
String query, {
Map<String, dynamic>? options,
}) async {
final requestId = ++_currentRequestId; final requestId = ++_currentRequestId;
state = TrackState( state = TrackState(
isLoading: true, isLoading: true,
hasSearchText: state.hasSearchText, hasSearchText: state.hasSearchText,
selectedSearchFilter: state.selectedSearchFilter, // Preserve filter during loading selectedSearchFilter:
state.selectedSearchFilter, // Preserve filter during loading
); );
try { try {
_log.i('Custom search started: extension=$extensionId, query="$query"'); _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)) { if (!_isRequestValid(requestId)) {
_log.w('Custom search request cancelled (requestId=$requestId)'); _log.w('Custom search request cancelled (requestId=$requestId)');
@@ -659,7 +767,9 @@ class TrackNotifier extends Notifier<TrackState> {
} }
} }
_log.i('Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)'); _log.i(
'Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)',
);
state = TrackState( state = TrackState(
tracks: tracks, tracks: tracks,
@@ -667,12 +777,17 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false, isLoading: false,
hasSearchText: state.hasSearchText, hasSearchText: state.hasSearchText,
searchExtensionId: extensionId, // Store which extension was used searchExtensionId: extensionId, // Store which extension was used
selectedSearchFilter: state.selectedSearchFilter, // Preserve selected filter selectedSearchFilter:
state.selectedSearchFilter, // Preserve selected filter
); );
} catch (e, stackTrace) { } catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return; if (!_isRequestValid(requestId)) return;
_log.e('Custom search failed: $e', e, stackTrace); _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<TrackState> {
if (track.isrc == null || track.isrc!.isEmpty) return; if (track.isrc == null || track.isrc!.isEmpty) return;
try { try {
final availability = await PlatformBridge.checkAvailability(track.id, track.isrc!); final availability = await PlatformBridge.checkAvailability(
track.id,
track.isrc!,
);
final updatedTrack = Track( final updatedTrack = Track(
id: track.id, id: track.id,
name: track.name, name: track.name,
@@ -738,6 +856,9 @@ class TrackNotifier extends Notifier<TrackState> {
} }
void setShowingRecentAccess(bool showing) { void setShowingRecentAccess(bool showing) {
if (state.isShowingRecentAccess == showing) {
return;
}
state = state.copyWith(isShowingRecentAccess: showing); state = state.copyWith(isShowingRecentAccess: showing);
} }
@@ -797,7 +918,10 @@ class TrackNotifier extends Notifier<TrackState> {
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
releaseDate: data['release_date']?.toString(), 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(), albumType: data['album_type']?.toString(),
itemType: itemType, itemType: itemType,
); );
@@ -849,16 +973,25 @@ class TrackNotifier extends Notifier<TrackState> {
} }
void _preWarmCacheForTracks(List<Track> tracks) { void _preWarmCacheForTracks(List<Track> tracks) {
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList(); if (tracks.isEmpty) return;
if (tracksWithIsrc.isEmpty) return; final cacheRequests = <Map<String, String>>[];
for (final track in tracks) {
final cacheRequests = tracksWithIsrc.map((t) => { final isrc = track.isrc;
'isrc': t.isrc!, if (isrc == null || isrc.isEmpty) {
'track_name': t.name, continue;
'artist_name': t.artistName, }
'spotify_id': t.id, // Include Spotify ID for Amazon lookup cacheRequests.add({
'service': 'tidal', 'isrc': isrc,
}).toList(); '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((_) {}); PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {});
} }
+54 -24
View File
@@ -34,6 +34,12 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final Set<String> _selectedIds = {}; final Set<String> _selectedIds = {};
bool _showTitleInAppBar = false; bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
bool _embeddedCoverRefreshScheduled = false;
List<DownloadHistoryItem>? _albumTracksSourceCache;
List<DownloadHistoryItem>? _albumTracksCache;
String get _albumLookupKey =>
'${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
@override @override
void initState() { void initState() {
@@ -48,6 +54,16 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
super.dispose(); 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() { void _onScroll() {
final shouldShow = _scrollController.offset > 280; final shouldShow = _scrollController.offset > 280;
if (shouldShow != _showTitleInAppBar) { if (shouldShow != _showTitleInAppBar) {
@@ -59,28 +75,36 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
List<DownloadHistoryItem> _getAlbumTracks( List<DownloadHistoryItem> _getAlbumTracks(
List<DownloadHistoryItem> allItems, List<DownloadHistoryItem> allItems,
) { ) {
return allItems.where((item) { final cached = _albumTracksCache;
// Use albumArtist if available and not empty, otherwise artistName if (cached != null && identical(allItems, _albumTracksSourceCache)) {
final itemArtist = return cached;
(item.albumArtist != null && item.albumArtist!.isNotEmpty) }
? item.albumArtist!
: item.artistName; final tracks =
// Use lowercase for case-insensitive matching allItems.where((item) {
final itemKey = // Use albumArtist if available and not empty, otherwise artistName
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}'; final itemArtist =
final albumKey = (item.albumArtist != null && item.albumArtist!.isNotEmpty)
'${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}'; ? item.albumArtist!
return itemKey == albumKey; : item.artistName;
}).toList()..sort((a, b) { // Use lowercase for case-insensitive matching
// Sort by disc number first, then by track number final itemKey =
final aDisc = a.discNumber ?? 1; '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
final bDisc = b.discNumber ?? 1; return itemKey == _albumLookupKey;
if (aDisc != bDisc) return aDisc.compareTo(bDisc); }).toList()..sort((a, b) {
final aNum = a.trackNumber ?? 999; // Sort by disc number first, then by track number
final bNum = b.trackNumber ?? 999; final aDisc = a.discNumber ?? 1;
if (aNum != bNum) return aNum.compareTo(bNum); final bDisc = b.discNumber ?? 1;
return a.trackName.compareTo(b.trackName); 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<int, List<DownloadHistoryItem>> _groupTracksByDisc( Map<int, List<DownloadHistoryItem>> _groupTracksByDisc(
@@ -194,8 +218,14 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
} }
void _onEmbeddedCoverChanged() { void _onEmbeddedCoverChanged() {
if (!mounted) return; if (!mounted || _embeddedCoverRefreshScheduled) return;
setState(() {}); _embeddedCoverRefreshScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_embeddedCoverRefreshScheduled = false;
if (mounted) {
setState(() {});
}
});
} }
Future<void> _navigateToMetadataScreen(DownloadHistoryItem item) async { Future<void> _navigateToMetadataScreen(DownloadHistoryItem item) async {
+85 -69
View File
@@ -35,18 +35,25 @@ class HomeTab extends ConsumerStatefulWidget {
class _RecentAccessView { class _RecentAccessView {
final List<RecentAccessItem> uniqueItems; final List<RecentAccessItem> uniqueItems;
final List<RecentAccessItem> downloadItems; final List<String> downloadIds;
final Map<String, String> downloadFilePathByRecentKey; final Map<String, String> downloadFilePathByRecentKey;
final bool hasHiddenDownloads; final bool hasHiddenDownloads;
const _RecentAccessView({ const _RecentAccessView({
required this.uniqueItems, required this.uniqueItems,
required this.downloadItems, required this.downloadIds,
required this.downloadFilePathByRecentKey, required this.downloadFilePathByRecentKey,
required this.hasHiddenDownloads, required this.hasHiddenDownloads,
}); });
} }
class _RecentAlbumAggregate {
int count;
DownloadHistoryItem mostRecent;
_RecentAlbumAggregate({required this.count, required this.mostRecent});
}
class _CsvImportOptions { class _CsvImportOptions {
final bool confirmed; final bool confirmed;
final bool skipDownloaded; final bool skipDownloaded;
@@ -60,7 +67,6 @@ class _CsvImportOptions {
class _HomeTabState extends ConsumerState<HomeTab> class _HomeTabState extends ConsumerState<HomeTab>
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin { with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
final _urlController = TextEditingController(); final _urlController = TextEditingController();
bool _isTyping = false;
final FocusNode _searchFocusNode = FocusNode(); final FocusNode _searchFocusNode = FocusNode();
String? _lastSearchQuery; String? _lastSearchQuery;
late final ProviderSubscription<TrackState> _trackStateSub; late final ProviderSubscription<TrackState> _trackStateSub;
@@ -77,6 +83,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
List<RecentAccessItem>? _recentAccessItemsCache; List<RecentAccessItem>? _recentAccessItemsCache;
Set<String>? _recentAccessHiddenIdsCache; Set<String>? _recentAccessHiddenIdsCache;
_RecentAccessView? _recentAccessViewCache; _RecentAccessView? _recentAccessViewCache;
bool _embeddedCoverRefreshScheduled = false;
List<Extension>? _thumbnailSizesExtensionsCache;
Map<String, (double, double)>? _thumbnailSizesCache;
double _responsiveScale({ double _responsiveScale({
required BuildContext context, required BuildContext context,
@@ -200,6 +209,27 @@ class _HomeTabState extends ConsumerState<HomeTab>
super.dispose(); super.dispose();
} }
Map<String, (double, double)> _getThumbnailSizesByExtensionId(
List<Extension> extensions,
) {
final cached = _thumbnailSizesCache;
if (cached != null &&
identical(extensions, _thumbnailSizesExtensionsCache)) {
return cached;
}
final map = <String, (double, double)>{
for (final extension in extensions)
if (extension.searchBehavior != null)
extension.id: extension.searchBehavior!.getThumbnailSize(
defaultSize: 56,
),
};
_thumbnailSizesExtensionsCache = extensions;
_thumbnailSizesCache = map;
return map;
}
void _onSearchFocusChanged() { void _onSearchFocusChanged() {
if (mounted) { if (mounted) {
setState(() {}); setState(() {});
@@ -217,7 +247,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
_urlController.text.isNotEmpty && _urlController.text.isNotEmpty &&
!_searchFocusNode.hasFocus) { !_searchFocusNode.hasFocus) {
_urlController.clear(); _urlController.clear();
setState(() => _isTyping = false);
} }
} }
@@ -240,10 +269,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
ref.read(trackProvider.notifier).setSearchText(text.isNotEmpty); ref.read(trackProvider.notifier).setSearchText(text.isNotEmpty);
if (text.isNotEmpty && !_isTyping) { if (text.isEmpty) {
setState(() => _isTyping = true);
} else if (text.isEmpty && _isTyping) {
setState(() => _isTyping = false);
_liveSearchDebounce?.cancel(); _liveSearchDebounce?.cancel();
return; return;
} }
@@ -350,7 +376,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
_urlController.clear(); _urlController.clear();
_searchFocusNode.unfocus(); _searchFocusNode.unfocus();
_lastSearchQuery = null; _lastSearchQuery = null;
setState(() => _isTyping = false);
ref.read(trackProvider.notifier).clear(); ref.read(trackProvider.notifier).clear();
} }
@@ -390,7 +415,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
); );
ref.read(trackProvider.notifier).clear(); ref.read(trackProvider.notifier).clear();
_urlController.clear(); _urlController.clear();
setState(() => _isTyping = false);
return; return;
} }
@@ -416,7 +440,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
); );
ref.read(trackProvider.notifier).clear(); ref.read(trackProvider.notifier).clear();
_urlController.clear(); _urlController.clear();
setState(() => _isTyping = false);
return; return;
} }
@@ -438,7 +461,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
); );
ref.read(trackProvider.notifier).clear(); ref.read(trackProvider.notifier).clear();
_urlController.clear(); _urlController.clear();
setState(() => _isTyping = false);
return; return;
} }
} }
@@ -781,13 +803,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
); );
final showLocalLibraryIndicator = final showLocalLibraryIndicator =
localLibrarySettings.$1 && localLibrarySettings.$2; localLibrarySettings.$1 && localLibrarySettings.$2;
final thumbnailSizesByExtensionId = <String, (double, double)>{ final thumbnailSizesByExtensionId = _getThumbnailSizesByExtensionId(
for (final extension in extensions) extensions,
if (extension.searchBehavior != null) );
extension.id: extension.searchBehavior!.getThumbnailSize(
defaultSize: 56,
),
};
Extension? currentSearchExtension; Extension? currentSearchExtension;
List<SearchFilter> searchFilters = []; List<SearchFilter> searchFilters = [];
@@ -1028,8 +1046,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
} }
void _onEmbeddedCoverChanged() { void _onEmbeddedCoverChanged() {
if (!mounted) return; if (!mounted || _embeddedCoverRefreshScheduled) return;
setState(() {}); _embeddedCoverRefreshScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_embeddedCoverRefreshScheduled = false;
if (mounted) {
setState(() {});
}
});
} }
Widget _buildRecentDownloads( Widget _buildRecentDownloads(
@@ -1148,66 +1172,58 @@ class _HomeTabState extends ConsumerState<HomeTab>
return cached; return cached;
} }
final albumGroups = <String, List<DownloadHistoryItem>>{}; final albumGroups = <String, _RecentAlbumAggregate>{};
for (final h in historyItems) { for (final h in historyItems) {
final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty) final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty)
? h.albumArtist! ? h.albumArtist!
: h.artistName; : h.artistName;
final albumKey = '${h.albumName}|$artistForKey'; 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 = <RecentAccessItem>[]; final downloadIds = <String>[];
final visibleDownloads = <RecentAccessItem>[];
final downloadFilePathByRecentKey = <String, String>{}; final downloadFilePathByRecentKey = <String, String>{};
for (final entry in albumGroups.entries) { for (final aggregate in albumGroups.values) {
final tracks = entry.value; final mostRecent = aggregate.mostRecent;
final mostRecent = tracks.reduce(
(a, b) => a.downloadedAt.isAfter(b.downloadedAt) ? a : b,
);
final artistForKey = final artistForKey =
(mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty) (mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty)
? mostRecent.albumArtist! ? mostRecent.albumArtist!
: mostRecent.artistName; : mostRecent.artistName;
if (tracks.length == 1) { final isSingleTrack = aggregate.count == 1;
final recent = RecentAccessItem( final recentId = isSingleTrack
id: mostRecent.spotifyId ?? mostRecent.id, ? (mostRecent.spotifyId ?? mostRecent.id)
name: mostRecent.trackName, : '${mostRecent.albumName}|$artistForKey';
subtitle: mostRecent.artistName, final recent = RecentAccessItem(
imageUrl: mostRecent.coverUrl, id: recentId,
type: RecentAccessType.track, name: isSingleTrack ? mostRecent.trackName : mostRecent.albumName,
accessedAt: mostRecent.downloadedAt, subtitle: isSingleTrack ? mostRecent.artistName : artistForKey,
providerId: 'download', imageUrl: mostRecent.coverUrl,
); type: isSingleTrack ? RecentAccessType.track : RecentAccessType.album,
downloadItems.add(recent); accessedAt: mostRecent.downloadedAt,
downloadFilePathByRecentKey['${recent.type.name}:${recent.id}'] = providerId: 'download',
mostRecent.filePath; );
} else {
final recent = RecentAccessItem( downloadIds.add(recentId);
id: '${mostRecent.albumName}|$artistForKey', downloadFilePathByRecentKey['${recent.type.name}:${recent.id}'] =
name: mostRecent.albumName, mostRecent.filePath;
subtitle: artistForKey, if (!hiddenIds.contains(recentId)) {
imageUrl: mostRecent.coverUrl, visibleDownloads.add(recent);
type: RecentAccessType.album,
accessedAt: mostRecent.downloadedAt,
providerId: 'download',
);
downloadItems.add(recent);
downloadFilePathByRecentKey['${recent.type.name}:${recent.id}'] =
mostRecent.filePath;
} }
} }
downloadItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); visibleDownloads.sort((a, b) => b.accessedAt.compareTo(a.accessedAt));
if (visibleDownloads.length > 10) {
final visibleDownloads = <RecentAccessItem>[]; visibleDownloads.removeRange(10, visibleDownloads.length);
for (final item in downloadItems) {
if (!hiddenIds.contains(item.id)) {
visibleDownloads.add(item);
if (visibleDownloads.length >= 10) {
break;
}
}
} }
final allItems = <RecentAccessItem>[...items, ...visibleDownloads]; final allItems = <RecentAccessItem>[...items, ...visibleDownloads];
@@ -1227,7 +1243,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
final view = _RecentAccessView( final view = _RecentAccessView(
uniqueItems: uniqueItems, uniqueItems: uniqueItems,
downloadItems: downloadItems, downloadIds: downloadIds,
downloadFilePathByRecentKey: downloadFilePathByRecentKey, downloadFilePathByRecentKey: downloadFilePathByRecentKey,
hasHiddenDownloads: hiddenIds.isNotEmpty, hasHiddenDownloads: hiddenIds.isNotEmpty,
); );
@@ -1641,7 +1657,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
Widget _buildRecentAccess(_RecentAccessView view, ColorScheme colorScheme) { Widget _buildRecentAccess(_RecentAccessView view, ColorScheme colorScheme) {
final uniqueItems = view.uniqueItems; final uniqueItems = view.uniqueItems;
final downloadItems = view.downloadItems; final downloadIds = view.downloadIds;
final hasHiddenDownloads = view.hasHiddenDownloads; final hasHiddenDownloads = view.hasHiddenDownloads;
return Padding( return Padding(
@@ -1661,10 +1677,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (uniqueItems.isNotEmpty) if (uniqueItems.isNotEmpty)
TextButton( TextButton(
onPressed: () { onPressed: () {
for (final item in downloadItems) { for (final id in downloadIds) {
ref ref
.read(recentAccessProvider.notifier) .read(recentAccessProvider.notifier)
.hideDownloadFromRecents(item.id); .hideDownloadFromRecents(id);
} }
ref.read(recentAccessProvider.notifier).clearHistory(); ref.read(recentAccessProvider.notifier).clearHistory();
}, },
+82 -69
View File
@@ -228,6 +228,7 @@ Map<String, List<String>> _filterHistoryInIsolate(Map<String, Object> payload) {
final entries = (payload['entries'] as List).cast<List>(); final entries = (payload['entries'] as List).cast<List>();
final albumCounts = (payload['albumCounts'] as Map).cast<String, int>(); final albumCounts = (payload['albumCounts'] as Map).cast<String, int>();
final query = (payload['query'] as String?) ?? ''; final query = (payload['query'] as String?) ?? '';
final hasQuery = query.isNotEmpty;
final allIds = <String>[]; final allIds = <String>[];
final albumIds = <String>[]; final albumIds = <String>[];
@@ -236,10 +237,11 @@ Map<String, List<String>> _filterHistoryInIsolate(Map<String, Object> payload) {
for (final entry in entries) { for (final entry in entries) {
final id = entry[0] as String; final id = entry[0] as String;
final albumKey = entry[1] as String; final albumKey = entry[1] as String;
final searchKey = entry[2] as String; if (hasQuery) {
final searchKey = entry[2] as String;
if (query.isNotEmpty && !searchKey.contains(query)) { if (!searchKey.contains(query)) {
continue; continue;
}
} }
allIds.add(id); allIds.add(id);
@@ -276,6 +278,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final ValueNotifier<bool> _alwaysMissingFileNotifier = ValueNotifier(false); final ValueNotifier<bool> _alwaysMissingFileNotifier = ValueNotifier(false);
final Set<String> _pendingChecks = {}; final Set<String> _pendingChecks = {};
static const int _maxCacheSize = 500; static const int _maxCacheSize = 500;
static const int _maxSearchIndexCacheSize = 4000;
static const int _maxDownloadedEmbeddedCoverCacheSize = 180;
bool _isSelectionMode = false; bool _isSelectionMode = false;
final Set<String> _selectedIds = {}; final Set<String> _selectedIds = {};
@@ -311,8 +315,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final Set<String> _pendingDownloadedCoverExtract = {}; final Set<String> _pendingDownloadedCoverExtract = {};
final Set<String> _pendingDownloadedCoverRefresh = {}; final Set<String> _pendingDownloadedCoverRefresh = {};
final Set<String> _failedDownloadedCoverExtract = {}; final Set<String> _failedDownloadedCoverExtract = {};
Map<String, DownloadHistoryItem> _historyItemsById = {};
List<List<String>> _historyFilterEntries = const [];
Map<String, List<DownloadHistoryItem>> _filteredHistoryCache = const {}; Map<String, List<DownloadHistoryItem>> _filteredHistoryCache = const {};
List<DownloadHistoryItem>? _filterItemsCache; List<DownloadHistoryItem>? _filterItemsCache;
String _filterQueryCache = ''; String _filterQueryCache = '';
@@ -407,32 +409,16 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_historyItemsCache = items; _historyItemsCache = items;
_localLibraryItemsCache = localItems; _localLibraryItemsCache = localItems;
_historyStatsCache = _buildHistoryStats(items, localItems); _historyStatsCache = _buildHistoryStats(items, localItems);
_searchIndexCache if (historyChanged) {
..clear() _searchIndexCache.clear();
..addEntries( }
items.map((item) => MapEntry(item.id, _buildSearchKey(item))),
);
if (localChanged) { if (localChanged) {
_localSearchIndexCache _localSearchIndexCache.clear();
..clear()
..addEntries(
localItems.map(
(item) => MapEntry(item.id, _buildLocalSearchKey(item)),
),
);
_localFilterItemsCache = null; _localFilterItemsCache = null;
_localFilterQueryCache = ''; _localFilterQueryCache = '';
_filteredLocalItemsCache = const []; _filteredLocalItemsCache = const [];
} }
_unifiedItemsCache.clear(); _unifiedItemsCache.clear();
_historyItemsById = {for (final item in items) item.id: item};
_historyFilterEntries = List<List<String>>.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) { if (historyChanged) {
final validPaths = items final validPaths = items
@@ -459,6 +445,30 @@ class _QueueTabState extends ConsumerState<QueueTab> {
.toLowerCase(); .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<LocalLibraryItem> _filterLocalItems( List<LocalLibraryItem> _filterLocalItems(
List<LocalLibraryItem> items, List<LocalLibraryItem> items,
String query, String query,
@@ -471,11 +481,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final filtered = items final filtered = items
.where((item) { .where((item) {
final searchKey = final searchKey = _localSearchKeyForItem(item);
_localSearchIndexCache[item.id] ?? _buildLocalSearchKey(item);
if (!_localSearchIndexCache.containsKey(item.id)) {
_localSearchIndexCache[item.id] = searchKey;
}
return searchKey.contains(query); return searchKey.contains(query);
}) })
.toList(growable: false); .toList(growable: false);
@@ -548,15 +554,26 @@ class _QueueTabState extends ConsumerState<QueueTab> {
} }
final requestId = ++_filterRequestId; final requestId = ++_filterRequestId;
final includeSearchKey = query.isNotEmpty;
final entries = List<List<String>>.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 = <String, Object>{ final payload = <String, Object>{
'entries': _historyFilterEntries, 'entries': entries,
'albumCounts': albumCounts, 'albumCounts': albumCounts,
'query': query, 'query': query,
}; };
compute(_filterHistoryInIsolate, payload).then((result) { compute(_filterHistoryInIsolate, payload).then((result) {
if (!mounted || requestId != _filterRequestId) return; if (!mounted || requestId != _filterRequestId) return;
final itemsById = _historyItemsById; final itemsById = {for (final item in items) item.id: item};
final filtered = <String, List<DownloadHistoryItem>>{}; final filtered = <String, List<DownloadHistoryItem>>{};
for (final entry in result.entries) { for (final entry in result.entries) {
filtered[entry.key] = entry.value filtered[entry.key] = entry.value
@@ -604,10 +621,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final query = searchQuery; final query = searchQuery;
return items return items
.where((item) { .where((item) {
final searchKey = _searchIndexCache[item.id] ?? _buildSearchKey(item); final searchKey = _historySearchKeyForItem(item);
if (!_searchIndexCache.containsKey(item.id)) {
_searchIndexCache[item.id] = searchKey;
}
return searchKey.contains(query); return searchKey.contains(query);
}) })
.toList(growable: false); .toList(growable: false);
@@ -812,6 +826,18 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_cleanupTempCoverPathSync(cachedPath); _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<int?> _readFileModTimeMillis(String? filePath) async { Future<int?> _readFileModTimeMillis(String? filePath) async {
final cleanPath = _cleanFilePath(filePath); final cleanPath = _cleanFilePath(filePath);
if (cleanPath.isEmpty) return null; if (cleanPath.isEmpty) return null;
@@ -918,6 +944,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final previous = _downloadedEmbeddedCoverCache[cleanPath]; final previous = _downloadedEmbeddedCoverCache[cleanPath];
_downloadedEmbeddedCoverCache[cleanPath] = outputPath; _downloadedEmbeddedCoverCache[cleanPath] = outputPath;
_failedDownloadedCoverExtract.remove(cleanPath); _failedDownloadedCoverExtract.remove(cleanPath);
_trimDownloadedEmbeddedCoverCache();
if (previous != null && previous != outputPath) { if (previous != null && previous != outputPath) {
_cleanupTempCoverPathSync(previous); _cleanupTempCoverPathSync(previous);
} }
@@ -1607,10 +1634,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
if (searchQuery.isNotEmpty) { if (searchQuery.isNotEmpty) {
final query = searchQuery; final query = searchQuery;
filteredItems = items.where((item) { filteredItems = items.where((item) {
final searchKey = _searchIndexCache[item.id] ?? _buildSearchKey(item); final searchKey = _historySearchKeyForItem(item);
if (!_searchIndexCache.containsKey(item.id)) {
_searchIndexCache[item.id] = searchKey;
}
return searchKey.contains(query); return searchKey.contains(query);
}).toList(); }).toList();
} }
@@ -1797,7 +1821,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_initializePageController(); _initializePageController();
final hasQueueItems = ref.watch( final hasQueueItems = ref.watch(
downloadQueueProvider.select((s) => s.items.isNotEmpty), downloadQueueLookupProvider.select((lookup) => lookup.itemIds.isNotEmpty),
); );
final allHistoryItems = ref.watch( final allHistoryItems = ref.watch(
downloadHistoryProvider.select((s) => s.items), downloadHistoryProvider.select((s) => s.items),
@@ -1825,6 +1849,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_buildHistoryStats(allHistoryItems, localLibraryItems); _buildHistoryStats(allHistoryItems, localLibraryItems);
final groupedAlbums = historyStats.groupedAlbums; final groupedAlbums = historyStats.groupedAlbums;
final groupedLocalAlbums = historyStats.groupedLocalAlbums; final groupedLocalAlbums = historyStats.groupedLocalAlbums;
final filteredGroupedAlbums = _filterGroupedAlbums(
groupedAlbums,
_searchQuery,
);
final filteredGroupedLocalAlbums = _filterGroupedLocalAlbums(
groupedLocalAlbums,
_searchQuery,
);
final albumCount = historyStats.totalAlbumCount; final albumCount = historyStats.totalAlbumCount;
final singleCount = historyStats.totalSingleTracks; final singleCount = historyStats.totalSingleTracks;
final filterDataCache = <String, _FilterContentData>{}; final filterDataCache = <String, _FilterContentData>{};
@@ -1835,8 +1867,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
() => _computeFilterContentData( () => _computeFilterContentData(
filterMode: filterMode, filterMode: filterMode,
allHistoryItems: allHistoryItems, allHistoryItems: allHistoryItems,
groupedAlbums: groupedAlbums, filteredGroupedAlbums: filteredGroupedAlbums,
groupedLocalAlbums: groupedLocalAlbums, filteredGroupedLocalAlbums: filteredGroupedLocalAlbums,
albumCounts: historyStats.albumCounts, albumCounts: historyStats.albumCounts,
localAlbumCounts: historyStats.localAlbumCounts, localAlbumCounts: historyStats.localAlbumCounts,
localLibraryItems: localLibraryItems, localLibraryItems: localLibraryItems,
@@ -2227,8 +2259,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_FilterContentData _computeFilterContentData({ _FilterContentData _computeFilterContentData({
required String filterMode, required String filterMode,
required List<DownloadHistoryItem> allHistoryItems, required List<DownloadHistoryItem> allHistoryItems,
required List<_GroupedAlbum> groupedAlbums, required List<_GroupedAlbum> filteredGroupedAlbums,
required List<_GroupedLocalAlbum> groupedLocalAlbums, required List<_GroupedLocalAlbum> filteredGroupedLocalAlbums,
required Map<String, int> albumCounts, required Map<String, int> albumCounts,
required Map<String, int> localAlbumCounts, required Map<String, int> localAlbumCounts,
required List<LocalLibraryItem> localLibraryItems, required List<LocalLibraryItem> localLibraryItems,
@@ -2243,16 +2275,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
filterMode: filterMode, filterMode: filterMode,
); );
final searchQuery = _searchQuery;
final filteredGroupedAlbums = _filterGroupedAlbums(
groupedAlbums,
searchQuery,
);
final filteredGroupedLocalAlbums = _filterGroupedLocalAlbums(
groupedLocalAlbums,
searchQuery,
);
final unifiedItems = _getUnifiedItems( final unifiedItems = _getUnifiedItems(
filterMode: filterMode, filterMode: filterMode,
historyItems: historyItems, historyItems: historyItems,
@@ -2278,7 +2300,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return Consumer( return Consumer(
builder: (context, ref, child) { builder: (context, ref, child) {
final queueCount = ref.watch( final queueCount = ref.watch(
downloadQueueProvider.select((s) => s.items.length), downloadQueueLookupProvider.select((lookup) => lookup.itemIds.length),
); );
if (queueCount == 0) { if (queueCount == 0) {
return const SliverToBoxAdapter(child: SizedBox.shrink()); return const SliverToBoxAdapter(child: SizedBox.shrink());
@@ -2310,10 +2332,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return Consumer( return Consumer(
builder: (context, ref, child) { builder: (context, ref, child) {
final queueIdsSnapshot = ref.watch( final queueIdsSnapshot = ref.watch(
downloadQueueProvider.select( downloadQueueLookupProvider.select(
(s) => _QueueItemIdsSnapshot( (lookup) => _QueueItemIdsSnapshot(lookup.itemIds),
s.items.map((item) => item.id).toList(growable: false),
),
), ),
); );
if (queueIdsSnapshot.ids.isEmpty) { if (queueIdsSnapshot.ids.isEmpty) {
@@ -4011,14 +4031,7 @@ class _QueueItemSliverRow extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final item = ref.watch( final item = ref.watch(
downloadQueueProvider.select((state) { downloadQueueLookupProvider.select((lookup) => lookup.byItemId[itemId]),
for (final current in state.items) {
if (current.id == itemId) {
return current;
}
}
return null;
}),
); );
if (item == null) { if (item == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
+73 -7
View File
@@ -8,6 +8,27 @@ import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
const int _maxLogMessageLength = 500; 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}) { String _truncateLogText(String value, {int maxLength = _maxLogMessageLength}) {
if (value.length <= maxLength) { if (value.length <= maxLength) {
@@ -16,6 +37,39 @@ String _truncateLogText(String value, {int maxLength = _maxLogMessageLength}) {
return '${value.substring(0, maxLength)}...[truncated]'; 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 { class LogEntry {
final DateTime timestamp; final DateTime timestamp;
final String level; final String level;
@@ -59,6 +113,7 @@ class LogBuffer extends ChangeNotifier {
final Queue<LogEntry> _entries = Queue<LogEntry>(); final Queue<LogEntry> _entries = Queue<LogEntry>();
Timer? _goLogTimer; Timer? _goLogTimer;
int _lastGoLogIndex = 0; int _lastGoLogIndex = 0;
bool _isFetchingGoLogs = false;
static bool _loggingEnabled = false; static bool _loggingEnabled = false;
static bool get loggingEnabled => _loggingEnabled; static bool get loggingEnabled => _loggingEnabled;
@@ -79,9 +134,11 @@ class LogBuffer extends ChangeNotifier {
return; return;
} }
final sanitizedMessage = _truncateLogText(entry.message); final sanitizedMessage = _truncateLogText(
_redactSensitiveText(entry.message),
);
final sanitizedError = entry.error != null final sanitizedError = entry.error != null
? _truncateLogText(entry.error!) ? _truncateLogText(_redactSensitiveText(entry.error!))
: null; : null;
final sanitizedEntry = final sanitizedEntry =
(sanitizedMessage == entry.message && sanitizedError == entry.error) (sanitizedMessage == entry.message && sanitizedError == entry.error)
@@ -105,13 +162,20 @@ class LogBuffer extends ChangeNotifier {
void startGoLogPolling() { void startGoLogPolling() {
_goLogTimer?.cancel(); _goLogTimer?.cancel();
_goLogTimer = Timer.periodic(_goLogPollingInterval, (_) async { _goLogTimer = Timer.periodic(_goLogPollingInterval, (_) async {
await _fetchGoLogs(); if (_isFetchingGoLogs) return;
_isFetchingGoLogs = true;
try {
await _fetchGoLogs();
} finally {
_isFetchingGoLogs = false;
}
}); });
} }
void stopGoLogPolling() { void stopGoLogPolling() {
_goLogTimer?.cancel(); _goLogTimer?.cancel();
_goLogTimer = null; _goLogTimer = null;
_isFetchingGoLogs = false;
} }
Future<void> _fetchGoLogs() async { Future<void> _fetchGoLogs() async {
@@ -216,7 +280,7 @@ class LogBuffer extends ChangeNotifier {
buffer.writeln( buffer.writeln(
'Android Version: ${android.version.release} (SDK ${android.version.sdkInt})', '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('Hardware: ${android.hardware}');
buffer.writeln('Product: ${android.product}'); buffer.writeln('Product: ${android.product}');
buffer.writeln('Supported ABIs: ${android.supportedAbis.join(', ')}'); buffer.writeln('Supported ABIs: ${android.supportedAbis.join(', ')}');
@@ -313,12 +377,14 @@ class BufferedOutput extends LogOutput {
void output(OutputEvent event) { void output(OutputEvent event) {
if (kDebugMode) { if (kDebugMode) {
for (final line in event.lines) { for (final line in event.lines) {
debugPrint(_truncateLogText(line)); debugPrint(_truncateLogText(_redactSensitiveText(line)));
} }
} }
final level = _levelToString(event.level); final level = _levelToString(event.level);
final message = _truncateLogText(event.lines.join('\n')); final message = _truncateLogText(
_redactSensitiveText(event.lines.join('\n')),
);
LogBuffer().add( LogBuffer().add(
LogEntry( LogEntry(
@@ -421,7 +487,7 @@ class AppLogger {
_addToBuffer('ERROR', message, error: error.toString()); _addToBuffer('ERROR', message, error: error.toString());
if (kDebugMode) { if (kDebugMode) {
debugPrint( debugPrint(
'[$_tag] ERROR: ${_truncateLogText(message)} | ${_truncateLogText(error.toString())}', '[$_tag] ERROR: ${_truncateLogText(_redactSensitiveText(message))} | ${_truncateLogText(_redactSensitiveText(error.toString()))}',
); );
if (stackTrace != null) { if (stackTrace != null) {
debugPrint(stackTrace.toString()); debugPrint(stackTrace.toString());