mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-14 23:20:25 +02:00
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:
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+243
-110
@@ -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() {
|
||||||
@@ -197,39 +208,42 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
final extensionHandler = await PlatformBridge.findURLHandler(url);
|
final extensionHandler = await PlatformBridge.findURLHandler(url);
|
||||||
if (extensionHandler != null) {
|
if (extensionHandler != null) {
|
||||||
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
||||||
|
|
||||||
// Retry logic for extension URL handlers (up to 3 attempts)
|
// Retry logic for extension URL handlers (up to 3 attempts)
|
||||||
Map<String, dynamic>? result;
|
Map<String, dynamic>? result;
|
||||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||||
result = await PlatformBridge.handleURLWithExtension(url);
|
result = await PlatformBridge.handleURLWithExtension(url);
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attempt < 3) {
|
if (attempt < 3) {
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
final type = result['type'] as String?;
|
final type = result['type'] as String?;
|
||||||
final extensionId = result['extension_id'] as String?;
|
final extensionId = result['extension_id'] as String?;
|
||||||
|
|
||||||
if (type == 'track' && result['track'] != null) {
|
if (type == 'track' && result['track'] != null) {
|
||||||
final trackData = result['track'] as Map<String, dynamic>;
|
final trackData = result['track'] as Map<String, dynamic>;
|
||||||
final track = _parseSearchTrack(trackData, source: extensionId);
|
final track = _parseSearchTrack(trackData, source: extensionId);
|
||||||
|
|
||||||
if (track.name.isEmpty) {
|
if (track.name.isEmpty) {
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -237,7 +251,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: [track],
|
tracks: [track],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -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>))
|
||||||
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
|
.toList();
|
||||||
final topTracks = topTracksList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).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(
|
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,
|
||||||
@@ -282,19 +320,19 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Try Deezer URL parsing
|
// Step 2: Try Deezer URL parsing
|
||||||
if (url.contains('deezer.com') || url.contains('deezer.page.link')) {
|
if (url.contains('deezer.com') || url.contains('deezer.page.link')) {
|
||||||
_log.i('Detected Deezer URL, parsing...');
|
_log.i('Detected Deezer URL, parsing...');
|
||||||
final parsed = await PlatformBridge.parseDeezerUrl(url);
|
final parsed = await PlatformBridge.parseDeezerUrl(url);
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
|
||||||
final type = parsed['type'] as String;
|
final type = parsed['type'] as String;
|
||||||
final id = parsed['id'] as String;
|
final id = parsed['id'] as String;
|
||||||
|
|
||||||
final metadata = await PlatformBridge.getDeezerMetadata(type, id);
|
final metadata = await PlatformBridge.getDeezerMetadata(type, id);
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
|
||||||
if (type == 'track') {
|
if (type == 'track') {
|
||||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||||
final track = _parseTrack(trackData);
|
final track = _parseTrack(trackData);
|
||||||
@@ -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,
|
||||||
@@ -341,33 +386,38 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Try Tidal URL parsing
|
// Step 3: Try Tidal URL parsing
|
||||||
if (url.contains('tidal.com')) {
|
if (url.contains('tidal.com')) {
|
||||||
_log.i('Detected Tidal URL, parsing...');
|
_log.i('Detected Tidal URL, parsing...');
|
||||||
final parsed = await PlatformBridge.parseTidalUrl(url);
|
final parsed = await PlatformBridge.parseTidalUrl(url);
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
|
||||||
final type = parsed['type'] as String;
|
final type = parsed['type'] as String;
|
||||||
final id = parsed['id'] as String;
|
final id = parsed['id'] as String;
|
||||||
|
|
||||||
_log.i('Tidal URL parsed: type=$type, id=$id');
|
_log.i('Tidal URL parsed: type=$type, id=$id');
|
||||||
|
|
||||||
// For track URLs, convert to Spotify/Deezer and fetch metadata from there
|
// For track URLs, convert to Spotify/Deezer and fetch metadata from there
|
||||||
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?;
|
||||||
final deezerUrl = conversion['deezer_url'] as String?;
|
final deezerUrl = conversion['deezer_url'] as String?;
|
||||||
|
|
||||||
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>;
|
||||||
final track = _parseTrack(trackData);
|
final track = _parseTrack(trackData);
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
@@ -378,10 +428,15 @@ 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>;
|
||||||
final track = _parseTrack(trackData);
|
final track = _parseTrack(trackData);
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
@@ -395,30 +450,31 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
_log.w('Failed to convert Tidal URL via SongLink: $e');
|
_log.w('Failed to convert Tidal URL via SongLink: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Fall back to Spotify parsing
|
// Step 4: Fall back to Spotify parsing
|
||||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
|
||||||
final type = parsed['type'] as String;
|
final type = parsed['type'] as String;
|
||||||
|
|
||||||
Map<String, dynamic> metadata;
|
Map<String, dynamic> metadata;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
|
||||||
if (type == 'track') {
|
if (type == 'track') {
|
||||||
@@ -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);
|
||||||
@@ -494,20 +568,23 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
searchProvider.isNotEmpty;
|
searchProvider.isNotEmpty;
|
||||||
|
|
||||||
final source = metadataSource ?? 'deezer';
|
final source = metadataSource ?? 'deezer';
|
||||||
|
|
||||||
_log.i(
|
_log.i(
|
||||||
'Search started: source=$source, query="$query", useExtensions=$useExtensions, filter=$currentFilter',
|
'Search started: source=$source, query="$query", useExtensions=$useExtensions, filter=$currentFilter',
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> results;
|
Map<String, dynamic> results;
|
||||||
List<Track> extensionTracks = [];
|
List<Track> extensionTracks = [];
|
||||||
|
|
||||||
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) {
|
||||||
try {
|
try {
|
||||||
extensionTracks.add(_parseSearchTrack(t));
|
extensionTracks.add(_parseSearchTrack(t));
|
||||||
@@ -519,37 +596,52 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
_log.w('Extension search failed, falling back to built-in: $e');
|
_log.w('Extension search failed, falling back to built-in: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)) {
|
||||||
_log.w('Search request cancelled (requestId=$requestId)');
|
_log.w('Search request cancelled (requestId=$requestId)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||||
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>[];
|
||||||
|
|
||||||
tracks.addAll(extensionTracks);
|
tracks.addAll(extensionTracks);
|
||||||
|
|
||||||
final existingIsrcs = extensionTracks
|
final existingIsrcs = extensionTracks
|
||||||
.where((t) => t.isrc != null && t.isrc!.isNotEmpty)
|
.where((t) => t.isrc != null && t.isrc!.isNotEmpty)
|
||||||
.map((t) => t.isrc!)
|
.map((t) => t.isrc!)
|
||||||
.toSet();
|
.toSet();
|
||||||
|
|
||||||
for (int i = 0; i < trackList.length; i++) {
|
for (int i = 0; i < trackList.length; i++) {
|
||||||
final t = trackList[i];
|
final t = trackList[i];
|
||||||
try {
|
try {
|
||||||
@@ -566,7 +658,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
_log.e('Failed to parse track[$i]: $e', e);
|
_log.e('Failed to parse track[$i]: $e', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final artists = <SearchArtist>[];
|
final artists = <SearchArtist>[];
|
||||||
for (int i = 0; i < artistList.length; i++) {
|
for (int i = 0; i < artistList.length; i++) {
|
||||||
final a = artistList[i];
|
final a = artistList[i];
|
||||||
@@ -580,7 +672,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
_log.e('Failed to parse artist[$i]: $e', e);
|
_log.e('Failed to parse artist[$i]: $e', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final albums = <SearchAlbum>[];
|
final albums = <SearchAlbum>[];
|
||||||
for (int i = 0; i < albumList.length; i++) {
|
for (int i = 0; i < albumList.length; i++) {
|
||||||
final a = albumList[i];
|
final a = albumList[i];
|
||||||
@@ -594,7 +686,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
_log.e('Failed to parse album[$i]: $e', e);
|
_log.e('Failed to parse album[$i]: $e', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final playlistList = results['playlists'] as List<dynamic>? ?? [];
|
final playlistList = results['playlists'] as List<dynamic>? ?? [];
|
||||||
final playlists = <SearchPlaylist>[];
|
final playlists = <SearchPlaylist>[];
|
||||||
for (int i = 0; i < playlistList.length; i++) {
|
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.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(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
searchArtists: artists,
|
searchArtists: artists,
|
||||||
@@ -624,31 +718,45 @@ 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)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.i('Custom search returned ${results.length} tracks');
|
_log.i('Custom search returned ${results.length} tracks');
|
||||||
|
|
||||||
final tracks = <Track>[];
|
final tracks = <Track>[];
|
||||||
for (int i = 0; i < results.length; i++) {
|
for (int i = 0; i < results.length; i++) {
|
||||||
final t = results[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.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(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
searchArtists: [],
|
searchArtists: [],
|
||||||
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,
|
||||||
@@ -736,11 +854,14 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
state = state.copyWith(hasSearchText: hasText);
|
state = state.copyWith(hasSearchText: hasText);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setShowingRecentAccess(bool showing) {
|
void setShowingRecentAccess(bool showing) {
|
||||||
|
if (state.isShowingRecentAccess == showing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
state = state.copyWith(isShowingRecentAccess: showing);
|
state = state.copyWith(isShowingRecentAccess: showing);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set tracks from a collection (album/playlist) opened from search results
|
/// Set tracks from a collection (album/playlist) opened from search results
|
||||||
void setTracksFromCollection({
|
void setTracksFromCollection({
|
||||||
required List<Track> tracks,
|
required List<Track> tracks,
|
||||||
@@ -782,9 +903,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
} else if (durationValue is double) {
|
} else if (durationValue is double) {
|
||||||
durationMs = durationValue.toInt();
|
durationMs = durationValue.toInt();
|
||||||
}
|
}
|
||||||
|
|
||||||
final itemType = data['item_type']?.toString();
|
final itemType = data['item_type']?.toString();
|
||||||
|
|
||||||
return Track(
|
return Track(
|
||||||
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||||
name: (data['name'] ?? '').toString(),
|
name: (data['name'] ?? '').toString(),
|
||||||
@@ -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((_) {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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());
|
||||||
|
|||||||
Reference in New Issue
Block a user