v6.3
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 @@
|
|||||||
72728e016fdcbb66d395ba3a681b8945
|
2d92c35b92c8ea713ea561773c5b7b7b
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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,6 +276,7 @@ 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">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
{track.isrc && (
|
{track.isrc && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -279,6 +296,36 @@ export function TrackList({
|
|||||||
)}
|
)}
|
||||||
</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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
@@ -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 }
|
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user