From 76669f551e3c51c903badcbc6f766feca3e41ef5 Mon Sep 17 00:00:00 2001 From: Sepehr Aroofzade Date: Sat, 13 Dec 2025 00:39:22 +0330 Subject: [PATCH] Add FLAC lyrics embedding with LRCLIB fallback (#151) * Update lyrics code * Refactor DownloadFile to use default HTTP client --- app.go | 52 +++++++++++ backend/deezer.go | 8 +- backend/lyrics.go | 222 +++++++++++++++++++++++++++++++++++++++++++- backend/metadata.go | 60 ++++++++++++ backend/qobuz.go | 8 +- backend/tidal.go | 1 + 6 files changed, 346 insertions(+), 5 deletions(-) diff --git a/app.go b/app.go index 03e29c9..e540274 100644 --- a/app.go +++ b/app.go @@ -299,6 +299,58 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { filename = strings.TrimPrefix(filename, "EXISTS:") } + // Embed lyrics after successful download (only for new downloads with Spotify ID) + if !alreadyExists && req.SpotifyID != "" && strings.HasSuffix(filename, ".flac") { + go func(filePath, spotifyID, trackName, artistName string) { + fmt.Printf("\n========== LYRICS FETCH START ==========\n") + fmt.Printf("Spotify ID: %s\n", spotifyID) + fmt.Printf("Track: %s\n", trackName) + fmt.Printf("Artist: %s\n", artistName) + fmt.Println("Searching all sources...") + + lyricsClient := backend.NewLyricsClient() + + // Try all sources with fallbacks + lyricsResp, source, err := lyricsClient.FetchLyricsAllSources(spotifyID, trackName, artistName) + if err != nil { + fmt.Printf("❌ All sources failed: %v\n", err) + fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n") + return + } + + if lyricsResp == nil || len(lyricsResp.Lines) == 0 { + fmt.Println("❌ No lyrics content found") + fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n") + return + } + + fmt.Printf("✓ Lyrics found from: %s\n", source) + fmt.Printf("✓ Sync type: %s\n", lyricsResp.SyncType) + fmt.Printf("✓ Total lines: %d\n", len(lyricsResp.Lines)) + + lyrics := lyricsClient.ConvertToLRC(lyricsResp, trackName, artistName) + if lyrics == "" { + fmt.Println("❌ No lyrics content to embed") + fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n") + return + } + + // Show full lyrics in console for debugging + fmt.Printf("\n--- Full LRC Content ---\n") + fmt.Println(lyrics) + fmt.Printf("--- End LRC Content ---\n\n") + + fmt.Printf("Embedding into: %s\n", filePath) + if err := backend.EmbedLyricsOnly(filePath, lyrics); err != nil { + fmt.Printf("❌ Failed to embed lyrics: %v\n", err) + fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n") + } else { + fmt.Printf("✓ Lyrics embedded successfully!\n") + fmt.Printf("========== LYRICS FETCH END (SUCCESS) ==========\n\n") + } + }(filename, req.SpotifyID, req.TrackName, req.ArtistName) + } + message := "Download completed successfully" if alreadyExists { message = "File already exists" diff --git a/backend/deezer.go b/backend/deezer.go index 9e553be..01f0bf4 100644 --- a/backend/deezer.go +++ b/backend/deezer.go @@ -173,7 +173,13 @@ func (d *DeezerDownloader) GetDownloadURL(trackID int64) (string, error) { } func (d *DeezerDownloader) DownloadFile(url, filepath string) error { - resp, err := d.client.Get(url) + // Use a separate client with a longer timeout. The default client's 60s limit + // causes downloads to fail on slow connections or for large Hi-Res files. + downloadClient := &http.Client{ + Timeout: 5 * time.Minute, // 5 minutes for large files + } + + resp, err := downloadClient.Get(url) if err != nil { return fmt.Errorf("failed to download file: %w", err) } diff --git a/backend/lyrics.go b/backend/lyrics.go index d796832..7af70ae 100644 --- a/backend/lyrics.go +++ b/backend/lyrics.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "regexp" @@ -13,6 +14,19 @@ import ( "time" ) +// LRCLibResponse represents the LRCLIB API response +type LRCLibResponse struct { + ID int `json:"id"` + Name string `json:"name"` + TrackName string `json:"trackName"` + ArtistName string `json:"artistName"` + AlbumName string `json:"albumName"` + Duration float64 `json:"duration"` + Instrumental bool `json:"instrumental"` + PlainLyrics string `json:"plainLyrics"` + SyncedLyrics string `json:"syncedLyrics"` +} + // LyricsLine represents a single line of lyrics type LyricsLine struct { StartTimeMs string `json:"startTimeMs"` @@ -60,13 +74,215 @@ func NewLyricsClient() *LyricsClient { } } -// FetchLyrics fetches lyrics from the Spotify Lyrics API +// FetchLyricsWithMetadata fetches lyrics using track name and artist (for LRCLIB fallback) +func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string) (*LyricsResponse, error) { + // Try LRCLIB API + apiURL := fmt.Sprintf("https://lrclib.net/api/get?artist_name=%s&track_name=%s", + url.QueryEscape(artistName), + url.QueryEscape(trackName)) + + resp, err := c.httpClient.Get(apiURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch from LRCLIB: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("LRCLIB returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read LRCLIB response: %v", err) + } + + var lrcLibResp LRCLibResponse + if err := json.Unmarshal(body, &lrcLibResp); err != nil { + return nil, fmt.Errorf("failed to parse LRCLIB response: %v", err) + } + + // Convert LRCLIB response to our LyricsResponse format + return c.convertLRCLibToLyricsResponse(&lrcLibResp), nil +} + +// convertLRCLibToLyricsResponse converts LRCLIB response to our standard format +func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *LyricsResponse { + resp := &LyricsResponse{ + Error: false, + SyncType: "LINE_SYNCED", + Lines: []LyricsLine{}, + } + + // Prefer synced lyrics, fall back to plain + lyricsText := lrcLib.SyncedLyrics + if lyricsText == "" { + lyricsText = lrcLib.PlainLyrics + resp.SyncType = "UNSYNCED" + } + + if lyricsText == "" { + resp.Error = true + return resp + } + + // Parse synced lyrics format [mm:ss.xx] text + lines := strings.Split(lyricsText, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + // Check if line has timestamp [mm:ss.xx] + if strings.HasPrefix(line, "[") && len(line) > 10 { + closeBracket := strings.Index(line, "]") + if closeBracket > 0 { + timestamp := line[1:closeBracket] + words := strings.TrimSpace(line[closeBracket+1:]) + + // Convert [mm:ss.xx] to milliseconds + ms := lrcTimestampToMs(timestamp) + resp.Lines = append(resp.Lines, LyricsLine{ + StartTimeMs: fmt.Sprintf("%d", ms), + Words: words, + }) + continue + } + } + + // Plain lyrics line (no timestamp) + resp.Lines = append(resp.Lines, LyricsLine{ + StartTimeMs: "0", + Words: line, + }) + } + + return resp +} + +// lrcTimestampToMs converts LRC timestamp [mm:ss.xx] to milliseconds +func lrcTimestampToMs(timestamp string) int64 { + var minutes, seconds, centiseconds int64 + // Try parsing mm:ss.xx format + n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, ¢iseconds) + if n >= 2 { + return minutes*60*1000 + seconds*1000 + centiseconds*10 + } + return 0 +} + +// FetchLyricsFromLRCLibSearch fetches lyrics using LRCLIB search API +func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) { + query := fmt.Sprintf("%s %s", artistName, trackName) + apiURL := fmt.Sprintf("https://lrclib.net/api/search?q=%s", url.QueryEscape(query)) + + resp, err := c.httpClient.Get(apiURL) + if err != nil { + return nil, fmt.Errorf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read failed: %v", err) + } + + var results []LRCLibResponse + if err := json.Unmarshal(body, &results); err != nil { + return nil, fmt.Errorf("parse failed: %v", err) + } + + if len(results) == 0 { + return nil, fmt.Errorf("no results found") + } + + // Find best match - prefer one with synced lyrics + var best *LRCLibResponse + for i := range results { + if results[i].SyncedLyrics != "" { + best = &results[i] + break + } + if best == nil && results[i].PlainLyrics != "" { + best = &results[i] + } + } + + if best == nil { + best = &results[0] + } + + return c.convertLRCLibToLyricsResponse(best), nil +} + +// simplifyTrackName removes common suffixes like "(feat. X)", "(Remastered)", etc. +func simplifyTrackName(name string) string { + // Remove content in parentheses + if idx := strings.Index(name, "("); idx > 0 { + name = strings.TrimSpace(name[:idx]) + } + // Remove content after " - " (like "From the Motion Picture") + if idx := strings.Index(name, " - "); idx > 0 { + name = strings.TrimSpace(name[:idx]) + } + return name +} + +// FetchLyricsAllSources tries all sources to get lyrics +func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, string, error) { + // 1. Try Spotify API + if spotifyID != "" { + resp, err := c.FetchLyrics(spotifyID) + if err == nil && resp != nil && len(resp.Lines) > 0 { + return resp, "Spotify", nil + } + fmt.Printf(" ↳ Spotify API: %v\n", err) + } + + // 2. Try LRCLIB exact match + resp, err := c.FetchLyricsWithMetadata(trackName, artistName) + if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 { + return resp, "LRCLIB", nil + } + fmt.Printf(" ↳ LRCLIB exact: %v\n", err) + + // 3. Try LRCLIB search + resp, err = c.FetchLyricsFromLRCLibSearch(trackName, artistName) + if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 { + return resp, "LRCLIB Search", nil + } + fmt.Printf(" ↳ LRCLIB search: %v\n", err) + + // 4. Try with simplified track name (remove parentheses, subtitles) + simplifiedTrack := simplifyTrackName(trackName) + if simplifiedTrack != trackName { + fmt.Printf(" ↳ Trying simplified name: %s\n", simplifiedTrack) + + resp, err = c.FetchLyricsWithMetadata(simplifiedTrack, artistName) + if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 { + return resp, "LRCLIB (simplified)", nil + } + + resp, err = c.FetchLyricsFromLRCLibSearch(simplifiedTrack, artistName) + if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 { + return resp, "LRCLIB Search (simplified)", nil + } + } + + return nil, "", fmt.Errorf("lyrics not found in any source") +} + +// FetchLyrics fetches lyrics from the Spotify Lyrics API with LRCLIB fallback func (c *LyricsClient) FetchLyrics(spotifyID string) (*LyricsResponse, error) { // Decode base64 API URL apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9zcG90aWZ5LWx5cmljcy1hcGktcGkudmVyY2VsLmFwcC8/dHJhY2tpZD0=") - url := fmt.Sprintf("%s%s", string(apiBase), spotifyID) + apiURL := fmt.Sprintf("%s%s", string(apiBase), spotifyID) - resp, err := c.httpClient.Get(url) + resp, err := c.httpClient.Get(apiURL) if err != nil { return nil, fmt.Errorf("failed to fetch lyrics: %v", err) } diff --git a/backend/metadata.go b/backend/metadata.go index 00ce5a4..ba1b666 100644 --- a/backend/metadata.go +++ b/backend/metadata.go @@ -19,6 +19,7 @@ type Metadata struct { TrackNumber int DiscNumber int ISRC string + Lyrics string } func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error { @@ -58,6 +59,9 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error { if metadata.ISRC != "" { _ = cmt.Add(flacvorbis.FIELD_ISRC, metadata.ISRC) } + if metadata.Lyrics != "" { + _ = cmt.Add("LYRICS", metadata.Lyrics) // Or "UNSYNCEDLYRICS" for unsynced + } cmtBlock := cmt.Marshal() if cmtIdx < 0 { @@ -113,6 +117,62 @@ func fileExists(path string) bool { return err == nil } +// EmbedLyricsOnly adds lyrics to a FLAC file while preserving existing metadata +func EmbedLyricsOnly(filepath string, lyrics string) error { + if lyrics == "" { + return nil + } + f, err := flac.ParseFile(filepath) + if err != nil { + return fmt.Errorf("failed to parse FLAC file: %w", err) + } + + var cmtIdx = -1 + var existingCmt *flacvorbis.MetaDataBlockVorbisComment + for idx, block := range f.Meta { + if block.Type == flac.VorbisComment { + cmtIdx = idx + existingCmt, err = flacvorbis.ParseFromMetaDataBlock(*block) + if err != nil { + existingCmt = nil + } + break + } + } + + // Create new comment block, preserving existing comments + cmt := flacvorbis.New() + + // Copy existing comments except LYRICS + if existingCmt != nil { + for _, comment := range existingCmt.Comments { + parts := strings.SplitN(comment, "=", 2) + if len(parts) == 2 { + fieldName := strings.ToUpper(parts[0]) + if fieldName != "LYRICS" && fieldName != "UNSYNCEDLYRICS" && fieldName != "SYNCEDLYRICS" { + _ = cmt.Add(parts[0], parts[1]) + } + } + } + } + + // Add lyrics + _ = cmt.Add("LYRICS", lyrics) + + cmtBlock := cmt.Marshal() + if cmtIdx < 0 { + f.Meta = append(f.Meta, &cmtBlock) + } else { + f.Meta[cmtIdx] = &cmtBlock + } + + if err := f.Save(filepath); err != nil { + return fmt.Errorf("failed to save FLAC file: %w", err) + } + + return nil +} + // ReadISRCFromFile reads ISRC metadata from a FLAC file func ReadISRCFromFile(filepath string) (string, error) { if !fileExists(filepath) { diff --git a/backend/qobuz.go b/backend/qobuz.go index 13819c7..9fa3771 100644 --- a/backend/qobuz.go +++ b/backend/qobuz.go @@ -169,7 +169,13 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, func (q *QobuzDownloader) DownloadFile(url, filepath string) error { fmt.Println("Starting file download...") - resp, err := q.client.Get(url) + // Use a separate client with a longer timeout. The default client's 60s limit + // causes downloads to fail on slow connections or for large Hi-Res files. + downloadClient := &http.Client{ + Timeout: 5 * time.Minute, // 5 minutes for large files + } + + resp, err := downloadClient.Get(url) if err != nil { return fmt.Errorf("failed to download file: %w", err) } diff --git a/backend/tidal.go b/backend/tidal.go index 00c778b..046bd12 100644 --- a/backend/tidal.go +++ b/backend/tidal.go @@ -617,6 +617,7 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error { } resp, err := t.client.Get(url) + if err != nil { return fmt.Errorf("failed to download file: %w", err) }