This commit is contained in:
afkarxyz
2025-11-26 10:47:02 +07:00
parent 4241a591aa
commit 48f9584027
17 changed files with 537 additions and 178 deletions
+36
View File
@@ -354,3 +354,39 @@ func (a *App) AnalyzeMultipleTracks(filePaths []string) (string, error) {
return string(jsonData), nil 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
}
+185
View File
@@ -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
}
-10
View File
@@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"math" "math"
"math/cmplx" "math/cmplx"
"os"
"github.com/mewkiz/flac" "github.com/mewkiz/flac"
) )
@@ -194,12 +193,3 @@ func fftRecursive(x []complex128) []complex128 {
return result 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
}
+1 -1
View File
@@ -1 +1 @@
72728e016fdcbb66d395ba3a681b8945 2d92c35b92c8ea713ea561773c5b7b7b
-42
View File
@@ -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;
}
+30 -1
View File
@@ -30,6 +30,7 @@ import type { HistoryItem } from "@/components/FetchHistory";
// Hooks // Hooks
import { useDownload } from "@/hooks/useDownload"; import { useDownload } from "@/hooks/useDownload";
import { useMetadata } from "@/hooks/useMetadata"; import { useMetadata } from "@/hooks/useMetadata";
import { useLyrics } from "@/hooks/useLyrics";
const HISTORY_KEY = "spotiflac_fetch_history"; const HISTORY_KEY = "spotiflac_fetch_history";
const MAX_HISTORY = 5; const MAX_HISTORY = 5;
@@ -44,10 +45,11 @@ function App() {
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]); const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
const ITEMS_PER_PAGE = 50; const ITEMS_PER_PAGE = 50;
const CURRENT_VERSION = "6.2"; const CURRENT_VERSION = "6.3";
const download = useDownload(); const download = useDownload();
const metadata = useMetadata(); const metadata = useMetadata();
const lyrics = useLyrics();
useEffect(() => { useEffect(() => {
const settings = getSettings(); const settings = getSettings();
@@ -76,6 +78,7 @@ function App() {
setSelectedTracks([]); setSelectedTracks([]);
setSearchQuery(""); setSearchQuery("");
download.resetDownloadedTracks(); download.resetDownloadedTracks();
lyrics.resetLyricsState();
setSortBy("default"); setSortBy("default");
setCurrentPage(1); setCurrentPage(1);
}, [metadata.metadata]); }, [metadata.metadata]);
@@ -249,7 +252,12 @@ function App() {
downloadingTrack={download.downloadingTrack} downloadingTrack={download.downloadingTrack}
isDownloaded={download.downloadedTracks.has(track.isrc)} isDownloaded={download.downloadedTracks.has(track.isrc)}
isFailed={download.failedTracks.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} onDownload={download.handleDownloadTrack}
onDownloadLyrics={lyrics.handleDownloadLyrics}
onOpenFolder={handleOpenFolder} onOpenFolder={handleOpenFolder}
/> />
); );
@@ -274,11 +282,18 @@ function App() {
currentDownloadInfo={download.currentDownloadInfo} currentDownloadInfo={download.currentDownloadInfo}
currentPage={currentPage} currentPage={currentPage}
itemsPerPage={ITEMS_PER_PAGE} itemsPerPage={ITEMS_PER_PAGE}
downloadedLyrics={lyrics.downloadedLyrics}
failedLyrics={lyrics.failedLyrics}
skippedLyrics={lyrics.skippedLyrics}
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
onSortChange={setSortBy} onSortChange={setSortBy}
onToggleTrack={toggleTrackSelection} onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll} onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack} 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)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name)}
onDownloadSelected={() => onDownloadSelected={() =>
download.handleDownloadSelected(selectedTracks, track_list, album_info.name) download.handleDownloadSelected(selectedTracks, track_list, album_info.name)
@@ -321,11 +336,18 @@ function App() {
currentDownloadInfo={download.currentDownloadInfo} currentDownloadInfo={download.currentDownloadInfo}
currentPage={currentPage} currentPage={currentPage}
itemsPerPage={ITEMS_PER_PAGE} itemsPerPage={ITEMS_PER_PAGE}
downloadedLyrics={lyrics.downloadedLyrics}
failedLyrics={lyrics.failedLyrics}
skippedLyrics={lyrics.skippedLyrics}
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
onSortChange={setSortBy} onSortChange={setSortBy}
onToggleTrack={toggleTrackSelection} onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll} onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack} 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)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)}
onDownloadSelected={() => onDownloadSelected={() =>
download.handleDownloadSelected( download.handleDownloadSelected(
@@ -374,11 +396,18 @@ function App() {
currentDownloadInfo={download.currentDownloadInfo} currentDownloadInfo={download.currentDownloadInfo}
currentPage={currentPage} currentPage={currentPage}
itemsPerPage={ITEMS_PER_PAGE} itemsPerPage={ITEMS_PER_PAGE}
downloadedLyrics={lyrics.downloadedLyrics}
failedLyrics={lyrics.failedLyrics}
skippedLyrics={lyrics.skippedLyrics}
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
onSortChange={setSortBy} onSortChange={setSortBy}
onToggleTrack={toggleTrackSelection} onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll} onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack} 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)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name, true)}
onDownloadSelected={() => onDownloadSelected={() =>
download.handleDownloadSelected(selectedTracks, track_list, artist_info.name, true) download.handleDownloadSelected(selectedTracks, track_list, artist_info.name, true)
+18 -5
View File
@@ -31,11 +31,17 @@ interface AlbumInfoProps {
currentDownloadInfo: { name: string; artists: string } | null; currentDownloadInfo: { name: string; artists: string } | null;
currentPage: number; currentPage: number;
itemsPerPage: number; itemsPerPage: number;
// Lyrics props
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onSortChange: (value: string) => void; onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void; onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => 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; onDownloadAll: () => void;
onDownloadSelected: () => void; onDownloadSelected: () => void;
onStopDownload: () => void; onStopDownload: () => void;
@@ -61,11 +67,16 @@ export function AlbumInfo({
currentDownloadInfo, currentDownloadInfo,
currentPage, currentPage,
itemsPerPage, itemsPerPage,
downloadedLyrics,
failedLyrics,
skippedLyrics,
downloadingLyricsTrack,
onSearchChange, onSearchChange,
onSortChange, onSortChange,
onToggleTrack, onToggleTrack,
onToggleSelectAll, onToggleSelectAll,
onDownloadTrack, onDownloadTrack,
onDownloadLyrics,
onDownloadAll, onDownloadAll,
onDownloadSelected, onDownloadSelected,
onStopDownload, onStopDownload,
@@ -113,11 +124,8 @@ export function AlbumInfo({
<span>{albumInfo.total_tracks} songs</span> <span>{albumInfo.total_tracks} songs</span>
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2 flex-wrap">
<Button <Button onClick={onDownloadAll} disabled={isDownloading}>
onClick={onDownloadAll}
disabled={isDownloading}
>
{isDownloading && bulkDownloadType === "all" ? ( {isDownloading && bulkDownloadType === "all" ? (
<Spinner /> <Spinner />
) : ( ) : (
@@ -179,9 +187,14 @@ export function AlbumInfo({
showCheckboxes={true} showCheckboxes={true}
hideAlbumColumn={true} hideAlbumColumn={true}
folderName={albumInfo.name} folderName={albumInfo.name}
downloadedLyrics={downloadedLyrics}
failedLyrics={failedLyrics}
skippedLyrics={skippedLyrics}
downloadingLyricsTrack={downloadingLyricsTrack}
onToggleTrack={onToggleTrack} onToggleTrack={onToggleTrack}
onToggleSelectAll={onToggleSelectAll} onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack} onDownloadTrack={onDownloadTrack}
onDownloadLyrics={onDownloadLyrics}
onPageChange={onPageChange} onPageChange={onPageChange}
onTrackClick={onTrackClick} onTrackClick={onTrackClick}
/> />
+19 -7
View File
@@ -36,11 +36,17 @@ interface ArtistInfoProps {
currentDownloadInfo: { name: string; artists: string } | null; currentDownloadInfo: { name: string; artists: string } | null;
currentPage: number; currentPage: number;
itemsPerPage: number; itemsPerPage: number;
// Lyrics props
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onSortChange: (value: string) => void; onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void; onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => 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; onDownloadAll: () => void;
onDownloadSelected: () => void; onDownloadSelected: () => void;
onStopDownload: () => void; onStopDownload: () => void;
@@ -68,11 +74,16 @@ export function ArtistInfo({
currentDownloadInfo, currentDownloadInfo,
currentPage, currentPage,
itemsPerPage, itemsPerPage,
downloadedLyrics,
failedLyrics,
skippedLyrics,
downloadingLyricsTrack,
onSearchChange, onSearchChange,
onSortChange, onSortChange,
onToggleTrack, onToggleTrack,
onToggleSelectAll, onToggleSelectAll,
onDownloadTrack, onDownloadTrack,
onDownloadLyrics,
onDownloadAll, onDownloadAll,
onDownloadSelected, onDownloadSelected,
onStopDownload, onStopDownload,
@@ -152,14 +163,10 @@ export function ArtistInfo({
{trackList.length > 0 && ( {trackList.length > 0 && (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between flex-wrap gap-2">
<h3 className="text-2xl font-bold">Popular Tracks</h3> <h3 className="text-2xl font-bold">Popular Tracks</h3>
<div className="flex gap-2"> <div className="flex gap-2 flex-wrap">
<Button <Button onClick={onDownloadAll} size="sm" disabled={isDownloading}>
onClick={onDownloadAll}
size="sm"
disabled={isDownloading}
>
{isDownloading && bulkDownloadType === "all" ? ( {isDownloading && bulkDownloadType === "all" ? (
<Spinner /> <Spinner />
) : ( ) : (
@@ -219,9 +226,14 @@ export function ArtistInfo({
hideAlbumColumn={false} hideAlbumColumn={false}
folderName={artistInfo.name} folderName={artistInfo.name}
isArtistDiscography={true} isArtistDiscography={true}
downloadedLyrics={downloadedLyrics}
failedLyrics={failedLyrics}
skippedLyrics={skippedLyrics}
downloadingLyricsTrack={downloadingLyricsTrack}
onToggleTrack={onToggleTrack} onToggleTrack={onToggleTrack}
onToggleSelectAll={onToggleSelectAll} onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack} onDownloadTrack={onDownloadTrack}
onDownloadLyrics={onDownloadLyrics}
onPageChange={onPageChange} onPageChange={onPageChange}
onAlbumClick={onAlbumClick} onAlbumClick={onAlbumClick}
onArtistClick={onArtistClick} onArtistClick={onArtistClick}
+18 -5
View File
@@ -35,11 +35,17 @@ interface PlaylistInfoProps {
currentDownloadInfo: { name: string; artists: string } | null; currentDownloadInfo: { name: string; artists: string } | null;
currentPage: number; currentPage: number;
itemsPerPage: number; itemsPerPage: number;
// Lyrics props
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onSortChange: (value: string) => void; onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void; onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => 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; onDownloadAll: () => void;
onDownloadSelected: () => void; onDownloadSelected: () => void;
onStopDownload: () => void; onStopDownload: () => void;
@@ -66,11 +72,16 @@ export function PlaylistInfo({
currentDownloadInfo, currentDownloadInfo,
currentPage, currentPage,
itemsPerPage, itemsPerPage,
downloadedLyrics,
failedLyrics,
skippedLyrics,
downloadingLyricsTrack,
onSearchChange, onSearchChange,
onSortChange, onSortChange,
onToggleTrack, onToggleTrack,
onToggleSelectAll, onToggleSelectAll,
onDownloadTrack, onDownloadTrack,
onDownloadLyrics,
onDownloadAll, onDownloadAll,
onDownloadSelected, onDownloadSelected,
onStopDownload, onStopDownload,
@@ -104,11 +115,8 @@ export function PlaylistInfo({
<span>{playlistInfo.followers.total.toLocaleString()} followers</span> <span>{playlistInfo.followers.total.toLocaleString()} followers</span>
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2 flex-wrap">
<Button <Button onClick={onDownloadAll} disabled={isDownloading}>
onClick={onDownloadAll}
disabled={isDownloading}
>
{isDownloading && bulkDownloadType === "all" ? ( {isDownloading && bulkDownloadType === "all" ? (
<Spinner /> <Spinner />
) : ( ) : (
@@ -170,9 +178,14 @@ export function PlaylistInfo({
showCheckboxes={true} showCheckboxes={true}
hideAlbumColumn={false} hideAlbumColumn={false}
folderName={playlistInfo.owner.name} folderName={playlistInfo.owner.name}
downloadedLyrics={downloadedLyrics}
failedLyrics={failedLyrics}
skippedLyrics={skippedLyrics}
downloadingLyricsTrack={downloadingLyricsTrack}
onToggleTrack={onToggleTrack} onToggleTrack={onToggleTrack}
onToggleSelectAll={onToggleSelectAll} onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack} onDownloadTrack={onDownloadTrack}
onDownloadLyrics={onDownloadLyrics}
onPageChange={onPageChange} onPageChange={onPageChange}
onAlbumClick={onAlbumClick} onAlbumClick={onAlbumClick}
onArtistClick={onArtistClick} onArtistClick={onArtistClick}
+36 -1
View File
@@ -1,6 +1,6 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen, CheckCircle, XCircle } from "lucide-react"; import { Download, FolderOpen, CheckCircle, XCircle, FileText, SkipForward } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import type { TrackMetadata } from "@/types/api"; import type { TrackMetadata } from "@/types/api";
@@ -10,7 +10,12 @@ interface TrackInfoProps {
downloadingTrack: string | null; downloadingTrack: string | null;
isDownloaded: boolean; isDownloaded: boolean;
isFailed: boolean; isFailed: boolean;
downloadingLyricsTrack?: string | null;
downloadedLyrics?: boolean;
failedLyrics?: boolean;
skippedLyrics?: boolean;
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string) => void; onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string) => void;
onOpenFolder: () => void; onOpenFolder: () => void;
} }
@@ -20,7 +25,12 @@ export function TrackInfo({
downloadingTrack, downloadingTrack,
isDownloaded, isDownloaded,
isFailed, isFailed,
downloadingLyricsTrack,
downloadedLyrics,
failedLyrics,
skippedLyrics,
onDownload, onDownload,
onDownloadLyrics,
onOpenFolder, onOpenFolder,
}: TrackInfoProps) { }: TrackInfoProps) {
return ( return (
@@ -72,6 +82,31 @@ export function TrackInfo({
</> </>
)} )}
</Button> </Button>
{track.spotify_id && onDownloadLyrics && (
<Button
onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name)}
variant="secondary"
disabled={downloadingLyricsTrack === track.spotify_id}
>
{downloadingLyricsTrack === track.spotify_id ? (
<Spinner />
) : (
<>
<FileText className="h-4 w-4" />
Download Lyric
{skippedLyrics && (
<SkipForward className="h-4 w-4 text-yellow-500 ml-1" />
)}
{downloadedLyrics && !skippedLyrics && (
<CheckCircle className="h-4 w-4 text-green-500 ml-1" />
)}
{failedLyrics && (
<XCircle className="h-4 w-4 text-red-500 ml-1" />
)}
</>
)}
</Button>
)}
{isDownloaded && ( {isDownloaded && (
<Button onClick={onOpenFolder} variant="outline"> <Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4" /> <FolderOpen className="h-4 w-4" />
+67 -20
View File
@@ -1,7 +1,12 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Download, CheckCircle, XCircle, SkipForward } from "lucide-react"; import { Download, CheckCircle, XCircle, SkipForward, FileText } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { import {
Pagination, Pagination,
PaginationContent, PaginationContent,
@@ -28,9 +33,15 @@ interface TrackListProps {
hideAlbumColumn?: boolean; hideAlbumColumn?: boolean;
folderName?: string; folderName?: string;
isArtistDiscography?: boolean; isArtistDiscography?: boolean;
// Lyrics props
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
onToggleTrack: (isrc: string) => void; onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, isArtistDiscography?: boolean) => void; onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, isArtistDiscography?: boolean) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean) => void;
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
onAlbumClick?: (album: { id: string; name: string; external_urls: string }) => void; onAlbumClick?: (album: { id: string; name: string; external_urls: string }) => void;
onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void; onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void;
@@ -53,9 +64,14 @@ export function TrackList({
hideAlbumColumn = false, hideAlbumColumn = false,
folderName, folderName,
isArtistDiscography = false, isArtistDiscography = false,
downloadedLyrics,
failedLyrics,
skippedLyrics,
downloadingLyricsTrack,
onToggleTrack, onToggleTrack,
onToggleSelectAll, onToggleSelectAll,
onDownloadTrack, onDownloadTrack,
onDownloadLyrics,
onPageChange, onPageChange,
onAlbumClick, onAlbumClick,
onArtistClick, onArtistClick,
@@ -260,25 +276,56 @@ export function TrackList({
{formatDuration(track.duration_ms)} {formatDuration(track.duration_ms)}
</td> </td>
<td className="p-4 align-middle text-center"> <td className="p-4 align-middle text-center">
{track.isrc && ( <div className="flex items-center justify-center gap-1">
<Button {track.isrc && (
onClick={() => <Button
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, isArtistDiscography) onClick={() =>
} onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, isArtistDiscography)
size="sm" }
className="gap-1.5" size="sm"
disabled={isDownloading || downloadingTrack === track.isrc} className="gap-1.5"
> disabled={isDownloading || downloadingTrack === track.isrc}
{downloadingTrack === track.isrc ? ( >
<Spinner /> {downloadingTrack === track.isrc ? (
) : ( <Spinner />
<> ) : (
<Download className="h-4 w-4" /> <>
Download <Download className="h-4 w-4" />
</> Download
)} </>
</Button> )}
)} </Button>
)}
{track.spotify_id && onDownloadLyrics && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() =>
onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography)
}
size="sm"
variant="outline"
disabled={downloadingLyricsTrack === track.spotify_id}
>
{downloadingLyricsTrack === track.spotify_id ? (
<Spinner />
) : skippedLyrics?.has(track.spotify_id) ? (
<SkipForward className="h-4 w-4 text-yellow-500" />
) : downloadedLyrics?.has(track.spotify_id) ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : failedLyrics?.has(track.spotify_id) ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<FileText className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Lyric</p>
</TooltipContent>
</Tooltip>
)}
</div>
</td> </td>
</tr> </tr>
))} ))}
-66
View File
@@ -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<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }
-18
View File
@@ -1,18 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }
+99
View File
@@ -0,0 +1,99 @@
import { useState } from "react";
import { downloadLyrics } from "@/lib/api";
import { getSettings } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath } from "@/lib/utils";
import { logger } from "@/lib/logger";
export function useLyrics() {
const [downloadingLyricsTrack, setDownloadingLyricsTrack] = useState<string | null>(null);
const [downloadedLyrics, setDownloadedLyrics] = useState<Set<string>>(new Set());
const [failedLyrics, setFailedLyrics] = useState<Set<string>>(new Set());
const [skippedLyrics, setSkippedLyrics] = useState<Set<string>>(new Set());
const handleDownloadLyrics = async (
spotifyId: string,
trackName: string,
artistName: string,
albumName?: string,
playlistName?: string,
isArtistDiscography?: boolean
) => {
if (!spotifyId) {
toast.error("No Spotify ID found for this track");
return;
}
logger.info(`downloading lyrics: ${trackName} - ${artistName}`);
const settings = getSettings();
setDownloadingLyricsTrack(spotifyId);
try {
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
// Build output path similar to audio download
if (playlistName) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
if (isArtistDiscography) {
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
}
} else {
if (settings.artistSubfolder && artistName) {
outputDir = joinPath(os, outputDir, sanitizePath(artistName, os));
}
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
}
}
}
const response = await downloadLyrics({
spotify_id: spotifyId,
track_name: trackName,
artist_name: artistName,
output_dir: outputDir,
});
if (response.success) {
if (response.already_exists) {
toast.info("Lyrics file already exists");
setSkippedLyrics((prev) => new Set(prev).add(spotifyId));
} else {
toast.success("Lyrics downloaded successfully");
setDownloadedLyrics((prev) => new Set(prev).add(spotifyId));
}
setFailedLyrics((prev) => {
const newSet = new Set(prev);
newSet.delete(spotifyId);
return newSet;
});
} else {
toast.error(response.error || "Failed to download lyrics");
setFailedLyrics((prev) => new Set(prev).add(spotifyId));
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to download lyrics");
setFailedLyrics((prev) => new Set(prev).add(spotifyId));
} finally {
setDownloadingLyricsTrack(null);
}
};
const resetLyricsState = () => {
setDownloadedLyrics(new Set());
setFailedLyrics(new Set());
setSkippedLyrics(new Set());
};
return {
downloadingLyricsTrack,
downloadedLyrics,
failedLyrics,
skippedLyrics,
handleDownloadLyrics,
resetLyricsState,
};
}
+10 -1
View File
@@ -3,8 +3,10 @@ import type {
DownloadRequest, DownloadRequest,
DownloadResponse, DownloadResponse,
HealthResponse, HealthResponse,
LyricsDownloadRequest,
LyricsDownloadResponse,
} from "@/types/api"; } from "@/types/api";
import { GetSpotifyMetadata, DownloadTrack } from "../../wailsjs/go/main/App"; import { GetSpotifyMetadata, DownloadTrack, DownloadLyrics } from "../../wailsjs/go/main/App";
import { main } from "../../wailsjs/go/models"; import { main } from "../../wailsjs/go/models";
export async function fetchSpotifyMetadata( export async function fetchSpotifyMetadata(
@@ -39,3 +41,10 @@ export async function checkHealth(): Promise<HealthResponse> {
time: new Date().toISOString(), time: new Date().toISOString(),
}; };
} }
export async function downloadLyrics(
request: LyricsDownloadRequest
): Promise<LyricsDownloadResponse> {
const req = new main.LyricsDownloadRequest(request);
return await DownloadLyrics(req);
}
+17
View File
@@ -166,3 +166,20 @@ export interface AnalysisResult {
rms_level: number; rms_level: number;
spectrum?: SpectrumData; spectrum?: SpectrumData;
} }
export interface LyricsDownloadRequest {
spotify_id: string;
track_name: string;
artist_name: string;
output_dir?: string;
}
export interface LyricsDownloadResponse {
success: boolean;
message: string;
file?: string;
error?: string;
already_exists?: boolean;
}
+1 -1
View File
@@ -11,7 +11,7 @@
}, },
"info": { "info": {
"productName": "SpotiFLAC", "productName": "SpotiFLAC",
"productVersion": "6.2" "productVersion": "6.3"
}, },
"wailsjsdir": "./frontend", "wailsjsdir": "./frontend",
"assetdir": "./frontend/dist", "assetdir": "./frontend/dist",