From 48f95840275b0151b0a062e077645784e96baaf7 Mon Sep 17 00:00:00 2001 From: afkarxyz Date: Wed, 26 Nov 2025 10:47:02 +0700 Subject: [PATCH] v6.3 --- app.go | 36 +++++ backend/lyrics.go | 185 +++++++++++++++++++++++ backend/spectrum.go | 10 -- frontend/package.json.md5 | 2 +- frontend/src/App.css | 42 ----- frontend/src/App.tsx | 31 +++- frontend/src/components/AlbumInfo.tsx | 23 ++- frontend/src/components/ArtistInfo.tsx | 26 +++- frontend/src/components/PlaylistInfo.tsx | 23 ++- frontend/src/components/TrackInfo.tsx | 37 ++++- frontend/src/components/TrackList.tsx | 87 ++++++++--- frontend/src/components/ui/alert.tsx | 66 -------- frontend/src/components/ui/textarea.tsx | 18 --- frontend/src/hooks/useLyrics.ts | 99 ++++++++++++ frontend/src/lib/api.ts | 11 +- frontend/src/types/api.ts | 17 +++ wails.json | 2 +- 17 files changed, 537 insertions(+), 178 deletions(-) create mode 100644 backend/lyrics.go delete mode 100644 frontend/src/App.css delete mode 100644 frontend/src/components/ui/alert.tsx delete mode 100644 frontend/src/components/ui/textarea.tsx create mode 100644 frontend/src/hooks/useLyrics.ts diff --git a/app.go b/app.go index 084d807..4609349 100644 --- a/app.go +++ b/app.go @@ -354,3 +354,39 @@ func (a *App) AnalyzeMultipleTracks(filePaths []string) (string, error) { return string(jsonData), nil } + +// LyricsDownloadRequest represents the request structure for downloading lyrics +type LyricsDownloadRequest struct { + SpotifyID string `json:"spotify_id"` + TrackName string `json:"track_name"` + ArtistName string `json:"artist_name"` + OutputDir string `json:"output_dir"` +} + +// DownloadLyrics downloads lyrics for a single track +func (a *App) DownloadLyrics(req LyricsDownloadRequest) (backend.LyricsDownloadResponse, error) { + if req.SpotifyID == "" { + return backend.LyricsDownloadResponse{ + Success: false, + Error: "Spotify ID is required", + }, fmt.Errorf("spotify ID is required") + } + + client := backend.NewLyricsClient() + backendReq := backend.LyricsDownloadRequest{ + SpotifyID: req.SpotifyID, + TrackName: req.TrackName, + ArtistName: req.ArtistName, + OutputDir: req.OutputDir, + } + + resp, err := client.DownloadLyrics(backendReq) + if err != nil { + return backend.LyricsDownloadResponse{ + Success: false, + Error: err.Error(), + }, err + } + + return *resp, nil +} diff --git a/backend/lyrics.go b/backend/lyrics.go new file mode 100644 index 0000000..56c6b39 --- /dev/null +++ b/backend/lyrics.go @@ -0,0 +1,185 @@ +package backend + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// LyricsLine represents a single line of lyrics +type LyricsLine struct { + StartTimeMs string `json:"startTimeMs"` + Words string `json:"words"` + EndTimeMs string `json:"endTimeMs"` +} + +// LyricsResponse represents the API response +type LyricsResponse struct { + Error bool `json:"error"` + SyncType string `json:"syncType"` + Lines []LyricsLine `json:"lines"` +} + +// LyricsDownloadRequest represents a request to download lyrics +type LyricsDownloadRequest struct { + SpotifyID string `json:"spotify_id"` + TrackName string `json:"track_name"` + ArtistName string `json:"artist_name"` + OutputDir string `json:"output_dir"` +} + +// LyricsDownloadResponse represents the response from lyrics download +type LyricsDownloadResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + File string `json:"file,omitempty"` + Error string `json:"error,omitempty"` + AlreadyExists bool `json:"already_exists,omitempty"` +} + +// LyricsClient handles lyrics fetching +type LyricsClient struct { + httpClient *http.Client +} + +// NewLyricsClient creates a new lyrics client +func NewLyricsClient() *LyricsClient { + return &LyricsClient{ + httpClient: &http.Client{Timeout: 15 * time.Second}, + } +} + +// FetchLyrics fetches lyrics from the Spotify Lyrics API +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) + + resp, err := c.httpClient.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch lyrics: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %v", err) + } + + var lyricsResp LyricsResponse + if err := json.Unmarshal(body, &lyricsResp); err != nil { + return nil, fmt.Errorf("failed to parse lyrics response: %v", err) + } + + if lyricsResp.Error { + return nil, fmt.Errorf("lyrics not found for this track") + } + + return &lyricsResp, nil +} + +// ConvertToLRC converts lyrics response to LRC format +func (c *LyricsClient) ConvertToLRC(lyrics *LyricsResponse, trackName, artistName string) string { + var sb strings.Builder + + // Add metadata + sb.WriteString(fmt.Sprintf("[ti:%s]\n", trackName)) + sb.WriteString(fmt.Sprintf("[ar:%s]\n", artistName)) + sb.WriteString("[by:SpotiFlac]\n") + sb.WriteString("\n") + + // Add lyrics lines + for _, line := range lyrics.Lines { + if line.Words == "" { + continue + } + + // Convert milliseconds to LRC timestamp format [mm:ss.xx] + timestamp := msToLRCTimestamp(line.StartTimeMs) + sb.WriteString(fmt.Sprintf("%s%s\n", timestamp, line.Words)) + } + + return sb.String() +} + +// msToLRCTimestamp converts milliseconds string to LRC timestamp format [mm:ss.xx] +func msToLRCTimestamp(msStr string) string { + var ms int64 + fmt.Sscanf(msStr, "%d", &ms) + + totalSeconds := ms / 1000 + minutes := totalSeconds / 60 + seconds := totalSeconds % 60 + centiseconds := (ms % 1000) / 10 + + return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds) +} + +// DownloadLyrics downloads lyrics for a single track +func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloadResponse, error) { + if req.SpotifyID == "" { + return &LyricsDownloadResponse{ + Success: false, + Error: "Spotify ID is required", + }, fmt.Errorf("spotify ID is required") + } + + // Create output directory if it doesn't exist + outputDir := req.OutputDir + if outputDir == "" { + outputDir = GetDefaultMusicPath() + } + + if err := os.MkdirAll(outputDir, 0755); err != nil { + return &LyricsDownloadResponse{ + Success: false, + Error: fmt.Sprintf("failed to create output directory: %v", err), + }, err + } + + // Generate filename + filename := sanitizeFilename(fmt.Sprintf("%s - %s.lrc", req.TrackName, req.ArtistName)) + filePath := filepath.Join(outputDir, filename) + + // Check if file already exists + if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 { + return &LyricsDownloadResponse{ + Success: true, + Message: "Lyrics file already exists", + File: filePath, + AlreadyExists: true, + }, nil + } + + // Fetch lyrics + lyrics, err := c.FetchLyrics(req.SpotifyID) + if err != nil { + return &LyricsDownloadResponse{ + Success: false, + Error: err.Error(), + }, err + } + + // Convert to LRC format + lrcContent := c.ConvertToLRC(lyrics, req.TrackName, req.ArtistName) + + // Write LRC file + if err := os.WriteFile(filePath, []byte(lrcContent), 0644); err != nil { + return &LyricsDownloadResponse{ + Success: false, + Error: fmt.Sprintf("failed to write LRC file: %v", err), + }, err + } + + return &LyricsDownloadResponse{ + Success: true, + Message: "Lyrics downloaded successfully", + File: filePath, + }, nil +} diff --git a/backend/spectrum.go b/backend/spectrum.go index b32ec49..e6b6412 100644 --- a/backend/spectrum.go +++ b/backend/spectrum.go @@ -4,7 +4,6 @@ import ( "fmt" "math" "math/cmplx" - "os" "github.com/mewkiz/flac" ) @@ -194,12 +193,3 @@ func fftRecursive(x []complex128) []complex128 { return result } - -// GetFileSize helper -func getSpectrumFileSize(filepath string) (int64, error) { - info, err := os.Stat(filepath) - if err != nil { - return 0, err - } - return info.Size(), nil -} diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index b96d780..5df5779 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -72728e016fdcbb66d395ba3a681b8945 \ No newline at end of file +2d92c35b92c8ea713ea561773c5b7b7b \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index b9d355d..0000000 --- a/frontend/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 193085d..2a99562 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -30,6 +30,7 @@ import type { HistoryItem } from "@/components/FetchHistory"; // Hooks import { useDownload } from "@/hooks/useDownload"; import { useMetadata } from "@/hooks/useMetadata"; +import { useLyrics } from "@/hooks/useLyrics"; const HISTORY_KEY = "spotiflac_fetch_history"; const MAX_HISTORY = 5; @@ -44,10 +45,11 @@ function App() { const [fetchHistory, setFetchHistory] = useState([]); const ITEMS_PER_PAGE = 50; - const CURRENT_VERSION = "6.2"; + const CURRENT_VERSION = "6.3"; const download = useDownload(); const metadata = useMetadata(); + const lyrics = useLyrics(); useEffect(() => { const settings = getSettings(); @@ -76,6 +78,7 @@ function App() { setSelectedTracks([]); setSearchQuery(""); download.resetDownloadedTracks(); + lyrics.resetLyricsState(); setSortBy("default"); setCurrentPage(1); }, [metadata.metadata]); @@ -249,7 +252,12 @@ function App() { downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(track.isrc)} isFailed={download.failedTracks.has(track.isrc)} + downloadingLyricsTrack={lyrics.downloadingLyricsTrack} + downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} + failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} + skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} onDownload={download.handleDownloadTrack} + onDownloadLyrics={lyrics.handleDownloadLyrics} onOpenFolder={handleOpenFolder} /> ); @@ -274,11 +282,18 @@ function App() { currentDownloadInfo={download.currentDownloadInfo} currentPage={currentPage} itemsPerPage={ITEMS_PER_PAGE} + downloadedLyrics={lyrics.downloadedLyrics} + failedLyrics={lyrics.failedLyrics} + skippedLyrics={lyrics.skippedLyrics} + downloadingLyricsTrack={lyrics.downloadingLyricsTrack} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} + onDownloadLyrics={(spotifyId, name, artists, albumName) => + lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name) + } onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name) @@ -321,11 +336,18 @@ function App() { currentDownloadInfo={download.currentDownloadInfo} currentPage={currentPage} itemsPerPage={ITEMS_PER_PAGE} + downloadedLyrics={lyrics.downloadedLyrics} + failedLyrics={lyrics.failedLyrics} + skippedLyrics={lyrics.skippedLyrics} + downloadingLyricsTrack={lyrics.downloadingLyricsTrack} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} + onDownloadLyrics={(spotifyId, name, artists, albumName) => + lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name) + } onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)} onDownloadSelected={() => download.handleDownloadSelected( @@ -374,11 +396,18 @@ function App() { currentDownloadInfo={download.currentDownloadInfo} currentPage={currentPage} itemsPerPage={ITEMS_PER_PAGE} + downloadedLyrics={lyrics.downloadedLyrics} + failedLyrics={lyrics.failedLyrics} + skippedLyrics={lyrics.skippedLyrics} + downloadingLyricsTrack={lyrics.downloadingLyricsTrack} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} + onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, isArtistDiscography) => + lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, isArtistDiscography) + } onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name, true) diff --git a/frontend/src/components/AlbumInfo.tsx b/frontend/src/components/AlbumInfo.tsx index 302f66b..633fbd8 100644 --- a/frontend/src/components/AlbumInfo.tsx +++ b/frontend/src/components/AlbumInfo.tsx @@ -31,11 +31,17 @@ interface AlbumInfoProps { currentDownloadInfo: { name: string; artists: string } | null; currentPage: number; itemsPerPage: number; + // Lyrics props + downloadedLyrics?: Set; + failedLyrics?: Set; + skippedLyrics?: Set; + downloadingLyricsTrack?: string | null; onSearchChange: (value: string) => void; onSortChange: (value: string) => void; onToggleTrack: (isrc: string) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void; onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void; + onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean) => void; onDownloadAll: () => void; onDownloadSelected: () => void; onStopDownload: () => void; @@ -61,11 +67,16 @@ export function AlbumInfo({ currentDownloadInfo, currentPage, itemsPerPage, + downloadedLyrics, + failedLyrics, + skippedLyrics, + downloadingLyricsTrack, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, + onDownloadLyrics, onDownloadAll, onDownloadSelected, onStopDownload, @@ -113,11 +124,8 @@ export function AlbumInfo({ {albumInfo.total_tracks} songs -
-
-
- + {track.spotify_id && onDownloadLyrics && ( + + )} {isDownloaded && ( - )} +
+ {track.isrc && ( + + )} + {track.spotify_id && onDownloadLyrics && ( + + + + + +

Download Lyric

+
+
+ )} +
))} diff --git a/frontend/src/components/ui/alert.tsx b/frontend/src/components/ui/alert.tsx deleted file mode 100644 index 1421354..0000000 --- a/frontend/src/components/ui/alert.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const alertVariants = cva( - "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", - { - variants: { - variant: { - default: "bg-card text-card-foreground", - destructive: - "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", - }, - }, - defaultVariants: { - variant: "default", - }, - } -) - -function Alert({ - className, - variant, - ...props -}: React.ComponentProps<"div"> & VariantProps) { - return ( -
- ) -} - -function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function AlertDescription({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
- ) -} - -export { Alert, AlertTitle, AlertDescription } diff --git a/frontend/src/components/ui/textarea.tsx b/frontend/src/components/ui/textarea.tsx deleted file mode 100644 index 7f21b5e..0000000 --- a/frontend/src/components/ui/textarea.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import * as React from "react" - -import { cn } from "@/lib/utils" - -function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { - return ( -