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()
}
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<String>("tree_uri") ?: ""
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 srcPath = call.argument<String>("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
+14 -2
View File
@@ -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
}
+58 -4
View File
@@ -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,
})
}
+3
View File
@@ -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 == "" {
+1 -1
View File
@@ -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 {
+16
View File
@@ -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{
+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 {
final List<DownloadHistoryItem> items;
final Set<String> _downloadedSpotifyIds;
final Map<String, DownloadHistoryItem> _bySpotifyId;
final Map<String, DownloadHistoryItem> _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<DownloadQueueState> {
bool _isLoaded = false;
final Set<String> _ensuredDirs = {};
int _progressPollingErrorCount = 0;
bool _isProgressPollingInFlight = false;
String? _lastServiceTrackName;
String? _lastServiceArtistName;
int _lastServicePercent = -1;
@@ -832,6 +827,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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<String, dynamic>? ?? {};
@@ -915,16 +912,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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<DownloadQueueState> {
if (_progressPollingErrorCount <= 3) {
_log.w('Progress polling failed: $e');
}
} finally {
_isProgressPollingInFlight = false;
}
});
}
@@ -1088,6 +1089,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_progressTimer?.cancel();
_progressTimer = null;
_progressPollingErrorCount = 0;
_isProgressPollingInFlight = false;
_lastServiceTrackName = null;
_lastServiceArtistName = null;
_lastServicePercent = -1;
@@ -4011,15 +4013,29 @@ final downloadQueueProvider =
class DownloadQueueLookup {
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) {
final map = <String, DownloadItem>{};
final byTrackId = <String, DownloadItem>{};
final byItemId = <String, DownloadItem>{};
final itemIds = <String>[];
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 DateTime? lastScannedAt;
final int excludedDownloadedCount;
final Set<String> _isrcSet;
final Set<String> _trackKeySet;
final Map<String, LocalLibraryItem> _byIsrc;
@@ -39,16 +38,9 @@ class LocalLibraryState {
this.scanWasCancelled = false,
this.lastScannedAt,
this.excludedDownloadedCount = 0,
Set<String>? isrcSet,
Set<String>? trackKeySet,
Map<String, LocalLibraryItem>? 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<LocalLibraryState> {
bool _isLoaded = false;
bool _scanCancelRequested = false;
int _progressPollingErrorCount = 0;
bool _isProgressPollingInFlight = false;
@override
LocalLibraryState build() {
@@ -408,6 +400,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
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<LocalLibraryState> {
if (_progressPollingErrorCount <= 3) {
_log.w('Library scan progress polling failed: $e');
}
} finally {
_isProgressPollingInFlight = false;
}
});
}
@@ -455,6 +451,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_progressTimer?.cancel();
_progressTimer = null;
_progressPollingErrorCount = 0;
_isProgressPollingInFlight = false;
}
Future<void> cancelScan() async {
+243 -110
View File
@@ -26,8 +26,10 @@ class TrackState {
final List<SearchPlaylist>? 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<Track>? 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<TrackState> {
int _currentRequestId = 0;
static const int _maxPreWarmTracksPerRequest = 80;
@override
TrackState build() {
@@ -197,39 +208,42 @@ class TrackNotifier extends Notifier<TrackState> {
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<String, dynamic>? 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<String, dynamic>;
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<String, dynamic>;
final track = _parseSearchTrack(trackData, source: extensionId);
if (track.name.isEmpty) {
state = TrackState(
isLoading: false,
@@ -237,7 +251,7 @@ class TrackNotifier extends Notifier<TrackState> {
);
return;
}
state = TrackState(
tracks: [track],
isLoading: false,
@@ -245,15 +259,27 @@ class TrackNotifier extends Notifier<TrackState> {
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<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(
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<TrackState> {
} else if (type == 'artist' && result['artist'] != null) {
final artistData = result['artist'] as Map<String, dynamic>;
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
final topTracks = topTracksList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
final albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
final topTracksList =
artistData['top_tracks'] as List<dynamic>? ?? [];
final topTracks = topTracksList
.map(
(t) => _parseSearchTrack(
t as Map<String, dynamic>,
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<TrackState> {
}
}
}
// 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<String, dynamic>;
final track = _parseTrack(trackData);
@@ -306,7 +344,9 @@ class TrackNotifier extends Notifier<TrackState> {
} else if (type == 'album') {
final albumInfo = metadata['album_info'] as Map<String, 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(
tracks: tracks,
isLoading: false,
@@ -316,9 +356,12 @@ class TrackNotifier extends Notifier<TrackState> {
);
_preWarmCacheForTracks(tracks);
} 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 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(
tracks: tracks,
isLoading: false,
@@ -329,7 +372,9 @@ class TrackNotifier extends Notifier<TrackState> {
} else if (type == 'artist') {
final artistInfo = metadata['artist_info'] as Map<String, 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(
tracks: [],
isLoading: false,
@@ -341,33 +386,38 @@ class TrackNotifier extends Notifier<TrackState> {
}
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<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
@@ -378,10 +428,15 @@ class TrackNotifier extends Notifier<TrackState> {
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<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
@@ -395,30 +450,31 @@ class TrackNotifier extends Notifier<TrackState> {
_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<String, dynamic> 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<TrackState> {
} else if (type == 'album') {
final albumInfo = metadata['album_info'] as Map<String, 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(
tracks: tracks,
isLoading: false,
@@ -444,7 +502,9 @@ class TrackNotifier extends Notifier<TrackState> {
} else if (type == 'playlist') {
final playlistInfo = metadata['playlist_info'] as Map<String, 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>?;
state = TrackState(
tracks: tracks,
@@ -456,7 +516,9 @@ class TrackNotifier extends Notifier<TrackState> {
} else if (type == 'artist') {
final artistInfo = metadata['artist_info'] as Map<String, 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(
tracks: [],
isLoading: false,
@@ -468,17 +530,29 @@ class TrackNotifier extends Notifier<TrackState> {
}
} 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<void> search(String query, {String? metadataSource, String? filterOverride}) async {
Future<void> 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<TrackState> {
searchProvider.isNotEmpty;
final source = metadataSource ?? 'deezer';
_log.i(
'Search started: source=$source, query="$query", useExtensions=$useExtensions, filter=$currentFilter',
);
Map<String, dynamic> results;
List<Track> 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<TrackState> {
_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<dynamic>? ?? [];
final artistList = results['artists'] 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>[];
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<TrackState> {
_log.e('Failed to parse track[$i]: $e', e);
}
}
final artists = <SearchArtist>[];
for (int i = 0; i < artistList.length; i++) {
final a = artistList[i];
@@ -580,7 +672,7 @@ class TrackNotifier extends Notifier<TrackState> {
_log.e('Failed to parse artist[$i]: $e', e);
}
}
final albums = <SearchAlbum>[];
for (int i = 0; i < albumList.length; i++) {
final a = albumList[i];
@@ -594,7 +686,7 @@ class TrackNotifier extends Notifier<TrackState> {
_log.e('Failed to parse album[$i]: $e', e);
}
}
final playlistList = results['playlists'] as List<dynamic>? ?? [];
final playlists = <SearchPlaylist>[];
for (int i = 0; i < playlistList.length; i++) {
@@ -609,9 +701,11 @@ class TrackNotifier extends Notifier<TrackState> {
_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<TrackState> {
} 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<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;
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 = <Track>[];
for (int i = 0; i < results.length; i++) {
final t = results[i];
@@ -658,21 +766,28 @@ class TrackNotifier extends Notifier<TrackState> {
_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<TrackState> {
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<TrackState> {
}
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<Track> tracks,
@@ -782,9 +903,9 @@ class TrackNotifier extends Notifier<TrackState> {
} 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<TrackState> {
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<TrackState> {
}
void _preWarmCacheForTracks(List<Track> 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 = <Map<String, String>>[];
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((_) {});
}
+54 -24
View File
@@ -34,6 +34,12 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final Set<String> _selectedIds = {};
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
bool _embeddedCoverRefreshScheduled = false;
List<DownloadHistoryItem>? _albumTracksSourceCache;
List<DownloadHistoryItem>? _albumTracksCache;
String get _albumLookupKey =>
'${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
@override
void initState() {
@@ -48,6 +54,16 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
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<DownloadedAlbumScreen> {
List<DownloadHistoryItem> _getAlbumTracks(
List<DownloadHistoryItem> 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<int, List<DownloadHistoryItem>> _groupTracksByDisc(
@@ -194,8 +218,14 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
void _onEmbeddedCoverChanged() {
if (!mounted) return;
setState(() {});
if (!mounted || _embeddedCoverRefreshScheduled) return;
_embeddedCoverRefreshScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_embeddedCoverRefreshScheduled = false;
if (mounted) {
setState(() {});
}
});
}
Future<void> _navigateToMetadataScreen(DownloadHistoryItem item) async {
+85 -69
View File
@@ -35,18 +35,25 @@ class HomeTab extends ConsumerStatefulWidget {
class _RecentAccessView {
final List<RecentAccessItem> uniqueItems;
final List<RecentAccessItem> downloadItems;
final List<String> downloadIds;
final Map<String, String> 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<HomeTab>
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
final _urlController = TextEditingController();
bool _isTyping = false;
final FocusNode _searchFocusNode = FocusNode();
String? _lastSearchQuery;
late final ProviderSubscription<TrackState> _trackStateSub;
@@ -77,6 +83,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
List<RecentAccessItem>? _recentAccessItemsCache;
Set<String>? _recentAccessHiddenIdsCache;
_RecentAccessView? _recentAccessViewCache;
bool _embeddedCoverRefreshScheduled = false;
List<Extension>? _thumbnailSizesExtensionsCache;
Map<String, (double, double)>? _thumbnailSizesCache;
double _responsiveScale({
required BuildContext context,
@@ -200,6 +209,27 @@ class _HomeTabState extends ConsumerState<HomeTab>
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() {
if (mounted) {
setState(() {});
@@ -217,7 +247,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
_urlController.text.isNotEmpty &&
!_searchFocusNode.hasFocus) {
_urlController.clear();
setState(() => _isTyping = false);
}
}
@@ -240,10 +269,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
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<HomeTab>
_urlController.clear();
_searchFocusNode.unfocus();
_lastSearchQuery = null;
setState(() => _isTyping = false);
ref.read(trackProvider.notifier).clear();
}
@@ -390,7 +415,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
ref.read(trackProvider.notifier).clear();
_urlController.clear();
setState(() => _isTyping = false);
return;
}
@@ -416,7 +440,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
ref.read(trackProvider.notifier).clear();
_urlController.clear();
setState(() => _isTyping = false);
return;
}
@@ -438,7 +461,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
ref.read(trackProvider.notifier).clear();
_urlController.clear();
setState(() => _isTyping = false);
return;
}
}
@@ -781,13 +803,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
final showLocalLibraryIndicator =
localLibrarySettings.$1 && localLibrarySettings.$2;
final thumbnailSizesByExtensionId = <String, (double, double)>{
for (final extension in extensions)
if (extension.searchBehavior != null)
extension.id: extension.searchBehavior!.getThumbnailSize(
defaultSize: 56,
),
};
final thumbnailSizesByExtensionId = _getThumbnailSizesByExtensionId(
extensions,
);
Extension? currentSearchExtension;
List<SearchFilter> searchFilters = [];
@@ -1028,8 +1046,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
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<HomeTab>
return cached;
}
final albumGroups = <String, List<DownloadHistoryItem>>{};
final albumGroups = <String, _RecentAlbumAggregate>{};
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 = <RecentAccessItem>[];
final downloadIds = <String>[];
final visibleDownloads = <RecentAccessItem>[];
final downloadFilePathByRecentKey = <String, String>{};
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 = <RecentAccessItem>[];
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 = <RecentAccessItem>[...items, ...visibleDownloads];
@@ -1227,7 +1243,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
final view = _RecentAccessView(
uniqueItems: uniqueItems,
downloadItems: downloadItems,
downloadIds: downloadIds,
downloadFilePathByRecentKey: downloadFilePathByRecentKey,
hasHiddenDownloads: hiddenIds.isNotEmpty,
);
@@ -1641,7 +1657,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
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<HomeTab>
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();
},
+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 albumCounts = (payload['albumCounts'] as Map).cast<String, int>();
final query = (payload['query'] as String?) ?? '';
final hasQuery = query.isNotEmpty;
final allIds = <String>[];
final albumIds = <String>[];
@@ -236,10 +237,11 @@ Map<String, List<String>> _filterHistoryInIsolate(Map<String, Object> 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<QueueTab> {
final ValueNotifier<bool> _alwaysMissingFileNotifier = ValueNotifier(false);
final Set<String> _pendingChecks = {};
static const int _maxCacheSize = 500;
static const int _maxSearchIndexCacheSize = 4000;
static const int _maxDownloadedEmbeddedCoverCacheSize = 180;
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
@@ -311,8 +315,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final Set<String> _pendingDownloadedCoverExtract = {};
final Set<String> _pendingDownloadedCoverRefresh = {};
final Set<String> _failedDownloadedCoverExtract = {};
Map<String, DownloadHistoryItem> _historyItemsById = {};
List<List<String>> _historyFilterEntries = const [];
Map<String, List<DownloadHistoryItem>> _filteredHistoryCache = const {};
List<DownloadHistoryItem>? _filterItemsCache;
String _filterQueryCache = '';
@@ -407,32 +409,16 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_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<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) {
final validPaths = items
@@ -459,6 +445,30 @@ class _QueueTabState extends ConsumerState<QueueTab> {
.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> items,
String query,
@@ -471,11 +481,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
}
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>{
'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 = <String, List<DownloadHistoryItem>>{};
for (final entry in result.entries) {
filtered[entry.key] = entry.value
@@ -604,10 +621,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
_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 {
final cleanPath = _cleanFilePath(filePath);
if (cleanPath.isEmpty) return null;
@@ -918,6 +944,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
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<QueueTab> {
_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<QueueTab> {
_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 = <String, _FilterContentData>{};
@@ -1835,8 +1867,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
() => _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<QueueTab> {
_FilterContentData _computeFilterContentData({
required String filterMode,
required List<DownloadHistoryItem> allHistoryItems,
required List<_GroupedAlbum> groupedAlbums,
required List<_GroupedLocalAlbum> groupedLocalAlbums,
required List<_GroupedAlbum> filteredGroupedAlbums,
required List<_GroupedLocalAlbum> filteredGroupedLocalAlbums,
required Map<String, int> albumCounts,
required Map<String, int> localAlbumCounts,
required List<LocalLibraryItem> localLibraryItems,
@@ -2243,16 +2275,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
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<QueueTab> {
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();
+73 -7
View File
@@ -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<LogEntry> _entries = Queue<LogEntry>();
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<void> _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());