diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 7380bd3..1b7855d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -105,7 +105,14 @@ body: id: logs attributes: label: Logs / Screenshots - description: If applicable, add logs or screenshots to help explain your problem + description: | + If applicable, add logs or screenshots to help explain your problem. + + **To get logs:** + 1. Go to Settings > Options > Detailed Logging (turn ON) + 2. Reproduce the bug + 3. Go to Settings > Logs + 4. Tap Share button to export logs placeholder: Paste logs or drag & drop screenshots here... - type: textarea diff --git a/.github/ISSUE_TEMPLATE/download_issue.yml b/.github/ISSUE_TEMPLATE/download_issue.yml index 250d654..11cb7e3 100644 --- a/.github/ISSUE_TEMPLATE/download_issue.yml +++ b/.github/ISSUE_TEMPLATE/download_issue.yml @@ -100,5 +100,12 @@ body: id: screenshots attributes: label: Screenshots / Logs - description: If applicable, add screenshots or logs - placeholder: Drag & drop screenshots here... + description: | + If applicable, add screenshots or logs. + + **To get logs:** + 1. Go to Settings > Options > Detailed Logging (turn ON) + 2. Try downloading the track again + 3. Go to Settings > Logs + 4. Tap Share button to export logs + placeholder: Drag & drop screenshots or paste logs here... diff --git a/CHANGELOG.md b/CHANGELOG.md index 1155cff..9143d6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,62 @@ # Changelog +## [2.2.5] - 2026-01-10 + +### Added + +- **In-App Log Viewer with Go Backend Logs**: Complete logging system for debugging + - Go backend logs now captured and displayed in app + - Circular buffer stores up to 500 log entries + - Real-time polling (500ms) for Go backend logs + - Logs include timestamp, level, tag, and message + - "Go" badge indicates logs from backend +- **Detailed Logging Toggle**: Control logging in Settings > Options > Debug + - Disabled by default for performance + - Errors are always logged regardless of setting + - Enable before reproducing bugs for detailed logs +- **Log Issue Summary**: Automatic detection of common issues in logs + - ISP Blocking detection with affected domains + - Rate limiting detection + - Network error detection + - Track not found detection + - Shows suggestions for each issue type +- **ISP Blocking Detection**: Detects when ISP blocks download services + - DNS resolution failure detection + - Connection reset/refused detection + - TLS handshake failure detection + - HTTP 403/451 blocking page detection + - Suggests VPN or DNS change (1.1.1.1 / 8.8.8.8) + +### Fixed + +- **Artist Profile Placeholder**: Shows person icon when artist has no profile image + - Validates image URL before loading + - Fallback icon on load error +- **Latin Extended Character Detection**: Fixed wrong track downloads for Polish, Czech, French, Spanish songs + - Characters like Ł, ę, ć, ñ, é now correctly treated as Latin script + - Previously treated as "different script" causing false matches + - Affects both Tidal and Qobuz search + +### Changed + +- **Log Screen UI Improvements**: + - Copy button moved to app bar (left of menu) + - Removed redundant info card + - Cleaner interface +- **Issue Templates Updated**: Instructions for enabling detailed logging before submitting bug reports + +### Technical + +- New file: `go_backend/logbuffer.go` with circular buffer and GoLog function +- Updated `go_backend/httputil.go` with ISP blocking detection +- Updated `go_backend/tidal.go` and `go_backend/qobuz.go` with `isLatinScript()` function +- Updated `lib/utils/logger.dart` with Go log polling +- Updated `lib/screens/settings/log_screen.dart` with issue summary +- Added method channel handlers for logging in Android and iOS +- New error type: `isp_blocked` for ISP blocking errors + +--- + ## [2.2.0] - 2026-01-10 ### Fixed diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 1302857..fb87483 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -284,6 +284,39 @@ class MainActivity: FlutterActivity() { } result.success(response) } + // Log methods + "getLogs" -> { + val response = withContext(Dispatchers.IO) { + Gobackend.getLogs() + } + result.success(response) + } + "getLogsSince" -> { + val index = call.argument("index") ?: 0 + val response = withContext(Dispatchers.IO) { + Gobackend.getLogsSince(index.toLong()) + } + result.success(response) + } + "clearLogs" -> { + withContext(Dispatchers.IO) { + Gobackend.clearLogs() + } + result.success(null) + } + "getLogCount" -> { + val count = withContext(Dispatchers.IO) { + Gobackend.getLogCount() + } + result.success(count.toInt()) + } + "setLoggingEnabled" -> { + val enabled = call.argument("enabled") ?: false + withContext(Dispatchers.IO) { + Gobackend.setLoggingEnabled(enabled) + } + result.success(null) + } else -> result.notImplemented() } } catch (e: Exception) { diff --git a/go_backend/amazon.go b/go_backend/amazon.go index 1e70cf7..e8db3cb 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -88,7 +88,7 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool { expectedASCII := amazonIsASCIIString(expectedArtist) foundASCII := amazonIsASCIIString(foundArtist) if expectedASCII != foundASCII { - fmt.Printf("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist) + GoLog("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist) return true } @@ -135,7 +135,7 @@ func (a *AmazonDownloader) waitForRateLimit() { if a.apiCallCount >= 9 { waitTime := time.Minute - now.Sub(a.apiCallResetTime) if waitTime > 0 { - fmt.Printf("[Amazon] Rate limit reached, waiting %v...\n", waitTime.Round(time.Second)) + GoLog("[Amazon] Rate limit reached, waiting %v...\n", waitTime.Round(time.Second)) time.Sleep(waitTime) a.apiCallCount = 0 a.apiCallResetTime = time.Now() @@ -148,7 +148,7 @@ func (a *AmazonDownloader) waitForRateLimit() { minDelay := 7 * time.Second if timeSinceLastCall < minDelay { waitTime := minDelay - timeSinceLastCall - fmt.Printf("[Amazon] Rate limiting: waiting %v...\n", waitTime.Round(time.Second)) + GoLog("[Amazon] Rate limiting: waiting %v...\n", waitTime.Round(time.Second)) time.Sleep(waitTime) } } @@ -177,7 +177,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir var lastError error for _, region := range a.regions { - fmt.Printf("[Amazon] Trying region: %s...\n", region) + GoLog("[Amazon] Trying region: %s...\n", region) // Build base URL for DoubleDouble service // Decode base64 service URL (same as PC) @@ -216,7 +216,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir resp.Body.Close() if retry < maxRetries-1 { waitTime := 15 * time.Second - fmt.Printf("[Amazon] Rate limited (429), waiting %v before retry %d/%d...\n", waitTime, retry+2, maxRetries) + GoLog("[Amazon] Rate limited (429), waiting %v before retry %d/%d...\n", waitTime, retry+2, maxRetries) time.Sleep(waitTime) continue } @@ -255,7 +255,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir } downloadID := submitResp.ID - fmt.Printf("[Amazon] Download ID: %s\n", downloadID) + GoLog("[Amazon] Download ID: %s\n", downloadID) // Step 2: Poll for completion statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID) @@ -310,7 +310,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir trackName := status.Current.Name artist := status.Current.Artist - fmt.Printf("[Amazon] Downloading: %s - %s\n", artist, trackName) + GoLog("[Amazon] Downloading: %s - %s\n", artist, trackName) return fileURL, trackName, artist, nil } else if status.Status == "error" { @@ -454,7 +454,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { if strings.HasPrefix(req.SpotifyID, "deezer:") { // Extract Deezer ID and use Deezer-based lookup deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:") - fmt.Printf("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID) + GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID) availability, err = songlink.CheckAvailabilityFromDeezer(deezerID) } else if req.SpotifyID != "" { // Use Spotify ID @@ -486,12 +486,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { // Verify artist matches if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) { - fmt.Printf("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName) + GoLog("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName) return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName) } // Log match found - fmt.Printf("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName) + GoLog("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName) // Build filename using Spotify metadata (more accurate) filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ @@ -542,7 +542,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { // Log track info from DoubleDouble (for debugging) if trackName != "" && artistName != "" { - fmt.Printf("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName) + GoLog("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName) } // Read existing metadata from downloaded file BEFORE embedding @@ -555,11 +555,11 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { // Use file metadata if it has valid track/disc numbers and request doesn't have them if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) { actualTrackNum = existingMeta.TrackNumber - fmt.Printf("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber) + GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber) } if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) { actualDiscNum = existingMeta.DiscNumber - fmt.Printf("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber) + GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber) } } @@ -581,7 +581,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { var coverData []byte if parallelResult != nil && parallelResult.CoverData != nil { coverData = parallelResult.CoverData - fmt.Printf("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData)) + GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData)) } if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { @@ -590,9 +590,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { // Embed lyrics from parallel fetch if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { - fmt.Printf("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) + GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { - fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr) + GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr) } else { fmt.Println("[Amazon] Lyrics embedded successfully") } @@ -606,16 +606,16 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { // Amazon API doesn't provide quality info, but we can read it from the file itself quality, err := GetAudioQuality(outputPath) if err != nil { - fmt.Printf("[Amazon] Warning: couldn't read quality from file: %v\n", err) + GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err) } else { - fmt.Printf("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) + GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) } // Read metadata from file AFTER embedding to get accurate values // This ensures we return what's actually in the file finalMeta, metaReadErr := ReadMetadata(outputPath) if metaReadErr == nil && finalMeta != nil { - fmt.Printf("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n", + GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n", finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date) actualTrackNum = finalMeta.TrackNumber actualDiscNum = finalMeta.DiscNumber diff --git a/go_backend/exports.go b/go_backend/exports.go index 5ef232e..909962c 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -286,9 +286,9 @@ func DownloadTrack(requestJSON string) (string, error) { if qErr == nil { result.BitDepth = quality.BitDepth result.SampleRate = quality.SampleRate - fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) + GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) } else { - fmt.Printf("[Download] Could not read quality from file: %v\n", qErr) + GoLog("[Download] Could not read quality from file: %v\n", qErr) } resp := DownloadResponse{ @@ -333,7 +333,7 @@ func DownloadWithFallback(requestJSON string) (string, error) { preferredService = "tidal" } - fmt.Printf("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service) + GoLog("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service) // Create ordered list: preferred first, then others services := []string{preferredService} @@ -343,12 +343,12 @@ func DownloadWithFallback(requestJSON string) (string, error) { } } - fmt.Printf("[DownloadWithFallback] Service order: %v\n", services) + GoLog("[DownloadWithFallback] Service order: %v\n", services) var lastErr error for _, service := range services { - fmt.Printf("[DownloadWithFallback] Trying service: %s\n", service) + GoLog("[DownloadWithFallback] Trying service: %s\n", service) req.Service = service var result DownloadResult @@ -371,7 +371,7 @@ func DownloadWithFallback(requestJSON string) (string, error) { ISRC: tidalResult.ISRC, } } else { - fmt.Printf("[DownloadWithFallback] Tidal error: %v\n", tidalErr) + GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr) } err = tidalErr case "qobuz": @@ -390,7 +390,7 @@ func DownloadWithFallback(requestJSON string) (string, error) { ISRC: qobuzResult.ISRC, } } else { - fmt.Printf("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr) + GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr) } err = qobuzErr case "amazon": @@ -409,7 +409,7 @@ func DownloadWithFallback(requestJSON string) (string, error) { ISRC: amazonResult.ISRC, } } else { - fmt.Printf("[DownloadWithFallback] Amazon error: %v\n", amazonErr) + GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr) } err = amazonErr } @@ -449,9 +449,9 @@ func DownloadWithFallback(requestJSON string) (string, error) { if qErr == nil { result.BitDepth = quality.BitDepth result.SampleRate = quality.SampleRate - fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) + GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) } else { - fmt.Printf("[Download] Could not read quality from file: %v\n", qErr) + GoLog("[Download] Could not read quality from file: %v\n", qErr) } resp := DownloadResponse{ @@ -904,7 +904,7 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) { return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr) } - fmt.Printf("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type) + GoLog("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type) if parsed.Type == "track" || parsed.Type == "album" { // Convert to Deezer @@ -980,7 +980,11 @@ func errorResponse(msg string) (string, error) { errorType := "unknown" lowerMsg := strings.ToLower(msg) - if strings.Contains(lowerMsg, "not found") || + if strings.Contains(lowerMsg, "isp blocking") || + strings.Contains(lowerMsg, "try using vpn") || + strings.Contains(lowerMsg, "change dns") { + errorType = "isp_blocked" + } else if strings.Contains(lowerMsg, "not found") || strings.Contains(lowerMsg, "not available") || strings.Contains(lowerMsg, "no results") || strings.Contains(lowerMsg, "track not found") || diff --git a/go_backend/httputil.go b/go_backend/httputil.go index 5a33146..88b0d6c 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -1,12 +1,17 @@ package gobackend import ( + "crypto/tls" + "errors" "fmt" "io" "math/rand" "net" "net/http" + "net/url" "strconv" + "strings" + "syscall" "time" ) @@ -134,9 +139,15 @@ func CloseIdleConnections() { } // DoRequestWithUserAgent executes an HTTP request with a random User-Agent header +// Also checks for ISP blocking on errors func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) { req.Header.Set("User-Agent", getRandomUserAgent()) - return client.Do(req) + resp, err := client.Do(req) + if err != nil { + // Check for ISP blocking + CheckAndLogISPBlocking(err, req.URL.String(), "HTTP") + } + return resp, err } // RetryConfig holds configuration for retry logic @@ -159,9 +170,11 @@ func DefaultRetryConfig() RetryConfig { // DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff // Handles 429 (Too Many Requests) responses with Retry-After header +// Also detects and logs ISP blocking func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) { var lastErr error delay := config.InitialDelay + requestURL := req.URL.String() for attempt := 0; attempt <= config.MaxRetries; attempt++ { // Clone request for retry (body needs to be re-readable) @@ -171,7 +184,16 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf resp, err := client.Do(reqCopy) if err != nil { lastErr = err + + // Check for ISP blocking on network errors + if CheckAndLogISPBlocking(err, requestURL, "HTTP") { + // Don't retry if ISP blocking is detected - it won't help + return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP") + } + if attempt < config.MaxRetries { + GoLog("[HTTP] Request failed (attempt %d/%d): %v, retrying in %v...\n", + attempt+1, config.MaxRetries+1, err, delay) time.Sleep(delay) delay = calculateNextDelay(delay, config) } @@ -192,17 +214,43 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf } lastErr = fmt.Errorf("rate limited (429)") if attempt < config.MaxRetries { + GoLog("[HTTP] Rate limited, waiting %v before retry...\n", delay) time.Sleep(delay) delay = calculateNextDelay(delay, config) } continue } + // Check for ISP blocking via HTTP status codes + // Some ISPs return 403 or 451 when blocking content + if resp.StatusCode == 403 || resp.StatusCode == 451 { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + bodyStr := strings.ToLower(string(body)) + + // Check if response looks like ISP blocking page + ispBlockingIndicators := []string{ + "blocked", "forbidden", "access denied", "not available in your", + "restricted", "censored", "unavailable for legal", "blocked by", + } + + for _, indicator := range ispBlockingIndicators { + if strings.Contains(bodyStr, indicator) { + LogError("HTTP", "ISP BLOCKING DETECTED via HTTP %d response", resp.StatusCode) + LogError("HTTP", "Domain: %s", req.URL.Host) + LogError("HTTP", "Response contains: %s", indicator) + LogError("HTTP", "Suggestion: Try using a VPN or changing your DNS to 1.1.1.1 or 8.8.8.8") + return nil, fmt.Errorf("ISP blocking detected for %s (HTTP %d) - try using VPN or change DNS", req.URL.Host, resp.StatusCode) + } + } + } + // Server errors (5xx) - retry if resp.StatusCode >= 500 { resp.Body.Close() lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode) if attempt < config.MaxRetries { + GoLog("[HTTP] Server error %d, retrying in %v...\n", resp.StatusCode, delay) time.Sleep(delay) delay = calculateNextDelay(delay, config) } @@ -296,3 +344,172 @@ func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) st } return msg } + +// ISPBlockingError represents an error caused by ISP blocking +type ISPBlockingError struct { + Domain string + Reason string + OriginalErr error +} + +func (e *ISPBlockingError) Error() string { + return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason) +} + +// IsISPBlocking checks if an error is likely caused by ISP blocking +// Returns the ISPBlockingError if detected, nil otherwise +func IsISPBlocking(err error, requestURL string) *ISPBlockingError { + if err == nil { + return nil + } + + // Extract domain from URL + domain := extractDomain(requestURL) + errStr := strings.ToLower(err.Error()) + + // Check for DNS resolution failure (common ISP blocking method) + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + if dnsErr.IsNotFound || dnsErr.IsTemporary { + return &ISPBlockingError{ + Domain: domain, + Reason: "DNS resolution failed - domain may be blocked by ISP", + OriginalErr: err, + } + } + } + + // Check for connection refused (ISP firewall blocking) + var opErr *net.OpError + if errors.As(err, &opErr) { + if opErr.Op == "dial" { + // Check for specific syscall errors + var syscallErr syscall.Errno + if errors.As(opErr.Err, &syscallErr) { + switch syscallErr { + case syscall.ECONNREFUSED: + return &ISPBlockingError{ + Domain: domain, + Reason: "Connection refused - port may be blocked by ISP/firewall", + OriginalErr: err, + } + case syscall.ECONNRESET: + return &ISPBlockingError{ + Domain: domain, + Reason: "Connection reset - ISP may be intercepting traffic", + OriginalErr: err, + } + case syscall.ETIMEDOUT: + return &ISPBlockingError{ + Domain: domain, + Reason: "Connection timed out - ISP may be blocking access", + OriginalErr: err, + } + case syscall.ENETUNREACH: + return &ISPBlockingError{ + Domain: domain, + Reason: "Network unreachable - ISP may be blocking route", + OriginalErr: err, + } + case syscall.EHOSTUNREACH: + return &ISPBlockingError{ + Domain: domain, + Reason: "Host unreachable - ISP may be blocking destination", + OriginalErr: err, + } + } + } + } + } + + // Check for TLS handshake failure (ISP MITM or blocking HTTPS) + var tlsErr *tls.RecordHeaderError + if errors.As(err, &tlsErr) { + return &ISPBlockingError{ + Domain: domain, + Reason: "TLS handshake failed - ISP may be intercepting HTTPS traffic", + OriginalErr: err, + } + } + + // Check error message patterns for common ISP blocking indicators + blockingPatterns := []struct { + pattern string + reason string + }{ + {"connection reset by peer", "Connection reset - ISP may be intercepting traffic"}, + {"connection refused", "Connection refused - port may be blocked"}, + {"no such host", "DNS lookup failed - domain may be blocked by ISP"}, + {"i/o timeout", "Connection timed out - ISP may be blocking access"}, + {"network is unreachable", "Network unreachable - ISP may be blocking route"}, + {"tls: ", "TLS error - ISP may be intercepting HTTPS traffic"}, + {"certificate", "Certificate error - ISP may be using MITM proxy"}, + {"eof", "Connection closed unexpectedly - ISP may be blocking"}, + {"context deadline exceeded", "Request timed out - ISP may be throttling"}, + } + + for _, bp := range blockingPatterns { + if strings.Contains(errStr, bp.pattern) { + return &ISPBlockingError{ + Domain: domain, + Reason: bp.reason, + OriginalErr: err, + } + } + } + + return nil +} + +// CheckAndLogISPBlocking checks for ISP blocking and logs if detected +// Returns true if ISP blocking was detected +func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool { + ispErr := IsISPBlocking(err, requestURL) + if ispErr != nil { + LogError(tag, "ISP BLOCKING DETECTED: %s", ispErr.Error()) + LogError(tag, "Domain: %s", ispErr.Domain) + LogError(tag, "Reason: %s", ispErr.Reason) + LogError(tag, "Original error: %v", ispErr.OriginalErr) + LogError(tag, "Suggestion: Try using a VPN or changing your DNS to 1.1.1.1 or 8.8.8.8") + return true + } + return false +} + +// extractDomain extracts the domain from a URL string +func extractDomain(rawURL string) string { + if rawURL == "" { + return "unknown" + } + + parsed, err := url.Parse(rawURL) + if err != nil { + // Try to extract domain manually + rawURL = strings.TrimPrefix(rawURL, "https://") + rawURL = strings.TrimPrefix(rawURL, "http://") + if idx := strings.Index(rawURL, "/"); idx > 0 { + return rawURL[:idx] + } + return rawURL + } + + if parsed.Host != "" { + return parsed.Host + } + return "unknown" +} + +// WrapErrorWithISPCheck wraps an error with ISP blocking detection +// If ISP blocking is detected, returns a more descriptive error +func WrapErrorWithISPCheck(err error, requestURL string, tag string) error { + if err == nil { + return nil + } + + if CheckAndLogISPBlocking(err, requestURL, tag) { + domain := extractDomain(requestURL) + return fmt.Errorf("ISP blocking detected for %s - try using VPN or change DNS to 1.1.1.1/8.8.8.8: %w", domain, err) + } + + return err +} diff --git a/go_backend/logbuffer.go b/go_backend/logbuffer.go new file mode 100644 index 0000000..c6f9b10 --- /dev/null +++ b/go_backend/logbuffer.go @@ -0,0 +1,203 @@ +package gobackend + +import ( + "encoding/json" + "fmt" + "strings" + "sync" + "time" +) + +// LogEntry represents a single log entry +type LogEntry struct { + Timestamp string `json:"timestamp"` + Level string `json:"level"` + Tag string `json:"tag"` + Message string `json:"message"` +} + +// LogBuffer stores logs in a circular buffer for retrieval by Flutter +type LogBuffer struct { + entries []LogEntry + maxSize int + mu sync.RWMutex + loggingEnabled bool // Whether logging is enabled (controlled by Flutter) +} + +var ( + globalLogBuffer *LogBuffer + logBufferOnce sync.Once +) + +// GetLogBuffer returns the singleton log buffer instance +func GetLogBuffer() *LogBuffer { + logBufferOnce.Do(func() { + globalLogBuffer = &LogBuffer{ + entries: make([]LogEntry, 0, 500), + maxSize: 500, + loggingEnabled: false, // Default: disabled for performance + } + }) + return globalLogBuffer +} + +// SetLoggingEnabled enables or disables logging +func (lb *LogBuffer) SetLoggingEnabled(enabled bool) { + lb.mu.Lock() + defer lb.mu.Unlock() + lb.loggingEnabled = enabled +} + +// IsLoggingEnabled returns whether logging is enabled +func (lb *LogBuffer) IsLoggingEnabled() bool { + lb.mu.RLock() + defer lb.mu.RUnlock() + return lb.loggingEnabled +} + +// Add adds a log entry to the buffer +func (lb *LogBuffer) Add(level, tag, message string) { + lb.mu.Lock() + defer lb.mu.Unlock() + + // Skip if logging is disabled (except for errors which are always logged) + if !lb.loggingEnabled && level != "ERROR" && level != "FATAL" { + return + } + + entry := LogEntry{ + Timestamp: time.Now().Format("15:04:05.000"), + Level: level, + Tag: tag, + Message: message, + } + + if len(lb.entries) >= lb.maxSize { + // Remove oldest entry + lb.entries = lb.entries[1:] + } + lb.entries = append(lb.entries, entry) + + // Also print to logcat for debugging + fmt.Printf("[%s] %s\n", tag, message) +} + +// GetAll returns all log entries as JSON +func (lb *LogBuffer) GetAll() string { + lb.mu.RLock() + defer lb.mu.RUnlock() + + jsonBytes, _ := json.Marshal(lb.entries) + return string(jsonBytes) +} + +// getSince returns log entries since the given index (internal use) +func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) { + lb.mu.RLock() + defer lb.mu.RUnlock() + + if index < 0 { + index = 0 + } + if index >= len(lb.entries) { + return []LogEntry{}, len(lb.entries) + } + + entries := lb.entries[index:] + return entries, len(lb.entries) +} + +// Clear clears all log entries +func (lb *LogBuffer) Clear() { + lb.mu.Lock() + defer lb.mu.Unlock() + lb.entries = lb.entries[:0] +} + +// Count returns the number of log entries +func (lb *LogBuffer) Count() int { + lb.mu.RLock() + defer lb.mu.RUnlock() + return len(lb.entries) +} + +// Helper functions for logging with different levels +func LogDebug(tag, format string, args ...interface{}) { + GetLogBuffer().Add("DEBUG", tag, fmt.Sprintf(format, args...)) +} + +func LogInfo(tag, format string, args ...interface{}) { + GetLogBuffer().Add("INFO", tag, fmt.Sprintf(format, args...)) +} + +func LogWarn(tag, format string, args ...interface{}) { + GetLogBuffer().Add("WARN", tag, fmt.Sprintf(format, args...)) +} + +func LogError(tag, format string, args ...interface{}) { + GetLogBuffer().Add("ERROR", tag, fmt.Sprintf(format, args...)) +} + +// GoLog is a drop-in replacement for fmt.Printf that also logs to buffer +// It parses the tag from the format string if it starts with [Tag] +func GoLog(format string, args ...interface{}) { + message := fmt.Sprintf(format, args...) + message = strings.TrimSuffix(message, "\n") + + // Extract tag from message if present (e.g., "[Tidal] message") + tag := "Go" + level := "INFO" + + if strings.HasPrefix(message, "[") { + endBracket := strings.Index(message, "]") + if endBracket > 1 { + tag = message[1:endBracket] + message = strings.TrimSpace(message[endBracket+1:]) + } + } + + // Determine level from message content + msgLower := strings.ToLower(message) + if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") || strings.HasPrefix(message, "✗") { + level = "ERROR" + } else if strings.Contains(msgLower, "warning") || strings.Contains(msgLower, "warn") { + level = "WARN" + } else if strings.HasPrefix(message, "✓") || strings.Contains(msgLower, "success") || strings.Contains(msgLower, "match found") { + level = "INFO" + } else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") { + level = "DEBUG" + } + + GetLogBuffer().Add(level, tag, message) +} + +// Exported functions for Flutter + +// GetLogs returns all logs as JSON array +func GetLogs() string { + return GetLogBuffer().GetAll() +} + +// GetLogsSince returns logs since the given index +// Returns JSON: {"logs": [...], "next_index": N} +func GetLogsSince(index int) string { + entries, nextIndex := GetLogBuffer().getSince(index) + logsJson, _ := json.Marshal(entries) + result := fmt.Sprintf(`{"logs":%s,"next_index":%d}`, string(logsJson), nextIndex) + return result +} + +// ClearLogs clears all logs +func ClearLogs() { + GetLogBuffer().Clear() +} + +// GetLogCount returns the number of log entries +func GetLogCount() int { + return GetLogBuffer().Count() +} + +// SetLoggingEnabled enables or disables logging from Flutter +func SetLoggingEnabled(enabled bool) { + GetLogBuffer().SetLoggingEnabled(enabled) +} diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 3be5dbd..bb8473a 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -83,18 +83,193 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool { return true } - // If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean), - // assume they're the same artist with different transliteration - expectedASCII := qobuzIsASCIIString(expectedArtist) - foundASCII := qobuzIsASCIIString(foundArtist) - if expectedASCII != foundASCII { - fmt.Printf("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist) + // If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration) + // Don't treat Latin Extended (Polish, French, etc.) as different script + expectedLatin := qobuzIsLatinScript(expectedArtist) + foundLatin := qobuzIsLatinScript(foundArtist) + if expectedLatin != foundLatin { + GoLog("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist) return true } return false } +// qobuzTitlesMatch checks if track titles are similar enough +func qobuzTitlesMatch(expectedTitle, foundTitle string) bool { + normExpected := strings.ToLower(strings.TrimSpace(expectedTitle)) + normFound := strings.ToLower(strings.TrimSpace(foundTitle)) + + // Exact match + if normExpected == normFound { + return true + } + + // Check if one contains the other + if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) { + return true + } + + // Clean BOTH titles and compare (removes suffixes like remaster, remix, etc) + cleanExpected := qobuzCleanTitle(normExpected) + cleanFound := qobuzCleanTitle(normFound) + + if cleanExpected == cleanFound { + return true + } + + // Check if cleaned versions contain each other + if cleanExpected != "" && cleanFound != "" { + if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) { + return true + } + } + + // Extract core title (before any parentheses/brackets) + coreExpected := qobuzExtractCoreTitle(normExpected) + coreFound := qobuzExtractCoreTitle(normFound) + + if coreExpected != "" && coreFound != "" && coreExpected == coreFound { + return true + } + + // If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration) + // Don't treat Latin Extended (Polish, French, etc.) as different script + expectedLatin := qobuzIsLatinScript(expectedTitle) + foundLatin := qobuzIsLatinScript(foundTitle) + if expectedLatin != foundLatin { + GoLog("[Qobuz] Titles in different scripts, assuming match: '%s' vs '%s'\n", expectedTitle, foundTitle) + return true + } + + return false +} + +// qobuzExtractCoreTitle extracts the main title before any parentheses or brackets +func qobuzExtractCoreTitle(title string) string { + // Find first occurrence of ( or [ + parenIdx := strings.Index(title, "(") + bracketIdx := strings.Index(title, "[") + dashIdx := strings.Index(title, " - ") + + cutIdx := len(title) + if parenIdx > 0 && parenIdx < cutIdx { + cutIdx = parenIdx + } + if bracketIdx > 0 && bracketIdx < cutIdx { + cutIdx = bracketIdx + } + if dashIdx > 0 && dashIdx < cutIdx { + cutIdx = dashIdx + } + + return strings.TrimSpace(title[:cutIdx]) +} + +// qobuzCleanTitle removes common suffixes from track titles for comparison +func qobuzCleanTitle(title string) string { + cleaned := title + + // Remove content in parentheses/brackets that are version indicators + // This helps match "Song (Remastered)" with "Song" or "Song (2024 Remaster)" + versionPatterns := []string{ + "remaster", "remastered", "deluxe", "bonus", "single", + "album version", "radio edit", "original mix", "extended", + "club mix", "remix", "live", "acoustic", "demo", + } + + // Remove parenthetical content if it contains version indicators + for { + startParen := strings.LastIndex(cleaned, "(") + endParen := strings.LastIndex(cleaned, ")") + if startParen >= 0 && endParen > startParen { + content := strings.ToLower(cleaned[startParen+1 : endParen]) + isVersionIndicator := false + for _, pattern := range versionPatterns { + if strings.Contains(content, pattern) { + isVersionIndicator = true + break + } + } + if isVersionIndicator { + cleaned = strings.TrimSpace(cleaned[:startParen]) + cleaned[endParen+1:] + continue + } + } + break + } + + // Same for brackets + for { + startBracket := strings.LastIndex(cleaned, "[") + endBracket := strings.LastIndex(cleaned, "]") + if startBracket >= 0 && endBracket > startBracket { + content := strings.ToLower(cleaned[startBracket+1 : endBracket]) + isVersionIndicator := false + for _, pattern := range versionPatterns { + if strings.Contains(content, pattern) { + isVersionIndicator = true + break + } + } + if isVersionIndicator { + cleaned = strings.TrimSpace(cleaned[:startBracket]) + cleaned[endBracket+1:] + continue + } + } + break + } + + // Remove trailing " - version" patterns + dashPatterns := []string{ + " - remaster", " - remastered", " - single version", " - radio edit", + " - live", " - acoustic", " - demo", " - remix", + } + for _, pattern := range dashPatterns { + if strings.HasSuffix(strings.ToLower(cleaned), pattern) { + cleaned = cleaned[:len(cleaned)-len(pattern)] + } + } + + // Remove multiple spaces + for strings.Contains(cleaned, " ") { + cleaned = strings.ReplaceAll(cleaned, " ", " ") + } + + return strings.TrimSpace(cleaned) +} + +// qobuzIsLatinScript checks if a string is primarily Latin script +// Returns true for ASCII and Latin Extended characters (European languages) +// Returns false for CJK, Arabic, Cyrillic, etc. +func qobuzIsLatinScript(s string) bool { + for _, r := range s { + // Skip common punctuation and numbers + if r < 128 { + continue + } + // Latin Extended-A: U+0100 to U+017F (Polish, Czech, etc.) + // Latin Extended-B: U+0180 to U+024F + // Latin Extended Additional: U+1E00 to U+1EFF + // Latin Extended-C/D/E: various ranges + if (r >= 0x0100 && r <= 0x024F) || // Latin Extended A & B + (r >= 0x1E00 && r <= 0x1EFF) || // Latin Extended Additional + (r >= 0x00C0 && r <= 0x00FF) { // Latin-1 Supplement (accented chars) + continue + } + // CJK ranges - definitely different script + if (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs + (r >= 0x3040 && r <= 0x309F) || // Hiragana + (r >= 0x30A0 && r <= 0x30FF) || // Katakana + (r >= 0xAC00 && r <= 0xD7AF) || // Hangul (Korean) + (r >= 0x0600 && r <= 0x06FF) || // Arabic + (r >= 0x0400 && r <= 0x04FF) { // Cyrillic + return false + } + } + return true +} + // qobuzIsASCIIString checks if a string contains only ASCII characters func qobuzIsASCIIString(s string) bool { for _, r := range s { @@ -194,7 +369,7 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { // SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification // expectedDurationSec is the expected duration in seconds (0 to skip verification) func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) { - fmt.Printf("[Qobuz] Searching by ISRC: %s\n", isrc) + GoLog("[Qobuz] Searching by ISRC: %s\n", isrc) apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID) @@ -223,7 +398,7 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur return nil, err } - fmt.Printf("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items)) + GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items)) // Find ISRC matches var isrcMatches []*QobuzTrack @@ -233,7 +408,7 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur } } - fmt.Printf("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches)) + GoLog("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches)) if len(isrcMatches) > 0 { // Verify duration if provided @@ -251,20 +426,20 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur } if len(durationVerifiedMatches) > 0 { - fmt.Printf("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n", + GoLog("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n", durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration) return durationVerifiedMatches[0], nil } // ISRC matches but duration doesn't - fmt.Printf("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n", + GoLog("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n", isrc, expectedDurationSec, isrcMatches[0].Duration) return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)", expectedDurationSec, isrcMatches[0].Duration) } // No duration to verify, return first match - fmt.Printf("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title) + GoLog("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title) return isrcMatches[0], nil } @@ -287,6 +462,7 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (* // SearchTrackByMetadataWithDuration searches for a track with duration verification // Now includes romaji conversion for Japanese text (same as Tidal) +// Also includes title verification to prevent wrong song downloads func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) { apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") @@ -318,7 +494,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack if !containsQueryQobuz(queries, romajiQuery) { queries = append(queries, romajiQuery) - fmt.Printf("[Qobuz] Japanese detected, adding romaji query: %s\n", romajiQuery) + GoLog("[Qobuz] Japanese detected, adding romaji query: %s\n", romajiQuery) } } @@ -348,7 +524,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam } searchedQueries[cleanQuery] = true - fmt.Printf("[Qobuz] Searching for: %s\n", cleanQuery) + GoLog("[Qobuz] Searching for: %s\n", cleanQuery) searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(cleanQuery), q.appID) @@ -359,7 +535,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam resp, err := DoRequestWithUserAgent(q.client, req) if err != nil { - fmt.Printf("[Qobuz] Search error for '%s': %v\n", cleanQuery, err) + GoLog("[Qobuz] Search error for '%s': %v\n", cleanQuery, err) continue } @@ -380,7 +556,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam resp.Body.Close() if len(result.Tracks.Items) > 0 { - fmt.Printf("[Qobuz] Found %d results for '%s'\n", len(result.Tracks.Items), cleanQuery) + GoLog("[Qobuz] Found %d results for '%s'\n", len(result.Tracks.Items), cleanQuery) allTracks = append(allTracks, result.Tracks.Items...) } } @@ -389,11 +565,30 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName) } + // Filter by title match first (NEW - like Tidal) + var titleMatches []*QobuzTrack + for i := range allTracks { + track := &allTracks[i] + if qobuzTitlesMatch(trackName, track.Title) { + titleMatches = append(titleMatches, track) + } + } + + GoLog("[Qobuz] Title matches: %d out of %d results\n", len(titleMatches), len(allTracks)) + + // If no title matches, log warning but continue with all tracks + tracksToCheck := titleMatches + if len(titleMatches) == 0 { + GoLog("[Qobuz] WARNING: No title matches for '%s', checking all %d results\n", trackName, len(allTracks)) + for i := range allTracks { + tracksToCheck = append(tracksToCheck, &allTracks[i]) + } + } + // If duration verification is requested if expectedDurationSec > 0 { var durationMatches []*QobuzTrack - for i := range allTracks { - track := &allTracks[i] + for _, track := range tracksToCheck { durationDiff := track.Duration - expectedDurationSec if durationDiff < 0 { durationDiff = -durationDiff @@ -407,24 +602,36 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam // Return best quality among duration matches for _, track := range durationMatches { if track.MaximumBitDepth >= 24 { + GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified, hi-res)\n", + track.Title, track.Performer.Name) return track, nil } } + GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified)\n", + durationMatches[0].Title, durationMatches[0].Performer.Name) return durationMatches[0], nil } // No duration match found - return nil, fmt.Errorf("no tracks found with matching duration (expected %ds)", expectedDurationSec) + return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec) } - // No duration verification, return best quality - for i := range allTracks { - track := &allTracks[i] + // No duration verification, return best quality from title matches + for _, track := range tracksToCheck { if track.MaximumBitDepth >= 24 { + GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified, hi-res)\n", + track.Title, track.Performer.Name) return track, nil } } - return &allTracks[0], nil + + if len(tracksToCheck) > 0 { + GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified)\n", + tracksToCheck[0].Title, tracksToCheck[0].Performer.Name) + return tracksToCheck[0], nil + } + + return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName) } // getQobuzDownloadURLSequential requests download URL from APIs sequentially @@ -443,7 +650,7 @@ func getQobuzDownloadURLSequential(apis []string, trackID int64, quality string) // The apiURL already includes the path, just append trackID and quality reqURL := fmt.Sprintf("%s%d&quality=%s", apiURL, trackID, quality) - fmt.Printf("[Qobuz] Trying: %s\n", reqURL) + GoLog("[Qobuz] Trying: %s\n", reqURL) req, err := http.NewRequest("GET", reqURL, nil) if err != nil { @@ -488,7 +695,7 @@ func getQobuzDownloadURLSequential(apis []string, trackID int64, quality string) } if result.URL != "" { - fmt.Printf("[Qobuz] Got download URL from: %s\n", apiURL) + GoLog("[Qobuz] Got download URL from: %s\n", apiURL) return apiURL, result.URL, nil } @@ -619,11 +826,11 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { // OPTIMIZATION: Check cache first for track ID if req.ISRC != "" { if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 { - fmt.Printf("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID) + GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID) // For Qobuz we need to search again to get full track info, but we can use the ID track, err = downloader.SearchTrackByISRC(req.ISRC) if err != nil { - fmt.Printf("[Qobuz] Cache hit but search failed: %v\n", err) + GoLog("[Qobuz] Cache hit but search failed: %v\n", err) track = nil } } @@ -631,22 +838,28 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { // Strategy 1: Search by ISRC with duration verification if track == nil && req.ISRC != "" { - fmt.Printf("[Qobuz] Trying ISRC search: %s\n", req.ISRC) + GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC) track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec) - // Verify artist - if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { - fmt.Printf("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", - req.ArtistName, track.Performer.Name) - track = nil + // Verify artist AND title + if track != nil { + if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { + GoLog("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", + req.ArtistName, track.Performer.Name) + track = nil + } else if !qobuzTitlesMatch(req.TrackName, track.Title) { + GoLog("[Qobuz] Title mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", + req.TrackName, track.Title) + track = nil + } } } - // Strategy 2: Search by metadata with duration verification + // Strategy 2: Search by metadata with duration verification (includes title verification) if track == nil { track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec) - // Verify artist + // Verify artist (title already verified in SearchTrackByMetadataWithDuration) if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { - fmt.Printf("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", + GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, track.Performer.Name) track = nil } @@ -661,7 +874,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { } // Log match found and cache the track ID - fmt.Printf("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration) + GoLog("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration) if req.ISRC != "" { GetTrackIDCache().SetQobuz(req.ISRC, track.ID) } @@ -695,12 +908,12 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { case "HI_RES_LOSSLESS": qobuzQuality = "27" // 24-bit 192kHz } - fmt.Printf("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality) + GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality) // Get actual quality from track metadata actualBitDepth := track.MaximumBitDepth actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz - fmt.Printf("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate) + GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate) // Get download URL using parallel API requests downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality) @@ -762,7 +975,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { var coverData []byte if parallelResult != nil && parallelResult.CoverData != nil { coverData = parallelResult.CoverData - fmt.Printf("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData)) + GoLog("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData)) } if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { @@ -771,9 +984,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { // Embed lyrics from parallel fetch if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { - fmt.Printf("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) + GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { - fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr) + GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr) } else { fmt.Println("[Qobuz] Lyrics embedded successfully") } diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 90fbbb5..cf4a6a2 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -403,7 +403,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack if !containsQuery(queries, romajiQuery) { queries = append(queries, romajiQuery) - fmt.Printf("[Tidal] Japanese detected, adding romaji query: %s\n", romajiQuery) + GoLog("[Tidal] Japanese detected, adding romaji query: %s\n", romajiQuery) } } @@ -444,7 +444,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s } searchedQueries[cleanQuery] = true - fmt.Printf("[Tidal] Searching for: %s\n", cleanQuery) + GoLog("[Tidal] Searching for: %s\n", cleanQuery) searchURL := fmt.Sprintf("%s%s&limit=100&countryCode=US", string(searchBase), url.QueryEscape(cleanQuery)) @@ -457,7 +457,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s resp, err := DoRequestWithUserAgent(t.client, req) if err != nil { - fmt.Printf("[Tidal] Search error for '%s': %v\n", cleanQuery, err) + GoLog("[Tidal] Search error for '%s': %v\n", cleanQuery, err) continue } @@ -476,7 +476,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s resp.Body.Close() if len(result.Items) > 0 { - fmt.Printf("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery) + GoLog("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery) // OPTIMIZATION: If ISRC provided, check for match immediately and return early if spotifyISRC != "" { @@ -490,14 +490,14 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s durationDiff = -durationDiff } if durationDiff <= 3 { - fmt.Printf("[Tidal] ✓ ISRC match: '%s' (duration verified)\n", track.Title) + GoLog("[Tidal] ✓ ISRC match: '%s' (duration verified)\n", track.Title) return track, nil } // Duration mismatch, continue searching - fmt.Printf("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n", + GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n", expectedDuration, track.Duration) } else { - fmt.Printf("[Tidal] ✓ ISRC match: '%s'\n", track.Title) + GoLog("[Tidal] ✓ ISRC match: '%s'\n", track.Title) return track, nil } } @@ -514,7 +514,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s // Priority 1: Match by ISRC (exact match) WITH title verification if spotifyISRC != "" { - fmt.Printf("[Tidal] Looking for ISRC match: %s\n", spotifyISRC) + GoLog("[Tidal] Looking for ISRC match: %s\n", spotifyISRC) var isrcMatches []*TidalTrack for i := range allTracks { track := &allTracks[i] @@ -540,25 +540,25 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s if len(durationVerifiedMatches) > 0 { // Return first duration-verified match - fmt.Printf("[Tidal] ✓ ISRC match with duration verification: '%s' (expected %ds, found %ds)\n", + GoLog("[Tidal] ✓ ISRC match with duration verification: '%s' (expected %ds, found %ds)\n", durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration) return durationVerifiedMatches[0], nil } // ISRC matches but duration doesn't - this is likely wrong version - fmt.Printf("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n", + GoLog("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n", spotifyISRC, expectedDuration, isrcMatches[0].Duration) return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)", expectedDuration, isrcMatches[0].Duration) } // No duration to verify, just return first ISRC match - fmt.Printf("[Tidal] ✓ ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title) + GoLog("[Tidal] ✓ ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title) return isrcMatches[0], nil } // If ISRC was provided but no match found, return error - fmt.Printf("[Tidal] ✗ No ISRC match found for: %s\n", spotifyISRC) + GoLog("[Tidal] ✗ No ISRC match found for: %s\n", spotifyISRC) return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC) } @@ -589,7 +589,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s } } } - fmt.Printf("[Tidal] Found via duration match: %s - %s (%s)\n", + GoLog("[Tidal] Found via duration match: %s - %s (%s)\n", bestMatch.Artist.Name, bestMatch.Title, bestMatch.AudioQuality) return bestMatch, nil } @@ -610,7 +610,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s } } - fmt.Printf("[Tidal] Found via search (no ISRC provided): %s - %s (ISRC: %s, Quality: %s)\n", + GoLog("[Tidal] Found via search (no ISRC provided): %s - %s (ISRC: %s, Quality: %s)\n", bestMatch.Artist.Name, bestMatch.Title, bestMatch.ISRC, bestMatch.AudioQuality) return bestMatch, nil @@ -651,7 +651,7 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str for _, apiURL := range apis { reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", apiURL, trackID, quality) - fmt.Printf("[Tidal] Trying API: %s\n", reqURL) + GoLog("[Tidal] Trying API: %s\n", reqURL) req, err := http.NewRequest("GET", reqURL, nil) if err != nil { @@ -661,7 +661,7 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str resp, err := DoRequestWithRetry(client, req, retryConfig) if err != nil { - fmt.Printf("[Tidal] API error: %v\n", err) + GoLog("[Tidal] API error: %v\n", err) errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error())) continue } @@ -669,7 +669,7 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str body, err := ReadResponseBody(resp) resp.Body.Close() if err != nil { - fmt.Printf("[Tidal] Read body error: %v\n", err) + GoLog("[Tidal] Read body error: %v\n", err) errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, err.Error())) continue } @@ -679,22 +679,22 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str if len(bodyPreview) > 300 { bodyPreview = bodyPreview[:300] + "..." } - fmt.Printf("[Tidal] API response (HTTP %d): %s\n", resp.StatusCode, bodyPreview) + GoLog("[Tidal] API response (HTTP %d): %s\n", resp.StatusCode, bodyPreview) // Try v2 format first (object with manifest) var v2Response TidalAPIResponseV2 if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" { - fmt.Printf("[Tidal] Got v2 response from %s - Quality: %d-bit/%dHz, AssetPresentation: %s\n", + GoLog("[Tidal] Got v2 response from %s - Quality: %d-bit/%dHz, AssetPresentation: %s\n", apiURL, v2Response.Data.BitDepth, v2Response.Data.SampleRate, v2Response.Data.AssetPresentation) // IMPORTANT: Reject PREVIEW responses - we need FULL tracks if v2Response.Data.AssetPresentation == "PREVIEW" { - fmt.Printf("[Tidal] ✗ Rejecting PREVIEW response from %s, trying next API...\n", apiURL) + GoLog("[Tidal] ✗ Rejecting PREVIEW response from %s, trying next API...\n", apiURL) errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "returned PREVIEW instead of FULL")) continue } - fmt.Printf("[Tidal] ✓ Got FULL track from %s\n", apiURL) + GoLog("[Tidal] ✓ Got FULL track from %s\n", apiURL) info := TidalDownloadInfo{ URL: "MANIFEST:" + v2Response.Data.Manifest, BitDepth: v2Response.Data.BitDepth, @@ -756,7 +756,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU if len(manifestPreview) > 500 { manifestPreview = manifestPreview[:500] + "..." } - fmt.Printf("[Tidal] Manifest content: %s\n", manifestPreview) + GoLog("[Tidal] Manifest content: %s\n", manifestPreview) // Check if it's BTS format (JSON) or DASH format (XML) if strings.HasPrefix(manifestStr, "{") { @@ -806,12 +806,12 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU // Calculate segment count from timeline segmentCount := 0 - fmt.Printf("[Tidal] XML parsed segments: %d entries in timeline\n", len(segTemplate.Timeline.Segments)) + GoLog("[Tidal] XML parsed segments: %d entries in timeline\n", len(segTemplate.Timeline.Segments)) for i, seg := range segTemplate.Timeline.Segments { - fmt.Printf("[Tidal] Segment[%d]: d=%d, r=%d\n", i, seg.Duration, seg.Repeat) + GoLog("[Tidal] Segment[%d]: d=%d, r=%d\n", i, seg.Duration, seg.Repeat) segmentCount += seg.Repeat + 1 } - fmt.Printf("[Tidal] Segment count from XML: %d\n", segmentCount) + GoLog("[Tidal] Segment count from XML: %d\n", segmentCount) // If no segments found via XML, try regex if segmentCount == 0 { @@ -819,18 +819,18 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU // Match or segRe := regexp.MustCompile(` 2 && match[2] != "" { fmt.Sscanf(match[2], "%d", &repeat) } if i < 5 || i == len(matches)-1 { - fmt.Printf("[Tidal] Regex segment[%d]: d=%s, r=%d\n", i, match[1], repeat) + GoLog("[Tidal] Regex segment[%d]: d=%s, r=%d\n", i, match[1], repeat) } segmentCount += repeat + 1 } - fmt.Printf("[Tidal] Total segments from regex: %d\n", segmentCount) + GoLog("[Tidal] Total segments from regex: %d\n", segmentCount) } // Generate media URLs for each segment @@ -930,10 +930,10 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s fmt.Println("[Tidal] Parsing manifest...") directURL, initURL, mediaURLs, err := parseManifest(manifestB64) if err != nil { - fmt.Printf("[Tidal] Manifest parse error: %v\n", err) + GoLog("[Tidal] Manifest parse error: %v\n", err) return fmt.Errorf("failed to parse manifest: %w", err) } - fmt.Printf("[Tidal] Manifest parsed - directURL: %v, initURL: %v, mediaURLs count: %d\n", + GoLog("[Tidal] Manifest parsed - directURL: %v, initURL: %v, mediaURLs count: %d\n", directURL != "", initURL != "", len(mediaURLs)) client := &http.Client{ @@ -942,27 +942,27 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s // If we have a direct URL (BTS format), download directly with progress tracking if directURL != "" { - fmt.Printf("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))]) + GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))]) // Note: Progress tracking is initialized by the caller (DownloadFile) req, err := http.NewRequest("GET", directURL, nil) if err != nil { - fmt.Printf("[Tidal] BTS request creation failed: %v\n", err) + GoLog("[Tidal] BTS request creation failed: %v\n", err) return fmt.Errorf("failed to create request: %w", err) } resp, err := client.Do(req) if err != nil { - fmt.Printf("[Tidal] BTS download failed: %v\n", err) + GoLog("[Tidal] BTS download failed: %v\n", err) return fmt.Errorf("failed to download file: %w", err) } defer resp.Body.Close() if resp.StatusCode != 200 { - fmt.Printf("[Tidal] BTS download HTTP error: %d\n", resp.StatusCode) + GoLog("[Tidal] BTS download HTTP error: %d\n", resp.StatusCode) return fmt.Errorf("download failed with status %d", resp.StatusCode) } - fmt.Printf("[Tidal] BTS response OK, Content-Length: %d\n", resp.ContentLength) + GoLog("[Tidal] BTS response OK, Content-Length: %d\n", resp.ContentLength) expectedSize := resp.ContentLength // Set total bytes for progress tracking @@ -1007,31 +1007,31 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s // DASH format - download segments directly to M4A file (no temp file to avoid Android permission issues) // On Android, we can't use ffmpeg, so we save as M4A directly m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a" - fmt.Printf("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath) + GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath) // Note: Progress tracking is initialized by the caller (DownloadFile or downloadFromTidal) // We just update progress here based on segment count out, err := os.Create(m4aPath) if err != nil { - fmt.Printf("[Tidal] Failed to create M4A file: %v\n", err) + GoLog("[Tidal] Failed to create M4A file: %v\n", err) return fmt.Errorf("failed to create M4A file: %w", err) } // Download initialization segment - fmt.Printf("[Tidal] Downloading init segment...\n") + GoLog("[Tidal] Downloading init segment...\n") resp, err := client.Get(initURL) if err != nil { out.Close() os.Remove(m4aPath) - fmt.Printf("[Tidal] Init segment download failed: %v\n", err) + GoLog("[Tidal] Init segment download failed: %v\n", err) return fmt.Errorf("failed to download init segment: %w", err) } if resp.StatusCode != 200 { resp.Body.Close() out.Close() os.Remove(m4aPath) - fmt.Printf("[Tidal] Init segment HTTP error: %d\n", resp.StatusCode) + GoLog("[Tidal] Init segment HTTP error: %d\n", resp.StatusCode) return fmt.Errorf("init segment download failed with status %d", resp.StatusCode) } _, err = io.Copy(out, resp.Body) @@ -1039,7 +1039,7 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s if err != nil { out.Close() os.Remove(m4aPath) - fmt.Printf("[Tidal] Init segment write failed: %v\n", err) + GoLog("[Tidal] Init segment write failed: %v\n", err) return fmt.Errorf("failed to write init segment: %w", err) } @@ -1047,7 +1047,7 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s totalSegments := len(mediaURLs) for i, mediaURL := range mediaURLs { if i%10 == 0 || i == totalSegments-1 { - fmt.Printf("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments) + GoLog("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments) } // Update progress based on segment count @@ -1060,14 +1060,14 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s if err != nil { out.Close() os.Remove(m4aPath) - fmt.Printf("[Tidal] Segment %d download failed: %v\n", i+1, err) + GoLog("[Tidal] Segment %d download failed: %v\n", i+1, err) return fmt.Errorf("failed to download segment %d: %w", i+1, err) } if resp.StatusCode != 200 { resp.Body.Close() out.Close() os.Remove(m4aPath) - fmt.Printf("[Tidal] Segment %d HTTP error: %d\n", i+1, resp.StatusCode) + GoLog("[Tidal] Segment %d HTTP error: %d\n", i+1, resp.StatusCode) return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode) } _, err = io.Copy(out, resp.Body) @@ -1075,18 +1075,18 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s if err != nil { out.Close() os.Remove(m4aPath) - fmt.Printf("[Tidal] Segment %d write failed: %v\n", i+1, err) + GoLog("[Tidal] Segment %d write failed: %v\n", i+1, err) return fmt.Errorf("failed to write segment %d: %w", i+1, err) } } if err := out.Close(); err != nil { os.Remove(m4aPath) - fmt.Printf("[Tidal] Failed to close M4A file: %v\n", err) + GoLog("[Tidal] Failed to close M4A file: %v\n", err) return fmt.Errorf("failed to close M4A file: %w", err) } - fmt.Printf("[Tidal] DASH download completed: %s\n", m4aPath) + GoLog("[Tidal] DASH download completed: %s\n", m4aPath) return nil } @@ -1139,19 +1139,192 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool { return true } - // If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean), - // assume they're the same artist with different transliteration + // If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration) + // Don't treat Latin Extended (Polish, French, etc.) as different script // This handles cases like "鈴木雅之" vs "Masayuki Suzuki" - spotifyASCII := isASCIIString(spotifyArtist) - tidalASCII := isASCIIString(tidalArtist) - if spotifyASCII != tidalASCII { - fmt.Printf("[Tidal] Artist names in different scripts, assuming match: '%s' vs '%s'\n", spotifyArtist, tidalArtist) + spotifyLatin := isLatinScript(spotifyArtist) + tidalLatin := isLatinScript(tidalArtist) + if spotifyLatin != tidalLatin { + GoLog("[Tidal] Artist names in different scripts, assuming match: '%s' vs '%s'\n", spotifyArtist, tidalArtist) return true } return false } +// titlesMatch checks if track titles are similar enough +func titlesMatch(expectedTitle, foundTitle string) bool { + normExpected := strings.ToLower(strings.TrimSpace(expectedTitle)) + normFound := strings.ToLower(strings.TrimSpace(foundTitle)) + + // Exact match + if normExpected == normFound { + return true + } + + // Check if one contains the other + if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) { + return true + } + + // Clean both titles and compare + cleanExpected := cleanTitle(normExpected) + cleanFound := cleanTitle(normFound) + + if cleanExpected == cleanFound { + return true + } + + // Check if cleaned versions contain each other + if cleanExpected != "" && cleanFound != "" { + if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) { + return true + } + } + + // Extract core title (before any parentheses/brackets) + coreExpected := extractCoreTitle(normExpected) + coreFound := extractCoreTitle(normFound) + + if coreExpected != "" && coreFound != "" && coreExpected == coreFound { + return true + } + + // If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration) + // Don't treat Latin Extended (Polish, French, etc.) as different script + expectedLatin := isLatinScript(expectedTitle) + foundLatin := isLatinScript(foundTitle) + if expectedLatin != foundLatin { + GoLog("[Tidal] Titles in different scripts, assuming match: '%s' vs '%s'\n", expectedTitle, foundTitle) + return true + } + + return false +} + +// extractCoreTitle extracts the main title before any parentheses or brackets +func extractCoreTitle(title string) string { + // Find first occurrence of ( or [ + parenIdx := strings.Index(title, "(") + bracketIdx := strings.Index(title, "[") + dashIdx := strings.Index(title, " - ") + + cutIdx := len(title) + if parenIdx > 0 && parenIdx < cutIdx { + cutIdx = parenIdx + } + if bracketIdx > 0 && bracketIdx < cutIdx { + cutIdx = bracketIdx + } + if dashIdx > 0 && dashIdx < cutIdx { + cutIdx = dashIdx + } + + return strings.TrimSpace(title[:cutIdx]) +} + +// cleanTitle removes common suffixes from track titles for comparison +func cleanTitle(title string) string { + cleaned := title + + // Version indicators to remove from parentheses/brackets + versionPatterns := []string{ + "remaster", "remastered", "deluxe", "bonus", "single", + "album version", "radio edit", "original mix", "extended", + "club mix", "remix", "live", "acoustic", "demo", + } + + // Remove parenthetical content if it contains version indicators + for { + startParen := strings.LastIndex(cleaned, "(") + endParen := strings.LastIndex(cleaned, ")") + if startParen >= 0 && endParen > startParen { + content := strings.ToLower(cleaned[startParen+1 : endParen]) + isVersionIndicator := false + for _, pattern := range versionPatterns { + if strings.Contains(content, pattern) { + isVersionIndicator = true + break + } + } + if isVersionIndicator { + cleaned = strings.TrimSpace(cleaned[:startParen]) + cleaned[endParen+1:] + continue + } + } + break + } + + // Same for brackets + for { + startBracket := strings.LastIndex(cleaned, "[") + endBracket := strings.LastIndex(cleaned, "]") + if startBracket >= 0 && endBracket > startBracket { + content := strings.ToLower(cleaned[startBracket+1 : endBracket]) + isVersionIndicator := false + for _, pattern := range versionPatterns { + if strings.Contains(content, pattern) { + isVersionIndicator = true + break + } + } + if isVersionIndicator { + cleaned = strings.TrimSpace(cleaned[:startBracket]) + cleaned[endBracket+1:] + continue + } + } + break + } + + // Remove trailing " - version" patterns + dashPatterns := []string{ + " - remaster", " - remastered", " - single version", " - radio edit", + " - live", " - acoustic", " - demo", " - remix", + } + for _, pattern := range dashPatterns { + if strings.HasSuffix(strings.ToLower(cleaned), pattern) { + cleaned = cleaned[:len(cleaned)-len(pattern)] + } + } + + // Remove multiple spaces + for strings.Contains(cleaned, " ") { + cleaned = strings.ReplaceAll(cleaned, " ", " ") + } + + return strings.TrimSpace(cleaned) +} + +// isLatinScript checks if a string is primarily Latin script +// Returns true for ASCII and Latin Extended characters (European languages) +// Returns false for CJK, Arabic, Cyrillic, etc. +func isLatinScript(s string) bool { + for _, r := range s { + // Skip common punctuation and numbers + if r < 128 { + continue + } + // Latin Extended-A: U+0100 to U+017F (Polish, Czech, etc.) + // Latin Extended-B: U+0180 to U+024F + // Latin Extended Additional: U+1E00 to U+1EFF + if (r >= 0x0100 && r <= 0x024F) || // Latin Extended A & B + (r >= 0x1E00 && r <= 0x1EFF) || // Latin Extended Additional + (r >= 0x00C0 && r <= 0x00FF) { // Latin-1 Supplement (accented chars) + continue + } + // CJK ranges - definitely different script + if (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs + (r >= 0x3040 && r <= 0x309F) || // Hiragana + (r >= 0x30A0 && r <= 0x30FF) || // Katakana + (r >= 0xAC00 && r <= 0xD7AF) || // Hangul (Korean) + (r >= 0x0600 && r <= 0x06FF) || // Arabic + (r >= 0x0400 && r <= 0x04FF) { // Cyrillic + return false + } + } + return true +} + // isASCIIString checks if a string contains only ASCII characters func isASCIIString(s string) bool { for _, r := range s { @@ -1180,10 +1353,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { // OPTIMIZATION: Check cache first for track ID if req.ISRC != "" { if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 { - fmt.Printf("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID) + GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID) track, err = downloader.GetTrackInfoByID(cached.TidalTrackID) if err != nil { - fmt.Printf("[Tidal] Cache hit but failed to get track info: %v\n", err) + GoLog("[Tidal] Cache hit but failed to get track info: %v\n", err) track = nil // Fall through to normal search } } @@ -1192,10 +1365,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { // OPTIMIZED: Try ISRC search with metadata (search by name, filter by ISRC) // Strategy 1: Search by metadata, match by ISRC (most accurate) if track == nil && req.ISRC != "" { - fmt.Printf("[Tidal] Trying ISRC search: %s\n", req.ISRC) + GoLog("[Tidal] Trying ISRC search: %s\n", req.ISRC) track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec) if track != nil { - // Verify artist + // Verify artist only (ISRC match is already accurate) tidalArtist := track.Artist.Name if len(track.Artists) > 0 { var artistNames []string @@ -1205,7 +1378,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { tidalArtist = strings.Join(artistNames, ", ") } if !artistsMatch(req.ArtistName, tidalArtist) { - fmt.Printf("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", + GoLog("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, tidalArtist) track = nil } @@ -1214,14 +1387,14 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { // Strategy 2: Try SongLink only if ISRC search failed (slower but more accurate) if track == nil && req.SpotifyID != "" { - fmt.Printf("[Tidal] ISRC search failed, trying SongLink...\n") + GoLog("[Tidal] ISRC search failed, trying SongLink...\n") var tidalURL string var slErr error // Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx") if strings.HasPrefix(req.SpotifyID, "deezer:") { deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:") - fmt.Printf("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID) + GoLog("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID) songlink := NewSongLinkClient() tidalURL, slErr = songlink.GetTidalURLFromDeezer(deezerID) } else { @@ -1244,9 +1417,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { tidalArtist = strings.Join(artistNames, ", ") } - // Verify artist matches + // Verify artist matches (SongLink is already accurate, no title check needed) if !artistsMatch(req.ArtistName, tidalArtist) { - fmt.Printf("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n", + GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, tidalArtist) track = nil } @@ -1259,7 +1432,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } // Allow 3 seconds tolerance (same as PC version) if durationDiff > 3 { - fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n", + GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n", expectedDurationSec, track.Duration) track = nil // Reject this match } @@ -1271,9 +1444,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { // Strategy 3: Search by metadata only (no ISRC requirement) - last resort if track == nil { - fmt.Printf("[Tidal] Trying metadata search as last resort...\n") + GoLog("[Tidal] Trying metadata search as last resort...\n") track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec) - // Verify artist for metadata search too + // Verify artist AND title for metadata search if track != nil { tidalArtist := track.Artist.Name if len(track.Artists) > 0 { @@ -1283,8 +1456,14 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } tidalArtist = strings.Join(artistNames, ", ") } - if !artistsMatch(req.ArtistName, tidalArtist) { - fmt.Printf("[Tidal] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", + + // Verify title first + if !titlesMatch(req.TrackName, track.Title) { + GoLog("[Tidal] Title mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", + req.TrackName, track.Title) + track = nil + } else if !artistsMatch(req.ArtistName, tidalArtist) { + GoLog("[Tidal] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, tidalArtist) track = nil } @@ -1308,7 +1487,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } tidalArtist = strings.Join(artistNames, ", ") } - fmt.Printf("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration) + GoLog("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration) // Cache the track ID for future use if req.ISRC != "" { @@ -1339,7 +1518,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { // Clean up any leftover .tmp files from previous failed downloads tmpPath := outputPath + ".m4a.tmp" if _, err := os.Stat(tmpPath); err == nil { - fmt.Printf("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath) + GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath) os.Remove(tmpPath) } @@ -1348,7 +1527,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { if quality == "" { quality = "LOSSLESS" } - fmt.Printf("[Tidal] Using quality: %s\n", quality) + GoLog("[Tidal] Using quality: %s\n", quality) // Get download URL using parallel API requests downloadInfo, err := downloader.GetDownloadURL(track.ID, quality) @@ -1357,7 +1536,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } // Log actual quality received - fmt.Printf("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate) + GoLog("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate) // START PARALLEL: Fetch cover and lyrics while downloading audio var parallelResult *ParallelDownloadResult @@ -1375,8 +1554,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { }() // Download audio file with item ID for progress tracking - fmt.Printf("[Tidal] Starting download to: %s\n", outputPath) - fmt.Printf("[Tidal] Download URL type: %s\n", func() string { + GoLog("[Tidal] Starting download to: %s\n", outputPath) + GoLog("[Tidal] Download URL type: %s\n", func() string { if strings.HasPrefix(downloadInfo.URL, "MANIFEST:") { return "MANIFEST (DASH/BTS)" } @@ -1384,7 +1563,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { }()) if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil { - fmt.Printf("[Tidal] Download failed with error: %v\n", err) + GoLog("[Tidal] Download failed with error: %v\n", err) return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err) } fmt.Println("[Tidal] Download completed successfully") @@ -1405,7 +1584,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { if _, err := os.Stat(m4aPath); err == nil { // File was saved as M4A, use that path actualOutputPath = m4aPath - fmt.Printf("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath) + GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath) } else if _, err := os.Stat(outputPath); err != nil { // Neither FLAC nor M4A exists return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath) @@ -1428,7 +1607,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { var coverData []byte if parallelResult != nil && parallelResult.CoverData != nil { coverData = parallelResult.CoverData - fmt.Printf("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData)) + GoLog("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData)) } // Embed metadata based on file type @@ -1439,9 +1618,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { // Embed lyrics from parallel fetch if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { - fmt.Printf("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) + GoLog("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil { - fmt.Printf("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr) + GoLog("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr) } else { fmt.Println("[Tidal] Lyrics embedded successfully") } @@ -1450,7 +1629,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } } else if strings.HasSuffix(actualOutputPath, ".m4a") { // Embed metadata to M4A file - // fmt.Printf("[Tidal] Embedding metadata to M4A file...\n") + // GoLog("[Tidal] Embedding metadata to M4A file...\n") // Add lyrics to metadata if available // if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { @@ -1464,7 +1643,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)") // if err := EmbedM4AMetadata(actualOutputPath, metadata, coverData); err != nil { - // fmt.Printf("[Tidal] Warning: failed to embed M4A metadata: %v\n", err) + // GoLog("[Tidal] Warning: failed to embed M4A metadata: %v\n", err) // } else { // fmt.Println("[Tidal] M4A metadata embedded successfully") // } diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 81b92a2..6af08b3 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -256,6 +256,31 @@ import Gobackend // Import Go framework GobackendSetSpotifyAPICredentials(clientId, clientSecret) return nil + // Log methods + case "getLogs": + let response = GobackendGetLogs() + return response + + case "getLogsSince": + let args = call.arguments as! [String: Any] + let index = args["index"] as? Int ?? 0 + let response = GobackendGetLogsSince(Int(index)) + return response + + case "clearLogs": + GobackendClearLogs() + return nil + + case "getLogCount": + let response = GobackendGetLogCount() + return response + + case "setLoggingEnabled": + let args = call.arguments as! [String: Any] + let enabled = args["enabled"] as? Bool ?? false + GobackendSetLoggingEnabled(enabled) + return nil + default: throw NSError( domain: "SpotiFLAC", diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index faeee5f..069c476 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '2.2.0'; - static const String buildNumber = '46'; + static const String version = '2.2.5'; + static const String buildNumber = '47'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/models/settings.dart b/lib/models/settings.dart index cad0de2..efab283 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -23,6 +23,7 @@ class AppSettings { final String spotifyClientSecret; // Custom Spotify client secret (empty = use default) final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set) final String metadataSource; // spotify, deezer - source for search and metadata + final bool enableLogging; // Enable detailed logging for debugging const AppSettings({ this.defaultService = 'tidal', @@ -44,6 +45,7 @@ class AppSettings { this.spotifyClientSecret = '', // Default: use built-in credentials this.useCustomSpotifyCredentials = true, // Default: use custom if set this.metadataSource = 'deezer', // Default: Deezer (no rate limit) + this.enableLogging = false, // Default: disabled for performance }); AppSettings copyWith({ @@ -66,6 +68,7 @@ class AppSettings { String? spotifyClientSecret, bool? useCustomSpotifyCredentials, String? metadataSource, + bool? enableLogging, }) { return AppSettings( defaultService: defaultService ?? this.defaultService, @@ -87,6 +90,7 @@ class AppSettings { spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret, useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials, metadataSource: metadataSource ?? this.metadataSource, + enableLogging: enableLogging ?? this.enableLogging, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 240114f..1446f8a 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -27,6 +27,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( useCustomSpotifyCredentials: json['useCustomSpotifyCredentials'] as bool? ?? true, metadataSource: json['metadataSource'] as String? ?? 'deezer', + enableLogging: json['enableLogging'] as bool? ?? false, ); Map _$AppSettingsToJson(AppSettings instance) => @@ -50,4 +51,5 @@ Map _$AppSettingsToJson(AppSettings instance) => 'spotifyClientSecret': instance.spotifyClientSecret, 'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials, 'metadataSource': instance.metadataSource, + 'enableLogging': instance.enableLogging, }; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 4cfcfe4..792aa77 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -770,7 +770,8 @@ class DownloadQueueNotifier extends Notifier { Future _embedMetadataAndCover(String flacPath, Track track) async { // Download cover first String? coverPath; - if (track.coverUrl != null && track.coverUrl!.isNotEmpty) { + final coverUrl = track.coverUrl; + if (coverUrl != null && coverUrl.isNotEmpty) { try { final tempDir = await getTemporaryDirectory(); final uniqueId = '${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}'; @@ -778,10 +779,10 @@ class DownloadQueueNotifier extends Notifier { // Download cover using HTTP final httpClient = HttpClient(); - final request = await httpClient.getUrl(Uri.parse(track.coverUrl!)); + final request = await httpClient.getUrl(Uri.parse(coverUrl)); final response = await request.close(); if (response.statusCode == 200) { - final file = File(coverPath!); + final file = File(coverPath); final sink = file.openWrite(); await response.pipe(sink); await sink.close(); @@ -845,7 +846,7 @@ class DownloadQueueNotifier extends Notifier { filePath: '', // No local file path yet (processed in memory) ); - if (lrcContent != null && lrcContent.isNotEmpty) { + if (lrcContent.isNotEmpty) { metadata['LYRICS'] = lrcContent; metadata['UNSYNCEDLYRICS'] = lrcContent; // Fallback for some players _log.d('Lyrics fetched for embedding (${lrcContent.length} chars)'); @@ -1250,11 +1251,11 @@ class DownloadQueueNotifier extends Notifier { // M4A files from Tidal DASH streams - try to convert to FLAC // M4A files from Tidal DASH streams - try to convert to FLAC - if (filePath != null && filePath!.endsWith('.m4a')) { + if (filePath != null && filePath.endsWith('.m4a')) { _log.d('M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...'); try { - final file = File(filePath!); + final file = File(filePath); if (!await file.exists()) { _log.e('File does not exist at path: $filePath'); } else { @@ -1265,7 +1266,7 @@ class DownloadQueueNotifier extends Notifier { _log.w('File is too small (<1KB), skipping conversion. Download might be corrupt.'); } else { updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.95); - final flacPath = await FFmpegService.convertM4aToFlac(filePath!); + final flacPath = await FFmpegService.convertM4aToFlac(filePath); if (flacPath != null) { filePath = flacPath; diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 8d665aa..2020adc 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/logger.dart'; const _settingsKey = 'app_settings'; const _migrationVersionKey = 'settings_migration_version'; @@ -26,6 +27,9 @@ class SettingsNotifier extends Notifier { // Apply Spotify credentials to Go backend on load _applySpotifyCredentials(); + + // Sync logging state + LogBuffer.loggingEnabled = state.enableLogging; } } @@ -187,6 +191,13 @@ class SettingsNotifier extends Notifier { state = state.copyWith(metadataSource: source); _saveSettings(); } + + void setEnableLogging(bool enabled) { + state = state.copyWith(enableLogging: enabled); + _saveSettings(); + // Sync logging state to LogBuffer + LogBuffer.loggingEnabled = enabled; + } } final settingsProvider = NotifierProvider( diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 5b96fd4..5235e1d 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -159,6 +159,11 @@ class _ArtistScreenState extends ConsumerState { } Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { + // Validate image URL - must be non-null, non-empty, and have a valid host + final hasValidImage = widget.coverUrl != null && + widget.coverUrl!.isNotEmpty && + Uri.tryParse(widget.coverUrl!)?.hasAuthority == true; + return SliverAppBar( expandedHeight: 280, pinned: true, @@ -169,8 +174,15 @@ class _ArtistScreenState extends ConsumerState { background: Stack( fit: StackFit.expand, children: [ - if (widget.coverUrl != null) - CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600), + if (hasValidImage) + CachedNetworkImage( + imageUrl: widget.coverUrl!, + fit: BoxFit.cover, + color: Colors.black.withValues(alpha: 0.5), + colorBlendMode: BlendMode.darken, + memCacheWidth: 600, + errorWidget: (context, url, error) => Container(color: colorScheme.surfaceContainerHighest), + ), Container( decoration: BoxDecoration( gradient: LinearGradient( @@ -192,8 +204,16 @@ class _ArtistScreenState extends ConsumerState { boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))], ), child: ClipOval( - child: widget.coverUrl != null - ? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280) + child: hasValidImage + ? CachedNetworkImage( + imageUrl: widget.coverUrl!, + fit: BoxFit.cover, + memCacheWidth: 280, + errorWidget: (context, url, error) => Container( + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant), + ), + ) : Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)), ), ), diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 6b9ea7a..73a412e 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -651,6 +651,11 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } Widget _buildArtistCard(SearchArtist artist, ColorScheme colorScheme) { + // Validate image URL - must be non-null, non-empty, and have a valid host + final hasValidImage = artist.imageUrl != null && + artist.imageUrl!.isNotEmpty && + Uri.tryParse(artist.imageUrl!)?.hasAuthority == true; + return GestureDetector( onTap: () => _navigateToArtist(artist.id, artist.name, artist.imageUrl), child: Container( @@ -666,12 +671,17 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient color: colorScheme.surfaceContainerHighest, ), child: ClipOval( - child: artist.imageUrl != null + child: hasValidImage ? CachedNetworkImage( imageUrl: artist.imageUrl!, fit: BoxFit.cover, memCacheWidth: 200, memCacheHeight: 200, + errorWidget: (context, url, error) => Icon( + Icons.person, + color: colorScheme.onSurfaceVariant, + size: 44, + ), ) : Icon(Icons.person, color: colorScheme.onSurfaceVariant, size: 44), ), diff --git a/lib/screens/settings/log_screen.dart b/lib/screens/settings/log_screen.dart new file mode 100644 index 0000000..46ba3c4 --- /dev/null +++ b/lib/screens/settings/log_screen.dart @@ -0,0 +1,801 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus; +import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +class LogScreen extends StatefulWidget { + const LogScreen({super.key}); + + @override + State createState() => _LogScreenState(); +} + +class _LogScreenState extends State { + final ScrollController _scrollController = ScrollController(); + final TextEditingController _searchController = TextEditingController(); + String _selectedLevel = 'ALL'; + String _searchQuery = ''; + bool _autoScroll = true; + + final List _levels = ['ALL', 'DEBUG', 'INFO', 'WARN', 'ERROR']; + + @override + void initState() { + super.initState(); + LogBuffer().addListener(_onLogUpdate); + // Start polling Go backend logs + LogBuffer().startGoLogPolling(); + } + + @override + void dispose() { + LogBuffer().removeListener(_onLogUpdate); + // Stop polling when leaving screen + LogBuffer().stopGoLogPolling(); + _scrollController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + void _onLogUpdate() { + if (mounted) { + setState(() {}); + if (_autoScroll && _scrollController.hasClients) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 100), + curve: Curves.easeOut, + ); + } + }); + } + } + } + + List get _filteredLogs { + return LogBuffer().filter( + level: _selectedLevel, + search: _searchQuery.isEmpty ? null : _searchQuery, + ); + } + + void _copyLogs() { + final logs = LogBuffer().export(); + Clipboard.setData(ClipboardData(text: logs)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Logs copied to clipboard'), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + duration: const Duration(seconds: 2), + ), + ); + } + + void _shareLogs() { + final logs = LogBuffer().export(); + SharePlus.instance.share(ShareParams(text: logs, subject: 'SpotiFLAC Logs')); + } + + void _clearLogs() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear Logs'), + content: const Text('Are you sure you want to clear all logs?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + LogBuffer().clear(); + Navigator.pop(context); + }, + child: const Text('Clear'), + ), + ], + ), + ); + } + + Color _getLevelColor(String level, ColorScheme colorScheme) { + switch (level) { + case 'ERROR': + case 'FATAL': + return colorScheme.error; + case 'WARN': + return Colors.orange; + case 'INFO': + return colorScheme.primary; + case 'DEBUG': + default: + return colorScheme.onSurfaceVariant; + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final topPadding = MediaQuery.of(context).padding.top; + final logs = _filteredLogs; + + return PopScope( + canPop: true, + child: Scaffold( + body: CustomScrollView( + controller: _scrollController, + slivers: [ + // Collapsing App Bar with back button - same as other settings pages + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + actions: [ + IconButton( + icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center), + tooltip: _autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF', + onPressed: () => setState(() => _autoScroll = !_autoScroll), + ), + IconButton( + icon: const Icon(Icons.copy), + tooltip: 'Copy logs', + onPressed: _copyLogs, + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: (value) { + switch (value) { + case 'share': + _shareLogs(); + break; + case 'clear': + _clearLogs(); + break; + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'share', + child: ListTile( + leading: Icon(Icons.share), + title: Text('Share logs'), + contentPadding: EdgeInsets.zero, + ), + ), + const PopupMenuItem( + value: 'clear', + child: ListTile( + leading: Icon(Icons.delete_outline), + title: Text('Clear logs'), + contentPadding: EdgeInsets.zero, + ), + ), + ], + ), + ], + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final maxHeight = 120 + topPadding; + final minHeight = kToolbarHeight + topPadding; + final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0); + final leftPadding = 56 - (32 * expandRatio); + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), + title: Text( + 'Logs', + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), + + // Filter section + const SliverToBoxAdapter( + child: SettingsSectionHeader(title: 'Filter'), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + // Level filter + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + Icon(Icons.filter_list, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Level', style: Theme.of(context).textTheme.bodyLarge), + const SizedBox(height: 2), + Text( + 'Filter logs by severity', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + DropdownButton( + value: _selectedLevel, + underline: const SizedBox(), + items: _levels.map((level) { + return DropdownMenuItem( + value: level, + child: Text( + level, + style: TextStyle( + color: level == 'ALL' + ? colorScheme.onSurface + : _getLevelColor(level, colorScheme), + fontWeight: FontWeight.w500, + ), + ), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() => _selectedLevel = value); + } + }, + ), + ], + ), + ), + Divider( + height: 1, + indent: 56, + endIndent: 20, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + // Search field + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + Icon(Icons.search, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 16), + Expanded( + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search logs...', + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 20), + onPressed: () { + _searchController.clear(); + setState(() => _searchQuery = ''); + }, + ) + : null, + ), + onChanged: (value) { + setState(() => _searchQuery = value); + }, + ), + ), + ], + ), + ), + ], + ), + ), + + // Log entries section + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: 'Entries (${logs.length}${_selectedLevel != 'ALL' || _searchQuery.isNotEmpty ? ' filtered' : ''})', + ), + ), + + // Error summary card - shows detected issues + SliverToBoxAdapter( + child: _LogSummaryCard(logs: LogBuffer().entries), + ), + + // Log list + logs.isEmpty + ? SliverToBoxAdapter( + child: SettingsGroup( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 48), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.article_outlined, + size: 48, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + ), + const SizedBox(height: 16), + Text( + 'No logs yet', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + 'Logs will appear here as you use the app', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + ], + ), + ) + : SliverToBoxAdapter( + child: SettingsGroup( + children: [ + ...logs.asMap().entries.map((entry) { + final index = entry.key; + final log = entry.value; + return _LogEntryTile( + entry: log, + levelColor: _getLevelColor(log.level, colorScheme), + showDivider: index < logs.length - 1, + ); + }), + ], + ), + ), + + // Bottom padding + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ), + ); + } +} + +class _LogEntryTile extends StatelessWidget { + final LogEntry entry; + final Color levelColor; + final bool showDivider; + + const _LogEntryTile({ + required this.entry, + required this.levelColor, + this.showDivider = true, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isError = entry.level == 'ERROR' || entry.level == 'FATAL'; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: BoxDecoration( + color: isError + ? colorScheme.errorContainer.withValues(alpha: 0.2) + : null, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header: time, level, tag + Row( + children: [ + Text( + entry.formattedTime, + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: levelColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + entry.level, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: levelColor, + ), + ), + ), + if (entry.isFromGo) ...[ + const SizedBox(width: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: Colors.teal.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'Go', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: Colors.teal, + ), + ), + ), + ], + const SizedBox(width: 8), + Expanded( + child: Text( + entry.tag, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: colorScheme.primary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 6), + // Message + Text( + entry.message, + style: TextStyle( + fontSize: 13, + fontFamily: 'monospace', + color: colorScheme.onSurface, + height: 1.4, + ), + ), + // Error if present + if (entry.error != null) ...[ + const SizedBox(height: 4), + Text( + entry.error!, + style: TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: colorScheme.error, + height: 1.3, + ), + ), + ], + ], + ), + ), + if (showDivider) + Divider( + height: 1, + indent: 20, + endIndent: 20, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } +} + +/// Summary card showing detected issues in logs +class _LogSummaryCard extends StatelessWidget { + final List logs; + + const _LogSummaryCard({required this.logs}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + // Analyze logs for issues + final analysis = _analyzeLogs(); + + // Don't show if no issues detected + if (!analysis.hasIssues) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Card( + elevation: 0, + color: analysis.hasISPBlocking + ? colorScheme.errorContainer.withValues(alpha: 0.5) + : colorScheme.tertiaryContainer.withValues(alpha: 0.5), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Icon( + analysis.hasISPBlocking ? Icons.block : Icons.warning_amber_rounded, + size: 20, + color: analysis.hasISPBlocking ? colorScheme.error : colorScheme.tertiary, + ), + const SizedBox(width: 8), + Text( + 'Issue Summary', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ], + ), + const SizedBox(height: 12), + + // ISP Blocking detected + if (analysis.hasISPBlocking) ...[ + _IssueBadge( + icon: Icons.block, + label: 'ISP BLOCKING DETECTED', + description: 'Your ISP may be blocking access to download services', + suggestion: 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8', + color: colorScheme.error, + domains: analysis.blockedDomains, + ), + const SizedBox(height: 8), + ], + + // Rate limiting + if (analysis.hasRateLimit) ...[ + _IssueBadge( + icon: Icons.speed, + label: 'RATE LIMITED', + description: 'Too many requests to the service', + suggestion: 'Wait a few minutes before trying again', + color: Colors.orange, + ), + const SizedBox(height: 8), + ], + + // Network errors + if (analysis.hasNetworkError && !analysis.hasISPBlocking) ...[ + _IssueBadge( + icon: Icons.wifi_off, + label: 'NETWORK ERROR', + description: 'Connection issues detected', + suggestion: 'Check your internet connection', + color: colorScheme.tertiary, + ), + const SizedBox(height: 8), + ], + + // Track not found + if (analysis.hasNotFound) ...[ + _IssueBadge( + icon: Icons.search_off, + label: 'TRACK NOT FOUND', + description: 'Some tracks could not be found on download services', + suggestion: 'The track may not be available in lossless quality', + color: colorScheme.onSurfaceVariant, + ), + ], + + // Error count + const SizedBox(height: 12), + Text( + 'Total errors: ${analysis.errorCount}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ); + } + + _LogAnalysis _analyzeLogs() { + int errorCount = 0; + bool hasISPBlocking = false; + bool hasRateLimit = false; + bool hasNetworkError = false; + bool hasNotFound = false; + final Set blockedDomains = {}; + + for (final log in logs) { + if (log.level == 'ERROR' || log.level == 'FATAL') { + errorCount++; + } + + final msgLower = log.message.toLowerCase(); + final errorLower = (log.error ?? '').toLowerCase(); + final combined = '$msgLower $errorLower'; + + // Check for ISP blocking (detected by Go backend) + if (combined.contains('isp blocking') || + combined.contains('isp may be') || + combined.contains('blocked by isp') || + combined.contains('connection reset') || + combined.contains('connection refused')) { + hasISPBlocking = true; + + // Try to extract domain + final domainMatch = RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false).firstMatch(combined); + if (domainMatch != null) { + blockedDomains.add(domainMatch.group(1)!); + } + } + + // Check for rate limiting + if (combined.contains('rate limit') || + combined.contains('429') || + combined.contains('too many requests')) { + hasRateLimit = true; + } + + // Check for network errors + if (combined.contains('connection') || + combined.contains('timeout') || + combined.contains('network') || + combined.contains('dial')) { + hasNetworkError = true; + } + + // Check for not found + if (combined.contains('not found') || + combined.contains('no results') || + combined.contains('could not find')) { + hasNotFound = true; + } + } + + return _LogAnalysis( + errorCount: errorCount, + hasISPBlocking: hasISPBlocking, + hasRateLimit: hasRateLimit, + hasNetworkError: hasNetworkError, + hasNotFound: hasNotFound, + blockedDomains: blockedDomains.toList(), + ); + } +} + +class _LogAnalysis { + final int errorCount; + final bool hasISPBlocking; + final bool hasRateLimit; + final bool hasNetworkError; + final bool hasNotFound; + final List blockedDomains; + + _LogAnalysis({ + required this.errorCount, + required this.hasISPBlocking, + required this.hasRateLimit, + required this.hasNetworkError, + required this.hasNotFound, + required this.blockedDomains, + }); + + bool get hasIssues => errorCount > 0 || hasISPBlocking || hasRateLimit || hasNetworkError || hasNotFound; +} + +class _IssueBadge extends StatelessWidget { + final IconData icon; + final String label; + final String description; + final String suggestion; + final Color color; + final List? domains; + + const _IssueBadge({ + required this.icon, + required this.label, + required this.description, + required this.suggestion, + required this.color, + this.domains, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: color, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface, + ), + ), + if (domains != null && domains!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + 'Affected: ${domains!.join(", ")}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontFamily: 'monospace', + fontSize: 11, + ), + ), + ], + const SizedBox(height: 6), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.lightbulb_outline, size: 14, color: colorScheme.primary), + const SizedBox(width: 4), + Expanded( + child: Text( + suggestion, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.primary, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 908c4f6..c34f06a 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -168,6 +168,25 @@ class OptionsSettingsPage extends ConsumerWidget { ), ), + // Debug section + const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Debug')), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsSwitchItem( + icon: Icons.bug_report, + title: 'Detailed Logging', + subtitle: settings.enableLogging + ? 'Detailed logs are being recorded' + : 'Enable for bug reports', + value: settings.enableLogging, + onChanged: (v) => ref.read(settingsProvider.notifier).setEnableLogging(v), + showDivider: false, + ), + ], + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 32)), ], ), diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index 8c737e8..8d63dd8 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -5,6 +5,7 @@ import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart import 'package:spotiflac_android/screens/settings/download_settings_page.dart'; import 'package:spotiflac_android/screens/settings/options_settings_page.dart'; import 'package:spotiflac_android/screens/settings/about_page.dart'; +import 'package:spotiflac_android/screens/settings/log_screen.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; class SettingsTab extends ConsumerWidget { @@ -67,10 +68,16 @@ class SettingsTab extends ConsumerWidget { ), ), - // Second group: About + // Second group: Logs & About SliverToBoxAdapter( child: SettingsGroup( children: [ + SettingsItem( + icon: Icons.article_outlined, + title: 'Logs', + subtitle: 'View app logs for debugging', + onTap: () => _navigateTo(context, const LogScreen()), + ), SettingsItem( icon: Icons.info_outline, title: 'About', diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 09458cc..fb4fb28 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -1,5 +1,8 @@ import 'dart:convert'; import 'package:flutter/services.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('PlatformBridge'); /// Bridge to communicate with Go backend via platform channels class PlatformBridge { @@ -7,18 +10,21 @@ class PlatformBridge { /// Parse and validate Spotify URL static Future> parseSpotifyUrl(String url) async { + _log.d('parseSpotifyUrl: $url'); final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url}); return jsonDecode(result as String) as Map; } /// Get Spotify metadata from URL static Future> getSpotifyMetadata(String url) async { + _log.d('getSpotifyMetadata: $url'); final result = await _channel.invokeMethod('getSpotifyMetadata', {'url': url}); return jsonDecode(result as String) as Map; } /// Search Spotify static Future> searchSpotify(String query, {int limit = 10}) async { + _log.d('searchSpotify: "$query" (limit: $limit)'); final result = await _channel.invokeMethod('searchSpotify', { 'query': query, 'limit': limit, @@ -28,6 +34,7 @@ class PlatformBridge { /// Search Spotify for tracks and artists static Future> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async { + _log.d('searchSpotifyAll: "$query"'); final result = await _channel.invokeMethod('searchSpotifyAll', { 'query': query, 'track_limit': trackLimit, @@ -38,6 +45,7 @@ class PlatformBridge { /// Check track availability on streaming services static Future> checkAvailability(String spotifyId, String isrc) async { + _log.d('checkAvailability: $spotifyId (ISRC: $isrc)'); final result = await _channel.invokeMethod('checkAvailability', { 'spotify_id': spotifyId, 'isrc': isrc, @@ -67,6 +75,7 @@ class PlatformBridge { String? itemId, int durationMs = 0, }) async { + _log.i('downloadTrack: "$trackName" by $artistName via $service'); final request = jsonEncode({ 'isrc': isrc, 'service': service, @@ -90,7 +99,13 @@ class PlatformBridge { }); final result = await _channel.invokeMethod('downloadTrack', request); - return jsonDecode(result as String) as Map; + final response = jsonDecode(result as String) as Map; + if (response['success'] == true) { + _log.i('Download success: ${response['file_path']}'); + } else { + _log.w('Download failed: ${response['error']}'); + } + return response; } /// Download with automatic fallback to other services @@ -115,6 +130,7 @@ class PlatformBridge { String? itemId, int durationMs = 0, }) async { + _log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)'); final request = jsonEncode({ 'isrc': isrc, 'service': preferredService, @@ -138,7 +154,22 @@ class PlatformBridge { }); final result = await _channel.invokeMethod('downloadWithFallback', request); - return jsonDecode(result as String) as Map; + final response = jsonDecode(result as String) as Map; + if (response['success'] == true) { + final service = response['service'] ?? 'unknown'; + final filePath = response['file_path'] ?? ''; + final bitDepth = response['actual_bit_depth']; + final sampleRate = response['actual_sample_rate']; + final qualityStr = bitDepth != null && sampleRate != null + ? ' ($bitDepth-bit/${(sampleRate / 1000).toStringAsFixed(1)}kHz)' + : ''; + _log.i('Download success via $service$qualityStr: $filePath'); + } else { + final error = response['error'] ?? 'Unknown error'; + final errorType = response['error_type'] ?? ''; + _log.e('Download failed: $error (type: $errorType)'); + } + return response; } /// Get download progress (legacy single download) @@ -377,4 +408,35 @@ class PlatformBridge { final result = await _channel.invokeMethod('getSpotifyMetadataWithFallback', {'url': url}); return jsonDecode(result as String) as Map; } + + // ==================== GO BACKEND LOGS ==================== + + /// Get all logs from Go backend + static Future>> getGoLogs() async { + final result = await _channel.invokeMethod('getLogs'); + final logs = jsonDecode(result as String) as List; + return logs.map((e) => e as Map).toList(); + } + + /// Get logs since a specific index (for incremental updates) + static Future> getGoLogsSince(int index) async { + final result = await _channel.invokeMethod('getLogsSince', {'index': index}); + return jsonDecode(result as String) as Map; + } + + /// Clear Go backend logs + static Future clearGoLogs() async { + await _channel.invokeMethod('clearLogs'); + } + + /// Get Go backend log count + static Future getGoLogCount() async { + final result = await _channel.invokeMethod('getLogCount'); + return result as int; + } + + /// Enable or disable Go backend logging + static Future setGoLoggingEnabled(bool enabled) async { + await _channel.invokeMethod('setLoggingEnabled', {'enabled': enabled}); + } } diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart index fa617c4..18093b0 100644 --- a/lib/utils/logger.dart +++ b/lib/utils/logger.dart @@ -1,7 +1,233 @@ +import 'dart:async'; +import 'dart:collection'; +import 'package:flutter/foundation.dart'; import 'package:logger/logger.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; + +/// Log entry with timestamp and level +class LogEntry { + final DateTime timestamp; + final String level; + final String tag; + final String message; + final String? error; + final bool isFromGo; // Track if this log came from Go backend + + LogEntry({ + required this.timestamp, + required this.level, + required this.tag, + required this.message, + this.error, + this.isFromGo = false, + }); + + String get formattedTime { + final h = timestamp.hour.toString().padLeft(2, '0'); + final m = timestamp.minute.toString().padLeft(2, '0'); + final s = timestamp.second.toString().padLeft(2, '0'); + final ms = timestamp.millisecond.toString().padLeft(3, '0'); + return '$h:$m:$s.$ms'; + } + + @override + String toString() { + final errorPart = error != null ? ' | $error' : ''; + final goPart = isFromGo ? ' [Go]' : ''; + return '[$formattedTime] [$level]$goPart [$tag] $message$errorPart'; + } +} + +/// Circular buffer for storing logs in memory +class LogBuffer extends ChangeNotifier { + static final LogBuffer _instance = LogBuffer._internal(); + factory LogBuffer() => _instance; + LogBuffer._internal(); + + static const int maxEntries = 500; + final Queue _entries = Queue(); + Timer? _goLogTimer; + int _lastGoLogIndex = 0; + + /// Whether logging is enabled (controlled by settings) + static bool _loggingEnabled = false; + static bool get loggingEnabled => _loggingEnabled; + static set loggingEnabled(bool value) { + _loggingEnabled = value; + // Also notify Go backend about logging state + if (value) { + PlatformBridge.setGoLoggingEnabled(true).catchError((_) {}); + } else { + PlatformBridge.setGoLoggingEnabled(false).catchError((_) {}); + } + } + + List get entries => _entries.toList(); + int get length => _entries.length; + + void add(LogEntry entry) { + // Skip adding if logging is disabled (except for errors which are always logged) + if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') { + return; + } + + if (_entries.length >= maxEntries) { + _entries.removeFirst(); + } + _entries.add(entry); + notifyListeners(); + } + + /// Start polling Go backend logs + void startGoLogPolling() { + _goLogTimer?.cancel(); + _goLogTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async { + await _fetchGoLogs(); + }); + } + + /// Stop polling Go backend logs + void stopGoLogPolling() { + _goLogTimer?.cancel(); + _goLogTimer = null; + } + + /// Fetch logs from Go backend since last index + Future _fetchGoLogs() async { + try { + final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex); + final logs = result['logs'] as List? ?? []; + final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex; + + for (final log in logs) { + final timestamp = log['timestamp'] as String? ?? ''; + final level = log['level'] as String? ?? 'INFO'; + final tag = log['tag'] as String? ?? 'Go'; + final message = log['message'] as String? ?? ''; + + // Parse timestamp (format: "15:04:05.000") + DateTime parsedTime = DateTime.now(); + if (timestamp.isNotEmpty) { + try { + final parts = timestamp.split(':'); + if (parts.length >= 3) { + final secParts = parts[2].split('.'); + parsedTime = DateTime( + parsedTime.year, parsedTime.month, parsedTime.day, + int.parse(parts[0]), int.parse(parts[1]), + int.parse(secParts[0]), + secParts.length > 1 ? int.parse(secParts[1]) : 0, + ); + } + } catch (_) { + // Use current time if parsing fails + } + } + + add(LogEntry( + timestamp: parsedTime, + level: level, + tag: tag, + message: message, + isFromGo: true, + )); + } + + _lastGoLogIndex = nextIndex; + } catch (e) { + // Ignore errors - Go backend might not be ready + if (kDebugMode) { + debugPrint('Failed to fetch Go logs: $e'); + } + } + } + + void clear() { + _entries.clear(); + _lastGoLogIndex = 0; + // Also clear Go backend logs + PlatformBridge.clearGoLogs().catchError((_) {}); + notifyListeners(); + } + + String export() { + final buffer = StringBuffer(); + buffer.writeln('SpotiFLAC Log Export'); + buffer.writeln('Generated: ${DateTime.now().toIso8601String()}'); + buffer.writeln('Entries: ${_entries.length}'); + buffer.writeln('=' * 60); + buffer.writeln(); + for (final entry in _entries) { + buffer.writeln(entry.toString()); + } + return buffer.toString(); + } + + List filter({String? level, String? tag, String? search}) { + return _entries.where((entry) { + if (level != null && level != 'ALL' && entry.level != level) { + return false; + } + if (tag != null && !entry.tag.toLowerCase().contains(tag.toLowerCase())) { + return false; + } + if (search != null && search.isNotEmpty) { + final searchLower = search.toLowerCase(); + return entry.message.toLowerCase().contains(searchLower) || + entry.tag.toLowerCase().contains(searchLower) || + (entry.error?.toLowerCase().contains(searchLower) ?? false); + } + return true; + }).toList(); + } +} + +/// Custom log output that writes to both console and buffer +class BufferedOutput extends LogOutput { + final String tag; + + BufferedOutput(this.tag); + + @override + void output(OutputEvent event) { + // Print to console in debug mode + if (kDebugMode) { + for (final line in event.lines) { + debugPrint(line); + } + } + + // Add to buffer + final level = _levelToString(event.level); + final message = event.lines.join('\n'); + + LogBuffer().add(LogEntry( + timestamp: DateTime.now(), + level: level, + tag: tag, + message: message, + )); + } + + String _levelToString(Level level) { + switch (level) { + case Level.debug: + return 'DEBUG'; + case Level.info: + return 'INFO'; + case Level.warning: + return 'WARN'; + case Level.error: + return 'ERROR'; + case Level.fatal: + return 'FATAL'; + default: + return 'LOG'; + } + } +} /// Global logger instance for the app -/// Uses pretty printer in debug mode for readable output final log = Logger( printer: PrettyPrinter( methodCount: 0, @@ -15,14 +241,48 @@ final log = Logger( ); /// Logger with class/tag prefix for better traceability +/// Now also writes to LogBuffer for in-app viewing class AppLogger { final String _tag; - - AppLogger(this._tag); - - void d(String message) => log.d('[$_tag] $message'); - void i(String message) => log.i('[$_tag] $message'); - void w(String message) => log.w('[$_tag] $message'); - void e(String message, [Object? error, StackTrace? stackTrace]) => - log.e('[$_tag] $message', error: error, stackTrace: stackTrace); + late final Logger _logger; + + AppLogger(this._tag) { + _logger = Logger( + printer: SimplePrinter(printTime: false, colors: false), + output: BufferedOutput(_tag), + level: Level.debug, + ); + } + + void d(String message) { + _logger.d(message); + } + + void i(String message) { + _logger.i(message); + } + + void w(String message) { + _logger.w(message); + } + + void e(String message, [Object? error, StackTrace? stackTrace]) { + if (error != null) { + LogBuffer().add(LogEntry( + timestamp: DateTime.now(), + level: 'ERROR', + tag: _tag, + message: message, + error: error.toString(), + )); + if (kDebugMode) { + debugPrint('[$_tag] ERROR: $message | $error'); + if (stackTrace != null) { + debugPrint(stackTrace.toString()); + } + } + } else { + _logger.e(message); + } + } } diff --git a/pubspec.yaml b/pubspec.yaml index d9447cb..e417696 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 2.2.0+46 +version: 2.2.5+47 environment: sdk: ^3.10.0 diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index 8520874..d3a3e35 100644 --- a/pubspec_ios.yaml +++ b/pubspec_ios.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 2.2.0+46 +version: 2.2.5+47 environment: sdk: ^3.10.0