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
This commit is contained in:
zarzet
2026-01-11 02:27:26 +07:00
parent 11e7034cec
commit f2aca734a3
8 changed files with 457 additions and 153 deletions
+98 -65
View File
@@ -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:]
+5 -5
View File
@@ -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)
}
+13 -13
View File
@@ -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)
}
+154 -7
View File
@@ -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",
+28 -19
View File
@@ -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<Track> tracks;
@@ -210,54 +213,60 @@ class TrackNotifier extends Notifier<TrackState> {
// 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<String, dynamic> 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<dynamic>? ?? [];
final artistList = results['artists'] as List<dynamic>? ?? [];
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists');
// Parse tracks with error handling per item
final tracks = <Track>[];
for (final t in trackList) {
for (int i = 0; i < trackList.length; i++) {
final t = trackList[i];
try {
if (t is Map<String, dynamic>) {
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 = <SearchArtist>[];
for (final a in artistList) {
for (int i = 0; i < artistList.length; i++) {
final a = artistList[i];
try {
if (a is Map<String, dynamic>) {
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<TrackState> {
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);
}
}
+20 -6
View File
@@ -328,13 +328,27 @@ class _HomeTabState extends ConsumerState<HomeTab> 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(
+93 -21
View File
@@ -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),
),
],
);
}
}
+46 -17
View File
@@ -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);
}
}
}
}