Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08d02be4f2 | |||
| 5b542dcf29 | |||
| 48f9584027 | |||
| 4241a591aa | |||
| f346fbb6ba | |||
| 2172981110 | |||
| ddf1844237 | |||
| 3a5aea4c91 | |||
| 6b4ad16882 | |||
| 4dbd88e689 | |||
| d0665fdcc5 | |||
| 6ee3c2f653 | |||
| 73d8205f6f |
@@ -6,9 +6,7 @@
|
||||
|
||||
Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music — no account required.
|
||||
|
||||
<br><br>
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
@@ -18,11 +16,11 @@ Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music — no
|
||||
|
||||
## Screenshot
|
||||
|
||||

|
||||

|
||||
|
||||
## Lossless Audio Check
|
||||
## Lossless Audio Checker
|
||||
|
||||
A simple utility for verifying the authenticity of FLAC files (lossless/lossy detection).
|
||||
A simple utility for verifying the authenticity of FLAC files.
|
||||
|
||||
#### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v0/FLAC-Checker.zip) - Windows only
|
||||
|
||||
|
||||
@@ -50,7 +50,9 @@ type DownloadRequest struct {
|
||||
TrackNumber bool `json:"track_number,omitempty"`
|
||||
Position int `json:"position,omitempty"` // Position in playlist/album (1-based)
|
||||
UseAlbumTrackNumber bool `json:"use_album_track_number,omitempty"` // Use album track number instead of playlist position
|
||||
SpotifyID string `json:"spotify_id,omitempty"` // Spotify track ID for Amazon Music
|
||||
SpotifyID string `json:"spotify_id,omitempty"` // Spotify track ID
|
||||
ServiceURL string `json:"service_url,omitempty"` // Direct service URL (Tidal/Deezer/Amazon) to skip song.link API call
|
||||
Duration int `json:"duration,omitempty"` // Track duration in seconds for better matching
|
||||
}
|
||||
|
||||
// DownloadResponse represents the response structure for download operations
|
||||
@@ -62,6 +64,27 @@ type DownloadResponse struct {
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
}
|
||||
|
||||
// GetStreamingURLs fetches all streaming URLs from song.link API
|
||||
func (a *App) GetStreamingURLs(spotifyTrackID string) (string, error) {
|
||||
if spotifyTrackID == "" {
|
||||
return "", fmt.Errorf("spotify track ID is required")
|
||||
}
|
||||
|
||||
fmt.Printf("[GetStreamingURLs] Called for track ID: %s\n", spotifyTrackID)
|
||||
client := backend.NewSongLinkClient()
|
||||
urls, err := client.GetAllURLsFromSpotify(spotifyTrackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(urls)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode response: %v", err)
|
||||
}
|
||||
|
||||
return string(jsonData), nil
|
||||
}
|
||||
|
||||
// GetSpotifyMetadata fetches metadata from Spotify
|
||||
func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) {
|
||||
if req.URL == "" {
|
||||
@@ -120,7 +143,18 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
req.FilenameFormat = "title-artist"
|
||||
}
|
||||
|
||||
// Early check: if we have track metadata, check if file already exists
|
||||
// Early check: Check if file with same ISRC already exists
|
||||
if existingFile, exists := backend.CheckISRCExists(req.OutputDir, req.ISRC); exists {
|
||||
fmt.Printf("File with ISRC %s already exists: %s\n", req.ISRC, existingFile)
|
||||
return DownloadResponse{
|
||||
Success: true,
|
||||
Message: "File with same ISRC already exists",
|
||||
File: existingFile,
|
||||
AlreadyExists: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fallback: if we have track metadata, check if file already exists by filename
|
||||
if req.TrackName != "" && req.ArtistName != "" {
|
||||
expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.FilenameFormat, req.TrackNumber, req.Position, req.UseAlbumTrackNumber)
|
||||
expectedPath := filepath.Join(req.OutputDir, expectedFilename)
|
||||
@@ -139,34 +173,73 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
backend.SetDownloading(true)
|
||||
defer backend.SetDownloading(false)
|
||||
|
||||
if req.Service == "amazon" {
|
||||
switch req.Service {
|
||||
case "amazon":
|
||||
downloader := backend.NewAmazonDownloader()
|
||||
if req.ServiceURL != "" {
|
||||
// Use provided URL directly
|
||||
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
|
||||
} else {
|
||||
if req.SpotifyID == "" {
|
||||
return DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Spotify ID is required for Amazon Music",
|
||||
}, fmt.Errorf("Spotify ID is required for Amazon Music")
|
||||
}, fmt.Errorf("spotify ID is required for Amazon Music")
|
||||
}
|
||||
downloader := backend.NewAmazonDownloader()
|
||||
filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
|
||||
} else if req.Service == "tidal" {
|
||||
searchQuery := req.Query
|
||||
if searchQuery == "" {
|
||||
searchQuery = req.ISRC
|
||||
}
|
||||
|
||||
case "tidal":
|
||||
if req.ApiURL == "" || req.ApiURL == "auto" {
|
||||
downloader := backend.NewTidalDownloader("")
|
||||
filename, err = downloader.DownloadWithFallback(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
|
||||
if req.ServiceURL != "" {
|
||||
// Use provided URL directly with fallback to multiple APIs
|
||||
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
|
||||
} else {
|
||||
if req.SpotifyID == "" {
|
||||
return DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Spotify ID is required for Tidal",
|
||||
}, fmt.Errorf("spotify ID is required for Tidal")
|
||||
}
|
||||
// Use ISRC matching for search fallback
|
||||
filename, err = downloader.DownloadWithFallbackAndISRC(req.SpotifyID, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber, req.Duration)
|
||||
}
|
||||
} else {
|
||||
downloader := backend.NewTidalDownloader(req.ApiURL)
|
||||
filename, err = downloader.Download(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
|
||||
if req.ServiceURL != "" {
|
||||
// Use provided URL directly with specific API
|
||||
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
|
||||
} else {
|
||||
if req.SpotifyID == "" {
|
||||
return DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Spotify ID is required for Tidal",
|
||||
}, fmt.Errorf("spotify ID is required for Tidal")
|
||||
}
|
||||
} else if req.Service == "qobuz" {
|
||||
// Use ISRC matching for search fallback
|
||||
filename, err = downloader.DownloadWithISRC(req.SpotifyID, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber, req.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
case "qobuz":
|
||||
downloader := backend.NewQobuzDownloader()
|
||||
filename, err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
|
||||
} else {
|
||||
|
||||
default: // deezer
|
||||
downloader := backend.NewDeezerDownloader()
|
||||
filename, err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
|
||||
if req.ServiceURL != "" {
|
||||
// Use provided URL directly
|
||||
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
|
||||
} else {
|
||||
if req.SpotifyID == "" {
|
||||
return DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Spotify ID is required for Deezer",
|
||||
}, fmt.Errorf("spotify ID is required for Deezer")
|
||||
}
|
||||
filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -215,6 +288,11 @@ func (a *App) SelectFolder(defaultPath string) (string, error) {
|
||||
return backend.SelectFolderDialog(a.ctx, defaultPath)
|
||||
}
|
||||
|
||||
// SelectFile opens a file selection dialog and returns the selected file path
|
||||
func (a *App) SelectFile() (string, error) {
|
||||
return backend.SelectFileDialog(a.ctx)
|
||||
}
|
||||
|
||||
// GetDefaults returns the default configuration
|
||||
func (a *App) GetDefaults() map[string]string {
|
||||
return map[string]string{
|
||||
@@ -232,3 +310,83 @@ func (a *App) Quit() {
|
||||
// You can add cleanup logic here if needed
|
||||
panic("quit") // This will trigger Wails to close the app
|
||||
}
|
||||
|
||||
// AnalyzeTrack analyzes audio quality of a FLAC file
|
||||
func (a *App) AnalyzeTrack(filePath string) (string, error) {
|
||||
if filePath == "" {
|
||||
return "", fmt.Errorf("file path is required")
|
||||
}
|
||||
|
||||
result, err := backend.AnalyzeTrack(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to analyze track: %v", err)
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode response: %v", err)
|
||||
}
|
||||
|
||||
return string(jsonData), nil
|
||||
}
|
||||
|
||||
// AnalyzeMultipleTracks analyzes multiple FLAC files
|
||||
func (a *App) AnalyzeMultipleTracks(filePaths []string) (string, error) {
|
||||
if len(filePaths) == 0 {
|
||||
return "", fmt.Errorf("at least one file path is required")
|
||||
}
|
||||
|
||||
results := make([]*backend.AnalysisResult, 0, len(filePaths))
|
||||
|
||||
for _, filePath := range filePaths {
|
||||
result, err := backend.AnalyzeTrack(filePath)
|
||||
if err != nil {
|
||||
// Skip failed analyses
|
||||
continue
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode response: %v", err)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
+23
-5
@@ -356,7 +356,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
|
||||
return "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
|
||||
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
|
||||
// Create output directory if needed
|
||||
if outputDir != "." {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
@@ -364,11 +364,18 @@ func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filena
|
||||
}
|
||||
}
|
||||
|
||||
// Get Amazon URL from Spotify track ID
|
||||
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
// Check if file with expected name already exists (Amazon doesn't provide ISRC before download)
|
||||
if spotifyTrackName != "" && spotifyArtistName != "" {
|
||||
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
expectedPath := filepath.Join(outputDir, expectedFilename)
|
||||
|
||||
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
|
||||
fmt.Printf("File already exists: %s (%.2f MB)\n", expectedPath, float64(fileInfo.Size())/(1024*1024))
|
||||
return "EXISTS:" + expectedPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Using Amazon URL: %s\n", amazonURL)
|
||||
|
||||
// Download from service
|
||||
filePath, err := a.DownloadFromService(amazonURL, outputDir)
|
||||
@@ -410,5 +417,16 @@ func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filena
|
||||
}
|
||||
|
||||
fmt.Println("Done")
|
||||
fmt.Println("✓ Downloaded successfully from Amazon Music")
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
|
||||
// Get Amazon URL from Spotify track ID
|
||||
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return a.DownloadByURL(amazonURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
|
||||
"github.com/go-flac/go-flac"
|
||||
mewflac "github.com/mewkiz/flac"
|
||||
)
|
||||
|
||||
// AnalysisResult contains the audio analysis data
|
||||
type AnalysisResult struct {
|
||||
FilePath string `json:"file_path"`
|
||||
SampleRate uint32 `json:"sample_rate"`
|
||||
Channels uint8 `json:"channels"`
|
||||
BitsPerSample uint8 `json:"bits_per_sample"`
|
||||
TotalSamples uint64 `json:"total_samples"`
|
||||
Duration float64 `json:"duration"`
|
||||
BitDepth string `json:"bit_depth"`
|
||||
DynamicRange float64 `json:"dynamic_range"`
|
||||
PeakAmplitude float64 `json:"peak_amplitude"`
|
||||
RMSLevel float64 `json:"rms_level"`
|
||||
Spectrum *SpectrumData `json:"spectrum,omitempty"`
|
||||
}
|
||||
|
||||
// AnalyzeTrack performs audio analysis on a FLAC file
|
||||
func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
|
||||
if !fileExists(filepath) {
|
||||
return nil, fmt.Errorf("file does not exist: %s", filepath)
|
||||
}
|
||||
|
||||
// Parse FLAC file
|
||||
f, err := flac.ParseFile(filepath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
result := &AnalysisResult{
|
||||
FilePath: filepath,
|
||||
}
|
||||
|
||||
// Extract basic audio properties from STREAMINFO block
|
||||
if len(f.Meta) > 0 {
|
||||
streamInfo := f.Meta[0]
|
||||
if streamInfo.Type == flac.StreamInfo {
|
||||
// Read STREAMINFO data
|
||||
data := streamInfo.Data
|
||||
if len(data) >= 18 {
|
||||
// Sample rate (bits 10-29 of bytes 10-13)
|
||||
result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
|
||||
|
||||
// Channels (bits 30-32 of byte 12)
|
||||
result.Channels = ((data[12] >> 1) & 0x07) + 1
|
||||
|
||||
// Bits per sample (bits 33-37 of bytes 12-13)
|
||||
result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1
|
||||
|
||||
// Total samples (bits 38-73 of bytes 13-17)
|
||||
result.TotalSamples = uint64(data[13]&0x0F)<<32 |
|
||||
uint64(data[14])<<24 |
|
||||
uint64(data[15])<<16 |
|
||||
uint64(data[16])<<8 |
|
||||
uint64(data[17])
|
||||
|
||||
// Calculate duration
|
||||
if result.SampleRate > 0 {
|
||||
result.Duration = float64(result.TotalSamples) / float64(result.SampleRate)
|
||||
}
|
||||
|
||||
// Read min/max frame size and block size for additional analysis
|
||||
// Min block size (bytes 0-1)
|
||||
// Max block size (bytes 2-3)
|
||||
// These can give us hints about encoding quality
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze spectrum and calculate real audio metrics
|
||||
spectrum, err := AnalyzeSpectrum(filepath)
|
||||
if err != nil {
|
||||
// Log error but continue
|
||||
fmt.Printf("Warning: failed to analyze spectrum: %v\n", err)
|
||||
} else {
|
||||
result.Spectrum = spectrum
|
||||
// Calculate dynamic range, peak, and RMS from decoded samples
|
||||
calculateRealAudioMetrics(result, filepath)
|
||||
}
|
||||
|
||||
// Set bit depth
|
||||
result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// calculateRealAudioMetrics calculates actual dynamic range, peak, and RMS from decoded audio
|
||||
func calculateRealAudioMetrics(result *AnalysisResult, filepath string) {
|
||||
// Decode FLAC to get actual samples
|
||||
samples, err := decodeFLACForMetrics(filepath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate peak amplitude
|
||||
var peak float64
|
||||
var sumSquares float64
|
||||
|
||||
for _, sample := range samples {
|
||||
absVal := sample
|
||||
if absVal < 0 {
|
||||
absVal = -absVal
|
||||
}
|
||||
if absVal > peak {
|
||||
peak = absVal
|
||||
}
|
||||
sumSquares += sample * sample
|
||||
}
|
||||
|
||||
// Convert peak to dB (reference: 1.0 = 0 dBFS)
|
||||
peakDB := 20.0 * math.Log10(peak)
|
||||
result.PeakAmplitude = peakDB
|
||||
|
||||
// Calculate RMS (Root Mean Square)
|
||||
rms := math.Sqrt(sumSquares / float64(len(samples)))
|
||||
rmsDB := 20.0 * math.Log10(rms)
|
||||
result.RMSLevel = rmsDB
|
||||
|
||||
// Dynamic range is the difference between peak and RMS
|
||||
result.DynamicRange = peakDB - rmsDB
|
||||
}
|
||||
|
||||
// decodeFLACForMetrics decodes FLAC file and returns normalized samples for metric calculation
|
||||
func decodeFLACForMetrics(filepath string) ([]float64, error) {
|
||||
stream, err := mewflac.ParseFile(filepath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
// Limit samples to prevent memory issues (10 million samples = ~3.8 minutes at 44.1kHz)
|
||||
maxSamples := 10000000
|
||||
samples := make([]float64, 0, maxSamples)
|
||||
|
||||
// Read all audio frames
|
||||
for {
|
||||
frame, err := stream.ParseNext()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// Get samples from first channel (mono or left channel)
|
||||
var channelSamples []int32
|
||||
if len(frame.Subframes) > 0 {
|
||||
channelSamples = frame.Subframes[0].Samples
|
||||
}
|
||||
|
||||
// Normalize samples to -1.0 to 1.0 range
|
||||
maxVal := float64(int64(1) << (stream.Info.BitsPerSample - 1))
|
||||
for _, sample := range channelSamples {
|
||||
if len(samples) >= maxSamples {
|
||||
return samples, nil
|
||||
}
|
||||
normalized := float64(sample) / maxVal
|
||||
samples = append(samples, normalized)
|
||||
}
|
||||
|
||||
if len(samples) >= maxSamples {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return samples, nil
|
||||
}
|
||||
|
||||
func GetFileSize(filepath string) (int64, error) {
|
||||
info, err := os.Stat(filepath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return info.Size(), nil
|
||||
}
|
||||
+96
-7
@@ -56,10 +56,75 @@ func NewDeezerDownloader() *DeezerDownloader {
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DeezerDownloader) GetTrackByISRC(isrc string) (*DeezerTrack, error) {
|
||||
func (d *DeezerDownloader) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||
// Decode base64 API URL
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuZGVlemVyLmNvbS8yLjAvdHJhY2svaXNyYzo=")
|
||||
url := fmt.Sprintf("%s%s", string(apiBase), isrc)
|
||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), spotifyURL)
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Getting Deezer URL...")
|
||||
|
||||
resp, err := d.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get Deezer URL: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]
|
||||
if !ok || deezerLink.URL == "" {
|
||||
return "", fmt.Errorf("deezer link not found")
|
||||
}
|
||||
|
||||
deezerURL := deezerLink.URL
|
||||
fmt.Printf("Found Deezer URL: %s\n", deezerURL)
|
||||
return deezerURL, nil
|
||||
}
|
||||
|
||||
func (d *DeezerDownloader) GetTrackIDFromURL(deezerURL string) (int64, error) {
|
||||
// Extract track ID from Deezer URL
|
||||
// Format: https://www.deezer.com/track/3412534581
|
||||
parts := strings.Split(deezerURL, "/track/")
|
||||
if len(parts) < 2 {
|
||||
return 0, fmt.Errorf("invalid Deezer URL format")
|
||||
}
|
||||
|
||||
// Get the track ID part and remove any query parameters
|
||||
trackIDStr := strings.Split(parts[1], "?")[0]
|
||||
trackIDStr = strings.TrimSpace(trackIDStr)
|
||||
|
||||
var trackID int64
|
||||
_, err := fmt.Sscanf(trackIDStr, "%d", &trackID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse track ID: %w", err)
|
||||
}
|
||||
|
||||
return trackID, nil
|
||||
}
|
||||
|
||||
func (d *DeezerDownloader) GetTrackByID(trackID int64) (*DeezerTrack, error) {
|
||||
// Decode base64 API URL
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuZGVlemVyLmNvbS8yLjAvdHJhY2sv")
|
||||
url := fmt.Sprintf("%s%d", string(apiBase), trackID)
|
||||
|
||||
resp, err := d.client.Get(url)
|
||||
if err != nil {
|
||||
@@ -77,7 +142,7 @@ func (d *DeezerDownloader) GetTrackByISRC(isrc string) (*DeezerTrack, error) {
|
||||
}
|
||||
|
||||
if track.ID == 0 {
|
||||
return nil, fmt.Errorf("track not found for ISRC: %s", isrc)
|
||||
return nil, fmt.Errorf("track not found")
|
||||
}
|
||||
|
||||
return &track, nil
|
||||
@@ -187,10 +252,17 @@ func buildFilename(title, artist string, trackNumber int, format string, include
|
||||
return filename + ".flac"
|
||||
}
|
||||
|
||||
func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
|
||||
fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
|
||||
func (d *DeezerDownloader) DownloadByURL(deezerURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
|
||||
fmt.Printf("Using Deezer URL: %s\n", deezerURL)
|
||||
|
||||
track, err := d.GetTrackByISRC(isrc)
|
||||
// Extract track ID from URL
|
||||
trackID, err := d.GetTrackIDFromURL(deezerURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Get track info by ID
|
||||
track, err := d.GetTrackByID(trackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -234,6 +306,12 @@ func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string
|
||||
safeArtist := sanitizeFilename(artists)
|
||||
safeTitle := sanitizeFilename(trackTitle)
|
||||
|
||||
// Check if file with same ISRC already exists
|
||||
if existingFile, exists := CheckISRCExists(outputDir, track.ISRC); exists {
|
||||
fmt.Printf("File with ISRC %s already exists: %s\n", track.ISRC, existingFile)
|
||||
return "EXISTS:" + existingFile, nil
|
||||
}
|
||||
|
||||
// Build filename based on format settings
|
||||
filename := buildFilename(safeTitle, safeArtist, track.TrackPos, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
filepath := filepath.Join(outputDir, filename)
|
||||
@@ -287,5 +365,16 @@ func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string
|
||||
}
|
||||
|
||||
fmt.Println("Metadata embedded successfully!")
|
||||
fmt.Println("✓ Downloaded successfully from Deezer")
|
||||
return filepath, nil
|
||||
}
|
||||
|
||||
func (d *DeezerDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
|
||||
// Get Deezer URL from Spotify track ID
|
||||
deezerURL, err := d.GetDeezerURLFromSpotify(spotifyTrackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return d.DownloadByURL(deezerURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
|
||||
}
|
||||
|
||||
@@ -48,3 +48,31 @@ func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error)
|
||||
|
||||
return selectedPath, nil
|
||||
}
|
||||
|
||||
func SelectFileDialog(ctx context.Context) (string, error) {
|
||||
options := wailsRuntime.OpenDialogOptions{
|
||||
Title: "Select FLAC File for Analysis",
|
||||
Filters: []wailsRuntime.FileFilter{
|
||||
{
|
||||
DisplayName: "FLAC Audio Files (*.flac)",
|
||||
Pattern: "*.flac",
|
||||
},
|
||||
{
|
||||
DisplayName: "All Files (*.*)",
|
||||
Pattern: "*.*",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
selectedFile, err := wailsRuntime.OpenFileDialog(ctx, options)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// If user cancelled, selectedFile will be empty
|
||||
if selectedFile == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return selectedFile, 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,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-flac/flacpicture"
|
||||
"github.com/go-flac/flacvorbis"
|
||||
@@ -111,3 +112,73 @@ func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ReadISRCFromFile reads ISRC metadata from a FLAC file
|
||||
func ReadISRCFromFile(filepath string) (string, error) {
|
||||
if !fileExists(filepath) {
|
||||
return "", fmt.Errorf("file does not exist")
|
||||
}
|
||||
|
||||
f, err := flac.ParseFile(filepath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
// Find VorbisComment block
|
||||
for _, block := range f.Meta {
|
||||
if block.Type == flac.VorbisComment {
|
||||
cmt, err := flacvorbis.ParseFromMetaDataBlock(*block)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get ISRC field
|
||||
isrcValues, err := cmt.Get(flacvorbis.FIELD_ISRC)
|
||||
if err == nil && len(isrcValues) > 0 {
|
||||
return isrcValues[0], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil // No ISRC found
|
||||
}
|
||||
|
||||
// CheckISRCExists checks if a file with the given ISRC already exists in the directory
|
||||
func CheckISRCExists(outputDir string, targetISRC string) (string, bool) {
|
||||
if targetISRC == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Read all .flac files in directory
|
||||
entries, err := os.ReadDir(outputDir)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check only .flac files
|
||||
filename := entry.Name()
|
||||
if len(filename) < 5 || filename[len(filename)-5:] != ".flac" {
|
||||
continue
|
||||
}
|
||||
|
||||
filepath := fmt.Sprintf("%s/%s", outputDir, filename)
|
||||
|
||||
// Read ISRC from file
|
||||
isrc, err := ReadISRCFromFile(filepath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Compare ISRC (case-insensitive)
|
||||
if isrc != "" && strings.EqualFold(isrc, targetISRC) {
|
||||
return filepath, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -315,6 +315,12 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
|
||||
safeArtist := sanitizeFilename(artists)
|
||||
safeTitle := sanitizeFilename(trackTitle)
|
||||
|
||||
// Check if file with same ISRC already exists
|
||||
if existingFile, exists := CheckISRCExists(outputDir, track.ISRC); exists {
|
||||
fmt.Printf("File with ISRC %s already exists: %s\n", track.ISRC, existingFile)
|
||||
return "EXISTS:" + existingFile, nil
|
||||
}
|
||||
|
||||
// Build filename based on format settings
|
||||
filename := buildQobuzFilename(safeTitle, safeArtist, track.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
filepath := filepath.Join(outputDir, filename)
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Hiragana to Romaji mapping
|
||||
var hiraganaToRomaji = map[rune]string{
|
||||
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
||||
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
||||
'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so",
|
||||
'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to",
|
||||
'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no",
|
||||
'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho",
|
||||
'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo",
|
||||
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
||||
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
||||
'わ': "wa", 'を': "wo", 'ん': "n",
|
||||
// Dakuten (voiced)
|
||||
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
||||
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
||||
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
||||
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
||||
// Handakuten (semi-voiced)
|
||||
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
||||
// Small characters
|
||||
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
||||
'っ': "", // Double consonant marker
|
||||
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
||||
}
|
||||
|
||||
// Katakana to Romaji mapping
|
||||
var katakanaToRomaji = map[rune]string{
|
||||
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
||||
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
||||
'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so",
|
||||
'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to",
|
||||
'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", 'ノ': "no",
|
||||
'ハ': "ha", 'ヒ': "hi", 'フ': "fu", 'ヘ': "he", 'ホ': "ho",
|
||||
'マ': "ma", 'ミ': "mi", 'ム': "mu", 'メ': "me", 'モ': "mo",
|
||||
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
||||
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
||||
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
|
||||
// Dakuten (voiced)
|
||||
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
||||
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
||||
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
||||
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
||||
// Handakuten (semi-voiced)
|
||||
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
||||
// Small characters
|
||||
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
||||
'ッ': "", // Double consonant marker
|
||||
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
||||
// Extended katakana
|
||||
'ー': "", // Long vowel mark
|
||||
'ヴ': "vu",
|
||||
}
|
||||
|
||||
// Combination mappings for きゃ, しゃ, etc.
|
||||
var combinationHiragana = map[string]string{
|
||||
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
||||
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
||||
"ちゃ": "cha", "ちゅ": "chu", "ちょ": "cho",
|
||||
"にゃ": "nya", "にゅ": "nyu", "にょ": "nyo",
|
||||
"ひゃ": "hya", "ひゅ": "hyu", "ひょ": "hyo",
|
||||
"みゃ": "mya", "みゅ": "myu", "みょ": "myo",
|
||||
"りゃ": "rya", "りゅ": "ryu", "りょ": "ryo",
|
||||
"ぎゃ": "gya", "ぎゅ": "gyu", "ぎょ": "gyo",
|
||||
"じゃ": "ja", "じゅ": "ju", "じょ": "jo",
|
||||
"びゃ": "bya", "びゅ": "byu", "びょ": "byo",
|
||||
"ぴゃ": "pya", "ぴゅ": "pyu", "ぴょ": "pyo",
|
||||
}
|
||||
|
||||
var combinationKatakana = map[string]string{
|
||||
"キャ": "kya", "キュ": "kyu", "キョ": "kyo",
|
||||
"シャ": "sha", "シュ": "shu", "ショ": "sho",
|
||||
"チャ": "cha", "チュ": "chu", "チョ": "cho",
|
||||
"ニャ": "nya", "ニュ": "nyu", "ニョ": "nyo",
|
||||
"ヒャ": "hya", "ヒュ": "hyu", "ヒョ": "hyo",
|
||||
"ミャ": "mya", "ミュ": "myu", "ミョ": "myo",
|
||||
"リャ": "rya", "リュ": "ryu", "リョ": "ryo",
|
||||
"ギャ": "gya", "ギュ": "gyu", "ギョ": "gyo",
|
||||
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
||||
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
||||
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
||||
// Extended combinations
|
||||
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
|
||||
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
||||
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
||||
}
|
||||
|
||||
// ContainsJapanese checks if a string contains Japanese characters
|
||||
func ContainsJapanese(s string) bool {
|
||||
for _, r := range s {
|
||||
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isHiragana(r rune) bool {
|
||||
return r >= 0x3040 && r <= 0x309F
|
||||
}
|
||||
|
||||
func isKatakana(r rune) bool {
|
||||
return r >= 0x30A0 && r <= 0x30FF
|
||||
}
|
||||
|
||||
func isKanji(r rune) bool {
|
||||
return (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
|
||||
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
|
||||
}
|
||||
|
||||
// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji
|
||||
// Note: Kanji cannot be converted without a dictionary, so they are kept as-is
|
||||
func JapaneseToRomaji(text string) string {
|
||||
if !ContainsJapanese(text) {
|
||||
return text
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
runes := []rune(text)
|
||||
i := 0
|
||||
|
||||
for i < len(runes) {
|
||||
// Check for っ/ッ (double consonant)
|
||||
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
|
||||
nextRomaji := ""
|
||||
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
|
||||
nextRomaji = romaji
|
||||
} else if romaji, ok := katakanaToRomaji[runes[i+1]]; ok {
|
||||
nextRomaji = romaji
|
||||
}
|
||||
if len(nextRomaji) > 0 {
|
||||
result.WriteByte(nextRomaji[0]) // Double the first consonant
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for two-character combinations
|
||||
if i < len(runes)-1 {
|
||||
combo := string(runes[i : i+2])
|
||||
if romaji, ok := combinationHiragana[combo]; ok {
|
||||
result.WriteString(romaji)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if romaji, ok := combinationKatakana[combo]; ok {
|
||||
result.WriteString(romaji)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Single character conversion
|
||||
r := runes[i]
|
||||
if romaji, ok := hiraganaToRomaji[r]; ok {
|
||||
result.WriteString(romaji)
|
||||
} else if romaji, ok := katakanaToRomaji[r]; ok {
|
||||
result.WriteString(romaji)
|
||||
} else if isKanji(r) {
|
||||
// Keep kanji as-is (would need dictionary for proper conversion)
|
||||
result.WriteRune(r)
|
||||
} else {
|
||||
// Keep other characters (punctuation, spaces, etc.)
|
||||
result.WriteRune(r)
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// BuildSearchQuery creates a search query from track name and artist
|
||||
// Converts Japanese to romaji if present
|
||||
func BuildSearchQuery(trackName, artistName string) string {
|
||||
// Convert Japanese to romaji
|
||||
trackRomaji := JapaneseToRomaji(trackName)
|
||||
artistRomaji := JapaneseToRomaji(artistName)
|
||||
|
||||
// Clean up the query - remove special characters that might interfere with search
|
||||
trackClean := cleanSearchQuery(trackRomaji)
|
||||
artistClean := cleanSearchQuery(artistRomaji)
|
||||
|
||||
return strings.TrimSpace(artistClean + " " + trackClean)
|
||||
}
|
||||
|
||||
// cleanSearchQuery removes special characters that might interfere with search
|
||||
func cleanSearchQuery(s string) string {
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) || unicode.IsNumber(r) || unicode.IsSpace(r) {
|
||||
result.WriteRune(r)
|
||||
} else if r == '-' || r == '\'' {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
// cleanToASCII removes all non-ASCII characters and keeps only letters, numbers, spaces
|
||||
// This is useful for creating search queries that work better with Tidal's search
|
||||
func cleanToASCII(s string) string {
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
// Keep only ASCII letters, numbers, spaces, and basic punctuation
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
|
||||
result.WriteRune(r)
|
||||
} else if r == ',' || r == '.' {
|
||||
// Convert punctuation to space
|
||||
result.WriteRune(' ')
|
||||
}
|
||||
}
|
||||
// Clean up multiple spaces
|
||||
cleaned := strings.Join(strings.Fields(result.String()), " ")
|
||||
return strings.TrimSpace(cleaned)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SongLinkClient struct {
|
||||
client *http.Client
|
||||
lastAPICallTime time.Time
|
||||
apiCallCount int
|
||||
apiCallResetTime time.Time
|
||||
}
|
||||
|
||||
type SongLinkURLs struct {
|
||||
TidalURL string `json:"tidal_url"`
|
||||
DeezerURL string `json:"deezer_url"`
|
||||
AmazonURL string `json:"amazon_url"`
|
||||
}
|
||||
|
||||
func NewSongLinkClient() *SongLinkClient {
|
||||
return &SongLinkClient{
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
apiCallResetTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLinkURLs, error) {
|
||||
// Rate limiting: max 10 requests per minute (song.link API limit)
|
||||
now := time.Now()
|
||||
if now.Sub(s.apiCallResetTime) >= time.Minute {
|
||||
s.apiCallCount = 0
|
||||
s.apiCallResetTime = now
|
||||
}
|
||||
|
||||
// If we've hit the limit, wait until the next minute
|
||||
if s.apiCallCount >= 9 {
|
||||
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
|
||||
if waitTime > 0 {
|
||||
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
|
||||
time.Sleep(waitTime)
|
||||
s.apiCallCount = 0
|
||||
s.apiCallResetTime = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// Add delay between requests (7 seconds to be safe)
|
||||
if !s.lastAPICallTime.IsZero() {
|
||||
timeSinceLastCall := now.Sub(s.lastAPICallTime)
|
||||
minDelay := 7 * time.Second
|
||||
if timeSinceLastCall < minDelay {
|
||||
waitTime := minDelay - timeSinceLastCall
|
||||
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
|
||||
time.Sleep(waitTime)
|
||||
}
|
||||
}
|
||||
|
||||
// Decode base64 API URL
|
||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Getting streaming URLs from song.link...")
|
||||
|
||||
// Retry logic for rate limit errors
|
||||
maxRetries := 3
|
||||
var resp *http.Response
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
resp, err = s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get URLs: %w", err)
|
||||
}
|
||||
|
||||
// Update rate limit tracking
|
||||
s.lastAPICallTime = time.Now()
|
||||
s.apiCallCount++
|
||||
|
||||
if resp.StatusCode == 429 {
|
||||
resp.Body.Close()
|
||||
if i < maxRetries-1 {
|
||||
waitTime := 15 * time.Second
|
||||
fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime)
|
||||
time.Sleep(waitTime)
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
urls := &SongLinkURLs{}
|
||||
|
||||
// Extract Tidal URL
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
urls.TidalURL = tidalLink.URL
|
||||
fmt.Printf("✓ Tidal URL found\n")
|
||||
}
|
||||
|
||||
// Extract Deezer URL
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
urls.DeezerURL = deezerLink.URL
|
||||
fmt.Printf("✓ Deezer URL found\n")
|
||||
}
|
||||
|
||||
// Extract Amazon URL
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
amazonURL := amazonLink.URL
|
||||
// Convert album URL to track URL if needed
|
||||
if len(amazonURL) > 0 {
|
||||
urls.AmazonURL = amazonURL
|
||||
fmt.Printf("✓ Amazon URL found\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Check if at least one URL was found
|
||||
if urls.TidalURL == "" && urls.DeezerURL == "" && urls.AmazonURL == "" {
|
||||
return nil, fmt.Errorf("no streaming URLs found")
|
||||
}
|
||||
|
||||
return urls, nil
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"math/cmplx"
|
||||
|
||||
"github.com/mewkiz/flac"
|
||||
)
|
||||
|
||||
// SpectrumData contains frequency spectrum information
|
||||
type SpectrumData struct {
|
||||
TimeSlices []TimeSlice `json:"time_slices"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
FreqBins int `json:"freq_bins"`
|
||||
Duration float64 `json:"duration"`
|
||||
MaxFreq float64 `json:"max_freq"`
|
||||
}
|
||||
|
||||
// TimeSlice represents spectrum data at a point in time
|
||||
type TimeSlice struct {
|
||||
Time float64 `json:"time"`
|
||||
Magnitudes []float64 `json:"magnitudes"`
|
||||
}
|
||||
|
||||
// AnalyzeSpectrum decodes FLAC file and performs FFT analysis
|
||||
func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
|
||||
// Open FLAC file
|
||||
stream, err := flac.ParseFile(filepath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse FLAC: %w", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
info := stream.Info
|
||||
sampleRate := int(info.SampleRate)
|
||||
channels := int(info.NChannels)
|
||||
|
||||
// Read audio samples
|
||||
samples, err := readSamples(stream, channels)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read samples: %w", err)
|
||||
}
|
||||
|
||||
if len(samples) == 0 {
|
||||
return nil, fmt.Errorf("no audio samples found")
|
||||
}
|
||||
|
||||
// Calculate spectrum
|
||||
return calculateSpectrum(samples, sampleRate), nil
|
||||
}
|
||||
|
||||
// readSamples reads and decodes audio samples from FLAC stream
|
||||
func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
|
||||
var allSamples []float64
|
||||
maxSamples := 10 * 1024 * 1024 // Limit to ~10 million samples to avoid memory issues
|
||||
|
||||
// Decode frames
|
||||
for {
|
||||
frame, err := stream.ParseNext()
|
||||
if err != nil {
|
||||
// End of stream
|
||||
break
|
||||
}
|
||||
|
||||
// Convert samples to float64 and mix channels to mono
|
||||
for i := 0; i < frame.Subframes[0].NSamples; i++ {
|
||||
var sample float64
|
||||
|
||||
// Mix all channels to mono by averaging
|
||||
for ch := 0; ch < channels; ch++ {
|
||||
sample += float64(frame.Subframes[ch].Samples[i])
|
||||
}
|
||||
sample /= float64(channels)
|
||||
|
||||
allSamples = append(allSamples, sample)
|
||||
|
||||
// Limit sample count
|
||||
if len(allSamples) >= maxSamples {
|
||||
return allSamples, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allSamples, nil
|
||||
}
|
||||
|
||||
// calculateSpectrum performs FFT analysis on audio samples
|
||||
func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData {
|
||||
fftSize := 8192
|
||||
numTimeSlices := 300
|
||||
|
||||
duration := float64(len(samples)) / float64(sampleRate)
|
||||
|
||||
samplesPerSlice := len(samples) / numTimeSlices
|
||||
if samplesPerSlice < fftSize {
|
||||
samplesPerSlice = fftSize
|
||||
numTimeSlices = len(samples) / fftSize
|
||||
}
|
||||
|
||||
timeSlices := make([]TimeSlice, 0, numTimeSlices)
|
||||
freqBins := fftSize / 2
|
||||
maxFreq := float64(sampleRate) / 2.0
|
||||
|
||||
for i := 0; i < numTimeSlices; i++ {
|
||||
startIdx := i * samplesPerSlice
|
||||
if startIdx+fftSize > len(samples) {
|
||||
break
|
||||
}
|
||||
|
||||
window := samples[startIdx : startIdx+fftSize]
|
||||
|
||||
windowedSamples := applyHannWindow(window)
|
||||
|
||||
spectrum := fft(windowedSamples)
|
||||
|
||||
magnitudes := make([]float64, freqBins)
|
||||
for j := 0; j < freqBins; j++ {
|
||||
magnitude := cmplx.Abs(spectrum[j])
|
||||
|
||||
if magnitude < 1e-10 {
|
||||
magnitude = 1e-10
|
||||
}
|
||||
magnitudes[j] = 20 * math.Log10(magnitude)
|
||||
}
|
||||
|
||||
timeSlice := TimeSlice{
|
||||
Time: float64(startIdx) / float64(sampleRate),
|
||||
Magnitudes: magnitudes,
|
||||
}
|
||||
timeSlices = append(timeSlices, timeSlice)
|
||||
}
|
||||
|
||||
return &SpectrumData{
|
||||
TimeSlices: timeSlices,
|
||||
SampleRate: sampleRate,
|
||||
FreqBins: freqBins,
|
||||
Duration: duration,
|
||||
MaxFreq: maxFreq,
|
||||
}
|
||||
}
|
||||
|
||||
// applyHannWindow applies Hann window to reduce spectral leakage
|
||||
func applyHannWindow(samples []float64) []float64 {
|
||||
n := len(samples)
|
||||
windowed := make([]float64, n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
window := 0.5 * (1.0 - math.Cos(2.0*math.Pi*float64(i)/float64(n-1)))
|
||||
windowed[i] = samples[i] * window
|
||||
}
|
||||
|
||||
return windowed
|
||||
}
|
||||
|
||||
// fft performs Fast Fourier Transform using Cooley-Tukey algorithm
|
||||
func fft(samples []float64) []complex128 {
|
||||
n := len(samples)
|
||||
|
||||
x := make([]complex128, n)
|
||||
for i := 0; i < n; i++ {
|
||||
x[i] = complex(samples[i], 0)
|
||||
}
|
||||
|
||||
return fftRecursive(x)
|
||||
}
|
||||
|
||||
// fftRecursive performs recursive FFT
|
||||
func fftRecursive(x []complex128) []complex128 {
|
||||
n := len(x)
|
||||
|
||||
if n <= 1 {
|
||||
return x
|
||||
}
|
||||
|
||||
even := make([]complex128, n/2)
|
||||
odd := make([]complex128, n/2)
|
||||
|
||||
for i := 0; i < n/2; i++ {
|
||||
even[i] = x[2*i]
|
||||
odd[i] = x[2*i+1]
|
||||
}
|
||||
|
||||
evenFFT := fftRecursive(even)
|
||||
oddFFT := fftRecursive(odd)
|
||||
|
||||
result := make([]complex128, n)
|
||||
for k := 0; k < n/2; k++ {
|
||||
t := cmplx.Exp(complex(0, -2*math.Pi*float64(k)/float64(n))) * oddFFT[k]
|
||||
result[k] = evenFFT[k] + t
|
||||
result[k+n/2] = evenFFT[k] - t
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -69,6 +69,13 @@ type TrackMetadata struct {
|
||||
SpotifyID string `json:"spotify_id,omitempty"`
|
||||
}
|
||||
|
||||
// ArtistSimple holds basic artist info for clickable artists
|
||||
type ArtistSimple struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ExternalURL string `json:"external_urls"`
|
||||
}
|
||||
|
||||
// AlbumTrackMetadata holds per-track info for album / playlist formatting.
|
||||
type AlbumTrackMetadata struct {
|
||||
Artists string `json:"artists"`
|
||||
@@ -82,6 +89,11 @@ type AlbumTrackMetadata struct {
|
||||
ISRC string `json:"isrc"`
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
SpotifyID string `json:"spotify_id,omitempty"`
|
||||
AlbumID string `json:"album_id,omitempty"`
|
||||
AlbumURL string `json:"album_url,omitempty"`
|
||||
ArtistID string `json:"artist_id,omitempty"`
|
||||
ArtistURL string `json:"artist_url,omitempty"`
|
||||
ArtistsData []ArtistSimple `json:"artists_data,omitempty"`
|
||||
}
|
||||
|
||||
type TrackResponse struct {
|
||||
@@ -95,6 +107,8 @@ type AlbumInfoMetadata struct {
|
||||
Artists string `json:"artists"`
|
||||
Images string `json:"images"`
|
||||
Batch string `json:"batch,omitempty"`
|
||||
ArtistID string `json:"artist_id,omitempty"`
|
||||
ArtistURL string `json:"artist_url,omitempty"`
|
||||
}
|
||||
|
||||
type AlbumResponsePayload struct {
|
||||
@@ -271,11 +285,6 @@ type artistResponse struct {
|
||||
Popularity int `json:"popularity"`
|
||||
}
|
||||
|
||||
type artistAlbumsResponse struct {
|
||||
Items []albumSimplified `json:"items"`
|
||||
Next string `json:"next"`
|
||||
}
|
||||
|
||||
type playlistRaw struct {
|
||||
Data playlistResponse
|
||||
BatchEnabled bool
|
||||
@@ -321,7 +330,7 @@ func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c.processSpotifyData(ctx, raw, parsed.Type)
|
||||
return c.processSpotifyData(ctx, raw)
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed spotifyURI, token string, batch bool, delay time.Duration) (interface{}, error) {
|
||||
@@ -341,7 +350,7 @@ func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed sp
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) processSpotifyData(ctx context.Context, raw interface{}, dataType string) (interface{}, error) {
|
||||
func (c *SpotifyMetadataClient) processSpotifyData(ctx context.Context, raw interface{}) (interface{}, error) {
|
||||
switch payload := raw.(type) {
|
||||
case *playlistRaw:
|
||||
return c.formatPlaylistData(payload), nil
|
||||
@@ -479,6 +488,19 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *playlistRaw) PlaylistRes
|
||||
if item.Track == nil {
|
||||
continue
|
||||
}
|
||||
var artistID, artistURL string
|
||||
if len(item.Track.Artists) > 0 {
|
||||
artistID = item.Track.Artists[0].ID
|
||||
artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", item.Track.Artists[0].ID)
|
||||
}
|
||||
artistsData := make([]ArtistSimple, 0, len(item.Track.Artists))
|
||||
for _, a := range item.Track.Artists {
|
||||
artistsData = append(artistsData, ArtistSimple{
|
||||
ID: a.ID,
|
||||
Name: a.Name,
|
||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", a.ID),
|
||||
})
|
||||
}
|
||||
tracks = append(tracks, AlbumTrackMetadata{
|
||||
Artists: joinArtists(item.Track.Artists),
|
||||
Name: item.Track.Name,
|
||||
@@ -490,6 +512,11 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *playlistRaw) PlaylistRes
|
||||
ExternalURL: item.Track.ExternalURL.Spotify,
|
||||
ISRC: item.Track.ExternalID.ISRC,
|
||||
SpotifyID: item.Track.ID,
|
||||
AlbumID: item.Track.Album.ID,
|
||||
AlbumURL: item.Track.Album.ExternalURL.Spotify,
|
||||
ArtistID: artistID,
|
||||
ArtistURL: artistURL,
|
||||
ArtistsData: artistsData,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -501,12 +528,19 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *playlistRaw) PlaylistRes
|
||||
|
||||
func (c *SpotifyMetadataClient) formatAlbumData(ctx context.Context, raw *albumRaw) (*AlbumResponsePayload, error) {
|
||||
albumImage := firstImageURL(raw.Data.Images)
|
||||
var artistID, artistURL string
|
||||
if len(raw.Data.Artists) > 0 {
|
||||
artistID = raw.Data.Artists[0].ID
|
||||
artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", raw.Data.Artists[0].ID)
|
||||
}
|
||||
info := AlbumInfoMetadata{
|
||||
TotalTracks: raw.Data.TotalTracks,
|
||||
Name: raw.Data.Name,
|
||||
ReleaseDate: raw.Data.ReleaseDate,
|
||||
Artists: joinArtists(raw.Data.Artists),
|
||||
Images: albumImage,
|
||||
ArtistID: artistID,
|
||||
ArtistURL: artistURL,
|
||||
}
|
||||
if raw.BatchEnabled {
|
||||
info.Batch = strconv.Itoa(maxInt(1, raw.BatchCount))
|
||||
@@ -581,6 +615,19 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
||||
|
||||
for _, tr := range tracks {
|
||||
isrc := c.fetchTrackISRC(ctx, tr.ID, raw.Token, isrcCache)
|
||||
var artistID, artistURL string
|
||||
if len(tr.Artists) > 0 {
|
||||
artistID = tr.Artists[0].ID
|
||||
artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", tr.Artists[0].ID)
|
||||
}
|
||||
artistsData := make([]ArtistSimple, 0, len(tr.Artists))
|
||||
for _, a := range tr.Artists {
|
||||
artistsData = append(artistsData, ArtistSimple{
|
||||
ID: a.ID,
|
||||
Name: a.Name,
|
||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", a.ID),
|
||||
})
|
||||
}
|
||||
allTracks = append(allTracks, AlbumTrackMetadata{
|
||||
Artists: joinArtists(tr.Artists),
|
||||
Name: tr.Name,
|
||||
@@ -593,6 +640,11 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
||||
ExternalURL: tr.ExternalURL.Spotify,
|
||||
ISRC: isrc,
|
||||
SpotifyID: tr.ID,
|
||||
AlbumID: alb.ID,
|
||||
AlbumURL: alb.ExternalURL.Spotify,
|
||||
ArtistID: artistID,
|
||||
ArtistURL: artistURL,
|
||||
ArtistsData: artistsData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+890
-68
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"postinstall": "node scripts/generate-icon.js",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"generate-icon": "node scripts/generate-icon.js"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+224
-17
@@ -5,12 +5,11 @@ import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Search } from "lucide-react";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { getSettings, applyThemeMode } from "@/lib/settings";
|
||||
import { applyTheme } from "@/lib/themes";
|
||||
@@ -26,10 +25,15 @@ import { AlbumInfo } from "@/components/AlbumInfo";
|
||||
import { PlaylistInfo } from "@/components/PlaylistInfo";
|
||||
import { ArtistInfo } from "@/components/ArtistInfo";
|
||||
import { DownloadProgressToast } from "@/components/DownloadProgressToast";
|
||||
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;
|
||||
|
||||
function App() {
|
||||
const [spotifyUrl, setSpotifyUrl] = useState("");
|
||||
@@ -38,12 +42,14 @@ function App() {
|
||||
const [sortBy, setSortBy] = useState<string>("default");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [hasUpdate, setHasUpdate] = useState(false);
|
||||
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
|
||||
|
||||
const ITEMS_PER_PAGE = 50;
|
||||
const CURRENT_VERSION = "6.0";
|
||||
const CURRENT_VERSION = "6.3";
|
||||
|
||||
const download = useDownload();
|
||||
const metadata = useMetadata();
|
||||
const lyrics = useLyrics();
|
||||
|
||||
useEffect(() => {
|
||||
const settings = getSettings();
|
||||
@@ -61,6 +67,7 @@ function App() {
|
||||
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
checkForUpdates();
|
||||
loadHistory();
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener("change", handleChange);
|
||||
@@ -71,6 +78,7 @@ function App() {
|
||||
setSelectedTracks([]);
|
||||
setSearchQuery("");
|
||||
download.resetDownloadedTracks();
|
||||
lyrics.resetLyricsState();
|
||||
setSortBy("default");
|
||||
setCurrentPage(1);
|
||||
}, [metadata.metadata]);
|
||||
@@ -78,12 +86,13 @@ function App() {
|
||||
const checkForUpdates = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://cdn.jsdelivr.net/gh/afkarxyz/SpotiFLAC@refs/heads/main/version.json"
|
||||
"https://api.github.com/repos/afkarxyz/SpotiFLAC/releases/latest"
|
||||
);
|
||||
const data = await response.json();
|
||||
const latestVersion = data.version;
|
||||
// tag_name format: "v6.1" -> extract "6.1"
|
||||
const latestVersion = data.tag_name?.replace(/^v/, "") || "";
|
||||
|
||||
if (latestVersion > CURRENT_VERSION) {
|
||||
if (latestVersion && latestVersion > CURRENT_VERSION) {
|
||||
setHasUpdate(true);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -91,6 +100,55 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadHistory = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(HISTORY_KEY);
|
||||
if (saved) {
|
||||
setFetchHistory(JSON.parse(saved));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load history:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const saveHistory = (history: HistoryItem[]) => {
|
||||
try {
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
||||
} catch (err) {
|
||||
console.error("Failed to save history:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const addToHistory = (item: Omit<HistoryItem, "id" | "timestamp">) => {
|
||||
setFetchHistory((prev) => {
|
||||
const filtered = prev.filter((h) => h.url !== item.url);
|
||||
const newItem: HistoryItem = {
|
||||
...item,
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const updated = [newItem, ...filtered].slice(0, MAX_HISTORY);
|
||||
saveHistory(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const removeFromHistory = (id: string) => {
|
||||
setFetchHistory((prev) => {
|
||||
const updated = prev.filter((h) => h.id !== id);
|
||||
saveHistory(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleHistorySelect = async (item: HistoryItem) => {
|
||||
setSpotifyUrl(item.url);
|
||||
const updatedUrl = await metadata.handleFetchMetadata(item.url);
|
||||
if (updatedUrl) {
|
||||
setSpotifyUrl(updatedUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFetchMetadata = async () => {
|
||||
const updatedUrl = await metadata.handleFetchMetadata(spotifyUrl);
|
||||
if (updatedUrl) {
|
||||
@@ -98,6 +156,55 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
// Add to history when metadata is successfully fetched
|
||||
useEffect(() => {
|
||||
if (!metadata.metadata || !spotifyUrl) return;
|
||||
|
||||
let historyItem: Omit<HistoryItem, "id" | "timestamp"> | null = null;
|
||||
|
||||
if ("track" in metadata.metadata) {
|
||||
const { track } = metadata.metadata;
|
||||
historyItem = {
|
||||
url: spotifyUrl,
|
||||
type: "track",
|
||||
name: track.name,
|
||||
artist: track.artists,
|
||||
image: track.images,
|
||||
};
|
||||
} else if ("album_info" in metadata.metadata) {
|
||||
const { album_info } = metadata.metadata;
|
||||
historyItem = {
|
||||
url: spotifyUrl,
|
||||
type: "album",
|
||||
name: album_info.name,
|
||||
artist: album_info.artists,
|
||||
image: album_info.images,
|
||||
};
|
||||
} else if ("playlist_info" in metadata.metadata) {
|
||||
const { playlist_info } = metadata.metadata;
|
||||
historyItem = {
|
||||
url: spotifyUrl,
|
||||
type: "playlist",
|
||||
name: playlist_info.owner.name,
|
||||
artist: `${playlist_info.tracks.total} tracks • ${playlist_info.owner.display_name}`,
|
||||
image: playlist_info.owner.images || "",
|
||||
};
|
||||
} else if ("artist_info" in metadata.metadata) {
|
||||
const { artist_info } = metadata.metadata;
|
||||
historyItem = {
|
||||
url: spotifyUrl,
|
||||
type: "artist",
|
||||
name: artist_info.name,
|
||||
artist: `${artist_info.total_albums} albums`,
|
||||
image: artist_info.images,
|
||||
};
|
||||
}
|
||||
|
||||
if (historyItem) {
|
||||
addToHistory(historyItem);
|
||||
}
|
||||
}, [metadata.metadata]);
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchQuery(value);
|
||||
setCurrentPage(1);
|
||||
@@ -144,7 +251,13 @@ function App() {
|
||||
isDownloading={download.isDownloading}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
@@ -160,6 +273,8 @@ function App() {
|
||||
sortBy={sortBy}
|
||||
selectedTracks={selectedTracks}
|
||||
downloadedTracks={download.downloadedTracks}
|
||||
failedTracks={download.failedTracks}
|
||||
skippedTracks={download.skippedTracks}
|
||||
downloadingTrack={download.downloadingTrack}
|
||||
isDownloading={download.isDownloading}
|
||||
bulkDownloadType={download.bulkDownloadType}
|
||||
@@ -167,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)
|
||||
@@ -179,6 +301,18 @@ function App() {
|
||||
onStopDownload={download.handleStopDownload}
|
||||
onOpenFolder={handleOpenFolder}
|
||||
onPageChange={setCurrentPage}
|
||||
onArtistClick={async (artist) => {
|
||||
const artistUrl = await metadata.handleArtistClick(artist);
|
||||
if (artistUrl) {
|
||||
setSpotifyUrl(artistUrl);
|
||||
}
|
||||
}}
|
||||
onTrackClick={async (track) => {
|
||||
if (track.external_urls) {
|
||||
setSpotifyUrl(track.external_urls);
|
||||
await metadata.handleFetchMetadata(track.external_urls);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -193,6 +327,8 @@ function App() {
|
||||
sortBy={sortBy}
|
||||
selectedTracks={selectedTracks}
|
||||
downloadedTracks={download.downloadedTracks}
|
||||
failedTracks={download.failedTracks}
|
||||
skippedTracks={download.skippedTracks}
|
||||
downloadingTrack={download.downloadingTrack}
|
||||
isDownloading={download.isDownloading}
|
||||
bulkDownloadType={download.bulkDownloadType}
|
||||
@@ -200,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(
|
||||
@@ -216,6 +359,19 @@ function App() {
|
||||
onStopDownload={download.handleStopDownload}
|
||||
onOpenFolder={handleOpenFolder}
|
||||
onPageChange={setCurrentPage}
|
||||
onAlbumClick={metadata.handleAlbumClick}
|
||||
onArtistClick={async (artist) => {
|
||||
const artistUrl = await metadata.handleArtistClick(artist);
|
||||
if (artistUrl) {
|
||||
setSpotifyUrl(artistUrl);
|
||||
}
|
||||
}}
|
||||
onTrackClick={async (track) => {
|
||||
if (track.external_urls) {
|
||||
setSpotifyUrl(track.external_urls);
|
||||
await metadata.handleFetchMetadata(track.external_urls);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -231,6 +387,8 @@ function App() {
|
||||
sortBy={sortBy}
|
||||
selectedTracks={selectedTracks}
|
||||
downloadedTracks={download.downloadedTracks}
|
||||
failedTracks={download.failedTracks}
|
||||
skippedTracks={download.skippedTracks}
|
||||
downloadingTrack={download.downloadingTrack}
|
||||
isDownloading={download.isDownloading}
|
||||
bulkDownloadType={download.bulkDownloadType}
|
||||
@@ -238,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)
|
||||
@@ -250,7 +415,19 @@ function App() {
|
||||
onStopDownload={download.handleStopDownload}
|
||||
onOpenFolder={handleOpenFolder}
|
||||
onAlbumClick={metadata.handleAlbumClick}
|
||||
onArtistClick={async (artist) => {
|
||||
const artistUrl = await metadata.handleArtistClick(artist);
|
||||
if (artistUrl) {
|
||||
setSpotifyUrl(artistUrl);
|
||||
}
|
||||
}}
|
||||
onPageChange={setCurrentPage}
|
||||
onTrackClick={async (track) => {
|
||||
if (track.external_urls) {
|
||||
setSpotifyUrl(track.external_urls);
|
||||
await metadata.handleFetchMetadata(track.external_urls);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -274,14 +451,27 @@ function App() {
|
||||
open={metadata.showTimeoutDialog}
|
||||
onOpenChange={metadata.setShowTimeoutDialog}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Fetch Settings</DialogTitle>
|
||||
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
||||
<div className="absolute right-4 top-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-70 hover:opacity-100"
|
||||
onClick={() => metadata.setShowTimeoutDialog(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<DialogTitle className="text-sm font-medium">Fetch Artist</DialogTitle>
|
||||
<DialogDescription>
|
||||
Set timeout for fetching metadata. Longer timeout is recommended for artists
|
||||
with large discography.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{metadata.pendingArtistName && (
|
||||
<div className="py-2">
|
||||
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.pendingArtistName}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timeout">Timeout (seconds)</Label>
|
||||
@@ -316,23 +506,36 @@ function App() {
|
||||
|
||||
{/* Album Fetch Dialog */}
|
||||
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Fetch Album</DialogTitle>
|
||||
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
||||
<div className="absolute right-4 top-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-70 hover:opacity-100"
|
||||
onClick={() => metadata.setShowAlbumDialog(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<DialogTitle className="text-sm font-medium">Fetch Album</DialogTitle>
|
||||
<DialogDescription>
|
||||
Do you want to fetch metadata for this album?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{metadata.selectedAlbum && (
|
||||
<div className="py-4">
|
||||
<p className="font-medium">{metadata.selectedAlbum.name}</p>
|
||||
<div className="py-2">
|
||||
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.selectedAlbum.name}</p>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => metadata.setShowAlbumDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={metadata.handleConfirmAlbumFetch}>
|
||||
<Button onClick={async () => {
|
||||
const albumUrl = await metadata.handleConfirmAlbumFetch();
|
||||
if (albumUrl) {
|
||||
setSpotifyUrl(albumUrl);
|
||||
}
|
||||
}}>
|
||||
<Search className="h-4 w-4" />
|
||||
Fetch Album
|
||||
</Button>
|
||||
@@ -345,6 +548,10 @@ function App() {
|
||||
loading={metadata.loading}
|
||||
onUrlChange={setSpotifyUrl}
|
||||
onFetch={handleFetchMetadata}
|
||||
history={fetchHistory}
|
||||
onHistorySelect={handleHistorySelect}
|
||||
onHistoryRemove={removeFromHistory}
|
||||
hasResult={!!metadata.metadata}
|
||||
/>
|
||||
|
||||
{metadata.metadata && renderMetadata()}
|
||||
|
||||
@@ -14,12 +14,16 @@ interface AlbumInfoProps {
|
||||
images: string;
|
||||
release_date: string;
|
||||
total_tracks: number;
|
||||
artist_id?: string;
|
||||
artist_url?: string;
|
||||
};
|
||||
trackList: TrackMetadata[];
|
||||
searchQuery: string;
|
||||
sortBy: string;
|
||||
selectedTracks: string[];
|
||||
downloadedTracks: Set<string>;
|
||||
failedTracks: Set<string>;
|
||||
skippedTracks: Set<string>;
|
||||
downloadingTrack: string | null;
|
||||
isDownloading: boolean;
|
||||
bulkDownloadType: "all" | "selected" | null;
|
||||
@@ -27,16 +31,24 @@ interface AlbumInfoProps {
|
||||
currentDownloadInfo: { name: string; artists: string } | null;
|
||||
currentPage: number;
|
||||
itemsPerPage: number;
|
||||
// Lyrics props
|
||||
downloadedLyrics?: Set<string>;
|
||||
failedLyrics?: Set<string>;
|
||||
skippedLyrics?: Set<string>;
|
||||
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) => 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;
|
||||
onOpenFolder: () => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void;
|
||||
onTrackClick?: (track: TrackMetadata) => void;
|
||||
}
|
||||
|
||||
export function AlbumInfo({
|
||||
@@ -46,6 +58,8 @@ export function AlbumInfo({
|
||||
sortBy,
|
||||
selectedTracks,
|
||||
downloadedTracks,
|
||||
failedTracks,
|
||||
skippedTracks,
|
||||
downloadingTrack,
|
||||
isDownloading,
|
||||
bulkDownloadType,
|
||||
@@ -53,16 +67,23 @@ export function AlbumInfo({
|
||||
currentDownloadInfo,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
downloadedLyrics,
|
||||
failedLyrics,
|
||||
skippedLyrics,
|
||||
downloadingLyricsTrack,
|
||||
onSearchChange,
|
||||
onSortChange,
|
||||
onToggleTrack,
|
||||
onToggleSelectAll,
|
||||
onDownloadTrack,
|
||||
onDownloadLyrics,
|
||||
onDownloadAll,
|
||||
onDownloadSelected,
|
||||
onStopDownload,
|
||||
onOpenFolder,
|
||||
onPageChange,
|
||||
onArtistClick,
|
||||
onTrackClick,
|
||||
}: AlbumInfoProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -81,18 +102,30 @@ export function AlbumInfo({
|
||||
<p className="text-sm font-medium">Album</p>
|
||||
<h2 className="text-4xl font-bold">{albumInfo.name}</h2>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{onArtistClick && albumInfo.artist_id && albumInfo.artist_url ? (
|
||||
<span
|
||||
className="font-medium cursor-pointer hover:underline"
|
||||
onClick={() =>
|
||||
onArtistClick({
|
||||
id: albumInfo.artist_id!,
|
||||
name: albumInfo.artists,
|
||||
external_urls: albumInfo.artist_url!,
|
||||
})
|
||||
}
|
||||
>
|
||||
{albumInfo.artists}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium">{albumInfo.artists}</span>
|
||||
)}
|
||||
<span>•</span>
|
||||
<span>{albumInfo.release_date}</span>
|
||||
<span>•</span>
|
||||
<span>{albumInfo.total_tracks} songs</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={onDownloadAll}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button onClick={onDownloadAll} disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "all" ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
@@ -145,16 +178,25 @@ export function AlbumInfo({
|
||||
sortBy={sortBy}
|
||||
selectedTracks={selectedTracks}
|
||||
downloadedTracks={downloadedTracks}
|
||||
failedTracks={failedTracks}
|
||||
skippedTracks={skippedTracks}
|
||||
downloadingTrack={downloadingTrack}
|
||||
isDownloading={isDownloading}
|
||||
currentPage={currentPage}
|
||||
itemsPerPage={itemsPerPage}
|
||||
showCheckboxes={true}
|
||||
hideAlbumColumn={true}
|
||||
folderName={albumInfo.name}
|
||||
downloadedLyrics={downloadedLyrics}
|
||||
failedLyrics={failedLyrics}
|
||||
skippedLyrics={skippedLyrics}
|
||||
downloadingLyricsTrack={downloadingLyricsTrack}
|
||||
onToggleTrack={onToggleTrack}
|
||||
onToggleSelectAll={onToggleSelectAll}
|
||||
onDownloadTrack={onDownloadTrack}
|
||||
onDownloadLyrics={onDownloadLyrics}
|
||||
onPageChange={onPageChange}
|
||||
onTrackClick={onTrackClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,8 @@ interface ArtistInfoProps {
|
||||
sortBy: string;
|
||||
selectedTracks: string[];
|
||||
downloadedTracks: Set<string>;
|
||||
failedTracks: Set<string>;
|
||||
skippedTracks: Set<string>;
|
||||
downloadingTrack: string | null;
|
||||
isDownloading: boolean;
|
||||
bulkDownloadType: "all" | "selected" | null;
|
||||
@@ -34,17 +36,25 @@ interface ArtistInfoProps {
|
||||
currentDownloadInfo: { name: string; artists: string } | null;
|
||||
currentPage: number;
|
||||
itemsPerPage: number;
|
||||
// Lyrics props
|
||||
downloadedLyrics?: Set<string>;
|
||||
failedLyrics?: Set<string>;
|
||||
skippedLyrics?: Set<string>;
|
||||
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) => 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;
|
||||
onOpenFolder: () => void;
|
||||
onAlbumClick: (album: { id: string; name: string; external_urls: string }) => void;
|
||||
onArtistClick: (artist: { id: string; name: string; external_urls: string }) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onTrackClick?: (track: TrackMetadata) => void;
|
||||
}
|
||||
|
||||
export function ArtistInfo({
|
||||
@@ -55,6 +65,8 @@ export function ArtistInfo({
|
||||
sortBy,
|
||||
selectedTracks,
|
||||
downloadedTracks,
|
||||
failedTracks,
|
||||
skippedTracks,
|
||||
downloadingTrack,
|
||||
isDownloading,
|
||||
bulkDownloadType,
|
||||
@@ -62,17 +74,24 @@ export function ArtistInfo({
|
||||
currentDownloadInfo,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
downloadedLyrics,
|
||||
failedLyrics,
|
||||
skippedLyrics,
|
||||
downloadingLyricsTrack,
|
||||
onSearchChange,
|
||||
onSortChange,
|
||||
onToggleTrack,
|
||||
onToggleSelectAll,
|
||||
onDownloadTrack,
|
||||
onDownloadLyrics,
|
||||
onDownloadAll,
|
||||
onDownloadSelected,
|
||||
onStopDownload,
|
||||
onOpenFolder,
|
||||
onAlbumClick,
|
||||
onArtistClick,
|
||||
onPageChange,
|
||||
onTrackClick,
|
||||
}: ArtistInfoProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -89,8 +108,12 @@ export function ArtistInfo({
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="text-sm font-medium">Artist</p>
|
||||
<h2 className="text-4xl font-bold">{artistInfo.name}</h2>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="flex items-center gap-2 text-sm flex-wrap">
|
||||
<span>{artistInfo.followers.toLocaleString()} followers</span>
|
||||
<span>•</span>
|
||||
<span>{albumList.length} albums</span>
|
||||
<span>•</span>
|
||||
<span>{trackList.length} tracks</span>
|
||||
{artistInfo.genres.length > 0 && (
|
||||
<>
|
||||
<span>•</span>
|
||||
@@ -140,14 +163,10 @@ export function ArtistInfo({
|
||||
|
||||
{trackList.length > 0 && (
|
||||
<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>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={onDownloadAll}
|
||||
size="sm"
|
||||
disabled={isDownloading}
|
||||
>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button onClick={onDownloadAll} size="sm" disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "all" ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
@@ -197,16 +216,28 @@ export function ArtistInfo({
|
||||
sortBy={sortBy}
|
||||
selectedTracks={selectedTracks}
|
||||
downloadedTracks={downloadedTracks}
|
||||
failedTracks={failedTracks}
|
||||
skippedTracks={skippedTracks}
|
||||
downloadingTrack={downloadingTrack}
|
||||
isDownloading={isDownloading}
|
||||
currentPage={currentPage}
|
||||
itemsPerPage={itemsPerPage}
|
||||
showCheckboxes={true}
|
||||
hideAlbumColumn={false}
|
||||
folderName={artistInfo.name}
|
||||
isArtistDiscography={true}
|
||||
downloadedLyrics={downloadedLyrics}
|
||||
failedLyrics={failedLyrics}
|
||||
skippedLyrics={skippedLyrics}
|
||||
downloadingLyricsTrack={downloadingLyricsTrack}
|
||||
onToggleTrack={onToggleTrack}
|
||||
onToggleSelectAll={onToggleSelectAll}
|
||||
onDownloadTrack={onDownloadTrack}
|
||||
onDownloadLyrics={onDownloadLyrics}
|
||||
onPageChange={onPageChange}
|
||||
onAlbumClick={onAlbumClick}
|
||||
onArtistClick={onArtistClick}
|
||||
onTrackClick={onTrackClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Activity,
|
||||
Waves,
|
||||
Radio,
|
||||
TrendingUp,
|
||||
FileAudio,
|
||||
Clock,
|
||||
Gauge,
|
||||
HardDrive
|
||||
} from "lucide-react";
|
||||
import type { AnalysisResult } from "@/types/api";
|
||||
|
||||
interface AudioAnalysisProps {
|
||||
result: AnalysisResult | null;
|
||||
analyzing: boolean;
|
||||
onAnalyze?: () => void;
|
||||
showAnalyzeButton?: boolean;
|
||||
}
|
||||
|
||||
export function AudioAnalysis({
|
||||
result,
|
||||
analyzing,
|
||||
onAnalyze,
|
||||
showAnalyzeButton = true
|
||||
}: AudioAnalysisProps) {
|
||||
if (analyzing) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex items-center justify-center py-8 gap-3">
|
||||
<Spinner />
|
||||
<span className="text-muted-foreground">Analyzing audio quality...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!result && showAnalyzeButton) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-4">
|
||||
<Activity className="h-12 w-12 text-muted-foreground/50" />
|
||||
<div className="text-center space-y-2">
|
||||
<p className="font-medium">Audio Quality Analysis</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Verify the true lossless quality of downloaded files
|
||||
</p>
|
||||
</div>
|
||||
{onAnalyze && (
|
||||
<Button onClick={onAnalyze}>
|
||||
<Activity className="h-4 w-4" />
|
||||
Analyze Audio
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toFixed(2);
|
||||
};
|
||||
|
||||
// Calculate Nyquist frequency (half of sample rate)
|
||||
const nyquistFreq = result.sample_rate / 2;
|
||||
|
||||
// Calculate approximate data size (uncompressed PCM)
|
||||
// Formula: sample_rate * channels * (bits_per_sample / 8) * duration
|
||||
const dataSizeBytes = result.sample_rate * result.channels * (result.bits_per_sample / 8) * result.duration;
|
||||
const dataSizeMB = dataSizeBytes / (1024 * 1024);
|
||||
|
||||
const formatDataSize = (mb: number) => {
|
||||
if (mb >= 1024) {
|
||||
return `${(mb / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
return `${mb.toFixed(2)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
Audio Quality Analysis
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Technical analysis of audio file properties
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-6 space-y-6">
|
||||
|
||||
{/* Technical Specifications */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Radio className="h-3 w-3" />
|
||||
Sample Rate
|
||||
</div>
|
||||
<p className="font-semibold">{(result.sample_rate / 1000).toFixed(1)} kHz</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<FileAudio className="h-3 w-3" />
|
||||
Bit Depth
|
||||
</div>
|
||||
<p className="font-semibold">{result.bit_depth}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Waves className="h-3 w-3" />
|
||||
Channels
|
||||
</div>
|
||||
<p className="font-semibold">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels} channels`}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
Duration
|
||||
</div>
|
||||
<p className="font-semibold">{formatDuration(result.duration)}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Gauge className="h-3 w-3" />
|
||||
Nyquist Frequency
|
||||
</div>
|
||||
<p className="font-semibold">{(nyquistFreq / 1000).toFixed(1)} kHz</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
Data Size
|
||||
</div>
|
||||
<p className="font-semibold">{formatDataSize(dataSizeMB)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Range Analysis */}
|
||||
<div className="border rounded-lg p-4 space-y-3 bg-muted/30">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Dynamic Range Analysis
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Dynamic Range</p>
|
||||
<p className="font-semibold">{formatNumber(result.dynamic_range)} dB</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Peak Level</p>
|
||||
<p className="font-semibold">{formatNumber(result.peak_amplitude)} dB</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">RMS Level</p>
|
||||
<p className="font-semibold">{formatNumber(result.rms_level)} dB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical Info Footer */}
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Total Samples: {result.total_samples.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Activity, Upload, X } from "lucide-react";
|
||||
import { AudioAnalysis } from "@/components/AudioAnalysis";
|
||||
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
|
||||
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
|
||||
import { SelectFile } from "../../wailsjs/go/main/App";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
|
||||
export function AudioAnalysisDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { analyzing, result, analyzeFile, clearResult } = useAudioAnalysis();
|
||||
const [selectedFilePath, setSelectedFilePath] = useState<string>("");
|
||||
|
||||
const handleSelectFile = async () => {
|
||||
try {
|
||||
const filePath = await SelectFile();
|
||||
if (filePath) {
|
||||
setSelectedFilePath(filePath);
|
||||
await analyzeFile(filePath);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("File Selection Failed", {
|
||||
description: err instanceof Error ? err.message : "Failed to select file",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
setTimeout(() => {
|
||||
clearResult();
|
||||
setSelectedFilePath("");
|
||||
}, 200);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
handleClose();
|
||||
} else {
|
||||
setOpen(true);
|
||||
}
|
||||
}}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Activity className="h-5 w-5" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Audio Quality Analyzer</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-y-auto flex flex-col p-6 [&>button]:hidden custom-scrollbar" aria-describedby={undefined}>
|
||||
<div className="absolute right-4 top-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-70 hover:opacity-100"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DialogTitle className="text-sm font-medium">Audio Quality Analyzer</DialogTitle>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* File Selection */}
|
||||
{!result && !analyzing && (
|
||||
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed rounded-lg">
|
||||
<Activity className="h-16 w-16 text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">Analyze FLAC Audio Quality</h3>
|
||||
<p className="text-sm text-muted-foreground mb-6 text-center max-w-md">
|
||||
Upload a FLAC file to verify true lossless quality, view detailed technical specifications, and see the frequency spectrum
|
||||
</p>
|
||||
<Button onClick={handleSelectFile} size="lg">
|
||||
<Upload className="h-5 w-5" />
|
||||
Select FLAC File
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analysis Results */}
|
||||
{result && (
|
||||
<div className="space-y-4">
|
||||
{/* File Info */}
|
||||
<div className="p-3 bg-muted/30 rounded-lg">
|
||||
<p className="text-xs text-muted-foreground">Analyzing file:</p>
|
||||
<p className="text-sm font-mono truncate">{selectedFilePath}</p>
|
||||
</div>
|
||||
|
||||
{/* Spectrum Visualization */}
|
||||
<SpectrumVisualization
|
||||
sampleRate={result.sample_rate}
|
||||
bitsPerSample={result.bits_per_sample}
|
||||
duration={result.duration}
|
||||
spectrumData={result.spectrum}
|
||||
/>
|
||||
|
||||
{/* Detailed Analysis */}
|
||||
<AudioAnalysis
|
||||
result={result}
|
||||
analyzing={analyzing}
|
||||
showAnalyzeButton={false}
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<Button onClick={handleSelectFile} variant="outline">
|
||||
<Upload className="h-4 w-4" />
|
||||
Analyze Another File
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{analyzing && !result && (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
|
||||
<p className="text-sm text-muted-foreground">Analyzing audio file...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Bug, Trash2, X, Copy, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { logger, type LogEntry } from "@/lib/logger";
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
info: "text-blue-500",
|
||||
success: "text-green-500",
|
||||
warning: "text-yellow-500",
|
||||
error: "text-red-500",
|
||||
debug: "text-gray-500",
|
||||
};
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function DebugLogger() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = logger.subscribe(() => {
|
||||
setLogs(logger.getLogs());
|
||||
});
|
||||
setLogs(logger.getLogs());
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
const handleClear = () => {
|
||||
logger.clear();
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
const logText = logs
|
||||
.map((log) => `[${formatTime(log.timestamp)}] [${log.level}] ${log.message}`)
|
||||
.join("\n");
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(logText);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 500);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy logs:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-50 hover:opacity-100"
|
||||
>
|
||||
<Bug className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[80vh] p-6 [&>button]:hidden">
|
||||
<DialogTitle className="text-sm font-medium">Debug Logs</DialogTitle>
|
||||
<div className="absolute right-4 top-4 flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-70 hover:opacity-100"
|
||||
onClick={handleCopy}
|
||||
disabled={logs.length === 0}
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-70 hover:opacity-100"
|
||||
onClick={handleClear}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-70 hover:opacity-100"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="h-[400px] overflow-y-auto bg-muted/50 rounded-md p-3 font-mono text-xs"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-muted-foreground lowercase">no logs yet...</p>
|
||||
) : (
|
||||
logs.map((log, i) => (
|
||||
<div key={i} className="flex gap-2 py-0.5">
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
[{formatTime(log.timestamp)}]
|
||||
</span>
|
||||
<span className={`shrink-0 w-16 ${levelColors[log.level]}`}>
|
||||
[{log.level}]
|
||||
</span>
|
||||
<span className="break-all">{log.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { X } from "lucide-react";
|
||||
|
||||
export interface HistoryItem {
|
||||
id: string;
|
||||
url: string;
|
||||
type: "track" | "album" | "playlist" | "artist";
|
||||
name: string;
|
||||
artist: string;
|
||||
image: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface FetchHistoryProps {
|
||||
history: HistoryItem[];
|
||||
onSelect: (item: HistoryItem) => void;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
export function FetchHistory({ history, onSelect, onRemove }: FetchHistoryProps) {
|
||||
if (history.length === 0) return null;
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case "track":
|
||||
return "Track";
|
||||
case "album":
|
||||
return "Album";
|
||||
case "playlist":
|
||||
return "Playlist";
|
||||
case "artist":
|
||||
return "Artist";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm text-muted-foreground">Recent Fetches</span>
|
||||
<div className="flex gap-2 overflow-x-auto pb-2 pt-2">
|
||||
{history.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="relative shrink-0 w-[130px] group cursor-pointer rounded-lg border bg-card hover:bg-accent transition-colors overflow-visible"
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute -top-1.5 -right-1.5 z-10 w-5 h-5 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all cursor-pointer shadow-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(item.id);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3 text-red-900" strokeWidth={3} />
|
||||
</button>
|
||||
<div className="p-2">
|
||||
<div className="aspect-square w-full rounded-md overflow-hidden mb-2 bg-muted">
|
||||
{item.image ? (
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-xs">
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs font-medium truncate" title={item.name}>
|
||||
{item.name}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs text-muted-foreground truncate"
|
||||
title={item.artist}
|
||||
>
|
||||
{item.artist}
|
||||
</p>
|
||||
<span className="inline-block text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{getTypeLabel(item.type)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Settings } from "@/components/Settings";
|
||||
import { AudioAnalysisDialog } from "@/components/AudioAnalysisDialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -72,6 +73,7 @@ export function Header({ version, hasUpdate }: HeaderProps) {
|
||||
<p>Report bug or request feature</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<AudioAnalysisDialog />
|
||||
<Settings />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,8 @@ interface PlaylistInfoProps {
|
||||
sortBy: string;
|
||||
selectedTracks: string[];
|
||||
downloadedTracks: Set<string>;
|
||||
failedTracks: Set<string>;
|
||||
skippedTracks: Set<string>;
|
||||
downloadingTrack: string | null;
|
||||
isDownloading: boolean;
|
||||
bulkDownloadType: "all" | "selected" | null;
|
||||
@@ -33,16 +35,25 @@ interface PlaylistInfoProps {
|
||||
currentDownloadInfo: { name: string; artists: string } | null;
|
||||
currentPage: number;
|
||||
itemsPerPage: number;
|
||||
// Lyrics props
|
||||
downloadedLyrics?: Set<string>;
|
||||
failedLyrics?: Set<string>;
|
||||
skippedLyrics?: Set<string>;
|
||||
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) => 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;
|
||||
onOpenFolder: () => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onAlbumClick: (album: { id: string; name: string; external_urls: string }) => void;
|
||||
onArtistClick: (artist: { id: string; name: string; external_urls: string }) => void;
|
||||
onTrackClick: (track: TrackMetadata) => void;
|
||||
}
|
||||
|
||||
export function PlaylistInfo({
|
||||
@@ -52,6 +63,8 @@ export function PlaylistInfo({
|
||||
sortBy,
|
||||
selectedTracks,
|
||||
downloadedTracks,
|
||||
failedTracks,
|
||||
skippedTracks,
|
||||
downloadingTrack,
|
||||
isDownloading,
|
||||
bulkDownloadType,
|
||||
@@ -59,16 +72,24 @@ export function PlaylistInfo({
|
||||
currentDownloadInfo,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
downloadedLyrics,
|
||||
failedLyrics,
|
||||
skippedLyrics,
|
||||
downloadingLyricsTrack,
|
||||
onSearchChange,
|
||||
onSortChange,
|
||||
onToggleTrack,
|
||||
onToggleSelectAll,
|
||||
onDownloadTrack,
|
||||
onDownloadLyrics,
|
||||
onDownloadAll,
|
||||
onDownloadSelected,
|
||||
onStopDownload,
|
||||
onOpenFolder,
|
||||
onPageChange,
|
||||
onAlbumClick,
|
||||
onArtistClick,
|
||||
onTrackClick,
|
||||
}: PlaylistInfoProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -94,11 +115,8 @@ export function PlaylistInfo({
|
||||
<span>{playlistInfo.followers.total.toLocaleString()} followers</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={onDownloadAll}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button onClick={onDownloadAll} disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "all" ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
@@ -151,16 +169,27 @@ export function PlaylistInfo({
|
||||
sortBy={sortBy}
|
||||
selectedTracks={selectedTracks}
|
||||
downloadedTracks={downloadedTracks}
|
||||
failedTracks={failedTracks}
|
||||
skippedTracks={skippedTracks}
|
||||
downloadingTrack={downloadingTrack}
|
||||
isDownloading={isDownloading}
|
||||
currentPage={currentPage}
|
||||
itemsPerPage={itemsPerPage}
|
||||
showCheckboxes={true}
|
||||
hideAlbumColumn={false}
|
||||
folderName={playlistInfo.owner.name}
|
||||
downloadedLyrics={downloadedLyrics}
|
||||
failedLyrics={failedLyrics}
|
||||
skippedLyrics={skippedLyrics}
|
||||
downloadingLyricsTrack={downloadingLyricsTrack}
|
||||
onToggleTrack={onToggleTrack}
|
||||
onToggleSelectAll={onToggleSelectAll}
|
||||
onDownloadTrack={onDownloadTrack}
|
||||
onDownloadLyrics={onDownloadLyrics}
|
||||
onPageChange={onPageChange}
|
||||
onAlbumClick={onAlbumClick}
|
||||
onArtistClick={onArtistClick}
|
||||
onTrackClick={onTrackClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Search, ArrowUpDown } from "lucide-react";
|
||||
import { Search, ArrowUpDown, XCircle } from "lucide-react";
|
||||
|
||||
interface SearchAndSortProps {
|
||||
searchQuery: string;
|
||||
@@ -29,8 +29,17 @@ export function SearchAndSort({
|
||||
placeholder="Search tracks..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10"
|
||||
className="pl-10 pr-8"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
onClick={() => onSearchChange("")}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Select value={sortBy} onValueChange={onSortChange}>
|
||||
<SelectTrigger className="w-[200px] gap-1.5">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputWithContext } from "@/components/ui/input-with-context";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Search, Info, XCircle } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
@@ -9,18 +8,32 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { FetchHistory } from "@/components/FetchHistory";
|
||||
import type { HistoryItem } from "@/components/FetchHistory";
|
||||
|
||||
interface SearchBarProps {
|
||||
url: string;
|
||||
loading: boolean;
|
||||
onUrlChange: (url: string) => void;
|
||||
onFetch: () => void;
|
||||
history: HistoryItem[];
|
||||
onHistorySelect: (item: HistoryItem) => void;
|
||||
onHistoryRemove: (id: string) => void;
|
||||
hasResult: boolean;
|
||||
}
|
||||
|
||||
export function SearchBar({ url, loading, onUrlChange, onFetch }: SearchBarProps) {
|
||||
export function SearchBar({
|
||||
url,
|
||||
loading,
|
||||
onUrlChange,
|
||||
onFetch,
|
||||
history,
|
||||
onHistorySelect,
|
||||
onHistoryRemove,
|
||||
hasResult,
|
||||
}: SearchBarProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="px-6 py-6 space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="spotify-url">Spotify URL</Label>
|
||||
@@ -46,7 +59,7 @@ export function SearchBar({ url, loading, onUrlChange, onFetch }: SearchBarProps
|
||||
{url && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
onClick={() => onUrlChange("")}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
@@ -68,7 +81,13 @@ export function SearchBar({ url, loading, onUrlChange, onFetch }: SearchBarProps
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{!hasResult && (
|
||||
<FetchHistory
|
||||
history={history}
|
||||
onSelect={onHistorySelect}
|
||||
onRemove={onHistoryRemove}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
@@ -20,34 +19,34 @@ import {
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Settings as SettingsIcon, FolderOpen, Save, RotateCcw, Info } from "lucide-react";
|
||||
import { Settings as SettingsIcon, FolderOpen, Save, RotateCcw, Info, X } from "lucide-react";
|
||||
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, type Settings as SettingsType } from "@/lib/settings";
|
||||
import { themes, applyTheme } from "@/lib/themes";
|
||||
import { SelectFolder } from "../../wailsjs/go/main/App";
|
||||
|
||||
// Service Icons
|
||||
const TidalIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="inline-block w-[1.1em] h-[1.1em] mr-2">
|
||||
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
|
||||
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DeezerIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="inline-block w-[1.1em] h-[1.1em] mr-2">
|
||||
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||
<path d="M18.77 5.55c.19-1.07.46-1.75.76-1.75.56 0 1.02 2.34 1.02 5.23 0 2.89-.46 5.23-1.02 5.23-.23 0-.44-.4-.61-1.06-.27 2.43-.83 4.11-1.48 4.11-.5 0-.96-1-1.26-2.6-.2 3.03-.73 5.17-1.33 5.17-.39 0-.73-.85-.99-2.23-.31 2.85-1.03 4.85-1.86 4.85-.83 0-1.55-2-1.86-4.85-.25 1.38-.6 2.23-.99 2.23-.6 0-1.12-2.14-1.33-5.16-.3 1.58-.75 2.6-1.26 2.6-.65 0-1.2-1.68-1.48-4.12-.17.66-.38 1.06-.61 1.06-.56 0-1.02-2.34-1.02-5.23 0-2.89.46-5.23 1.02-5.23.3 0 .57.68.76 1.75C5.53 3.7 6 2.5 6.56 2.5c.66 0 1.22 1.7 1.49 4.17.26-1.8.66-2.94 1.1-2.94.63 0 1.16 2.25 1.36 5.4.36-1.62.9-2.63 1.5-2.63.58 0 1.12 1.01 1.49 2.62.2-3.14.72-5.4 1.35-5.4.44 0 .84 1.15 1.1 2.95.27-2.47.84-4.17 1.49-4.17.55 0 1.03 1.2 1.33 3.05ZM2 8.52c0-1.3.26-2.34.58-2.34.32 0 .57 1.05.57 2.34 0 1.29-.25 2.34-.57 2.34-.32 0-.58-1.05-.58-2.34Zm18.85 0c0-1.3.25-2.34.57-2.34.32 0 .58 1.05.58 2.34 0 1.29-.26 2.34-.58 2.34-.32 0-.57-1.05-.57-2.34Z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const QobuzIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="inline-block w-[1.1em] h-[1.1em] mr-2">
|
||||
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
|
||||
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AmazonIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" style={{ display: "block", width: "1.1em", height: "1.1em" }} className="inline-block mr-2">
|
||||
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
|
||||
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
|
||||
</svg>
|
||||
@@ -58,6 +57,7 @@ export function Settings() {
|
||||
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
|
||||
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
|
||||
const [, setIsLoadingDefaults] = useState(false);
|
||||
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
|
||||
|
||||
// Apply saved settings
|
||||
useEffect(() => {
|
||||
@@ -86,12 +86,20 @@ export function Settings() {
|
||||
applyThemeMode(tempSettings.themeMode);
|
||||
applyTheme(tempSettings.theme);
|
||||
|
||||
// Update isDark state after theme is applied
|
||||
setTimeout(() => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
}, 0);
|
||||
|
||||
// Setup listener for system theme changes during preview
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleChange = () => {
|
||||
if (tempSettings.themeMode === "auto") {
|
||||
applyThemeMode("auto");
|
||||
applyTheme(tempSettings.theme);
|
||||
setTimeout(() => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -199,11 +207,19 @@ export function Settings() {
|
||||
<SettingsIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[700px] flex flex-col" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-6 py-2">
|
||||
<DialogContent className="sm:max-w-[600px] flex flex-col p-6 [&>button]:hidden" aria-describedby={undefined}>
|
||||
<div className="absolute right-4 top-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-70 hover:opacity-100"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<DialogTitle className="text-sm font-medium">Settings</DialogTitle>
|
||||
<div className="grid grid-cols-[1.2fr_0.8fr] gap-6 py-2">
|
||||
{/* Left Column */}
|
||||
<div className="space-y-4">
|
||||
{/* Download Path */}
|
||||
@@ -290,8 +306,12 @@ export function Settings() {
|
||||
<SelectItem key={theme.name} value={theme.name}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: theme.cssVars.light.primary }}
|
||||
className="w-3 h-3 rounded-full border border-border"
|
||||
style={{
|
||||
backgroundColor: isDark
|
||||
? theme.cssVars.dark.primary
|
||||
: theme.cssVars.light.primary
|
||||
}}
|
||||
/>
|
||||
{theme.label}
|
||||
</span>
|
||||
@@ -353,7 +373,15 @@ export function Settings() {
|
||||
checked={tempSettings.artistSubfolder}
|
||||
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, artistSubfolder: checked as boolean }))}
|
||||
/>
|
||||
<Label htmlFor="artist-subfolder" className="cursor-pointer text-sm">Artist Subfolder <span className="font-normal">(Playlist only)</span></Label>
|
||||
<Label htmlFor="artist-subfolder" className="cursor-pointer text-sm">Artist Subfolder</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<p className="text-xs text-center">Playlist only</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
@@ -361,22 +389,30 @@ export function Settings() {
|
||||
checked={tempSettings.albumSubfolder}
|
||||
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, albumSubfolder: checked as boolean }))}
|
||||
/>
|
||||
<Label htmlFor="album-subfolder" className="cursor-pointer text-sm">Album Subfolder <span className="font-normal">(Playlist & Discography)</span></Label>
|
||||
<Label htmlFor="album-subfolder" className="cursor-pointer text-sm">Album Subfolder</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<p className="text-xs text-center">Playlist & Discography</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
<Button variant="outline" onClick={handleReset} size="sm" className="gap-1.5">
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
<Button variant="outline" onClick={handleReset} className="gap-1.5">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Reset to Default
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleCancel} size="sm">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} size="sm" className="gap-1.5">
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
<Button onClick={handleSave} className="gap-1.5">
|
||||
<Save className="h-4 w-4" />
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { SpectrumData } from "@/types/api";
|
||||
|
||||
interface SpectrumVisualizationProps {
|
||||
sampleRate: number;
|
||||
bitsPerSample: number;
|
||||
duration: number;
|
||||
spectrumData?: SpectrumData;
|
||||
}
|
||||
|
||||
export function SpectrumVisualization({
|
||||
sampleRate,
|
||||
bitsPerSample,
|
||||
duration,
|
||||
spectrumData,
|
||||
}: SpectrumVisualizationProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
|
||||
// Calculate margins for labels
|
||||
const marginLeft = 70; // More space for Frequency label
|
||||
const marginRight = 70; // Space for color bar
|
||||
const marginTop = 30; // More space at top
|
||||
const marginBottom = 65; // More space at bottom for Time label
|
||||
|
||||
const plotWidth = width - marginLeft - marginRight;
|
||||
const plotHeight = height - marginTop - marginBottom;
|
||||
|
||||
// Black background
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Calculate Nyquist frequency
|
||||
const nyquistFreq = sampleRate / 2;
|
||||
|
||||
if (spectrumData) {
|
||||
drawRealSpectrum(
|
||||
ctx,
|
||||
marginLeft,
|
||||
marginTop,
|
||||
plotWidth,
|
||||
plotHeight,
|
||||
spectrumData
|
||||
);
|
||||
}
|
||||
|
||||
// Draw axes, labels, and color bar
|
||||
drawAxesAndLabels(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq, duration, sampleRate);
|
||||
drawColorBar(ctx, marginLeft + plotWidth + 15, marginTop, 20, plotHeight);
|
||||
}, [sampleRate, bitsPerSample, duration, spectrumData]);
|
||||
|
||||
const drawRealSpectrum = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
spectrum: SpectrumData
|
||||
) => {
|
||||
const timeSlices = spectrum.time_slices;
|
||||
if (timeSlices.length === 0) return;
|
||||
|
||||
const freqBins = timeSlices[0].magnitudes.length;
|
||||
const nyquistFreq = spectrum.max_freq;
|
||||
|
||||
// Find min/max dB values
|
||||
let minDB = 0;
|
||||
let maxDB = -200;
|
||||
|
||||
timeSlices.forEach((slice) => {
|
||||
slice.magnitudes.forEach((db) => {
|
||||
if (db > maxDB) maxDB = db;
|
||||
if (db < minDB && db > -200) minDB = db;
|
||||
});
|
||||
});
|
||||
|
||||
// Clamp range for better visualization
|
||||
minDB = Math.max(minDB, maxDB - 90); // 90dB dynamic range
|
||||
const dbRange = maxDB - minDB;
|
||||
|
||||
const sliceWidth = Math.ceil(width / timeSlices.length);
|
||||
|
||||
for (let t = 0; t < timeSlices.length; t++) {
|
||||
const slice = timeSlices[t];
|
||||
const xPos = x + (t / timeSlices.length) * width;
|
||||
|
||||
for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) {
|
||||
const db = slice.magnitudes[f];
|
||||
|
||||
// Linear frequency scale
|
||||
const freq = (f / freqBins) * nyquistFreq;
|
||||
const freqRatio = freq / nyquistFreq;
|
||||
|
||||
const yPos = y + height - (freqRatio * height);
|
||||
|
||||
// Calculate bin height
|
||||
const nextFreq = ((f + 1) / freqBins) * nyquistFreq;
|
||||
const nextFreqRatio = nextFreq / nyquistFreq;
|
||||
const nextYPos = y + height - (nextFreqRatio * height);
|
||||
|
||||
const binHeight = Math.max(1, Math.abs(yPos - nextYPos) + 1);
|
||||
|
||||
// Normalize intensity
|
||||
const intensity = Math.max(0, Math.min(1, (db - minDB) / dbRange));
|
||||
|
||||
const color = getSpekColor(intensity);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(xPos, nextYPos, sliceWidth, binHeight);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Vibrant color scheme like Spek - NGEJERENG!
|
||||
const getSpekColor = (intensity: number): string => {
|
||||
if (intensity < 0.08) {
|
||||
// Black to deep blue
|
||||
const t = intensity / 0.08;
|
||||
return `rgb(0, 0, ${Math.floor(t * 80)})`;
|
||||
} else if (intensity < 0.18) {
|
||||
// Deep blue to bright blue
|
||||
const t = (intensity - 0.08) / 0.10;
|
||||
return `rgb(${Math.floor(t * 50)}, ${Math.floor(t * 30)}, ${Math.floor(80 + t * 175)})`;
|
||||
} else if (intensity < 0.28) {
|
||||
// Blue to magenta/purple
|
||||
const t = (intensity - 0.18) / 0.10;
|
||||
return `rgb(${Math.floor(50 + t * 150)}, ${Math.floor(30 - t * 30)}, ${Math.floor(255 - t * 55)})`;
|
||||
} else if (intensity < 0.40) {
|
||||
// Magenta to bright red
|
||||
const t = (intensity - 0.28) / 0.12;
|
||||
return `rgb(${Math.floor(200 + t * 55)}, 0, ${Math.floor(200 - t * 200)})`;
|
||||
} else if (intensity < 0.52) {
|
||||
// Red to orange-red
|
||||
const t = (intensity - 0.40) / 0.12;
|
||||
return `rgb(255, ${Math.floor(t * 100)}, 0)`;
|
||||
} else if (intensity < 0.65) {
|
||||
// Orange-red to bright orange
|
||||
const t = (intensity - 0.52) / 0.13;
|
||||
return `rgb(255, ${Math.floor(100 + t * 80)}, 0)`;
|
||||
} else if (intensity < 0.78) {
|
||||
// Orange to yellow-orange
|
||||
const t = (intensity - 0.65) / 0.13;
|
||||
return `rgb(255, ${Math.floor(180 + t * 55)}, ${Math.floor(t * 30)})`;
|
||||
} else if (intensity < 0.90) {
|
||||
// Yellow-orange to bright yellow
|
||||
const t = (intensity - 0.78) / 0.12;
|
||||
return `rgb(255, ${Math.floor(235 + t * 20)}, ${Math.floor(30 + t * 100)})`;
|
||||
} else {
|
||||
// Yellow to white (hottest)
|
||||
const t = (intensity - 0.90) / 0.10;
|
||||
return `rgb(255, 255, ${Math.floor(130 + t * 125)})`;
|
||||
}
|
||||
};
|
||||
|
||||
const drawAxesAndLabels = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
nyquistFreq: number,
|
||||
duration: number,
|
||||
sampleRate: number
|
||||
) => {
|
||||
// Frequency labels on Y-axis
|
||||
ctx.fillStyle = "#CCCCCC";
|
||||
ctx.font = "12px Arial";
|
||||
ctx.textAlign = "right";
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
// Generate frequency labels based on Nyquist
|
||||
const freqLabels = generateFreqLabels(nyquistFreq);
|
||||
|
||||
freqLabels.forEach(freq => {
|
||||
if (freq <= nyquistFreq) {
|
||||
const freqRatio = freq / nyquistFreq;
|
||||
const yPos = y + height - (freqRatio * height);
|
||||
const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`;
|
||||
ctx.fillText(label, x - 8, yPos);
|
||||
}
|
||||
});
|
||||
|
||||
// "0" at bottom
|
||||
ctx.fillText("0", x - 8, y + height);
|
||||
|
||||
// Time labels on X-axis
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
|
||||
const timeStep = getTimeStep(duration);
|
||||
for (let t = 0; t <= duration; t += timeStep) {
|
||||
const xPos = x + (t / duration) * width;
|
||||
ctx.fillText(`${Math.round(t)}s`, xPos, y + height + 8);
|
||||
}
|
||||
|
||||
// Axis titles
|
||||
ctx.fillStyle = "#FFFFFF";
|
||||
ctx.font = "13px Arial";
|
||||
|
||||
// Y-axis title: "Frequency (Hz)"
|
||||
ctx.save();
|
||||
ctx.translate(12, y + height / 2);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("Frequency (Hz)", 0, 0);
|
||||
ctx.restore();
|
||||
|
||||
// X-axis title: "Time (seconds)"
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("Time (seconds)", x + width / 2, y + height + 35);
|
||||
|
||||
// Sample rate info in top right
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillStyle = "#CCCCCC";
|
||||
ctx.font = "12px Arial";
|
||||
ctx.fillText(`Sample Rate: ${sampleRate} Hz`, x + width - 5, y - 3);
|
||||
};
|
||||
|
||||
const generateFreqLabels = (nyquistFreq: number): number[] => {
|
||||
if (nyquistFreq <= 24000) {
|
||||
return [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000, 22000];
|
||||
} else if (nyquistFreq <= 48000) {
|
||||
return [5000, 10000, 15000, 20000, 25000, 30000, 35000, 40000, 45000];
|
||||
} else if (nyquistFreq <= 96000) {
|
||||
return [10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000];
|
||||
} else {
|
||||
return [20000, 40000, 60000, 80000, 100000, 120000, 140000, 160000, 180000];
|
||||
}
|
||||
};
|
||||
|
||||
const getTimeStep = (duration: number): number => {
|
||||
// Always use 30s intervals like the reference image
|
||||
if (duration <= 60) return 15;
|
||||
if (duration <= 120) return 30;
|
||||
if (duration <= 300) return 30;
|
||||
if (duration <= 600) return 60;
|
||||
return 60;
|
||||
};
|
||||
|
||||
const drawColorBar = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number
|
||||
) => {
|
||||
// Draw gradient color bar
|
||||
for (let i = 0; i < height; i++) {
|
||||
const intensity = 1 - (i / height); // Top is high, bottom is low
|
||||
const color = getSpekColor(intensity);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x, y + i, width, 1);
|
||||
}
|
||||
|
||||
// Border around color bar
|
||||
ctx.strokeStyle = "#666666";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x, y, width, height);
|
||||
|
||||
// Labels
|
||||
ctx.fillStyle = "#FFFFFF";
|
||||
ctx.font = "11px Arial";
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
ctx.fillText("High", x + width + 5, y + 10);
|
||||
ctx.fillText("Low", x + width + 5, y + height - 10);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={1200}
|
||||
height={600}
|
||||
className="w-full h-auto"
|
||||
style={{ imageRendering: "auto" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export function TitleBar() {
|
||||
/>
|
||||
|
||||
{/* Window control buttons */}
|
||||
<div className="absolute top-4 left-4 z-50 flex gap-2">
|
||||
<div className="fixed top-4 left-4 z-50 flex gap-2">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
onMouseEnter={() => setHoveredButton("close")}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Download, FolderOpen } from "lucide-react";
|
||||
import { Download, FolderOpen, CheckCircle, XCircle, FileText, SkipForward } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type { TrackMetadata } from "@/types/api";
|
||||
|
||||
@@ -9,7 +9,13 @@ interface TrackInfoProps {
|
||||
isDownloading: boolean;
|
||||
downloadingTrack: string | null;
|
||||
isDownloaded: boolean;
|
||||
isFailed: boolean;
|
||||
downloadingLyricsTrack?: string | null;
|
||||
downloadedLyrics?: boolean;
|
||||
failedLyrics?: boolean;
|
||||
skippedLyrics?: boolean;
|
||||
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string) => void;
|
||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string) => void;
|
||||
onOpenFolder: () => void;
|
||||
}
|
||||
|
||||
@@ -18,7 +24,13 @@ export function TrackInfo({
|
||||
isDownloading,
|
||||
downloadingTrack,
|
||||
isDownloaded,
|
||||
isFailed,
|
||||
downloadingLyricsTrack,
|
||||
downloadedLyrics,
|
||||
failedLyrics,
|
||||
skippedLyrics,
|
||||
onDownload,
|
||||
onDownloadLyrics,
|
||||
onOpenFolder,
|
||||
}: TrackInfoProps) {
|
||||
return (
|
||||
@@ -34,7 +46,15 @@ export function TrackInfo({
|
||||
)}
|
||||
<div className="flex-1 space-y-4 min-w-0">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
|
||||
{isDownloaded && (
|
||||
<CheckCircle className="h-6 w-6 text-green-500 shrink-0" />
|
||||
)}
|
||||
{isFailed && (
|
||||
<XCircle className="h-6 w-6 text-red-500 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg text-muted-foreground">{track.artists}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
@@ -62,6 +82,31 @@ export function TrackInfo({
|
||||
</>
|
||||
)}
|
||||
</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 && (
|
||||
<Button onClick={onOpenFolder} variant="outline">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Download, CheckCircle } from "lucide-react";
|
||||
import { Download, CheckCircle, XCircle, SkipForward, FileText } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
@@ -18,16 +23,29 @@ interface TrackListProps {
|
||||
sortBy: string;
|
||||
selectedTracks: string[];
|
||||
downloadedTracks: Set<string>;
|
||||
failedTracks: Set<string>;
|
||||
skippedTracks: Set<string>;
|
||||
downloadingTrack: string | null;
|
||||
isDownloading: boolean;
|
||||
currentPage: number;
|
||||
itemsPerPage: number;
|
||||
showCheckboxes?: boolean;
|
||||
hideAlbumColumn?: boolean;
|
||||
folderName?: string;
|
||||
isArtistDiscography?: boolean;
|
||||
// Lyrics props
|
||||
downloadedLyrics?: Set<string>;
|
||||
failedLyrics?: Set<string>;
|
||||
skippedLyrics?: Set<string>;
|
||||
downloadingLyricsTrack?: string | null;
|
||||
onToggleTrack: (isrc: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string) => 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;
|
||||
onAlbumClick?: (album: { id: string; name: string; external_urls: string }) => void;
|
||||
onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void;
|
||||
onTrackClick?: (track: TrackMetadata) => void;
|
||||
}
|
||||
|
||||
export function TrackList({
|
||||
@@ -36,16 +54,28 @@ export function TrackList({
|
||||
sortBy,
|
||||
selectedTracks,
|
||||
downloadedTracks,
|
||||
failedTracks,
|
||||
skippedTracks,
|
||||
downloadingTrack,
|
||||
isDownloading,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
showCheckboxes = false,
|
||||
hideAlbumColumn = false,
|
||||
folderName,
|
||||
isArtistDiscography = false,
|
||||
downloadedLyrics,
|
||||
failedLyrics,
|
||||
skippedLyrics,
|
||||
downloadingLyricsTrack,
|
||||
onToggleTrack,
|
||||
onToggleSelectAll,
|
||||
onDownloadTrack,
|
||||
onDownloadLyrics,
|
||||
onPageChange,
|
||||
onAlbumClick,
|
||||
onArtistClick,
|
||||
onTrackClick,
|
||||
}: TrackListProps) {
|
||||
let filteredTracks = tracks.filter((track) => {
|
||||
if (!searchQuery) return true;
|
||||
@@ -161,30 +191,96 @@ export function TrackList({
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
{onTrackClick ? (
|
||||
<span
|
||||
className="font-medium cursor-pointer hover:underline"
|
||||
onClick={() => onTrackClick(track)}
|
||||
>
|
||||
{track.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium">{track.name}</span>
|
||||
{downloadedTracks.has(track.isrc) && (
|
||||
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
|
||||
)}
|
||||
{skippedTracks.has(track.isrc) ? (
|
||||
<SkipForward className="h-4 w-4 text-yellow-500 shrink-0" />
|
||||
) : downloadedTracks.has(track.isrc) ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
|
||||
) : failedTracks.has(track.isrc) ? (
|
||||
<XCircle className="h-4 w-4 text-red-500 shrink-0" />
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{track.artists_data && track.artists_data.length > 0 ? (
|
||||
track.artists_data.map((artist, i, arr) => (
|
||||
<span key={artist.id}>
|
||||
{onArtistClick ? (
|
||||
<span
|
||||
className="cursor-pointer hover:underline"
|
||||
onClick={() =>
|
||||
onArtistClick({
|
||||
id: artist.id,
|
||||
name: artist.name,
|
||||
external_urls: artist.external_urls,
|
||||
})
|
||||
}
|
||||
>
|
||||
{artist.name}
|
||||
</span>
|
||||
) : (
|
||||
artist.name
|
||||
)}
|
||||
{i < arr.length - 1 && ", "}
|
||||
</span>
|
||||
))
|
||||
) : onArtistClick && track.artist_id && track.artist_url ? (
|
||||
<span
|
||||
className="cursor-pointer hover:underline"
|
||||
onClick={() =>
|
||||
onArtistClick({
|
||||
id: track.artist_id!,
|
||||
name: track.artists,
|
||||
external_urls: track.artist_url!,
|
||||
})
|
||||
}
|
||||
>
|
||||
{track.artists}
|
||||
</span>
|
||||
) : (
|
||||
track.artists
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{!hideAlbumColumn && (
|
||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
||||
{onAlbumClick && track.album_id && track.album_url ? (
|
||||
<span
|
||||
className="cursor-pointer hover:underline"
|
||||
onClick={() =>
|
||||
onAlbumClick({
|
||||
id: track.album_id!,
|
||||
name: track.album_name,
|
||||
external_urls: track.album_url!,
|
||||
})
|
||||
}
|
||||
>
|
||||
{track.album_name}
|
||||
</span>
|
||||
) : (
|
||||
track.album_name
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
|
||||
{formatDuration(track.duration_ms)}
|
||||
</td>
|
||||
<td className="p-4 align-middle text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{track.isrc && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name)
|
||||
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, isArtistDiscography)
|
||||
}
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
@@ -200,6 +296,36 @@ export function TrackList({
|
||||
)}
|
||||
</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>
|
||||
</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,60 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { AnalyzeTrack } from "../../wailsjs/go/main/App";
|
||||
import type { AnalysisResult } from "@/types/api";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
|
||||
export function useAudioAnalysis() {
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const [result, setResult] = useState<AnalysisResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const analyzeFile = useCallback(async (filePath: string) => {
|
||||
if (!filePath) {
|
||||
setError("No file path provided");
|
||||
return null;
|
||||
}
|
||||
|
||||
setAnalyzing(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
logger.info(`Analyzing audio file: ${filePath}`);
|
||||
const startTime = Date.now();
|
||||
|
||||
const response = await AnalyzeTrack(filePath);
|
||||
const analysisResult: AnalysisResult = JSON.parse(response);
|
||||
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
logger.success(`Audio analysis completed in ${elapsed}s`);
|
||||
|
||||
setResult(analysisResult);
|
||||
|
||||
return analysisResult;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
|
||||
logger.error(`Analysis error: ${errorMessage}`);
|
||||
setError(errorMessage);
|
||||
toast.error("Audio Analysis Failed", {
|
||||
description: errorMessage,
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearResult = useCallback(() => {
|
||||
setResult(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
analyzing,
|
||||
result,
|
||||
error,
|
||||
analyzeFile,
|
||||
clearResult,
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { downloadTrack } 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";
|
||||
import type { TrackMetadata } from "@/types/api";
|
||||
|
||||
export function useDownload() {
|
||||
@@ -11,6 +12,8 @@ export function useDownload() {
|
||||
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
|
||||
const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null);
|
||||
const [downloadedTracks, setDownloadedTracks] = useState<Set<string>>(new Set());
|
||||
const [failedTracks, setFailedTracks] = useState<Set<string>>(new Set());
|
||||
const [skippedTracks, setSkippedTracks] = useState<Set<string>>(new Set());
|
||||
const [currentDownloadInfo, setCurrentDownloadInfo] = useState<{
|
||||
name: string;
|
||||
artists: string;
|
||||
@@ -26,7 +29,8 @@ export function useDownload() {
|
||||
playlistName?: string,
|
||||
isArtistDiscography?: boolean,
|
||||
position?: number,
|
||||
spotifyId?: string
|
||||
spotifyId?: string,
|
||||
durationMs?: number
|
||||
) => {
|
||||
let service = settings.downloader;
|
||||
|
||||
@@ -57,8 +61,25 @@ export function useDownload() {
|
||||
}
|
||||
|
||||
if (service === "auto") {
|
||||
// Try Tidal first
|
||||
// Get all streaming URLs once from song.link API
|
||||
let streamingURLs: any = null;
|
||||
if (spotifyId) {
|
||||
try {
|
||||
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
||||
const urlsJson = await GetStreamingURLs(spotifyId);
|
||||
streamingURLs = JSON.parse(urlsJson);
|
||||
} catch (err) {
|
||||
console.error("Failed to get streaming URLs:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert duration from ms to seconds for backend
|
||||
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||
|
||||
// Try Tidal first
|
||||
if (streamingURLs?.tidal_url) {
|
||||
try {
|
||||
logger.debug(`trying tidal for: ${trackName} - ${artistName}`);
|
||||
const tidalResponse = await downloadTrack({
|
||||
isrc,
|
||||
service: "tidal",
|
||||
@@ -71,17 +92,25 @@ export function useDownload() {
|
||||
track_number: settings.trackNumber,
|
||||
position,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
spotify_id: spotifyId,
|
||||
service_url: streamingURLs.tidal_url,
|
||||
duration: durationSeconds,
|
||||
});
|
||||
|
||||
if (tidalResponse.success) {
|
||||
logger.success(`tidal: ${trackName} - ${artistName}`);
|
||||
return tidalResponse;
|
||||
}
|
||||
logger.warning(`tidal failed, trying deezer...`);
|
||||
} catch (tidalErr) {
|
||||
// Tidal failed, continue to Deezer
|
||||
logger.error(`tidal error: ${tidalErr}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Try Deezer second
|
||||
if (streamingURLs?.deezer_url) {
|
||||
try {
|
||||
logger.debug(`trying deezer for: ${trackName} - ${artistName}`);
|
||||
const deezerResponse = await downloadTrack({
|
||||
isrc,
|
||||
service: "deezer",
|
||||
@@ -94,19 +123,58 @@ export function useDownload() {
|
||||
track_number: settings.trackNumber,
|
||||
position,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
spotify_id: spotifyId,
|
||||
service_url: streamingURLs.deezer_url,
|
||||
});
|
||||
|
||||
if (deezerResponse.success) {
|
||||
logger.success(`deezer: ${trackName} - ${artistName}`);
|
||||
return deezerResponse;
|
||||
}
|
||||
logger.warning(`deezer failed, trying amazon...`);
|
||||
} catch (deezerErr) {
|
||||
// Deezer failed, continue to Qobuz
|
||||
logger.error(`deezer error: ${deezerErr}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Try Amazon third
|
||||
if (streamingURLs?.amazon_url) {
|
||||
try {
|
||||
logger.debug(`trying amazon for: ${trackName} - ${artistName}`);
|
||||
const amazonResponse = await downloadTrack({
|
||||
isrc,
|
||||
service: "amazon",
|
||||
query,
|
||||
track_name: trackName,
|
||||
artist_name: artistName,
|
||||
album_name: albumName,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameFormat,
|
||||
track_number: settings.trackNumber,
|
||||
position,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
spotify_id: spotifyId,
|
||||
service_url: streamingURLs.amazon_url,
|
||||
});
|
||||
|
||||
if (amazonResponse.success) {
|
||||
logger.success(`amazon: ${trackName} - ${artistName}`);
|
||||
return amazonResponse;
|
||||
}
|
||||
logger.warning(`amazon failed, trying qobuz...`);
|
||||
} catch (amazonErr) {
|
||||
logger.error(`amazon error: ${amazonErr}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Try Qobuz as last fallback
|
||||
logger.debug(`trying qobuz (fallback) for: ${trackName} - ${artistName}`);
|
||||
service = "qobuz";
|
||||
}
|
||||
|
||||
// Convert duration from ms to seconds for backend (if not already done above)
|
||||
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||
|
||||
return await downloadTrack({
|
||||
isrc,
|
||||
service: service as "deezer" | "tidal" | "qobuz" | "amazon",
|
||||
@@ -120,6 +188,7 @@ export function useDownload() {
|
||||
position,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
spotify_id: spotifyId,
|
||||
duration: durationSecondsForFallback,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -128,42 +197,55 @@ export function useDownload() {
|
||||
trackName?: string,
|
||||
artistName?: string,
|
||||
albumName?: string,
|
||||
spotifyId?: string
|
||||
spotifyId?: string,
|
||||
playlistName?: string,
|
||||
isArtistDiscography?: boolean,
|
||||
durationMs?: number
|
||||
) => {
|
||||
if (!isrc) {
|
||||
toast.error("No ISRC found for this track");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`starting download: ${trackName} - ${artistName}`);
|
||||
const settings = getSettings();
|
||||
setDownloadingTrack(isrc);
|
||||
|
||||
try {
|
||||
// Single track download - no position parameter
|
||||
// Single track download - use playlistName if provided for folder structure
|
||||
const response = await downloadWithAutoFallback(
|
||||
isrc,
|
||||
settings,
|
||||
trackName,
|
||||
artistName,
|
||||
albumName,
|
||||
undefined,
|
||||
false,
|
||||
playlistName,
|
||||
isArtistDiscography,
|
||||
undefined, // Don't pass position for single track
|
||||
spotifyId
|
||||
spotifyId,
|
||||
durationMs
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
if (response.already_exists) {
|
||||
toast.info(response.message);
|
||||
setSkippedTracks((prev) => new Set(prev).add(isrc));
|
||||
} else {
|
||||
toast.success(response.message);
|
||||
}
|
||||
setDownloadedTracks((prev) => new Set(prev).add(isrc));
|
||||
setFailedTracks((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(isrc);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
toast.error(response.error || "Download failed");
|
||||
setFailedTracks((prev) => new Set(prev).add(isrc));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Download failed");
|
||||
setFailedTracks((prev) => new Set(prev).add(isrc));
|
||||
} finally {
|
||||
setDownloadingTrack(null);
|
||||
}
|
||||
@@ -180,6 +262,7 @@ export function useDownload() {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`starting batch download: ${selectedTracks.length} selected tracks`);
|
||||
const settings = getSettings();
|
||||
setIsDownloading(true);
|
||||
setBulkDownloadType("selected");
|
||||
@@ -187,6 +270,7 @@ export function useDownload() {
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
let skippedCount = 0;
|
||||
const total = selectedTracks.length;
|
||||
|
||||
for (let i = 0; i < selectedTracks.length; i++) {
|
||||
@@ -217,17 +301,34 @@ export function useDownload() {
|
||||
playlistName,
|
||||
isArtistDiscography,
|
||||
i + 1, // Sequential position based on selection order
|
||||
track?.spotify_id
|
||||
track?.spotify_id,
|
||||
track?.duration_ms
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
if (response.already_exists) {
|
||||
skippedCount++;
|
||||
logger.info(`skipped: ${track?.name} - ${track?.artists} (already exists)`);
|
||||
setSkippedTracks((prev) => new Set(prev).add(isrc));
|
||||
} else {
|
||||
successCount++;
|
||||
logger.success(`downloaded: ${track?.name} - ${track?.artists}`);
|
||||
}
|
||||
setDownloadedTracks((prev) => new Set(prev).add(isrc));
|
||||
setFailedTracks((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(isrc); // Remove from failed if it was there
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
errorCount++;
|
||||
logger.error(`failed: ${track?.name} - ${track?.artists}`);
|
||||
setFailedTracks((prev) => new Set(prev).add(isrc));
|
||||
}
|
||||
} catch (err) {
|
||||
errorCount++;
|
||||
logger.error(`error: ${track?.name} - ${err}`);
|
||||
setFailedTracks((prev) => new Set(prev).add(isrc));
|
||||
}
|
||||
|
||||
setDownloadProgress(Math.round(((i + 1) / total) * 100));
|
||||
@@ -239,10 +340,23 @@ export function useDownload() {
|
||||
setBulkDownloadType(null);
|
||||
shouldStopDownloadRef.current = false;
|
||||
|
||||
if (errorCount === 0) {
|
||||
// Build summary message
|
||||
logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
|
||||
if (errorCount === 0 && skippedCount === 0) {
|
||||
toast.success(`Downloaded ${successCount} tracks successfully`);
|
||||
} else if (errorCount === 0 && successCount === 0) {
|
||||
// All skipped
|
||||
toast.info(`${skippedCount} tracks already exist`);
|
||||
} else if (errorCount === 0) {
|
||||
// Mix of downloaded and skipped
|
||||
toast.info(`${successCount} downloaded, ${skippedCount} skipped`);
|
||||
} else {
|
||||
toast.warning(`Downloaded ${successCount} tracks, ${errorCount} failed`);
|
||||
// Has errors
|
||||
const parts = [];
|
||||
if (successCount > 0) parts.push(`${successCount} downloaded`);
|
||||
if (skippedCount > 0) parts.push(`${skippedCount} skipped`);
|
||||
parts.push(`${errorCount} failed`);
|
||||
toast.warning(parts.join(", "));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -258,6 +372,7 @@ export function useDownload() {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`starting batch download: ${tracksWithIsrc.length} tracks`);
|
||||
const settings = getSettings();
|
||||
setIsDownloading(true);
|
||||
setBulkDownloadType("all");
|
||||
@@ -265,6 +380,7 @@ export function useDownload() {
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
let skippedCount = 0;
|
||||
const total = tracksWithIsrc.length;
|
||||
|
||||
for (let i = 0; i < tracksWithIsrc.length; i++) {
|
||||
@@ -290,17 +406,34 @@ export function useDownload() {
|
||||
playlistName,
|
||||
isArtistDiscography,
|
||||
i + 1,
|
||||
track.spotify_id
|
||||
track.spotify_id,
|
||||
track.duration_ms
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
if (response.already_exists) {
|
||||
skippedCount++;
|
||||
logger.info(`skipped: ${track.name} - ${track.artists} (already exists)`);
|
||||
setSkippedTracks((prev) => new Set(prev).add(track.isrc));
|
||||
} else {
|
||||
successCount++;
|
||||
logger.success(`downloaded: ${track.name} - ${track.artists}`);
|
||||
}
|
||||
setDownloadedTracks((prev) => new Set(prev).add(track.isrc));
|
||||
setFailedTracks((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(track.isrc); // Remove from failed if it was there
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
errorCount++;
|
||||
logger.error(`failed: ${track.name} - ${track.artists}`);
|
||||
setFailedTracks((prev) => new Set(prev).add(track.isrc));
|
||||
}
|
||||
} catch (err) {
|
||||
errorCount++;
|
||||
logger.error(`error: ${track.name} - ${err}`);
|
||||
setFailedTracks((prev) => new Set(prev).add(track.isrc));
|
||||
}
|
||||
|
||||
setDownloadProgress(Math.round(((i + 1) / total) * 100));
|
||||
@@ -312,20 +445,36 @@ export function useDownload() {
|
||||
setBulkDownloadType(null);
|
||||
shouldStopDownloadRef.current = false;
|
||||
|
||||
if (errorCount === 0) {
|
||||
// Build summary message
|
||||
logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
|
||||
if (errorCount === 0 && skippedCount === 0) {
|
||||
toast.success(`Downloaded ${successCount} tracks successfully`);
|
||||
} else if (errorCount === 0 && successCount === 0) {
|
||||
// All skipped
|
||||
toast.info(`${skippedCount} tracks already exist`);
|
||||
} else if (errorCount === 0) {
|
||||
// Mix of downloaded and skipped
|
||||
toast.info(`${successCount} downloaded, ${skippedCount} skipped`);
|
||||
} else {
|
||||
toast.warning(`Downloaded ${successCount} tracks, ${errorCount} failed`);
|
||||
// Has errors
|
||||
const parts = [];
|
||||
if (successCount > 0) parts.push(`${successCount} downloaded`);
|
||||
if (skippedCount > 0) parts.push(`${skippedCount} skipped`);
|
||||
parts.push(`${errorCount} failed`);
|
||||
toast.warning(parts.join(", "));
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopDownload = () => {
|
||||
logger.info("download stopped by user");
|
||||
shouldStopDownloadRef.current = true;
|
||||
toast.info("Stopping download...");
|
||||
};
|
||||
|
||||
const resetDownloadedTracks = () => {
|
||||
setDownloadedTracks(new Set());
|
||||
setFailedTracks(new Set());
|
||||
setSkippedTracks(new Set());
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -334,6 +483,8 @@ export function useDownload() {
|
||||
downloadingTrack,
|
||||
bulkDownloadType,
|
||||
downloadedTracks,
|
||||
failedTracks,
|
||||
skippedTracks,
|
||||
currentDownloadInfo,
|
||||
handleDownloadTrack,
|
||||
handleDownloadSelected,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { fetchSpotifyMetadata } from "@/lib/api";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { logger } from "@/lib/logger";
|
||||
import type { SpotifyMetadataResponse } from "@/types/api";
|
||||
|
||||
export function useMetadata() {
|
||||
@@ -15,17 +16,52 @@ export function useMetadata() {
|
||||
name: string;
|
||||
external_urls: string;
|
||||
} | null>(null);
|
||||
const [pendingArtistName, setPendingArtistName] = useState<string | null>(null);
|
||||
|
||||
const getUrlType = (url: string): string => {
|
||||
if (url.includes("/track/")) return "track";
|
||||
if (url.includes("/album/")) return "album";
|
||||
if (url.includes("/playlist/")) return "playlist";
|
||||
if (url.includes("/artist/")) return "artist";
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
const fetchMetadataDirectly = async (url: string) => {
|
||||
const urlType = getUrlType(url);
|
||||
logger.info(`fetching ${urlType} metadata...`);
|
||||
logger.debug(`url: ${url}`);
|
||||
|
||||
setLoading(true);
|
||||
setMetadata(null);
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const data = await fetchSpotifyMetadata(url);
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
|
||||
setMetadata(data);
|
||||
|
||||
// Log detailed info based on type
|
||||
if ("track" in data) {
|
||||
logger.success(`fetched track: ${data.track.name} - ${data.track.artists}`);
|
||||
logger.debug(`isrc: ${data.track.isrc}, duration: ${data.track.duration_ms}ms`);
|
||||
} else if ("album_info" in data) {
|
||||
logger.success(`fetched album: ${data.album_info.name}`);
|
||||
logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`);
|
||||
} else if ("playlist_info" in data) {
|
||||
logger.success(`fetched playlist: ${data.track_list.length} tracks`);
|
||||
logger.debug(`by ${data.playlist_info.owner.display_name || data.playlist_info.owner.name}`);
|
||||
} else if ("artist_info" in data) {
|
||||
logger.success(`fetched artist: ${data.artist_info.name}`);
|
||||
logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`);
|
||||
}
|
||||
|
||||
logger.info(`fetch completed in ${elapsed}s`);
|
||||
toast.success("Metadata fetched successfully");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to fetch metadata");
|
||||
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
|
||||
logger.error(`fetch failed: ${errorMsg}`);
|
||||
toast.error(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -33,6 +69,7 @@ export function useMetadata() {
|
||||
|
||||
const handleFetchMetadata = async (url: string) => {
|
||||
if (!url.trim()) {
|
||||
logger.warning("empty url provided");
|
||||
toast.error("Please enter a Spotify URL");
|
||||
return;
|
||||
}
|
||||
@@ -42,10 +79,13 @@ export function useMetadata() {
|
||||
|
||||
if (isArtistUrl && !urlToFetch.includes("/discography")) {
|
||||
urlToFetch = urlToFetch.replace(/\/$/, "") + "/discography/all";
|
||||
logger.debug("converted to discography url");
|
||||
}
|
||||
|
||||
if (isArtistUrl) {
|
||||
logger.info("artist url detected, showing timeout dialog");
|
||||
setPendingUrl(urlToFetch);
|
||||
setPendingArtistName(null); // Clear artist name for URL input
|
||||
setShowTimeoutDialog(true);
|
||||
} else {
|
||||
await fetchMetadataDirectly(urlToFetch);
|
||||
@@ -56,15 +96,30 @@ export function useMetadata() {
|
||||
|
||||
const handleConfirmFetch = async () => {
|
||||
setShowTimeoutDialog(false);
|
||||
logger.info(`fetching artist discography (timeout: ${timeoutValue}s)...`);
|
||||
logger.debug(`url: ${pendingUrl}`);
|
||||
|
||||
setLoading(true);
|
||||
setMetadata(null);
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue);
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
|
||||
setMetadata(data);
|
||||
|
||||
if ("artist_info" in data) {
|
||||
logger.success(`fetched artist: ${data.artist_info.name}`);
|
||||
logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`);
|
||||
}
|
||||
|
||||
logger.info(`fetch completed in ${elapsed}s`);
|
||||
toast.success("Metadata fetched successfully");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to fetch metadata");
|
||||
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
|
||||
logger.error(`fetch failed: ${errorMsg}`);
|
||||
toast.error(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -75,23 +130,54 @@ export function useMetadata() {
|
||||
name: string;
|
||||
external_urls: string;
|
||||
}) => {
|
||||
logger.debug(`album clicked: ${album.name}`);
|
||||
setSelectedAlbum(album);
|
||||
setShowAlbumDialog(true);
|
||||
};
|
||||
|
||||
const handleArtistClick = async (artist: {
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: string;
|
||||
}) => {
|
||||
logger.debug(`artist clicked: ${artist.name}`);
|
||||
const artistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||
setPendingUrl(artistUrl);
|
||||
setPendingArtistName(artist.name);
|
||||
setShowTimeoutDialog(true);
|
||||
return artistUrl;
|
||||
};
|
||||
|
||||
const handleConfirmAlbumFetch = async () => {
|
||||
if (!selectedAlbum) return;
|
||||
|
||||
const albumUrl = selectedAlbum.external_urls;
|
||||
logger.info(`fetching album: ${selectedAlbum.name}...`);
|
||||
logger.debug(`url: ${albumUrl}`);
|
||||
|
||||
setShowAlbumDialog(false);
|
||||
setLoading(true);
|
||||
setMetadata(null);
|
||||
|
||||
try {
|
||||
const data = await fetchSpotifyMetadata(selectedAlbum.external_urls);
|
||||
const startTime = Date.now();
|
||||
const data = await fetchSpotifyMetadata(albumUrl);
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
|
||||
setMetadata(data);
|
||||
|
||||
if ("album_info" in data) {
|
||||
logger.success(`fetched album: ${data.album_info.name}`);
|
||||
logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`);
|
||||
}
|
||||
|
||||
logger.info(`fetch completed in ${elapsed}s`);
|
||||
toast.success("Album metadata fetched successfully");
|
||||
return albumUrl;
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to fetch album metadata");
|
||||
const errorMsg = err instanceof Error ? err.message : "Failed to fetch album metadata";
|
||||
logger.error(`fetch failed: ${errorMsg}`);
|
||||
toast.error(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setSelectedAlbum(null);
|
||||
@@ -108,9 +194,11 @@ export function useMetadata() {
|
||||
showAlbumDialog,
|
||||
setShowAlbumDialog,
|
||||
selectedAlbum,
|
||||
pendingArtistName,
|
||||
handleFetchMetadata,
|
||||
handleConfirmFetch,
|
||||
handleAlbumClick,
|
||||
handleConfirmAlbumFetch,
|
||||
handleArtistClick,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -119,10 +119,14 @@
|
||||
font-family: "Google Sans Flex", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
code, pre, .font-mono {
|
||||
font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-mono: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
/* Sonner Toast Styling */
|
||||
[data-sonner-toast] {
|
||||
@apply rounded-lg shadow-lg border;
|
||||
|
||||
+10
-1
@@ -3,8 +3,10 @@ import type {
|
||||
DownloadRequest,
|
||||
DownloadResponse,
|
||||
HealthResponse,
|
||||
LyricsDownloadRequest,
|
||||
LyricsDownloadResponse,
|
||||
} 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";
|
||||
|
||||
export async function fetchSpotifyMetadata(
|
||||
@@ -39,3 +41,10 @@ export async function checkHealth(): Promise<HealthResponse> {
|
||||
time: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function downloadLyrics(
|
||||
request: LyricsDownloadRequest
|
||||
): Promise<LyricsDownloadResponse> {
|
||||
const req = new main.LyricsDownloadRequest(request);
|
||||
return await DownloadLyrics(req);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
export type LogLevel = "info" | "success" | "warning" | "error" | "debug";
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: Date;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
}
|
||||
|
||||
class Logger {
|
||||
private logs: LogEntry[] = [];
|
||||
private maxLogs = 500;
|
||||
private listeners: Set<() => void> = new Set();
|
||||
|
||||
private addLog(level: LogLevel, message: string) {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date(),
|
||||
level,
|
||||
message: message.toLowerCase(),
|
||||
};
|
||||
this.logs.push(entry);
|
||||
if (this.logs.length > this.maxLogs) {
|
||||
this.logs.shift();
|
||||
}
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
info(message: string) {
|
||||
this.addLog("info", message);
|
||||
}
|
||||
|
||||
success(message: string) {
|
||||
this.addLog("success", message);
|
||||
}
|
||||
|
||||
warning(message: string) {
|
||||
this.addLog("warning", message);
|
||||
}
|
||||
|
||||
error(message: string) {
|
||||
this.addLog("error", message);
|
||||
}
|
||||
|
||||
debug(message: string) {
|
||||
this.addLog("debug", message);
|
||||
}
|
||||
|
||||
getLogs(): LogEntry[] {
|
||||
return [...this.logs];
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.logs = [];
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
subscribe(listener: () => void) {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notifyListeners() {
|
||||
this.listeners.forEach((listener) => listener());
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Logger();
|
||||
@@ -1,31 +1,51 @@
|
||||
import { toast } from 'sonner';
|
||||
import { playSuccessSound, playErrorSound, playWarningSound, playInfoSound } from './audio';
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
playSuccessSound,
|
||||
playErrorSound,
|
||||
playWarningSound,
|
||||
playInfoSound,
|
||||
} from "./audio";
|
||||
import { logger } from "./logger";
|
||||
|
||||
const toastStyle = {
|
||||
className: "font-mono lowercase",
|
||||
};
|
||||
|
||||
// Wrapper functions for toast with sound effects
|
||||
export const toastWithSound = {
|
||||
success: (message: string, data?: any) => {
|
||||
const msg = message.toLowerCase();
|
||||
logger.success(msg);
|
||||
playSuccessSound();
|
||||
return toast.success(message, data);
|
||||
return toast.success(msg, { ...toastStyle, ...data });
|
||||
},
|
||||
|
||||
error: (message: string, data?: any) => {
|
||||
const msg = message.toLowerCase();
|
||||
logger.error(msg);
|
||||
playErrorSound();
|
||||
return toast.error(message, data);
|
||||
return toast.error(msg, { ...toastStyle, ...data });
|
||||
},
|
||||
|
||||
warning: (message: string, data?: any) => {
|
||||
const msg = message.toLowerCase();
|
||||
logger.warning(msg);
|
||||
playWarningSound();
|
||||
return toast.warning(message, data);
|
||||
return toast.warning(msg, { ...toastStyle, ...data });
|
||||
},
|
||||
|
||||
info: (message: string, data?: any) => {
|
||||
const msg = message.toLowerCase();
|
||||
logger.info(msg);
|
||||
playInfoSound();
|
||||
return toast.info(message, data);
|
||||
return toast.info(msg, { ...toastStyle, ...data });
|
||||
},
|
||||
|
||||
// Default toast without specific type
|
||||
message: (message: string, data?: any) => {
|
||||
const msg = message.toLowerCase();
|
||||
logger.info(msg);
|
||||
playInfoSound();
|
||||
return toast(message, data);
|
||||
return toast(msg, { ...toastStyle, ...data });
|
||||
},
|
||||
};
|
||||
|
||||
+12
-8
@@ -1,12 +1,16 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { DebugLogger } from "@/components/DebugLogger";
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<Toaster position="bottom-left" duration={1000} />
|
||||
</StrictMode>,
|
||||
)
|
||||
<div className="fixed bottom-2 left-2 z-50">
|
||||
<DebugLogger />
|
||||
</div>
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
export interface ArtistSimple {
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: string;
|
||||
}
|
||||
|
||||
export interface TrackMetadata {
|
||||
artists: string;
|
||||
name: string;
|
||||
@@ -10,6 +16,11 @@ export interface TrackMetadata {
|
||||
isrc: string;
|
||||
album_type?: string;
|
||||
spotify_id?: string;
|
||||
album_id?: string;
|
||||
album_url?: string;
|
||||
artist_id?: string;
|
||||
artist_url?: string;
|
||||
artists_data?: ArtistSimple[];
|
||||
}
|
||||
|
||||
export interface TrackResponse {
|
||||
@@ -112,6 +123,8 @@ export interface DownloadRequest {
|
||||
position?: number;
|
||||
use_album_track_number?: boolean;
|
||||
spotify_id?: string;
|
||||
service_url?: string;
|
||||
duration?: number; // Track duration in seconds for better matching
|
||||
}
|
||||
|
||||
export interface DownloadResponse {
|
||||
@@ -126,3 +139,47 @@ export interface HealthResponse {
|
||||
status: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export interface TimeSlice {
|
||||
time: number;
|
||||
magnitudes: number[];
|
||||
}
|
||||
|
||||
export interface SpectrumData {
|
||||
time_slices: TimeSlice[];
|
||||
sample_rate: number;
|
||||
freq_bins: number;
|
||||
duration: number;
|
||||
max_freq: number;
|
||||
}
|
||||
|
||||
export interface AnalysisResult {
|
||||
file_path: string;
|
||||
sample_rate: number;
|
||||
channels: number;
|
||||
bits_per_sample: number;
|
||||
total_samples: number;
|
||||
duration: number;
|
||||
bit_depth: string;
|
||||
dynamic_range: number;
|
||||
peak_amplitude: number;
|
||||
rms_level: number;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,35 +6,39 @@ require (
|
||||
github.com/go-flac/flacpicture v0.3.0
|
||||
github.com/go-flac/flacvorbis v0.2.0
|
||||
github.com/go-flac/go-flac v1.0.0
|
||||
github.com/mewkiz/flac v1.0.13
|
||||
github.com/wailsapp/wails/v2 v2.11.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||
github.com/icza/bitio v1.1.0 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||
github.com/labstack/echo/v4 v4.13.4 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect
|
||||
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/samber/lo v1.49.1 // indirect
|
||||
github.com/samber/lo v1.52.0 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
golang.org/x/crypto v0.33.0 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
)
|
||||
|
||||
@@ -10,16 +10,20 @@ github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY
|
||||
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8=
|
||||
github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||
github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
|
||||
github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
|
||||
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
|
||||
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
|
||||
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||
@@ -35,11 +39,16 @@ github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQc
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs=
|
||||
github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k=
|
||||
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU=
|
||||
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI=
|
||||
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8=
|
||||
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -49,8 +58,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
||||
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
@@ -59,29 +68,28 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
|
||||
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+1
-2
@@ -6,6 +6,5 @@
|
||||
"eu-katze.qqdl.site",
|
||||
"katze.qqdl.site",
|
||||
"wolf.qqdl.site",
|
||||
"tidal.kinoplus.online",
|
||||
"jakarta.monochrome.tf"
|
||||
"tidal.kinoplus.online"
|
||||
]
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "5.9"
|
||||
"version": "6.2"
|
||||
}
|
||||
|
||||
+3
-2
@@ -2,7 +2,8 @@
|
||||
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||
"name": "SpotiFLAC",
|
||||
"outputfilename": "SpotiFLAC",
|
||||
"frontend:install": "pnpm install && pnpm run generate-icon",
|
||||
"frontend:install": "pnpm install",
|
||||
"frontend:dev:install": "pnpm install",
|
||||
"frontend:build": "pnpm run build",
|
||||
"frontend:dev:watcher": "pnpm run dev",
|
||||
"frontend:dev:serverUrl": "auto",
|
||||
@@ -11,7 +12,7 @@
|
||||
},
|
||||
"info": {
|
||||
"productName": "SpotiFLAC",
|
||||
"productVersion": "6.0"
|
||||
"productVersion": "6.3"
|
||||
},
|
||||
"wailsjsdir": "./frontend",
|
||||
"assetdir": "./frontend/dist",
|
||||
|
||||
Reference in New Issue
Block a user