From f2aca734a3c095f978f11a8c7017f8a8ed3fb338 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 11 Jan 2026 02:27:26 +0700 Subject: [PATCH] fix: improve logging for release builds and UI improvements - Fix Flutter logs not appearing in release mode by bypassing Logger package - Add detailed logging for Deezer search API calls - Replace music_note icon with app logo on home screen - Remove shadow/border from logo in About and Home screens - Align icon size (40x40) with avatar in About page for consistent layout --- go_backend/deezer.go | 163 ++++++++++++++++----------- go_backend/logbuffer.go | 10 +- go_backend/qobuz.go | 26 ++--- go_backend/tidal.go | 161 ++++++++++++++++++++++++-- lib/providers/track_provider.dart | 47 ++++---- lib/screens/home_tab.dart | 26 ++++- lib/screens/settings/about_page.dart | 114 +++++++++++++++---- lib/utils/logger.dart | 63 ++++++++--- 8 files changed, 457 insertions(+), 153 deletions(-) diff --git a/go_backend/deezer.go b/go_backend/deezer.go index e6b4e538..dce19752 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -19,9 +19,9 @@ const ( deezerAlbumURL = deezerBaseURL + "/album/%s" deezerArtistURL = deezerBaseURL + "/artist/%s" deezerPlaylistURL = deezerBaseURL + "/playlist/%s" - + deezerCacheTTL = 10 * time.Minute - + // Parallel ISRC fetching settings deezerMaxParallelISRC = 10 // Max concurrent ISRC fetches ) @@ -58,27 +58,27 @@ func GetDeezerClient() *DeezerClient { // Deezer API response types type deezerTrack struct { - ID int64 `json:"id"` - Title string `json:"title"` - Duration int `json:"duration"` // in seconds - TrackPosition int `json:"track_position"` - DiskNumber int `json:"disk_number"` - ISRC string `json:"isrc"` - Link string `json:"link"` - ReleaseDate string `json:"release_date"` // Sometimes at track level - Artist deezerArtist `json:"artist"` - Album deezerAlbumSimple `json:"album"` - Contributors []deezerArtist `json:"contributors"` + ID int64 `json:"id"` + Title string `json:"title"` + Duration int `json:"duration"` // in seconds + TrackPosition int `json:"track_position"` + DiskNumber int `json:"disk_number"` + ISRC string `json:"isrc"` + Link string `json:"link"` + ReleaseDate string `json:"release_date"` // Sometimes at track level + Artist deezerArtist `json:"artist"` + Album deezerAlbumSimple `json:"album"` + Contributors []deezerArtist `json:"contributors"` } type deezerArtist struct { - ID int64 `json:"id"` - Name string `json:"name"` - Picture string `json:"picture"` + ID int64 `json:"id"` + Name string `json:"name"` + Picture string `json:"picture"` PictureMedium string `json:"picture_medium"` PictureBig string `json:"picture_big"` PictureXL string `json:"picture_xl"` - NbFan int `json:"nb_fan"` + NbFan int `json:"nb_fan"` } type deezerAlbumSimple struct { @@ -90,6 +90,7 @@ type deezerAlbumSimple struct { CoverXL string `json:"cover_xl"` ReleaseDate string `json:"release_date"` // Sometimes at album level } + // ... (skip other structs as they are fine/unchanged) ... // ... (in convertTrack) ... @@ -113,7 +114,7 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata { if albumImage == "" { albumImage = track.Album.Cover } - + // Try to find release date releaseDate := track.ReleaseDate if releaseDate == "" { @@ -137,17 +138,17 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata { } type deezerAlbumFull struct { - ID int64 `json:"id"` - Title string `json:"title"` - Cover string `json:"cover"` - CoverMedium string `json:"cover_medium"` - CoverBig string `json:"cover_big"` - CoverXL string `json:"cover_xl"` - ReleaseDate string `json:"release_date"` - NbTracks int `json:"nb_tracks"` - Artist deezerArtist `json:"artist"` + ID int64 `json:"id"` + Title string `json:"title"` + Cover string `json:"cover"` + CoverMedium string `json:"cover_medium"` + CoverBig string `json:"cover_big"` + CoverXL string `json:"cover_xl"` + ReleaseDate string `json:"release_date"` + NbTracks int `json:"nb_tracks"` + Artist deezerArtist `json:"artist"` Contributors []deezerArtist `json:"contributors"` - Tracks struct { + Tracks struct { Data []deezerTrack `json:"data"` } `json:"tracks"` } @@ -164,17 +165,17 @@ type deezerArtistFull struct { } type deezerPlaylistFull struct { - ID int64 `json:"id"` - Title string `json:"title"` - Picture string `json:"picture"` + ID int64 `json:"id"` + Title string `json:"title"` + Picture string `json:"picture"` PictureMedium string `json:"picture_medium"` PictureBig string `json:"picture_big"` PictureXL string `json:"picture_xl"` - NbTracks int `json:"nb_tracks"` - Creator struct { + NbTracks int `json:"nb_tracks"` + Creator struct { Name string `json:"name"` } `json:"creator"` - Tracks struct { + Tracks struct { Data []deezerTrack `json:"data"` } `json:"tracks"` } @@ -182,11 +183,14 @@ type deezerPlaylistFull struct { // SearchAll searches for tracks and artists on Deezer // NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) { + GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit) + cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit) - + c.cacheMu.RLock() if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() { c.cacheMu.RUnlock() + GoLog("[Deezer] SearchAll: returning cached result\n") return entry.data.(*SearchAllResult), nil } c.cacheMu.RUnlock() @@ -198,13 +202,28 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, // Search tracks - NO ISRC fetch for performance trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit) + GoLog("[Deezer] Fetching tracks from: %s\n", trackURL) + var trackResp struct { - Data []deezerTrack `json:"data"` + Data []deezerTrack `json:"data"` + Error *struct { + Type string `json:"type"` + Message string `json:"message"` + Code int `json:"code"` + } `json:"error"` } if err := c.getJSON(ctx, trackURL, &trackResp); err != nil { + GoLog("[Deezer] Track search failed: %v\n", err) return nil, fmt.Errorf("deezer track search failed: %w", err) } + if trackResp.Error != nil { + GoLog("[Deezer] API error: type=%s, code=%d, message=%s\n", trackResp.Error.Type, trackResp.Error.Code, trackResp.Error.Message) + return nil, fmt.Errorf("deezer API error: %s (code %d)", trackResp.Error.Message, trackResp.Error.Code) + } + + GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data)) + for _, track := range trackResp.Data { // Convert directly without fetching ISRC - much faster result.Tracks = append(result.Tracks, c.convertTrack(track)) @@ -212,21 +231,37 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, // Search artists artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit) + GoLog("[Deezer] Fetching artists from: %s\n", artistURL) + var artistResp struct { - Data []deezerArtist `json:"data"` + Data []deezerArtist `json:"data"` + Error *struct { + Type string `json:"type"` + Message string `json:"message"` + Code int `json:"code"` + } `json:"error"` } if err := c.getJSON(ctx, artistURL, &artistResp); err == nil { - for _, artist := range artistResp.Data { - result.Artists = append(result.Artists, SearchArtistResult{ - ID: fmt.Sprintf("deezer:%d", artist.ID), - Name: artist.Name, - Images: c.getBestArtistImage(artist), - Followers: artist.NbFan, - Popularity: 0, - }) + if artistResp.Error != nil { + GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message) + } else { + GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data)) + for _, artist := range artistResp.Data { + result.Artists = append(result.Artists, SearchArtistResult{ + ID: fmt.Sprintf("deezer:%d", artist.ID), + Name: artist.Name, + Images: c.getBestArtistImage(artist), + Followers: artist.NbFan, + Popularity: 0, + }) + } } + } else { + GoLog("[Deezer] Artist search failed: %v\n", err) } + GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists)) + // Cache result c.cacheMu.Lock() c.searchCache[cacheKey] = &cacheEntry{ @@ -241,7 +276,7 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, // GetTrack fetches a single track by Deezer ID func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) { trackURL := fmt.Sprintf(deezerTrackURL, trackID) - + var track deezerTrack if err := c.getJSON(ctx, trackURL, &track); err != nil { return nil, err @@ -263,7 +298,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp c.cacheMu.RUnlock() albumURL := fmt.Sprintf(deezerAlbumURL, albumID) - + var album deezerAlbumFull if err := c.getJSON(ctx, albumURL, &album); err != nil { return nil, err @@ -375,7 +410,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR if albumType == "compile" { albumType = "compilation" } - + coverURL := album.CoverXL if coverURL == "" { coverURL = album.CoverBig @@ -418,7 +453,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR // ISRC is fetched in parallel for better performance func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) { playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID) - + var playlist deezerPlaylistFull if err := c.getJSON(ctx, playlistURL, &playlist); err != nil { return nil, err @@ -482,7 +517,7 @@ func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMet // Use direct ISRC endpoint (API 2.0) // https://api.deezer.com/2.0/track/isrc:{ISRC} directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc) - + var track deezerTrack if err := c.getJSON(ctx, directURL, &track); err != nil { // Fallback to search if direct endpoint fails @@ -522,7 +557,7 @@ func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*dee func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string { result := make(map[string]string) var resultMu sync.Mutex - + // First, check cache for existing ISRCs var tracksToFetch []deezerTrack c.cacheMu.RLock() @@ -535,20 +570,20 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr } } c.cacheMu.RUnlock() - + if len(tracksToFetch) == 0 { return result } - + // Use semaphore to limit concurrent requests sem := make(chan struct{}, deezerMaxParallelISRC) var wg sync.WaitGroup - + for _, track := range tracksToFetch { wg.Add(1) go func(t deezerTrack) { defer wg.Done() - + // Acquire semaphore select { case sem <- struct{}{}: @@ -556,24 +591,24 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr case <-ctx.Done(): return } - + trackIDStr := fmt.Sprintf("%d", t.ID) fullTrack, err := c.fetchFullTrack(ctx, trackIDStr) if err != nil || fullTrack == nil { return } - + // Store in result and cache resultMu.Lock() result[trackIDStr] = fullTrack.ISRC resultMu.Unlock() - + c.cacheMu.Lock() c.isrcCache[trackIDStr] = fullTrack.ISRC c.cacheMu.Unlock() }(track) } - + wg.Wait() return result } @@ -588,23 +623,21 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string return isrc, nil } c.cacheMu.RUnlock() - + // Fetch from API fullTrack, err := c.fetchFullTrack(ctx, trackID) if err != nil { return "", err } - + // Cache the result c.cacheMu.Lock() c.isrcCache[trackID] = fullTrack.ISRC c.cacheMu.Unlock() - + return fullTrack.ISRC, nil } - - func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string { if artist.PictureXL != "" { return artist.PictureXL @@ -687,7 +720,7 @@ func parseDeezerURL(input string) (string, string, error) { } parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") - + // Skip language prefix if present (e.g., /en/, /fr/) if len(parts) > 0 && len(parts[0]) == 2 { parts = parts[1:] diff --git a/go_backend/logbuffer.go b/go_backend/logbuffer.go index c6f9b100..e39ddd9c 100644 --- a/go_backend/logbuffer.go +++ b/go_backend/logbuffer.go @@ -35,7 +35,7 @@ func GetLogBuffer() *LogBuffer { globalLogBuffer = &LogBuffer{ entries: make([]LogEntry, 0, 500), maxSize: 500, - loggingEnabled: false, // Default: disabled for performance + loggingEnabled: false, // Default: disabled for performance (user can enable in settings) } }) return globalLogBuffer @@ -143,11 +143,11 @@ func LogError(tag, format string, args ...interface{}) { 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 { @@ -155,7 +155,7 @@ func GoLog(format string, args ...interface{}) { 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, "✗") { @@ -167,7 +167,7 @@ func GoLog(format string, args ...interface{}) { } else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") { level = "DEBUG" } - + GetLogBuffer().Add(level, tag, message) } diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index bb8473a5..8670312e 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -128,7 +128,7 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool { // Extract core title (before any parentheses/brackets) coreExpected := qobuzExtractCoreTitle(normExpected) coreFound := qobuzExtractCoreTitle(normFound) - + if coreExpected != "" && coreFound != "" && coreExpected == coreFound { return true } @@ -151,7 +151,7 @@ func qobuzExtractCoreTitle(title string) string { parenIdx := strings.Index(title, "(") bracketIdx := strings.Index(title, "[") dashIdx := strings.Index(title, " - ") - + cutIdx := len(title) if parenIdx > 0 && parenIdx < cutIdx { cutIdx = parenIdx @@ -162,7 +162,7 @@ func qobuzExtractCoreTitle(title string) string { if dashIdx > 0 && dashIdx < cutIdx { cutIdx = dashIdx } - + return strings.TrimSpace(title[:cutIdx]) } @@ -173,11 +173,11 @@ func qobuzCleanTitle(title string) string { // 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", + "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, "(") @@ -198,7 +198,7 @@ func qobuzCleanTitle(title string) string { } break } - + // Same for brackets for { startBracket := strings.LastIndex(cleaned, "[") @@ -370,7 +370,7 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { // expectedDurationSec is the expected duration in seconds (0 to skip verification) func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) { 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) @@ -602,12 +602,12 @@ 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", + 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", + GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified)\n", durationMatches[0].Title, durationMatches[0].Performer.Name) return durationMatches[0], nil } @@ -619,18 +619,18 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam // 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", + GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified, hi-res)\n", track.Title, track.Performer.Name) return track, nil } } - + if len(tracksToCheck) > 0 { - GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified)\n", + 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) } diff --git a/go_backend/tidal.go b/go_backend/tidal.go index cf4a6a20..fba8552a 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -477,7 +477,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s if len(result.Items) > 0 { 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 != "" { for i := range result.Items { @@ -494,7 +494,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s return track, nil } // Duration mismatch, continue searching - GoLog("[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 { GoLog("[Tidal] ✓ ISRC match: '%s'\n", track.Title) @@ -503,7 +503,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s } } } - + allTracks = append(allTracks, result.Items...) } } @@ -638,7 +638,154 @@ type TidalDownloadInfo struct { SampleRate int } -// getDownloadURLSequential requests download URL from APIs sequentially +// tidalAPIResult holds the result from a parallel API request +type tidalAPIResult struct { + apiURL string + info TidalDownloadInfo + err error + duration time.Duration +} + +// getDownloadURLParallel requests download URL from all APIs in parallel +// Returns the first successful result (supports both v1 and v2 API formats) +func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) { + if len(apis) == 0 { + return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available") + } + + GoLog("[Tidal] Requesting download URL from %d APIs in parallel...\n", len(apis)) + + resultChan := make(chan tidalAPIResult, len(apis)) + startTime := time.Now() + + // Start all requests in parallel + for _, apiURL := range apis { + go func(api string) { + reqStart := time.Now() + + // Create client with longer timeout for parallel requests + client := &http.Client{ + Timeout: 15 * time.Second, + } + + reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality) + GoLog("[Tidal] [Parallel] Starting request to: %s\n", api) + + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + GoLog("[Tidal] [Parallel] %s - Failed to create request: %v\n", api, err) + resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)} + return + } + + resp, err := client.Do(req) + if err != nil { + GoLog("[Tidal] [Parallel] %s - Request failed: %v\n", api, err) + resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)} + return + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + GoLog("[Tidal] [Parallel] %s - HTTP %d\n", api, resp.StatusCode) + resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)} + return + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + GoLog("[Tidal] [Parallel] %s - Failed to read body: %v\n", api, err) + resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)} + return + } + + // Try v2 format first (object with manifest) + var v2Response TidalAPIResponseV2 + if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" { + // IMPORTANT: Reject PREVIEW responses - we need FULL tracks + if v2Response.Data.AssetPresentation == "PREVIEW" { + GoLog("[Tidal] [Parallel] %s - Rejecting PREVIEW response\n", api) + resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)} + return + } + + GoLog("[Tidal] [Parallel] %s - Got FULL track (v2): %d-bit/%dHz in %v\n", + api, v2Response.Data.BitDepth, v2Response.Data.SampleRate, time.Since(reqStart)) + + info := TidalDownloadInfo{ + URL: "MANIFEST:" + v2Response.Data.Manifest, + BitDepth: v2Response.Data.BitDepth, + SampleRate: v2Response.Data.SampleRate, + } + resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)} + return + } + + // Fallback to v1 format (array with OriginalTrackUrl) + var v1Responses []struct { + OriginalTrackURL string `json:"OriginalTrackUrl"` + } + if err := json.Unmarshal(body, &v1Responses); err == nil { + for _, item := range v1Responses { + if item.OriginalTrackURL != "" { + GoLog("[Tidal] [Parallel] %s - Got direct URL (v1) in %v\n", api, time.Since(reqStart)) + info := TidalDownloadInfo{ + URL: item.OriginalTrackURL, + BitDepth: 16, + SampleRate: 44100, + } + resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)} + return + } + } + } + + GoLog("[Tidal] [Parallel] %s - No download URL in response\n", api) + resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("no download URL or manifest in response"), duration: time.Since(reqStart)} + }(apiURL) + } + + // Collect results - return first success + var errors []string + successCount := 0 + failCount := 0 + + for i := 0; i < len(apis); i++ { + result := <-resultChan + if result.err == nil { + successCount++ + if successCount == 1 { + // First success - use this one + GoLog("[Tidal] [Parallel] ✓ Using response from %s (took %v, total %v)\n", + result.apiURL, result.duration, time.Since(startTime)) + + // Don't return immediately - let other goroutines finish to avoid leaks + // But we'll use this result + go func() { + // Drain remaining results + for j := i + 1; j < len(apis); j++ { + <-resultChan + } + }() + + return result.apiURL, result.info, nil + } + } else { + failCount++ + errMsg := result.err.Error() + if len(errMsg) > 50 { + errMsg = errMsg[:50] + "..." + } + errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg)) + GoLog("[Tidal] [Parallel] ✗ %s failed: %s (took %v)\n", result.apiURL, errMsg, result.duration) + } + } + + GoLog("[Tidal] [Parallel] All %d APIs failed in %v\n", len(apis), time.Since(startTime)) + return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors) +} + +// getDownloadURLSequential requests download URL from APIs sequentially (fallback) // Returns the first successful result (supports both v1 and v2 API formats) func getDownloadURLSequential(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) { if len(apis) == 0 { @@ -1390,7 +1537,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { 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:") @@ -1400,7 +1547,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } else { tidalURL, slErr = downloader.GetTidalURLFromSpotify(req.SpotifyID) } - + if slErr == nil && tidalURL != "" { // Extract track ID and get track info trackID, idErr := downloader.GetTrackIDFromURL(tidalURL) @@ -1456,7 +1603,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } tidalArtist = strings.Join(artistNames, ", ") } - + // Verify title first if !titlesMatch(req.TrackName, track.Title) { GoLog("[Tidal] Title mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 0107f741..ef0ca6b5 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -1,6 +1,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('TrackProvider'); class TrackState { final List tracks; @@ -210,54 +213,60 @@ class TrackNotifier extends Notifier { // Use Deezer or Spotify based on settings final source = metadataSource ?? 'deezer'; - // Debug log to show which source is being used - // ignore: avoid_print - print('[Search] Using metadata source: $source for query: "$query"'); + _log.i('Search started: source=$source, query="$query"'); Map results; if (source == 'deezer') { + _log.d('Calling Deezer search API...'); results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5); - // ignore: avoid_print - print('[Search] Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks'); + _log.i('Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists'); } else { + _log.d('Calling Spotify search API...'); results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5); - // ignore: avoid_print - print('[Search] Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks'); + _log.i('Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists'); } - if (!_isRequestValid(requestId)) return; // Request cancelled + if (!_isRequestValid(requestId)) { + _log.w('Search request cancelled (requestId=$requestId)'); + return; + } final trackList = results['tracks'] as List? ?? []; final artistList = results['artists'] as List? ?? []; + _log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists'); + // Parse tracks with error handling per item final tracks = []; - for (final t in trackList) { + for (int i = 0; i < trackList.length; i++) { + final t = trackList[i]; try { if (t is Map) { tracks.add(_parseSearchTrack(t)); + } else { + _log.w('Track[$i] is not a Map: ${t.runtimeType}'); } } catch (e) { - // ignore: avoid_print - print('[Search] Failed to parse track: $e'); + _log.e('Failed to parse track[$i]: $e', e); } } // Parse artists with error handling per item final artists = []; - for (final a in artistList) { + for (int i = 0; i < artistList.length; i++) { + final a = artistList[i]; try { if (a is Map) { artists.add(_parseSearchArtist(a)); + } else { + _log.w('Artist[$i] is not a Map: ${a.runtimeType}'); } } catch (e) { - // ignore: avoid_print - print('[Search] Failed to parse artist: $e'); + _log.e('Failed to parse artist[$i]: $e', e); } } - // ignore: avoid_print - print('[Search] Parsed ${tracks.length} tracks, ${artists.length} artists'); + _log.i('Search complete: ${tracks.length} tracks, ${artists.length} artists parsed successfully'); state = TrackState( tracks: tracks, @@ -265,9 +274,9 @@ class TrackNotifier extends Notifier { isLoading: false, hasSearchText: state.hasSearchText, ); - } catch (e) { - if (!_isRequestValid(requestId)) return; // Request cancelled - // Preserve hasSearchText on error so user stays on search screen + } catch (e, stackTrace) { + if (!_isRequestValid(requestId)) return; + _log.e('Search failed: $e', e, stackTrace); state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText); } } diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 73a412ed..c8e353d3 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -328,13 +328,27 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient : Column( children: [ SizedBox(height: screenHeight * 0.06), - Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: colorScheme.primaryContainer.withValues(alpha: 0.3), - shape: BoxShape.circle, + ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Image.asset( + 'assets/images/logo.png', + width: 96, + height: 96, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Container( + width: 96, + height: 96, + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(24), + ), + child: Icon( + Icons.music_note, + size: 48, + color: colorScheme.onPrimaryContainer, + ), + ), ), - child: Icon(Icons.music_note, size: 48, color: colorScheme.primary), ), const SizedBox(height: 16), Text( diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index d441602a..36f055ac 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -109,14 +109,14 @@ class AboutPage extends StatelessWidget { githubUsername: 'sachinsenal0x64', showDivider: true, ), - SettingsItem( + _AboutSettingsItem( icon: Icons.cloud_outlined, title: 'DoubleDouble', subtitle: 'Amazing API for Amazon Music downloads. Thank you for making it free!', onTap: () => _launchUrl('https://doubledouble.top'), showDivider: true, ), - SettingsItem( + _AboutSettingsItem( icon: Icons.music_note_outlined, title: 'DAB Music', subtitle: 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!', @@ -250,26 +250,21 @@ class _AppHeaderCard extends StatelessWidget { child: Column( children: [ // App logo - Container( - width: 88, - height: 88, - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(24), - boxShadow: [ - BoxShadow( - color: colorScheme.primary.withValues(alpha: 0.2), - blurRadius: 16, - offset: const Offset(0, 4), + ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Image.asset( + 'assets/images/logo.png', + width: 88, + height: 88, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Container( + width: 88, + height: 88, + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(24), ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(24), - child: Image.asset( - 'assets/images/logo.png', - fit: BoxFit.cover, - errorBuilder: (_, _, _) => Icon( + child: Icon( Icons.music_note, size: 48, color: colorScheme.onPrimaryContainer, @@ -417,3 +412,80 @@ class _ContributorItem extends StatelessWidget { await launchUrl(uri, mode: LaunchMode.inAppBrowserView); } } + +/// Settings item with 40x40 icon area to align with contributor avatars +class _AboutSettingsItem extends StatelessWidget { + final IconData icon; + final String title; + final String? subtitle; + final VoidCallback? onTap; + final bool showDivider; + + const _AboutSettingsItem({ + required this.icon, + required this.title, + this.subtitle, + this.onTap, + this.showDivider = true, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onTap, + splashColor: colorScheme.primary.withValues(alpha: 0.12), + highlightColor: colorScheme.primary.withValues(alpha: 0.08), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + // Icon with 40x40 size to match avatar + SizedBox( + width: 40, + height: 40, + child: Icon(icon, color: colorScheme.onSurfaceVariant, size: 24), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodyLarge, + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + if (onTap != null) + Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant), + ], + ), + ), + ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: 76, // 20 + 40 + 16 = 76 (same as contributor item) + endIndent: 20, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } +} diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart index 18093b0f..e3119030 100644 --- a/lib/utils/logger.dart +++ b/lib/utils/logger.dart @@ -50,6 +50,7 @@ class LogBuffer extends ChangeNotifier { int _lastGoLogIndex = 0; /// Whether logging is enabled (controlled by settings) + /// User must enable "Detailed Logging" in settings to capture logs static bool _loggingEnabled = false; static bool get loggingEnabled => _loggingEnabled; static set loggingEnabled(bool value) { @@ -242,39 +243,63 @@ final log = Logger( /// Logger with class/tag prefix for better traceability /// Now also writes to LogBuffer for in-app viewing +/// Works in both debug and release mode class AppLogger { final String _tag; - late final Logger _logger; + late final Logger? _logger; AppLogger(this._tag) { - _logger = Logger( - printer: SimplePrinter(printTime: false, colors: false), - output: BufferedOutput(_tag), - level: Level.debug, - ); + // Only create Logger instance in debug mode + // In release mode, we write directly to LogBuffer + if (kDebugMode) { + _logger = Logger( + printer: SimplePrinter(printTime: false, colors: false), + output: BufferedOutput(_tag), + level: Level.debug, + ); + } else { + _logger = null; + } + } + + void _addToBuffer(String level, String message, {String? error}) { + LogBuffer().add(LogEntry( + timestamp: DateTime.now(), + level: level, + tag: _tag, + message: message, + error: error, + )); } void d(String message) { - _logger.d(message); + if (kDebugMode) { + _logger?.d(message); + } else { + // In release mode, write directly to buffer + _addToBuffer('DEBUG', message); + } } void i(String message) { - _logger.i(message); + if (kDebugMode) { + _logger?.i(message); + } else { + _addToBuffer('INFO', message); + } } void w(String message) { - _logger.w(message); + if (kDebugMode) { + _logger?.w(message); + } else { + _addToBuffer('WARN', 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(), - )); + _addToBuffer('ERROR', message, error: error.toString()); if (kDebugMode) { debugPrint('[$_tag] ERROR: $message | $error'); if (stackTrace != null) { @@ -282,7 +307,11 @@ class AppLogger { } } } else { - _logger.e(message); + if (kDebugMode) { + _logger?.e(message); + } else { + _addToBuffer('ERROR', message); + } } } }