Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b85ed89af3 | |||
| 8e78a882a3 | |||
| 237ee777c3 | |||
| b44a9abdd6 | |||
| 6c3fb13b25 | |||
| 9398e496e8 | |||
| a8d6276d00 | |||
| 4d3aac4990 | |||
| 64d7f82e52 | |||
| 910420634c | |||
| 22742f1ddd | |||
| 5c1d6619b5 | |||
| 76669f551e | |||
| ffd4daf031 | |||
| 64b86b65a1 |
@@ -243,10 +243,21 @@ jobs:
|
||||
run: |
|
||||
upx --best --lzma build/bin/SpotiFLAC
|
||||
|
||||
- name: Cache appimagetool
|
||||
id: cache-appimagetool
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: appimagetool
|
||||
key: appimagetool-x86_64-v1
|
||||
|
||||
- name: Download appimagetool
|
||||
if: steps.cache-appimagetool.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
wget -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||
chmod +x appimagetool
|
||||
wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage || \
|
||||
wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
|
||||
|
||||
- name: Make appimagetool executable
|
||||
run: chmod +x appimagetool
|
||||
|
||||
- name: Create AppImage
|
||||
run: |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music — no account required.
|
||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||
|
||||

|
||||

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

|
||||

|
||||
|
||||
## Lossless Audio Checker
|
||||
|
||||
|
||||
@@ -37,23 +37,31 @@ type SpotifyMetadataRequest struct {
|
||||
|
||||
// DownloadRequest represents the request structure for downloading tracks
|
||||
type DownloadRequest struct {
|
||||
ISRC string `json:"isrc"`
|
||||
Service string `json:"service"`
|
||||
Query string `json:"query,omitempty"`
|
||||
TrackName string `json:"track_name,omitempty"`
|
||||
ArtistName string `json:"artist_name,omitempty"`
|
||||
AlbumName string `json:"album_name,omitempty"`
|
||||
ApiURL string `json:"api_url,omitempty"`
|
||||
OutputDir string `json:"output_dir,omitempty"`
|
||||
AudioFormat string `json:"audio_format,omitempty"`
|
||||
FilenameFormat string `json:"filename_format,omitempty"`
|
||||
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
|
||||
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
|
||||
ItemID string `json:"item_id,omitempty"` // Optional queue item ID for multi-service fallback tracking
|
||||
ISRC string `json:"isrc"`
|
||||
Service string `json:"service"`
|
||||
Query string `json:"query,omitempty"`
|
||||
TrackName string `json:"track_name,omitempty"`
|
||||
ArtistName string `json:"artist_name,omitempty"`
|
||||
AlbumName string `json:"album_name,omitempty"`
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"` // Spotify cover URL for embedding
|
||||
ApiURL string `json:"api_url,omitempty"`
|
||||
OutputDir string `json:"output_dir,omitempty"`
|
||||
AudioFormat string `json:"audio_format,omitempty"`
|
||||
FilenameFormat string `json:"filename_format,omitempty"`
|
||||
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
|
||||
EmbedLyrics bool `json:"embed_lyrics,omitempty"` // Whether to embed lyrics into the audio file
|
||||
EmbedMaxQualityCover bool `json:"embed_max_quality_cover,omitempty"` // Whether to embed max quality cover art
|
||||
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
|
||||
ItemID string `json:"item_id,omitempty"` // Optional queue item ID for multi-service fallback tracking
|
||||
SpotifyTrackNumber int `json:"spotify_track_number,omitempty"` // Track number from Spotify album
|
||||
SpotifyDiscNumber int `json:"spotify_disc_number,omitempty"` // Disc number from Spotify album
|
||||
SpotifyTotalTracks int `json:"spotify_total_tracks,omitempty"` // Total tracks in album from Spotify
|
||||
}
|
||||
|
||||
// DownloadResponse represents the response structure for download operations
|
||||
@@ -126,7 +134,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
}
|
||||
|
||||
if req.Service == "" {
|
||||
req.Service = "deezer"
|
||||
req.Service = "tidal"
|
||||
}
|
||||
|
||||
if req.OutputDir == "" {
|
||||
@@ -207,7 +215,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
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)
|
||||
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.ISRC, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover)
|
||||
} else {
|
||||
if req.SpotifyID == "" {
|
||||
return DownloadResponse{
|
||||
@@ -215,7 +223,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
Error: "Spotify ID is required for Amazon Music",
|
||||
}, fmt.Errorf("spotify ID is required for Amazon Music")
|
||||
}
|
||||
filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber)
|
||||
filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.ISRC, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover)
|
||||
}
|
||||
|
||||
case "tidal":
|
||||
@@ -223,7 +231,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
downloader := backend.NewTidalDownloader("")
|
||||
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)
|
||||
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.ISRC)
|
||||
} else {
|
||||
if req.SpotifyID == "" {
|
||||
return DownloadResponse{
|
||||
@@ -232,13 +240,13 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
}, 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)
|
||||
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.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.Duration, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks)
|
||||
}
|
||||
} else {
|
||||
downloader := backend.NewTidalDownloader(req.ApiURL)
|
||||
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)
|
||||
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.ISRC)
|
||||
} else {
|
||||
if req.SpotifyID == "" {
|
||||
return DownloadResponse{
|
||||
@@ -247,28 +255,24 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
}, fmt.Errorf("spotify ID is required for Tidal")
|
||||
}
|
||||
// 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)
|
||||
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.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.Duration, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
default: // deezer
|
||||
downloader := backend.NewDeezerDownloader()
|
||||
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)
|
||||
// Default to "6" (FLAC 16-bit) for Qobuz if not specified
|
||||
quality := req.AudioFormat
|
||||
if quality == "" {
|
||||
quality = "6"
|
||||
}
|
||||
filename, err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks)
|
||||
|
||||
default:
|
||||
return DownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Unknown service: %s", req.Service),
|
||||
}, fmt.Errorf("unknown service: %s", req.Service)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -299,6 +303,58 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
filename = strings.TrimPrefix(filename, "EXISTS:")
|
||||
}
|
||||
|
||||
// Embed lyrics after successful download (only for new downloads with Spotify ID and if embedLyrics is enabled)
|
||||
if !alreadyExists && req.SpotifyID != "" && req.EmbedLyrics && strings.HasSuffix(filename, ".flac") {
|
||||
go func(filePath, spotifyID, trackName, artistName string) {
|
||||
fmt.Printf("\n========== LYRICS FETCH START ==========\n")
|
||||
fmt.Printf("Spotify ID: %s\n", spotifyID)
|
||||
fmt.Printf("Track: %s\n", trackName)
|
||||
fmt.Printf("Artist: %s\n", artistName)
|
||||
fmt.Println("Searching all sources...")
|
||||
|
||||
lyricsClient := backend.NewLyricsClient()
|
||||
|
||||
// Try all sources with fallbacks
|
||||
lyricsResp, source, err := lyricsClient.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
||||
if err != nil {
|
||||
fmt.Printf("All sources failed: %v\n", err)
|
||||
fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n")
|
||||
return
|
||||
}
|
||||
|
||||
if lyricsResp == nil || len(lyricsResp.Lines) == 0 {
|
||||
fmt.Println("No lyrics content found")
|
||||
fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Lyrics found from: %s\n", source)
|
||||
fmt.Printf("Sync type: %s\n", lyricsResp.SyncType)
|
||||
fmt.Printf("Total lines: %d\n", len(lyricsResp.Lines))
|
||||
|
||||
lyrics := lyricsClient.ConvertToLRC(lyricsResp, trackName, artistName)
|
||||
if lyrics == "" {
|
||||
fmt.Println("No lyrics content to embed")
|
||||
fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n")
|
||||
return
|
||||
}
|
||||
|
||||
// Show full lyrics in console for debugging
|
||||
fmt.Printf("\n--- Full LRC Content ---\n")
|
||||
fmt.Println(lyrics)
|
||||
fmt.Printf("--- End LRC Content ---\n\n")
|
||||
|
||||
fmt.Printf("Embedding into: %s\n", filePath)
|
||||
if err := backend.EmbedLyricsOnly(filePath, lyrics); err != nil {
|
||||
fmt.Printf("Failed to embed lyrics: %v\n", err)
|
||||
fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n")
|
||||
} else {
|
||||
fmt.Printf("Lyrics embedded successfully!\n")
|
||||
fmt.Printf("========== LYRICS FETCH END (SUCCESS) ==========\n\n")
|
||||
}
|
||||
}(filename, req.SpotifyID, req.TrackName, req.ArtistName)
|
||||
}
|
||||
|
||||
message := "Download completed successfully"
|
||||
if alreadyExists {
|
||||
message = "File already exists"
|
||||
@@ -546,3 +602,82 @@ func (a *App) CheckTrackAvailability(spotifyTrackID string, isrc string) (string
|
||||
|
||||
return string(jsonData), nil
|
||||
}
|
||||
|
||||
// IsFFmpegInstalled checks if ffmpeg is installed
|
||||
func (a *App) IsFFmpegInstalled() (bool, error) {
|
||||
return backend.IsFFmpegInstalled()
|
||||
}
|
||||
|
||||
// GetFFmpegPath returns the path to ffmpeg
|
||||
func (a *App) GetFFmpegPath() (string, error) {
|
||||
return backend.GetFFmpegPath()
|
||||
}
|
||||
|
||||
// DownloadFFmpegRequest represents a request to download ffmpeg
|
||||
type DownloadFFmpegRequest struct{}
|
||||
|
||||
// DownloadFFmpegResponse represents the response from downloading ffmpeg
|
||||
type DownloadFFmpegResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DownloadFFmpeg downloads and installs ffmpeg
|
||||
func (a *App) DownloadFFmpeg() DownloadFFmpegResponse {
|
||||
err := backend.DownloadFFmpeg(func(progress int) {
|
||||
fmt.Printf("[FFmpeg] Download progress: %d%%\n", progress)
|
||||
})
|
||||
if err != nil {
|
||||
return DownloadFFmpegResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
return DownloadFFmpegResponse{
|
||||
Success: true,
|
||||
Message: "FFmpeg installed successfully",
|
||||
}
|
||||
}
|
||||
|
||||
// InstallFFmpegFromFile installs ffmpeg from a local file path
|
||||
func (a *App) InstallFFmpegFromFile(filePath string) DownloadFFmpegResponse {
|
||||
err := backend.InstallFFmpegFromFile(filePath)
|
||||
if err != nil {
|
||||
return DownloadFFmpegResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
}
|
||||
return DownloadFFmpegResponse{
|
||||
Success: true,
|
||||
Message: "FFmpeg installed successfully from file",
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertAudioRequest represents a request to convert audio files
|
||||
type ConvertAudioRequest struct {
|
||||
InputFiles []string `json:"input_files"`
|
||||
OutputFormat string `json:"output_format"`
|
||||
Bitrate string `json:"bitrate"`
|
||||
}
|
||||
|
||||
// ConvertAudio converts audio files using ffmpeg
|
||||
func (a *App) ConvertAudio(req ConvertAudioRequest) ([]backend.ConvertAudioResult, error) {
|
||||
backendReq := backend.ConvertAudioRequest{
|
||||
InputFiles: req.InputFiles,
|
||||
OutputFormat: req.OutputFormat,
|
||||
Bitrate: req.Bitrate,
|
||||
}
|
||||
return backend.ConvertAudio(backendReq)
|
||||
}
|
||||
|
||||
// SelectAudioFiles opens a file dialog to select audio files for conversion
|
||||
func (a *App) SelectAudioFiles() ([]string, error) {
|
||||
files, err := backend.SelectMultipleFiles(a.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
+69
-7
@@ -142,9 +142,24 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body first to handle encoding issues and provide better error messages
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return "", fmt.Errorf("API returned empty response")
|
||||
}
|
||||
|
||||
var songLinkResp SongLinkResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
// Truncate body for error message (max 200 chars)
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
}
|
||||
return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
||||
}
|
||||
|
||||
amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]
|
||||
@@ -357,7 +372,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
|
||||
return "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadByURL(amazonURL, 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, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyISRC string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool) (string, error) {
|
||||
// Create output directory if needed
|
||||
if outputDir != "." {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
@@ -367,7 +382,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
||||
|
||||
// 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)
|
||||
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, filenameFormat, includeTrackNumber, position, false)
|
||||
expectedPath := filepath.Join(outputDir, expectedFilename)
|
||||
|
||||
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
|
||||
@@ -384,7 +399,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
||||
return "", err
|
||||
}
|
||||
|
||||
// File already has embedded metadata, just rename if needed
|
||||
// Rename file based on Spotify metadata
|
||||
if spotifyTrackName != "" && spotifyArtistName != "" {
|
||||
safeArtist := sanitizeFilename(spotifyArtistName)
|
||||
safeTitle := sanitizeFilename(spotifyTrackName)
|
||||
@@ -436,17 +451,64 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
||||
}
|
||||
}
|
||||
|
||||
// Embed Spotify metadata (replace Amazon's embedded metadata)
|
||||
fmt.Println("Embedding Spotify metadata...")
|
||||
|
||||
coverPath := ""
|
||||
// Download Spotify cover (with max resolution if enabled)
|
||||
if spotifyCoverURL != "" {
|
||||
coverPath = filePath + ".cover.jpg"
|
||||
coverClient := NewCoverClient()
|
||||
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
|
||||
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
|
||||
coverPath = ""
|
||||
} else {
|
||||
defer os.Remove(coverPath)
|
||||
fmt.Println("Spotify cover downloaded")
|
||||
}
|
||||
}
|
||||
|
||||
// Determine track number to embed
|
||||
// Use Spotify track number (album track number) if available, otherwise use position
|
||||
trackNumberToEmbed := spotifyTrackNumber
|
||||
if trackNumberToEmbed == 0 {
|
||||
trackNumberToEmbed = position // Fallback to playlist position
|
||||
}
|
||||
if trackNumberToEmbed == 0 {
|
||||
trackNumberToEmbed = 1 // Default to track 1 for single track downloads without track number
|
||||
}
|
||||
|
||||
// Build metadata from Spotify
|
||||
metadata := Metadata{
|
||||
Title: spotifyTrackName,
|
||||
Artist: spotifyArtistName,
|
||||
Album: spotifyAlbumName,
|
||||
AlbumArtist: spotifyAlbumArtist,
|
||||
Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD)
|
||||
TrackNumber: trackNumberToEmbed,
|
||||
TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify
|
||||
DiscNumber: spotifyDiscNumber, // Disc number from Spotify
|
||||
ISRC: spotifyISRC, // Use ISRC from Spotify
|
||||
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||
}
|
||||
|
||||
if err := EmbedMetadata(filePath, metadata, coverPath); err != nil {
|
||||
fmt.Printf("Warning: Failed to embed metadata: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("Metadata embedded successfully")
|
||||
}
|
||||
|
||||
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) {
|
||||
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyISRC string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover 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)
|
||||
return a.DownloadByURL(amazonURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyISRC, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover)
|
||||
}
|
||||
|
||||
@@ -107,6 +107,46 @@ func (c *CoverClient) getMaxResolutionURL(coverURL string) string {
|
||||
return coverURL
|
||||
}
|
||||
|
||||
// DownloadCoverToPath downloads cover art from URL to a specific path
|
||||
// If embedMaxQualityCover is true, it will try to get max resolution
|
||||
func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQualityCover bool) error {
|
||||
if coverURL == "" {
|
||||
return fmt.Errorf("cover URL is required")
|
||||
}
|
||||
|
||||
// Use max quality URL if setting is enabled
|
||||
downloadURL := coverURL
|
||||
if embedMaxQualityCover {
|
||||
downloadURL = c.getMaxResolutionURL(coverURL)
|
||||
}
|
||||
|
||||
// Download cover image
|
||||
resp, err := c.httpClient.Get(downloadURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download cover: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("failed to download cover: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Create file
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Write content to file
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write cover file: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadCover downloads cover art for a single track
|
||||
func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadResponse, error) {
|
||||
if req.CoverURL == "" {
|
||||
|
||||
@@ -1,399 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DeezerDownloader struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type DeezerTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
TitleShort string `json:"title_short"`
|
||||
Duration int `json:"duration"`
|
||||
TrackPos int `json:"track_position"`
|
||||
DiskNumber int `json:"disk_number"`
|
||||
ISRC string `json:"isrc"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Artist struct {
|
||||
Name string `json:"name"`
|
||||
ID int64 `json:"id"`
|
||||
} `json:"artist"`
|
||||
Album struct {
|
||||
Title string `json:"title"`
|
||||
ID int64 `json:"id"`
|
||||
CoverXL string `json:"cover_xl"`
|
||||
CoverBig string `json:"cover_big"`
|
||||
} `json:"album"`
|
||||
Contributors []struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
} `json:"contributors"`
|
||||
}
|
||||
|
||||
type DeezMateResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Links struct {
|
||||
FLAC string `json:"flac"`
|
||||
} `json:"links"`
|
||||
}
|
||||
|
||||
func NewDeezerDownloader() *DeezerDownloader {
|
||||
return &DeezerDownloader{
|
||||
client: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DeezerDownloader) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||
// 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), 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 {
|
||||
return nil, fmt.Errorf("failed to fetch track: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var track DeezerTrack
|
||||
if err := json.NewDecoder(resp.Body).Decode(&track); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if track.ID == 0 {
|
||||
return nil, fmt.Errorf("track not found")
|
||||
}
|
||||
|
||||
return &track, nil
|
||||
}
|
||||
|
||||
func (d *DeezerDownloader) GetDownloadURL(trackID int64) (string, error) {
|
||||
// Decode base64 API URL
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuZGVlem1hdGUuY29tL2RsLw==")
|
||||
url := fmt.Sprintf("%s%d", string(apiBase), trackID)
|
||||
|
||||
resp, err := d.client.Get(url)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var apiResp DeezMateResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode API response: %w", err)
|
||||
}
|
||||
|
||||
if !apiResp.Success || apiResp.Links.FLAC == "" {
|
||||
return "", fmt.Errorf("no FLAC download link available")
|
||||
}
|
||||
|
||||
return apiResp.Links.FLAC, nil
|
||||
}
|
||||
|
||||
func (d *DeezerDownloader) DownloadFile(url, filepath string) error {
|
||||
resp, err := d.client.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(filepath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
fmt.Println("Downloading...")
|
||||
// Use progress writer to track download
|
||||
pw := NewProgressWriter(out)
|
||||
_, err = io.Copy(pw, resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
// Print final size
|
||||
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DeezerDownloader) DownloadCoverArt(coverURL, filepath string) error {
|
||||
if coverURL == "" {
|
||||
return fmt.Errorf("no cover URL provided")
|
||||
}
|
||||
|
||||
resp, err := d.client.Get(coverURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download cover: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("cover download failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(filepath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create cover file: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
func buildFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
|
||||
var filename string
|
||||
|
||||
// Determine track number to use
|
||||
numberToUse := position
|
||||
if useAlbumTrackNumber && trackNumber > 0 {
|
||||
numberToUse = trackNumber
|
||||
}
|
||||
|
||||
// Check if format is a template (contains {})
|
||||
if strings.Contains(format, "{") {
|
||||
filename = format
|
||||
filename = strings.ReplaceAll(filename, "{title}", title)
|
||||
filename = strings.ReplaceAll(filename, "{artist}", artist)
|
||||
|
||||
// Handle track number - if numberToUse is 0, remove {track} and surrounding separators
|
||||
if numberToUse > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", numberToUse))
|
||||
} else {
|
||||
// Remove {track} with common separators
|
||||
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
|
||||
}
|
||||
} else {
|
||||
// Legacy format support
|
||||
switch format {
|
||||
case "artist-title":
|
||||
filename = fmt.Sprintf("%s - %s", artist, title)
|
||||
case "title":
|
||||
filename = title
|
||||
default: // "title-artist"
|
||||
filename = fmt.Sprintf("%s - %s", title, artist)
|
||||
}
|
||||
|
||||
// Add track number prefix if enabled (legacy behavior)
|
||||
if includeTrackNumber && position > 0 {
|
||||
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
|
||||
}
|
||||
}
|
||||
|
||||
return filename + ".flac"
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Use Spotify metadata if provided, otherwise fallback to Deezer metadata
|
||||
artists := spotifyArtistName
|
||||
trackTitle := spotifyTrackName
|
||||
albumTitle := spotifyAlbumName
|
||||
|
||||
if artists == "" {
|
||||
artists = track.Artist.Name
|
||||
if len(track.Contributors) > 0 {
|
||||
var mainArtists []string
|
||||
for _, contrib := range track.Contributors {
|
||||
if contrib.Role == "Main" {
|
||||
mainArtists = append(mainArtists, contrib.Name)
|
||||
}
|
||||
}
|
||||
if len(mainArtists) > 0 {
|
||||
artists = strings.Join(mainArtists, ", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if trackTitle == "" {
|
||||
trackTitle = track.Title
|
||||
}
|
||||
|
||||
if albumTitle == "" {
|
||||
albumTitle = track.Album.Title
|
||||
}
|
||||
|
||||
fmt.Printf("Found track: %s - %s\n", artists, trackTitle)
|
||||
fmt.Printf("Album: %s\n", albumTitle)
|
||||
|
||||
downloadURL, err := d.GetDownloadURL(track.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if fileInfo, err := os.Stat(filepath); err == nil && fileInfo.Size() > 0 {
|
||||
fmt.Printf("File already exists: %s (%.2f MB)\n", filepath, float64(fileInfo.Size())/(1024*1024))
|
||||
return "EXISTS:" + filepath, nil
|
||||
}
|
||||
|
||||
fmt.Println("Downloading FLAC file...")
|
||||
if err := d.DownloadFile(downloadURL, filepath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
fmt.Printf("Downloaded: %s\n", filepath)
|
||||
|
||||
coverPath := ""
|
||||
if track.Album.CoverXL != "" {
|
||||
coverPath = filepath + ".cover.jpg"
|
||||
fmt.Println("Downloading cover art...")
|
||||
if err := d.DownloadCoverArt(track.Album.CoverXL, coverPath); err != nil {
|
||||
fmt.Printf("Warning: Failed to download cover art: %v\n", err)
|
||||
} else {
|
||||
defer os.Remove(coverPath)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Embedding metadata and cover art...")
|
||||
// Use album track number if in album folder structure, otherwise use playlist position
|
||||
trackNumberToEmbed := 0
|
||||
if position > 0 {
|
||||
if useAlbumTrackNumber && track.TrackPos > 0 {
|
||||
trackNumberToEmbed = track.TrackPos
|
||||
} else {
|
||||
trackNumberToEmbed = position
|
||||
}
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: trackTitle,
|
||||
Artist: artists,
|
||||
Album: albumTitle,
|
||||
Date: track.ReleaseDate,
|
||||
TrackNumber: trackNumberToEmbed,
|
||||
DiscNumber: track.DiskNumber,
|
||||
ISRC: track.ISRC,
|
||||
}
|
||||
|
||||
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
|
||||
return "", fmt.Errorf("failed to embed metadata: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ulikunitz/xz"
|
||||
)
|
||||
|
||||
// decodeBase64 decodes a base64 encoded string
|
||||
func decodeBase64(encoded string) (string, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(decoded), nil
|
||||
}
|
||||
|
||||
const (
|
||||
ffmpegWindowsURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3Qtd2luNjQtZ3BsLnppcA=="
|
||||
ffmpegLinuxURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3QtbGludXg2NC1ncGwudGFyLnh6"
|
||||
ffmpegMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZm1wZWcvemlw"
|
||||
)
|
||||
|
||||
// GetFFmpegDir returns the directory where ffmpeg should be stored
|
||||
func GetFFmpegDir() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(homeDir, ".spotiflac"), nil
|
||||
}
|
||||
|
||||
// GetFFmpegPath returns the full path to the ffmpeg executable
|
||||
func GetFFmpegPath() (string, error) {
|
||||
ffmpegDir, err := GetFFmpegDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ffmpegName := "ffmpeg"
|
||||
if runtime.GOOS == "windows" {
|
||||
ffmpegName = "ffmpeg.exe"
|
||||
}
|
||||
|
||||
return filepath.Join(ffmpegDir, ffmpegName), nil
|
||||
}
|
||||
|
||||
// IsFFmpegInstalled checks if ffmpeg is installed in the app directory
|
||||
func IsFFmpegInstalled() (bool, error) {
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
_, err = os.Stat(ffmpegPath)
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Verify it's executable
|
||||
cmd := exec.Command(ffmpegPath, "-version")
|
||||
// Hide console window on Windows
|
||||
setHideWindow(cmd)
|
||||
err = cmd.Run()
|
||||
return err == nil, nil
|
||||
}
|
||||
|
||||
// DownloadFFmpeg downloads and extracts ffmpeg to the app directory
|
||||
func DownloadFFmpeg(progressCallback func(int)) error {
|
||||
ffmpegDir, err := GetFFmpegDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if err := os.MkdirAll(ffmpegDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create ffmpeg directory: %w", err)
|
||||
}
|
||||
|
||||
// Get the appropriate URL for the current OS
|
||||
var encodedURL string
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
encodedURL = ffmpegWindowsURL
|
||||
case "linux":
|
||||
encodedURL = ffmpegLinuxURL
|
||||
case "darwin":
|
||||
encodedURL = ffmpegMacOSURL
|
||||
default:
|
||||
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
// Decode URL
|
||||
url, err := decodeBase64(encodedURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode ffmpeg URL: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[FFmpeg] Downloading from: %s\n", url)
|
||||
|
||||
// Create temporary file for download
|
||||
tmpFile, err := os.CreateTemp("", "ffmpeg-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
// Download the file
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download ffmpeg: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("failed to download ffmpeg: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
totalSize := resp.ContentLength
|
||||
var downloaded int64
|
||||
|
||||
// Create a progress reader
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
_, writeErr := tmpFile.Write(buf[:n])
|
||||
if writeErr != nil {
|
||||
return fmt.Errorf("failed to write to temp file: %w", writeErr)
|
||||
}
|
||||
downloaded += int64(n)
|
||||
if totalSize > 0 && progressCallback != nil {
|
||||
progress := int(float64(downloaded) / float64(totalSize) * 100)
|
||||
progressCallback(progress)
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
tmpFile.Close()
|
||||
|
||||
fmt.Printf("[FFmpeg] Download complete, extracting...\n")
|
||||
|
||||
// Extract the archive
|
||||
switch runtime.GOOS {
|
||||
case "windows", "darwin":
|
||||
return extractZip(tmpFile.Name(), ffmpegDir)
|
||||
case "linux":
|
||||
return extractTarXz(tmpFile.Name(), ffmpegDir)
|
||||
default:
|
||||
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
|
||||
}
|
||||
}
|
||||
|
||||
// extractZip extracts ffmpeg from a zip archive
|
||||
func extractZip(zipPath, destDir string) error {
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open zip: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
ffmpegName := "ffmpeg"
|
||||
if runtime.GOOS == "windows" {
|
||||
ffmpegName = "ffmpeg.exe"
|
||||
}
|
||||
|
||||
destPath := filepath.Join(destDir, ffmpegName)
|
||||
|
||||
for _, f := range r.File {
|
||||
// Look for ffmpeg executable in any subdirectory
|
||||
baseName := filepath.Base(f.Name)
|
||||
if baseName == ffmpegName && !f.FileInfo().IsDir() {
|
||||
fmt.Printf("[FFmpeg] Found: %s\n", f.Name)
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file in zip: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
_, err = io.Copy(outFile, rc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("ffmpeg executable not found in archive")
|
||||
}
|
||||
|
||||
// extractTarXz extracts ffmpeg from a tar.xz archive
|
||||
func extractTarXz(tarXzPath, destDir string) error {
|
||||
file, err := os.Open(tarXzPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open tar.xz: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
xzReader, err := xz.NewReader(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create xz reader: %w", err)
|
||||
}
|
||||
|
||||
tarReader := tar.NewReader(xzReader)
|
||||
|
||||
ffmpegName := "ffmpeg"
|
||||
destPath := filepath.Join(destDir, ffmpegName)
|
||||
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read tar: %w", err)
|
||||
}
|
||||
|
||||
baseName := filepath.Base(header.Name)
|
||||
if baseName == ffmpegName && header.Typeflag == tar.TypeReg {
|
||||
fmt.Printf("[FFmpeg] Found: %s\n", header.Name)
|
||||
|
||||
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
_, err = io.Copy(outFile, tarReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("ffmpeg executable not found in archive")
|
||||
}
|
||||
|
||||
// ConvertAudioRequest represents a request to convert audio files
|
||||
type ConvertAudioRequest struct {
|
||||
InputFiles []string `json:"input_files"`
|
||||
OutputFormat string `json:"output_format"` // mp3, m4a
|
||||
Bitrate string `json:"bitrate"` // e.g., "320k", "256k", "192k", "128k"
|
||||
}
|
||||
|
||||
// ConvertAudioResult represents the result of a single file conversion
|
||||
type ConvertAudioResult struct {
|
||||
InputFile string `json:"input_file"`
|
||||
OutputFile string `json:"output_file"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ConvertAudio converts audio files using ffmpeg while preserving metadata
|
||||
func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get ffmpeg path: %w", err)
|
||||
}
|
||||
|
||||
installed, err := IsFFmpegInstalled()
|
||||
if err != nil || !installed {
|
||||
return nil, fmt.Errorf("ffmpeg is not installed")
|
||||
}
|
||||
|
||||
results := make([]ConvertAudioResult, len(req.InputFiles))
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
// Convert files in parallel
|
||||
for i, inputFile := range req.InputFiles {
|
||||
wg.Add(1)
|
||||
go func(idx int, inputFile string) {
|
||||
defer wg.Done()
|
||||
|
||||
result := ConvertAudioResult{
|
||||
InputFile: inputFile,
|
||||
}
|
||||
|
||||
// Get input file info
|
||||
inputExt := strings.ToLower(filepath.Ext(inputFile))
|
||||
baseName := strings.TrimSuffix(filepath.Base(inputFile), inputExt)
|
||||
inputDir := filepath.Dir(inputFile)
|
||||
|
||||
// Determine output directory: same as input file location + subfolder (MP3 or M4A)
|
||||
outputFormatUpper := strings.ToUpper(req.OutputFormat)
|
||||
outputDir := filepath.Join(inputDir, outputFormatUpper)
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
result.Error = fmt.Sprintf("failed to create output directory: %v", err)
|
||||
result.Success = false
|
||||
mu.Lock()
|
||||
results[idx] = result
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Determine output path
|
||||
outputExt := "." + strings.ToLower(req.OutputFormat)
|
||||
outputFile := filepath.Join(outputDir, baseName+outputExt)
|
||||
|
||||
// Skip if same format
|
||||
if inputExt == outputExt {
|
||||
result.Error = "Input and output formats are the same"
|
||||
result.Success = false
|
||||
mu.Lock()
|
||||
results[idx] = result
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
result.OutputFile = outputFile
|
||||
|
||||
// Extract cover art and lyrics from input file before conversion
|
||||
var coverArtPath string
|
||||
var lyrics string
|
||||
|
||||
coverArtPath, _ = ExtractCoverArt(inputFile)
|
||||
lyrics, err = ExtractLyrics(inputFile)
|
||||
if err != nil {
|
||||
fmt.Printf("[FFmpeg] Warning: Failed to extract lyrics from %s: %v\n", inputFile, err)
|
||||
} else if lyrics != "" {
|
||||
fmt.Printf("[FFmpeg] Lyrics extracted from %s: %d characters\n", inputFile, len(lyrics))
|
||||
} else {
|
||||
fmt.Printf("[FFmpeg] No lyrics found in %s\n", inputFile)
|
||||
}
|
||||
|
||||
// Build ffmpeg command
|
||||
args := []string{
|
||||
"-i", inputFile,
|
||||
"-y", // Overwrite output
|
||||
}
|
||||
|
||||
// Add codec and bitrate based on output format
|
||||
switch req.OutputFormat {
|
||||
case "mp3":
|
||||
args = append(args,
|
||||
"-codec:a", "libmp3lame",
|
||||
"-b:a", req.Bitrate,
|
||||
"-map", "0:a", // Map audio stream
|
||||
"-map_metadata", "0", // Copy all metadata
|
||||
"-id3v2_version", "3", // Use ID3v2.3 for better compatibility
|
||||
)
|
||||
// Map video stream if exists (for cover art)
|
||||
args = append(args, "-map", "0:v?", "-c:v", "copy")
|
||||
case "m4a":
|
||||
args = append(args,
|
||||
"-codec:a", "aac",
|
||||
"-b:a", req.Bitrate,
|
||||
"-map", "0:a", // Map audio stream
|
||||
"-map_metadata", "0", // Copy all metadata
|
||||
)
|
||||
// Map video stream for cover art in M4A
|
||||
args = append(args, "-map", "0:v?", "-c:v", "copy", "-disposition:v:0", "attached_pic")
|
||||
}
|
||||
|
||||
args = append(args, outputFile)
|
||||
|
||||
fmt.Printf("[FFmpeg] Converting: %s -> %s\n", inputFile, outputFile)
|
||||
|
||||
cmd := exec.Command(ffmpegPath, args...)
|
||||
// Hide console window on Windows
|
||||
setHideWindow(cmd)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("conversion failed: %s - %s", err.Error(), string(output))
|
||||
result.Success = false
|
||||
mu.Lock()
|
||||
results[idx] = result
|
||||
mu.Unlock()
|
||||
// Clean up temp cover art file if exists
|
||||
if coverArtPath != "" {
|
||||
os.Remove(coverArtPath)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Embed cover art and lyrics after conversion if they were extracted
|
||||
if coverArtPath != "" {
|
||||
if err := EmbedCoverArtOnly(outputFile, coverArtPath); err != nil {
|
||||
fmt.Printf("[FFmpeg] Warning: Failed to embed cover art: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("[FFmpeg] Cover art embedded successfully\n")
|
||||
}
|
||||
os.Remove(coverArtPath) // Clean up temp file
|
||||
}
|
||||
|
||||
if lyrics != "" {
|
||||
if err := EmbedLyricsOnlyUniversal(outputFile, lyrics); err != nil {
|
||||
fmt.Printf("[FFmpeg] Warning: Failed to embed lyrics: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("[FFmpeg] Lyrics embedded successfully\n")
|
||||
}
|
||||
}
|
||||
|
||||
result.Success = true
|
||||
fmt.Printf("[FFmpeg] Successfully converted: %s\n", outputFile)
|
||||
|
||||
mu.Lock()
|
||||
results[idx] = result
|
||||
mu.Unlock()
|
||||
}(i, inputFile)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetAudioInfo returns information about an audio file
|
||||
type AudioFileInfo struct {
|
||||
Path string `json:"path"`
|
||||
Filename string `json:"filename"`
|
||||
Format string `json:"format"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// GetAudioFileInfo gets information about an audio file
|
||||
func GetAudioFileInfo(filePath string) (*AudioFileInfo, error) {
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filePath), "."))
|
||||
return &AudioFileInfo{
|
||||
Path: filePath,
|
||||
Filename: filepath.Base(filePath),
|
||||
Format: ext,
|
||||
Size: info.Size(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InstallFFmpegFromFile installs ffmpeg from a local file path
|
||||
func InstallFFmpegFromFile(filePath string) error {
|
||||
// Check if file exists
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("file does not exist: %w", err)
|
||||
}
|
||||
|
||||
// Check if it's a regular file (not a directory)
|
||||
if info.IsDir() {
|
||||
return fmt.Errorf("path is a directory, not a file")
|
||||
}
|
||||
|
||||
// Verify it's likely an ffmpeg executable by checking the filename
|
||||
fileName := strings.ToLower(filepath.Base(filePath))
|
||||
expectedName := "ffmpeg"
|
||||
if runtime.GOOS == "windows" {
|
||||
expectedName = "ffmpeg.exe"
|
||||
}
|
||||
|
||||
if fileName != expectedName && !strings.Contains(fileName, "ffmpeg") {
|
||||
return fmt.Errorf("file does not appear to be an ffmpeg executable (expected name containing 'ffmpeg')")
|
||||
}
|
||||
|
||||
// Get destination path
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get ffmpeg path: %w", err)
|
||||
}
|
||||
|
||||
ffmpegDir := filepath.Dir(ffmpegPath)
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if err := os.MkdirAll(ffmpegDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create ffmpeg directory: %w", err)
|
||||
}
|
||||
|
||||
// Copy file to destination
|
||||
sourceFile, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open source file: %w", err)
|
||||
}
|
||||
|
||||
destFile, err := os.OpenFile(ffmpegPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
|
||||
if err != nil {
|
||||
sourceFile.Close()
|
||||
return fmt.Errorf("failed to create destination file: %w", err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(destFile, sourceFile)
|
||||
sourceFile.Close()
|
||||
if err != nil {
|
||||
destFile.Close()
|
||||
return fmt.Errorf("failed to copy file: %w", err)
|
||||
}
|
||||
|
||||
// Ensure all data is written to disk
|
||||
if err := destFile.Sync(); err != nil {
|
||||
destFile.Close()
|
||||
return fmt.Errorf("failed to sync file: %w", err)
|
||||
}
|
||||
destFile.Close()
|
||||
|
||||
// On Windows, file may still be locked by antivirus or system
|
||||
// Wait a bit and retry verification
|
||||
maxRetries := 3
|
||||
retryDelay := 500 * time.Millisecond
|
||||
|
||||
var verifyErr error
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
if i > 0 {
|
||||
time.Sleep(retryDelay)
|
||||
}
|
||||
|
||||
cmd := exec.Command(ffmpegPath, "-version")
|
||||
// Hide console window on Windows
|
||||
setHideWindow(cmd)
|
||||
verifyErr = cmd.Run()
|
||||
if verifyErr == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if verifyErr != nil {
|
||||
return fmt.Errorf("file copied but ffmpeg verification failed after %d attempts: %w", maxRetries, verifyErr)
|
||||
}
|
||||
|
||||
fmt.Printf("[FFmpeg] Successfully installed from: %s\n", filePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package backend
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// setHideWindow is a no-op on non-Windows platforms
|
||||
func setHideWindow(cmd *exec.Cmd) {
|
||||
// No-op on Unix-like systems
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package backend
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// setHideWindow sets HideWindow attribute for Windows processes
|
||||
func setHideWindow(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// SelectMultipleFiles opens a file dialog to select multiple audio files
|
||||
func SelectMultipleFiles(ctx context.Context) ([]string, error) {
|
||||
files, err := runtime.OpenMultipleFilesDialog(ctx, runtime.OpenDialogOptions{
|
||||
Title: "Select Audio Files",
|
||||
Filters: []runtime.FileFilter{
|
||||
{
|
||||
DisplayName: "Audio Files (*.mp3, *.m4a, *.flac)",
|
||||
Pattern: "*.mp3;*.m4a;*.flac",
|
||||
},
|
||||
{
|
||||
DisplayName: "MP3 Files (*.mp3)",
|
||||
Pattern: "*.mp3",
|
||||
},
|
||||
{
|
||||
DisplayName: "M4A Files (*.m4a)",
|
||||
Pattern: "*.m4a",
|
||||
},
|
||||
{
|
||||
DisplayName: "FLAC Files (*.flac)",
|
||||
Pattern: "*.flac",
|
||||
},
|
||||
{
|
||||
DisplayName: "All Files (*.*)",
|
||||
Pattern: "*.*",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// SelectOutputDirectory opens a directory dialog to select output folder
|
||||
func SelectOutputDirectory(ctx context.Context) (string, error) {
|
||||
dir, err := runtime.OpenDirectoryDialog(ctx, runtime.OpenDialogOptions{
|
||||
Title: "Select Output Directory",
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
+70
-10
@@ -5,6 +5,8 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// BuildExpectedFilename builds the expected filename based on track metadata and settings
|
||||
@@ -52,12 +54,70 @@ func BuildExpectedFilename(trackName, artistName, filenameFormat string, include
|
||||
|
||||
// sanitizeFilename removes invalid characters from filename
|
||||
func sanitizeFilename(name string) string {
|
||||
re := regexp.MustCompile(`[<>:"/\\|?*]`)
|
||||
sanitized := re.ReplaceAllString(name, "_")
|
||||
// Replace forward slash with space (more natural than underscore)
|
||||
sanitized := strings.ReplaceAll(name, "/", " ")
|
||||
|
||||
// Remove other invalid filesystem characters (replace with space)
|
||||
re := regexp.MustCompile(`[<>:"\\|?*]`)
|
||||
sanitized = re.ReplaceAllString(sanitized, " ")
|
||||
|
||||
// Remove control characters (0x00-0x1F, 0x7F)
|
||||
var result strings.Builder
|
||||
for _, r := range sanitized {
|
||||
// Keep printable characters and valid Unicode characters
|
||||
// Remove control characters, but keep spaces, tabs, newlines for now
|
||||
if r < 0x20 && r != 0x09 && r != 0x0A && r != 0x0D {
|
||||
continue
|
||||
}
|
||||
if r == 0x7F {
|
||||
continue
|
||||
}
|
||||
// Remove emoji and other symbols that might cause issues
|
||||
// Keep letters, numbers, and common punctuation
|
||||
if unicode.IsControl(r) && r != 0x09 && r != 0x0A && r != 0x0D {
|
||||
continue
|
||||
}
|
||||
// Remove emoji ranges (most emoji are in these ranges)
|
||||
if (r >= 0x1F300 && r <= 0x1F9FF) || // Miscellaneous Symbols and Pictographs, Emoticons
|
||||
(r >= 0x2600 && r <= 0x26FF) || // Miscellaneous Symbols
|
||||
(r >= 0x2700 && r <= 0x27BF) || // Dingbats
|
||||
(r >= 0xFE00 && r <= 0xFE0F) || // Variation Selectors
|
||||
(r >= 0x1F900 && r <= 0x1F9FF) || // Supplemental Symbols and Pictographs
|
||||
(r >= 0x1F600 && r <= 0x1F64F) || // Emoticons
|
||||
(r >= 0x1F680 && r <= 0x1F6FF) || // Transport and Map Symbols
|
||||
(r >= 0x1F1E0 && r <= 0x1F1FF) { // Regional Indicator Symbols (flags)
|
||||
continue
|
||||
}
|
||||
result.WriteRune(r)
|
||||
}
|
||||
|
||||
sanitized = result.String()
|
||||
sanitized = strings.TrimSpace(sanitized)
|
||||
|
||||
// Remove leading/trailing dots and spaces (Windows doesn't allow these)
|
||||
sanitized = strings.Trim(sanitized, ". ")
|
||||
|
||||
// Normalize consecutive spaces to single space
|
||||
re = regexp.MustCompile(`\s+`)
|
||||
sanitized = re.ReplaceAllString(sanitized, " ")
|
||||
|
||||
// Normalize consecutive underscores to single underscore
|
||||
re = regexp.MustCompile(`_+`)
|
||||
sanitized = re.ReplaceAllString(sanitized, "_")
|
||||
|
||||
// Remove leading/trailing underscores and spaces
|
||||
sanitized = strings.Trim(sanitized, "_ ")
|
||||
|
||||
if sanitized == "" {
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
// Ensure the result is valid UTF-8
|
||||
if !utf8.ValidString(sanitized) {
|
||||
// If invalid UTF-8, try to fix it
|
||||
sanitized = strings.ToValidUTF8(sanitized, "_")
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
@@ -80,6 +140,12 @@ func SanitizeFolderPath(folderPath string) string {
|
||||
continue
|
||||
}
|
||||
|
||||
// Keep empty first part for absolute paths on Unix (e.g., "/Users/...")
|
||||
if i == 0 && part == "" {
|
||||
sanitizedParts = append(sanitizedParts, part)
|
||||
continue
|
||||
}
|
||||
|
||||
// Sanitize each folder name (but don't replace / or \ since we already normalized)
|
||||
sanitized := sanitizeFolderName(part)
|
||||
if sanitized != "" {
|
||||
@@ -92,12 +158,6 @@ func SanitizeFolderPath(folderPath string) string {
|
||||
|
||||
// sanitizeFolderName removes invalid characters from a single folder name
|
||||
func sanitizeFolderName(name string) string {
|
||||
// Remove or replace invalid characters for folder names (excluding path separators)
|
||||
re := regexp.MustCompile(`[<>:"|?*]`)
|
||||
sanitized := re.ReplaceAllString(name, "_")
|
||||
sanitized = strings.TrimSpace(sanitized)
|
||||
if sanitized == "" {
|
||||
return "Unknown"
|
||||
}
|
||||
return sanitized
|
||||
// Use the same sanitization as filename
|
||||
return sanitizeFilename(name)
|
||||
}
|
||||
|
||||
+200
-18
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -13,6 +14,19 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// LRCLibResponse represents the LRCLIB API response
|
||||
type LRCLibResponse struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TrackName string `json:"trackName"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
Duration float64 `json:"duration"`
|
||||
Instrumental bool `json:"instrumental"`
|
||||
PlainLyrics string `json:"plainLyrics"`
|
||||
SyncedLyrics string `json:"syncedLyrics"`
|
||||
}
|
||||
|
||||
// LyricsLine represents a single line of lyrics
|
||||
type LyricsLine struct {
|
||||
StartTimeMs string `json:"startTimeMs"`
|
||||
@@ -60,35 +74,203 @@ func NewLyricsClient() *LyricsClient {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// FetchLyricsWithMetadata fetches lyrics using track name and artist from LRCLIB
|
||||
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string) (*LyricsResponse, error) {
|
||||
// Try LRCLIB API
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9nZXQ/YXJ0aXN0X25hbWU9")
|
||||
apiURL := fmt.Sprintf("%s%s&track_name=%s",
|
||||
string(apiBase),
|
||||
url.QueryEscape(artistName),
|
||||
url.QueryEscape(trackName))
|
||||
|
||||
resp, err := c.httpClient.Get(url)
|
||||
resp, err := c.httpClient.Get(apiURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch lyrics: %v", err)
|
||||
return nil, fmt.Errorf("failed to fetch from LRCLIB: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("LRCLIB returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %v", err)
|
||||
return nil, fmt.Errorf("failed to read LRCLIB 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)
|
||||
var lrcLibResp LRCLibResponse
|
||||
if err := json.Unmarshal(body, &lrcLibResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse LRCLIB response: %v", err)
|
||||
}
|
||||
|
||||
if lyricsResp.Error {
|
||||
return nil, fmt.Errorf("lyrics not found for this track")
|
||||
}
|
||||
|
||||
return &lyricsResp, nil
|
||||
// Convert LRCLIB response to our LyricsResponse format
|
||||
return c.convertLRCLibToLyricsResponse(&lrcLibResp), nil
|
||||
}
|
||||
|
||||
// convertLRCLibToLyricsResponse converts LRCLIB response to our standard format
|
||||
func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *LyricsResponse {
|
||||
resp := &LyricsResponse{
|
||||
Error: false,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Lines: []LyricsLine{},
|
||||
}
|
||||
|
||||
// Prefer synced lyrics, fall back to plain
|
||||
lyricsText := lrcLib.SyncedLyrics
|
||||
if lyricsText == "" {
|
||||
lyricsText = lrcLib.PlainLyrics
|
||||
resp.SyncType = "UNSYNCED"
|
||||
}
|
||||
|
||||
if lyricsText == "" {
|
||||
resp.Error = true
|
||||
return resp
|
||||
}
|
||||
|
||||
// Parse synced lyrics format [mm:ss.xx] text
|
||||
lines := strings.Split(lyricsText, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if line has timestamp [mm:ss.xx]
|
||||
if strings.HasPrefix(line, "[") && len(line) > 10 {
|
||||
closeBracket := strings.Index(line, "]")
|
||||
if closeBracket > 0 {
|
||||
timestamp := line[1:closeBracket]
|
||||
words := strings.TrimSpace(line[closeBracket+1:])
|
||||
|
||||
// Convert [mm:ss.xx] to milliseconds
|
||||
ms := lrcTimestampToMs(timestamp)
|
||||
resp.Lines = append(resp.Lines, LyricsLine{
|
||||
StartTimeMs: fmt.Sprintf("%d", ms),
|
||||
Words: words,
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Plain lyrics line (no timestamp)
|
||||
resp.Lines = append(resp.Lines, LyricsLine{
|
||||
StartTimeMs: "0",
|
||||
Words: line,
|
||||
})
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// lrcTimestampToMs converts LRC timestamp [mm:ss.xx] to milliseconds
|
||||
func lrcTimestampToMs(timestamp string) int64 {
|
||||
var minutes, seconds, centiseconds int64
|
||||
// Try parsing mm:ss.xx format
|
||||
n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, ¢iseconds)
|
||||
if n >= 2 {
|
||||
return minutes*60*1000 + seconds*1000 + centiseconds*10
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// FetchLyricsFromLRCLibSearch fetches lyrics using LRCLIB search API
|
||||
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) {
|
||||
query := fmt.Sprintf("%s %s", artistName, trackName)
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9zZWFyY2g/cT0=")
|
||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(query))
|
||||
|
||||
resp, err := c.httpClient.Get(apiURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read failed: %v", err)
|
||||
}
|
||||
|
||||
var results []LRCLibResponse
|
||||
if err := json.Unmarshal(body, &results); err != nil {
|
||||
return nil, fmt.Errorf("parse failed: %v", err)
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return nil, fmt.Errorf("no results found")
|
||||
}
|
||||
|
||||
// Find best match - prefer one with synced lyrics
|
||||
var best *LRCLibResponse
|
||||
for i := range results {
|
||||
if results[i].SyncedLyrics != "" {
|
||||
best = &results[i]
|
||||
break
|
||||
}
|
||||
if best == nil && results[i].PlainLyrics != "" {
|
||||
best = &results[i]
|
||||
}
|
||||
}
|
||||
|
||||
if best == nil {
|
||||
best = &results[0]
|
||||
}
|
||||
|
||||
return c.convertLRCLibToLyricsResponse(best), nil
|
||||
}
|
||||
|
||||
// simplifyTrackName removes common suffixes like "(feat. X)", "(Remastered)", etc.
|
||||
func simplifyTrackName(name string) string {
|
||||
// Remove content in parentheses
|
||||
if idx := strings.Index(name, "("); idx > 0 {
|
||||
name = strings.TrimSpace(name[:idx])
|
||||
}
|
||||
// Remove content after " - " (like "From the Motion Picture")
|
||||
if idx := strings.Index(name, " - "); idx > 0 {
|
||||
name = strings.TrimSpace(name[:idx])
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// FetchLyricsAllSources tries all LRCLIB sources to get lyrics
|
||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, string, error) {
|
||||
// 1. Try LRCLIB exact match
|
||||
resp, err := c.FetchLyricsWithMetadata(trackName, artistName)
|
||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
||||
return resp, "LRCLIB", nil
|
||||
}
|
||||
fmt.Printf(" LRCLIB exact: %v\n", err)
|
||||
|
||||
// 2. Try LRCLIB search
|
||||
resp, err = c.FetchLyricsFromLRCLibSearch(trackName, artistName)
|
||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
||||
return resp, "LRCLIB Search", nil
|
||||
}
|
||||
fmt.Printf(" LRCLIB search: %v\n", err)
|
||||
|
||||
// 3. Try with simplified track name (remove parentheses, subtitles)
|
||||
simplifiedTrack := simplifyTrackName(trackName)
|
||||
if simplifiedTrack != trackName {
|
||||
fmt.Printf(" Trying simplified name: %s\n", simplifiedTrack)
|
||||
|
||||
resp, err = c.FetchLyricsWithMetadata(simplifiedTrack, artistName)
|
||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
||||
return resp, "LRCLIB (simplified)", nil
|
||||
}
|
||||
|
||||
resp, err = c.FetchLyricsFromLRCLibSearch(simplifiedTrack, artistName)
|
||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
||||
return resp, "LRCLIB Search (simplified)", nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf("lyrics not found in any source")
|
||||
}
|
||||
|
||||
|
||||
// ConvertToLRC converts lyrics response to LRC format
|
||||
func (c *LyricsClient) ConvertToLRC(lyrics *LyricsResponse, trackName, artistName string) string {
|
||||
var sb strings.Builder
|
||||
@@ -210,8 +392,8 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fetch lyrics
|
||||
lyrics, err := c.FetchLyrics(req.SpotifyID)
|
||||
// Fetch lyrics from LRCLIB
|
||||
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
|
||||
if err != nil {
|
||||
return &LyricsDownloadResponse{
|
||||
Success: false,
|
||||
|
||||
+414
-1
@@ -3,9 +3,12 @@ package backend
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
pathfilepath "path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
id3v2 "github.com/bogem/id3v2/v2"
|
||||
"github.com/go-flac/flacpicture"
|
||||
"github.com/go-flac/flacvorbis"
|
||||
"github.com/go-flac/go-flac"
|
||||
@@ -15,10 +18,15 @@ type Metadata struct {
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
Date string
|
||||
AlbumArtist string
|
||||
Date string // Recorded date (full date YYYY-MM-DD)
|
||||
ReleaseDate string // Release date (full date) - kept for compatibility
|
||||
TrackNumber int
|
||||
TotalTracks int // Total tracks in album
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
Lyrics string
|
||||
Description string
|
||||
}
|
||||
|
||||
func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
|
||||
@@ -46,18 +54,31 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
|
||||
if metadata.Album != "" {
|
||||
_ = cmt.Add(flacvorbis.FIELD_ALBUM, metadata.Album)
|
||||
}
|
||||
if metadata.AlbumArtist != "" {
|
||||
_ = cmt.Add("ALBUMARTIST", metadata.AlbumArtist)
|
||||
}
|
||||
if metadata.Date != "" {
|
||||
_ = cmt.Add(flacvorbis.FIELD_DATE, metadata.Date)
|
||||
}
|
||||
if metadata.TrackNumber > 0 {
|
||||
_ = cmt.Add(flacvorbis.FIELD_TRACKNUMBER, strconv.Itoa(metadata.TrackNumber))
|
||||
}
|
||||
if metadata.TotalTracks > 0 {
|
||||
_ = cmt.Add("TOTALTRACKS", strconv.Itoa(metadata.TotalTracks))
|
||||
}
|
||||
if metadata.DiscNumber > 0 {
|
||||
_ = cmt.Add("DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
||||
}
|
||||
if metadata.ISRC != "" {
|
||||
_ = cmt.Add(flacvorbis.FIELD_ISRC, metadata.ISRC)
|
||||
}
|
||||
if metadata.Description != "" {
|
||||
_ = cmt.Add("DESCRIPTION", metadata.Description)
|
||||
}
|
||||
// Lyrics is added last to keep it at the bottom
|
||||
if metadata.Lyrics != "" {
|
||||
_ = cmt.Add("LYRICS", metadata.Lyrics) // Or "UNSYNCEDLYRICS" for unsynced
|
||||
}
|
||||
|
||||
cmtBlock := cmt.Marshal()
|
||||
if cmtIdx < 0 {
|
||||
@@ -113,6 +134,75 @@ func fileExists(path string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// extractYear extracts the year from a release date string
|
||||
// Handles formats: "YYYY-MM-DD", "YYYY-MM", "YYYY"
|
||||
func extractYear(releaseDate string) string {
|
||||
if releaseDate == "" {
|
||||
return ""
|
||||
}
|
||||
// Try to extract year (first 4 digits)
|
||||
if len(releaseDate) >= 4 {
|
||||
return releaseDate[:4]
|
||||
}
|
||||
return releaseDate
|
||||
}
|
||||
|
||||
// EmbedLyricsOnly adds lyrics to a FLAC file while preserving existing metadata
|
||||
func EmbedLyricsOnly(filepath string, lyrics string) error {
|
||||
if lyrics == "" {
|
||||
return nil
|
||||
}
|
||||
f, err := flac.ParseFile(filepath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
var cmtIdx = -1
|
||||
var existingCmt *flacvorbis.MetaDataBlockVorbisComment
|
||||
for idx, block := range f.Meta {
|
||||
if block.Type == flac.VorbisComment {
|
||||
cmtIdx = idx
|
||||
existingCmt, err = flacvorbis.ParseFromMetaDataBlock(*block)
|
||||
if err != nil {
|
||||
existingCmt = nil
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Create new comment block, preserving existing comments
|
||||
cmt := flacvorbis.New()
|
||||
|
||||
// Copy existing comments except LYRICS
|
||||
if existingCmt != nil {
|
||||
for _, comment := range existingCmt.Comments {
|
||||
parts := strings.SplitN(comment, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
fieldName := strings.ToUpper(parts[0])
|
||||
if fieldName != "LYRICS" && fieldName != "UNSYNCEDLYRICS" && fieldName != "SYNCEDLYRICS" {
|
||||
_ = cmt.Add(parts[0], parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add lyrics
|
||||
_ = cmt.Add("LYRICS", lyrics)
|
||||
|
||||
cmtBlock := cmt.Marshal()
|
||||
if cmtIdx < 0 {
|
||||
f.Meta = append(f.Meta, &cmtBlock)
|
||||
} else {
|
||||
f.Meta[cmtIdx] = &cmtBlock
|
||||
}
|
||||
|
||||
if err := f.Save(filepath); err != nil {
|
||||
return fmt.Errorf("failed to save FLAC file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadISRCFromFile reads ISRC metadata from a FLAC file
|
||||
func ReadISRCFromFile(filepath string) (string, error) {
|
||||
if !fileExists(filepath) {
|
||||
@@ -187,3 +277,326 @@ func CheckISRCExists(outputDir string, targetISRC string) (string, bool) {
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ExtractCoverArt extracts cover art from an audio file and saves it to a temporary file
|
||||
func ExtractCoverArt(filePath string) (string, error) {
|
||||
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||
|
||||
switch ext {
|
||||
case ".mp3":
|
||||
return extractCoverFromMp3(filePath)
|
||||
case ".m4a", ".flac":
|
||||
return extractCoverFromM4AOrFlac(filePath)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported file format: %s", ext)
|
||||
}
|
||||
}
|
||||
|
||||
// extractCoverFromMp3 extracts cover art from MP3 file
|
||||
func extractCoverFromMp3(filePath string) (string, error) {
|
||||
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open MP3 file: %w", err)
|
||||
}
|
||||
defer tag.Close()
|
||||
|
||||
pictures := tag.GetFrames(tag.CommonID("Attached picture"))
|
||||
if len(pictures) == 0 {
|
||||
return "", fmt.Errorf("no cover art found")
|
||||
}
|
||||
|
||||
pic, ok := pictures[0].(id3v2.PictureFrame)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("invalid picture frame")
|
||||
}
|
||||
|
||||
// Create temporary file
|
||||
tmpFile, err := os.CreateTemp("", "cover-*.jpg")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
defer tmpFile.Close()
|
||||
|
||||
if _, err := tmpFile.Write(pic.Picture); err != nil {
|
||||
os.Remove(tmpFile.Name())
|
||||
return "", fmt.Errorf("failed to write cover art: %w", err)
|
||||
}
|
||||
|
||||
return tmpFile.Name(), nil
|
||||
}
|
||||
|
||||
// extractCoverFromM4AOrFlac extracts cover art from M4A or FLAC file
|
||||
func extractCoverFromM4AOrFlac(filePath string) (string, error) {
|
||||
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||
|
||||
if ext == ".flac" {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
for _, block := range f.Meta {
|
||||
if block.Type == flac.Picture {
|
||||
pic, err := flacpicture.ParseFromMetaDataBlock(*block)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create temporary file
|
||||
tmpFile, err := os.CreateTemp("", "cover-*.jpg")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
defer tmpFile.Close()
|
||||
|
||||
if _, err := tmpFile.Write(pic.ImageData); err != nil {
|
||||
os.Remove(tmpFile.Name())
|
||||
return "", fmt.Errorf("failed to write cover art: %w", err)
|
||||
}
|
||||
|
||||
return tmpFile.Name(), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no cover art found")
|
||||
}
|
||||
|
||||
// For M4A, try to extract using ffmpeg or return empty
|
||||
// M4A cover art should be preserved by ffmpeg during conversion
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// ExtractLyrics extracts lyrics from an audio file
|
||||
func ExtractLyrics(filePath string) (string, error) {
|
||||
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||
|
||||
switch ext {
|
||||
case ".mp3":
|
||||
return extractLyricsFromMp3(filePath)
|
||||
case ".flac":
|
||||
return extractLyricsFromFlac(filePath)
|
||||
case ".m4a":
|
||||
// M4A lyrics extraction would need different approach
|
||||
return "", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported file format: %s", ext)
|
||||
}
|
||||
}
|
||||
|
||||
// extractLyricsFromMp3 extracts lyrics from MP3 file
|
||||
func extractLyricsFromMp3(filePath string) (string, error) {
|
||||
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open MP3 file: %w", err)
|
||||
}
|
||||
defer tag.Close()
|
||||
|
||||
usltFrames := tag.GetFrames(tag.CommonID("Unsynchronised lyrics/text transcription"))
|
||||
if len(usltFrames) == 0 {
|
||||
fmt.Printf("[ExtractLyrics] No USLT frames found in MP3: %s\n", filePath)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
uslt, ok := usltFrames[0].(id3v2.UnsynchronisedLyricsFrame)
|
||||
if !ok {
|
||||
fmt.Printf("[ExtractLyrics] USLT frame type assertion failed in MP3: %s\n", filePath)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if uslt.Lyrics == "" {
|
||||
fmt.Printf("[ExtractLyrics] USLT frame has empty lyrics in MP3: %s\n", filePath)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
fmt.Printf("[ExtractLyrics] Successfully extracted lyrics from MP3: %s (%d characters)\n", filePath, len(uslt.Lyrics))
|
||||
return uslt.Lyrics, nil
|
||||
}
|
||||
|
||||
// extractLyricsFromFlac extracts lyrics from FLAC file
|
||||
func extractLyricsFromFlac(filePath string) (string, error) {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
for _, block := range f.Meta {
|
||||
if block.Type == flac.VorbisComment {
|
||||
cmt, err := flacvorbis.ParseFromMetaDataBlock(*block)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Search through comments for lyrics
|
||||
for _, comment := range cmt.Comments {
|
||||
parts := strings.SplitN(comment, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
fieldName := strings.ToUpper(parts[0])
|
||||
if fieldName == "LYRICS" || fieldName == "UNSYNCEDLYRICS" {
|
||||
lyrics := parts[1]
|
||||
fmt.Printf("[ExtractLyrics] Successfully extracted lyrics from FLAC: %s (%d characters)\n", filePath, len(lyrics))
|
||||
return lyrics, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("[ExtractLyrics] No lyrics found in FLAC: %s\n", filePath)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// EmbedCoverArtOnly embeds cover art into an audio file
|
||||
func EmbedCoverArtOnly(filePath string, coverPath string) error {
|
||||
if coverPath == "" || !fileExists(coverPath) {
|
||||
return nil
|
||||
}
|
||||
|
||||
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||
|
||||
switch ext {
|
||||
case ".mp3":
|
||||
return embedCoverToMp3(filePath, coverPath)
|
||||
case ".m4a":
|
||||
// M4A cover art should be handled by ffmpeg during conversion
|
||||
// If not, we can try to embed using atomicparsley or similar tool
|
||||
// For now, return nil as ffmpeg should handle it
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported file format: %s", ext)
|
||||
}
|
||||
}
|
||||
|
||||
// embedCoverToMp3 embeds cover art into MP3 file
|
||||
func embedCoverToMp3(filePath string, coverPath string) error {
|
||||
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open MP3 file: %w", err)
|
||||
}
|
||||
defer tag.Close()
|
||||
|
||||
// Remove existing cover art
|
||||
tag.DeleteFrames(tag.CommonID("Attached picture"))
|
||||
|
||||
// Read cover art
|
||||
artwork, err := os.ReadFile(coverPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read cover art: %w", err)
|
||||
}
|
||||
|
||||
// Add new cover art
|
||||
pic := id3v2.PictureFrame{
|
||||
Encoding: id3v2.EncodingUTF8,
|
||||
MimeType: "image/jpeg",
|
||||
PictureType: id3v2.PTFrontCover,
|
||||
Description: "Front cover",
|
||||
Picture: artwork,
|
||||
}
|
||||
tag.AddAttachedPicture(pic)
|
||||
|
||||
if err := tag.Save(); err != nil {
|
||||
return fmt.Errorf("failed to save MP3 tags: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmbedLyricsOnlyMP3 adds lyrics to an MP3 file using ID3v2 USLT frame
|
||||
func EmbedLyricsOnlyMP3(filepath string, lyrics string) error {
|
||||
if lyrics == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
tag, err := id3v2.Open(filepath, id3v2.Options{Parse: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open MP3 file: %w", err)
|
||||
}
|
||||
defer tag.Close()
|
||||
|
||||
// Remove existing USLT frames
|
||||
tag.DeleteFrames(tag.CommonID("Unsynchronised lyrics/text transcription"))
|
||||
|
||||
// Add new USLT frame with lyrics
|
||||
// Use UTF-8 encoding for better compatibility with AIMP and other players
|
||||
usltFrame := id3v2.UnsynchronisedLyricsFrame{
|
||||
Encoding: id3v2.EncodingUTF8, // Use UTF-8 instead of default encoding
|
||||
Language: "eng",
|
||||
ContentDescriptor: "", // Empty descriptor for better compatibility
|
||||
Lyrics: lyrics,
|
||||
}
|
||||
tag.AddUnsynchronisedLyricsFrame(usltFrame)
|
||||
|
||||
if err := tag.Save(); err != nil {
|
||||
return fmt.Errorf("failed to save MP3 tags: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// embedLyricsToM4A adds lyrics to an M4A file using ffmpeg
|
||||
func embedLyricsToM4A(filepath string, lyrics string) error {
|
||||
// Use ffmpeg to embed lyrics into M4A file
|
||||
// M4A uses iTunes metadata format with atom '©lyr' for lyrics
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ffmpeg not found: %w", err)
|
||||
}
|
||||
|
||||
// Create temporary output file with proper extension so ffmpeg can detect format
|
||||
tmpOutputFile := strings.TrimSuffix(filepath, pathfilepath.Ext(filepath)) + ".tmp" + pathfilepath.Ext(filepath)
|
||||
defer func() {
|
||||
// Only remove if file still exists (rename might have failed)
|
||||
if _, err := os.Stat(tmpOutputFile); err == nil {
|
||||
os.Remove(tmpOutputFile)
|
||||
}
|
||||
}()
|
||||
|
||||
// Use ffmpeg to copy file and add lyrics metadata
|
||||
// For M4A, we need to use the correct metadata tag format and specify output format
|
||||
// Use -f ipod for M4A format (iPod format is compatible with M4A)
|
||||
cmd := exec.Command(ffmpegPath,
|
||||
"-i", filepath,
|
||||
"-map", "0",
|
||||
"-map_metadata", "0",
|
||||
"-metadata", "lyrics-eng="+lyrics,
|
||||
"-metadata", "lyrics="+lyrics,
|
||||
"-codec", "copy",
|
||||
"-f", "ipod", // Explicitly specify M4A/iPod format
|
||||
"-y", // Overwrite
|
||||
tmpOutputFile,
|
||||
)
|
||||
|
||||
// Hide console window on Windows
|
||||
setHideWindow(cmd)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
fmt.Printf("[FFmpeg] Error embedding lyrics to M4A: %s\n", string(output))
|
||||
return fmt.Errorf("ffmpeg failed to embed lyrics: %s - %w", string(output), err)
|
||||
}
|
||||
|
||||
// Replace original file with new file
|
||||
if err := os.Rename(tmpOutputFile, filepath); err != nil {
|
||||
return fmt.Errorf("failed to replace original file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[FFmpeg] Lyrics embedded to M4A successfully: %d characters\n", len(lyrics))
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmbedLyricsOnlyUniversal embeds lyrics to MP3, FLAC, or M4A file
|
||||
func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error {
|
||||
if lyrics == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
ext := strings.ToLower(pathfilepath.Ext(filepath))
|
||||
switch ext {
|
||||
case ".mp3":
|
||||
return EmbedLyricsOnlyMP3(filepath, lyrics)
|
||||
case ".flac":
|
||||
return EmbedLyricsOnly(filepath, lyrics)
|
||||
case ".m4a":
|
||||
return embedLyricsToM4A(filepath, lyrics)
|
||||
default:
|
||||
return fmt.Errorf("unsupported file format for lyrics embedding: %s", ext)
|
||||
}
|
||||
}
|
||||
|
||||
+71
-52
@@ -93,8 +93,23 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
|
||||
}
|
||||
|
||||
var searchResp QobuzSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
// Read body first to handle encoding issues
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return nil, fmt.Errorf("API returned empty response")
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &searchResp); err != nil {
|
||||
// Truncate body for error message (max 200 chars)
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
}
|
||||
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
||||
}
|
||||
|
||||
if len(searchResp.Tracks.Items) == 0 {
|
||||
@@ -107,15 +122,20 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
|
||||
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
||||
// Map quality to Qobuz quality code
|
||||
// Qobuz uses: 5 (MP3 320), 6 (FLAC 16-bit), 7 (FLAC 24-bit), 27 (Hi-Res)
|
||||
qualityCode := "27" // Default to Hi-Res
|
||||
qualityCode := quality // Use the provided quality parameter
|
||||
if qualityCode == "" {
|
||||
qualityCode = "6" // Default to FLAC 16-bit if not specified
|
||||
}
|
||||
|
||||
fmt.Printf("Getting download URL for track ID: %d\n", trackID)
|
||||
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
|
||||
fmt.Printf("Quality codes: 6=FLAC 16-bit, 7=FLAC 24-bit, 27=Hi-Res\n")
|
||||
|
||||
// Decode base64 API URLs
|
||||
primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9")
|
||||
|
||||
// Try primary API first
|
||||
primaryURL := fmt.Sprintf("%s%d&quality=%s", string(primaryBase), trackID, qualityCode)
|
||||
fmt.Printf("Qobuz API URL: %s\n", primaryURL)
|
||||
|
||||
resp, err := q.client.Get(primaryURL)
|
||||
if err == nil && resp.StatusCode == 200 {
|
||||
@@ -151,12 +171,25 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
||||
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return "", fmt.Errorf("API returned empty response")
|
||||
}
|
||||
|
||||
fmt.Printf("Fallback API response: %s\n", string(body))
|
||||
|
||||
var streamResp QobuzStreamResponse
|
||||
if err := json.Unmarshal(body, &streamResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||
// Truncate body for error message (max 200 chars)
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
}
|
||||
return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
||||
}
|
||||
|
||||
if streamResp.URL == "" {
|
||||
@@ -169,7 +202,13 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
||||
|
||||
func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
|
||||
fmt.Println("Starting file download...")
|
||||
resp, err := q.client.Get(url)
|
||||
// Use a separate client with a longer timeout. The default client's 60s limit
|
||||
// causes downloads to fail on slow connections or for large Hi-Res files.
|
||||
downloadClient := &http.Client{
|
||||
Timeout: 5 * time.Minute, // 5 minutes for large files
|
||||
}
|
||||
|
||||
resp, err := downloadClient.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
@@ -268,7 +307,7 @@ func buildQobuzFilename(title, artist string, trackNumber int, format string, in
|
||||
return filename + ".flac"
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
|
||||
func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int) (string, error) {
|
||||
fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
@@ -283,29 +322,11 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Use Spotify metadata if provided, otherwise fallback to Qobuz metadata
|
||||
// All metadata from Spotify - no fallback to Qobuz
|
||||
artists := spotifyArtistName
|
||||
trackTitle := spotifyTrackName
|
||||
albumTitle := spotifyAlbumName
|
||||
|
||||
if artists == "" {
|
||||
artists = track.Performer.Name
|
||||
if track.Album.Artist.Name != "" {
|
||||
artists = track.Album.Artist.Name
|
||||
}
|
||||
}
|
||||
|
||||
if trackTitle == "" {
|
||||
trackTitle = track.Title
|
||||
if track.Version != "" && track.Version != "null" {
|
||||
trackTitle = fmt.Sprintf("%s (%s)", track.Title, track.Version)
|
||||
}
|
||||
}
|
||||
|
||||
if albumTitle == "" {
|
||||
albumTitle = track.Album.Title
|
||||
}
|
||||
|
||||
fmt.Printf("Found track: %s - %s\n", artists, trackTitle)
|
||||
fmt.Printf("Album: %s\n", albumTitle)
|
||||
|
||||
@@ -335,14 +356,14 @@ 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)
|
||||
// Check if file with same ISRC already exists (use Spotify ISRC)
|
||||
if existingFile, exists := CheckISRCExists(outputDir, isrc); exists {
|
||||
fmt.Printf("File with ISRC %s already exists: %s\n", isrc, existingFile)
|
||||
return "EXISTS:" + existingFile, nil
|
||||
}
|
||||
|
||||
// Build filename based on format settings
|
||||
filename := buildQobuzFilename(safeTitle, safeArtist, track.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
// Build filename based on format settings (use Spotify track number)
|
||||
filename := buildQobuzFilename(safeTitle, safeArtist, spotifyTrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
filepath := filepath.Join(outputDir, filename)
|
||||
|
||||
if fileInfo, err := os.Stat(filepath); err == nil && fileInfo.Size() > 0 {
|
||||
@@ -358,41 +379,39 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
|
||||
fmt.Printf("Downloaded: %s\n", filepath)
|
||||
|
||||
coverPath := ""
|
||||
if track.Album.Image.Large != "" {
|
||||
// Use Spotify cover URL (with max resolution if enabled) - all metadata from Spotify
|
||||
if spotifyCoverURL != "" {
|
||||
coverPath = filepath + ".cover.jpg"
|
||||
fmt.Println("Downloading cover art...")
|
||||
if err := q.DownloadCoverArt(track.Album.Image.Large, coverPath); err != nil {
|
||||
fmt.Printf("Warning: Failed to download cover art: %v\n", err)
|
||||
coverClient := NewCoverClient()
|
||||
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
|
||||
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
|
||||
coverPath = ""
|
||||
} else {
|
||||
defer os.Remove(coverPath)
|
||||
fmt.Println("Spotify cover downloaded")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Embedding metadata and cover art...")
|
||||
|
||||
releaseYear := ""
|
||||
if len(track.ReleaseDateOriginal) >= 4 {
|
||||
releaseYear = track.ReleaseDateOriginal[:4]
|
||||
}
|
||||
|
||||
// Use album track number if in album folder structure, otherwise use playlist position
|
||||
trackNumberToEmbed := 0
|
||||
if position > 0 {
|
||||
if useAlbumTrackNumber && track.TrackNumber > 0 {
|
||||
trackNumberToEmbed = track.TrackNumber
|
||||
} else {
|
||||
trackNumberToEmbed = position
|
||||
}
|
||||
// Determine track number to embed - ALL from Spotify
|
||||
trackNumberToEmbed := spotifyTrackNumber
|
||||
if position > 0 && !useAlbumTrackNumber {
|
||||
trackNumberToEmbed = position // Use playlist position
|
||||
}
|
||||
|
||||
// ALL metadata from Spotify
|
||||
metadata := Metadata{
|
||||
Title: trackTitle,
|
||||
Artist: artists,
|
||||
Album: albumTitle,
|
||||
Date: releaseYear,
|
||||
AlbumArtist: spotifyAlbumArtist,
|
||||
Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD)
|
||||
TrackNumber: trackNumberToEmbed,
|
||||
DiscNumber: track.MediaNumber,
|
||||
ISRC: track.ISRC,
|
||||
TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify
|
||||
DiscNumber: spotifyDiscNumber, // Disc number from Spotify
|
||||
ISRC: isrc, // ISRC from Spotify (passed as parameter)
|
||||
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||
}
|
||||
|
||||
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
|
||||
|
||||
+36
-20
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
@@ -18,7 +19,6 @@ type SongLinkClient struct {
|
||||
|
||||
type SongLinkURLs struct {
|
||||
TidalURL string `json:"tidal_url"`
|
||||
DeezerURL string `json:"deezer_url"`
|
||||
AmazonURL string `json:"amazon_url"`
|
||||
}
|
||||
|
||||
@@ -26,11 +26,9 @@ type SongLinkURLs struct {
|
||||
type TrackAvailability struct {
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Tidal bool `json:"tidal"`
|
||||
Deezer bool `json:"deezer"`
|
||||
Amazon bool `json:"amazon"`
|
||||
Qobuz bool `json:"qobuz"`
|
||||
TidalURL string `json:"tidal_url,omitempty"`
|
||||
DeezerURL string `json:"deezer_url,omitempty"`
|
||||
AmazonURL string `json:"amazon_url,omitempty"`
|
||||
QobuzURL string `json:"qobuz_url,omitempty"`
|
||||
}
|
||||
@@ -126,8 +124,23 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
|
||||
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)
|
||||
// Read body first to handle encoding issues
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return nil, fmt.Errorf("API returned empty response")
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
// Truncate body for error message (max 200 chars)
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
}
|
||||
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
||||
}
|
||||
|
||||
urls := &SongLinkURLs{}
|
||||
@@ -138,12 +151,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
|
||||
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
|
||||
@@ -155,7 +162,7 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
|
||||
}
|
||||
|
||||
// Check if at least one URL was found
|
||||
if urls.TidalURL == "" && urls.DeezerURL == "" && urls.AmazonURL == "" {
|
||||
if urls.TidalURL == "" && urls.AmazonURL == "" {
|
||||
return nil, fmt.Errorf("no streaming URLs found")
|
||||
}
|
||||
|
||||
@@ -245,8 +252,23 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
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)
|
||||
// Read body first to handle encoding issues
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return nil, fmt.Errorf("API returned empty response")
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
// Truncate body for error message (max 200 chars)
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
}
|
||||
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
||||
}
|
||||
|
||||
availability := &TrackAvailability{
|
||||
@@ -259,12 +281,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
availability.TidalURL = tidalLink.URL
|
||||
}
|
||||
|
||||
// Check Deezer
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
}
|
||||
|
||||
// Check Amazon
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
|
||||
@@ -57,16 +57,19 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
||||
|
||||
// TrackMetadata mirrors the filtered track payload returned by the Python script.
|
||||
type TrackMetadata struct {
|
||||
SpotifyID string `json:"spotify_id,omitempty"`
|
||||
Artists string `json:"artists"`
|
||||
Name string `json:"name"`
|
||||
AlbumName string `json:"album_name"`
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
Images string `json:"images"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
TotalTracks int `json:"total_tracks,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
ExternalURL string `json:"external_urls"`
|
||||
ISRC string `json:"isrc"`
|
||||
SpotifyID string `json:"spotify_id,omitempty"`
|
||||
}
|
||||
|
||||
// ArtistSimple holds basic artist info for clickable artists
|
||||
@@ -78,17 +81,20 @@ type ArtistSimple struct {
|
||||
|
||||
// AlbumTrackMetadata holds per-track info for album / playlist formatting.
|
||||
type AlbumTrackMetadata struct {
|
||||
SpotifyID string `json:"spotify_id,omitempty"`
|
||||
Artists string `json:"artists"`
|
||||
Name string `json:"name"`
|
||||
AlbumName string `json:"album_name"`
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
Images string `json:"images"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
TotalTracks int `json:"total_tracks,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
ExternalURL string `json:"external_urls"`
|
||||
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"`
|
||||
@@ -227,6 +233,7 @@ type trackSimplified struct {
|
||||
Name string `json:"name"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
ExternalURL externalURL `json:"external_urls"`
|
||||
Artists []artist `json:"artists"`
|
||||
}
|
||||
@@ -236,6 +243,7 @@ type trackFull struct {
|
||||
Name string `json:"name"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
ExternalURL externalURL `json:"external_urls"`
|
||||
ExternalID externalID `json:"external_ids"`
|
||||
Album albumSimplified `json:"album"`
|
||||
@@ -502,16 +510,19 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *playlistRaw) PlaylistRes
|
||||
})
|
||||
}
|
||||
tracks = append(tracks, AlbumTrackMetadata{
|
||||
SpotifyID: item.Track.ID,
|
||||
Artists: joinArtists(item.Track.Artists),
|
||||
Name: item.Track.Name,
|
||||
AlbumName: item.Track.Album.Name,
|
||||
AlbumArtist: joinArtists(item.Track.Album.Artists),
|
||||
DurationMS: item.Track.DurationMS,
|
||||
Images: firstNonEmpty(firstImageURL(item.Track.Album.Images), info.Owner.Images),
|
||||
ReleaseDate: item.Track.Album.ReleaseDate,
|
||||
TrackNumber: item.Track.TrackNumber,
|
||||
TotalTracks: item.Track.Album.TotalTracks,
|
||||
DiscNumber: item.Track.DiscNumber,
|
||||
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,
|
||||
@@ -551,16 +562,19 @@ func (c *SpotifyMetadataClient) formatAlbumData(ctx context.Context, raw *albumR
|
||||
for _, item := range raw.Data.Tracks.Items {
|
||||
isrc := c.fetchTrackISRC(ctx, item.ID, raw.Token, cache)
|
||||
tracks = append(tracks, AlbumTrackMetadata{
|
||||
SpotifyID: item.ID,
|
||||
Artists: joinArtists(item.Artists),
|
||||
Name: item.Name,
|
||||
AlbumName: raw.Data.Name,
|
||||
AlbumArtist: joinArtists(raw.Data.Artists),
|
||||
DurationMS: item.DurationMS,
|
||||
Images: albumImage,
|
||||
ReleaseDate: raw.Data.ReleaseDate,
|
||||
TrackNumber: item.TrackNumber,
|
||||
TotalTracks: raw.Data.TotalTracks,
|
||||
DiscNumber: item.DiscNumber,
|
||||
ExternalURL: item.ExternalURL.Spotify,
|
||||
ISRC: isrc,
|
||||
SpotifyID: item.ID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -629,17 +643,20 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
||||
})
|
||||
}
|
||||
allTracks = append(allTracks, AlbumTrackMetadata{
|
||||
SpotifyID: tr.ID,
|
||||
Artists: joinArtists(tr.Artists),
|
||||
Name: tr.Name,
|
||||
AlbumName: alb.Name,
|
||||
AlbumArtist: joinArtists(alb.Artists),
|
||||
AlbumType: alb.AlbumType,
|
||||
DurationMS: tr.DurationMS,
|
||||
Images: albumImage,
|
||||
ReleaseDate: alb.ReleaseDate,
|
||||
TrackNumber: tr.TrackNumber,
|
||||
TotalTracks: alb.TotalTracks,
|
||||
DiscNumber: tr.DiscNumber,
|
||||
ExternalURL: tr.ExternalURL.Spotify,
|
||||
ISRC: isrc,
|
||||
SpotifyID: tr.ID,
|
||||
AlbumID: alb.ID,
|
||||
AlbumURL: alb.ExternalURL.Spotify,
|
||||
ArtistID: artistID,
|
||||
@@ -676,16 +693,19 @@ func formatTrackData(raw *trackFull) TrackResponse {
|
||||
}
|
||||
return TrackResponse{
|
||||
Track: TrackMetadata{
|
||||
SpotifyID: raw.ID,
|
||||
Artists: joinArtists(raw.Artists),
|
||||
Name: raw.Name,
|
||||
AlbumName: raw.Album.Name,
|
||||
AlbumArtist: joinArtists(raw.Album.Artists),
|
||||
DurationMS: raw.DurationMS,
|
||||
Images: firstImageURL(raw.Album.Images),
|
||||
ReleaseDate: raw.Album.ReleaseDate,
|
||||
TrackNumber: raw.TrackNumber,
|
||||
TotalTracks: raw.Album.TotalTracks,
|
||||
DiscNumber: raw.DiscNumber,
|
||||
ExternalURL: raw.ExternalURL.Spotify,
|
||||
ISRC: raw.ExternalID.ISRC,
|
||||
SpotifyID: raw.ID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+131
-255
@@ -571,8 +571,13 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
||||
// Fallback to v1 format (array with OriginalTrackUrl)
|
||||
var apiResponses []TidalAPIResponse
|
||||
if err := json.Unmarshal(body, &apiResponses); err != nil {
|
||||
fmt.Printf("✗ Failed to decode Tidal API response: %v\n", err)
|
||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||
// Truncate body for error message (max 200 chars)
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
}
|
||||
fmt.Printf("✗ Failed to decode Tidal API response: %v (response: %s)\n", err, bodyStr)
|
||||
return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
||||
}
|
||||
|
||||
if len(apiResponses) == 0 {
|
||||
@@ -617,6 +622,7 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
|
||||
}
|
||||
|
||||
resp, err := t.client.Get(url)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
@@ -795,7 +801,7 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
|
||||
func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyISRC string) (string, error) {
|
||||
if outputDir != "." {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("directory error: %w", err)
|
||||
@@ -820,41 +826,14 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
||||
return "", fmt.Errorf("no track ID found")
|
||||
}
|
||||
|
||||
// Use Spotify metadata if provided, otherwise fallback to Tidal metadata
|
||||
// All metadata from Spotify - no fallback to Tidal
|
||||
artistName := spotifyArtistName
|
||||
trackTitle := spotifyTrackName
|
||||
albumTitle := spotifyAlbumName
|
||||
|
||||
if artistName == "" {
|
||||
var artists []string
|
||||
if len(trackInfo.Artists) > 0 {
|
||||
for _, artist := range trackInfo.Artists {
|
||||
if artist.Name != "" {
|
||||
artists = append(artists, artist.Name)
|
||||
}
|
||||
}
|
||||
} else if trackInfo.Artist.Name != "" {
|
||||
artists = append(artists, trackInfo.Artist.Name)
|
||||
}
|
||||
|
||||
artistName = "Unknown Artist"
|
||||
if len(artists) > 0 {
|
||||
artistName = strings.Join(artists, ", ")
|
||||
}
|
||||
}
|
||||
artistName = sanitizeFilename(artistName)
|
||||
|
||||
if trackTitle == "" {
|
||||
trackTitle = trackInfo.Title
|
||||
if trackTitle == "" {
|
||||
trackTitle = fmt.Sprintf("track_%d", trackInfo.ID)
|
||||
}
|
||||
}
|
||||
trackTitle = sanitizeFilename(trackTitle)
|
||||
|
||||
if albumTitle == "" {
|
||||
albumTitle = trackInfo.Album.Title
|
||||
}
|
||||
// Sanitize for filename only (not for metadata)
|
||||
artistNameForFile := sanitizeFilename(artistName)
|
||||
trackTitleForFile := sanitizeFilename(trackTitle)
|
||||
|
||||
// Check if file with same ISRC already exists
|
||||
if existingFile, exists := CheckISRCExists(outputDir, trackInfo.ISRC); exists {
|
||||
@@ -862,8 +841,8 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
||||
return "EXISTS:" + existingFile, nil
|
||||
}
|
||||
|
||||
// Build filename based on format settings
|
||||
filename := buildTidalFilename(trackTitle, artistName, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
// Build filename based on format settings (use sanitized versions for filename)
|
||||
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
outputFilename := filepath.Join(outputDir, filename)
|
||||
|
||||
if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 {
|
||||
@@ -884,44 +863,39 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
||||
fmt.Println("Adding metadata...")
|
||||
|
||||
coverPath := ""
|
||||
if trackInfo.Album.Cover != "" {
|
||||
// Use Spotify cover URL (with max resolution if enabled) - all metadata from Spotify
|
||||
if spotifyCoverURL != "" {
|
||||
coverPath = outputFilename + ".cover.jpg"
|
||||
albumArt, err := t.DownloadAlbumArt(trackInfo.Album.Cover)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to download album art: %v\n", err)
|
||||
coverClient := NewCoverClient()
|
||||
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
|
||||
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
|
||||
coverPath = ""
|
||||
} else {
|
||||
if err := os.WriteFile(coverPath, albumArt, 0644); err != nil {
|
||||
fmt.Printf("Warning: Failed to save album art: %v\n", err)
|
||||
} else {
|
||||
defer os.Remove(coverPath)
|
||||
fmt.Println("Album art downloaded")
|
||||
}
|
||||
defer os.Remove(coverPath)
|
||||
fmt.Println("Spotify cover downloaded")
|
||||
}
|
||||
}
|
||||
|
||||
releaseYear := ""
|
||||
if len(trackInfo.Album.ReleaseDate) >= 4 {
|
||||
releaseYear = trackInfo.Album.ReleaseDate[:4]
|
||||
}
|
||||
|
||||
// Use album track number if in album folder structure, otherwise use playlist position
|
||||
trackNumberToEmbed := 0
|
||||
if position > 0 {
|
||||
if useAlbumTrackNumber && trackInfo.TrackNumber > 0 {
|
||||
trackNumberToEmbed = trackInfo.TrackNumber
|
||||
} else {
|
||||
trackNumberToEmbed = position
|
||||
}
|
||||
// Determine track number to embed - ALL from Spotify
|
||||
// - If position > 0 and !useAlbumTrackNumber: use playlist position
|
||||
// - Otherwise: use Spotify track number
|
||||
trackNumberToEmbed := spotifyTrackNumber
|
||||
if position > 0 && !useAlbumTrackNumber {
|
||||
trackNumberToEmbed = position // Use playlist position
|
||||
}
|
||||
|
||||
// ALL metadata from Spotify
|
||||
metadata := Metadata{
|
||||
Title: trackTitle,
|
||||
Artist: artistName,
|
||||
Album: albumTitle,
|
||||
Date: releaseYear,
|
||||
AlbumArtist: spotifyAlbumArtist,
|
||||
Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD)
|
||||
TrackNumber: trackNumberToEmbed,
|
||||
DiscNumber: trackInfo.VolumeNumber,
|
||||
ISRC: trackInfo.ISRC,
|
||||
TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify
|
||||
DiscNumber: spotifyDiscNumber, // Disc number from Spotify
|
||||
ISRC: spotifyISRC, // ISRC from Spotify
|
||||
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||
}
|
||||
|
||||
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
|
||||
@@ -935,7 +909,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
||||
return outputFilename, nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
|
||||
func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyISRC string) (string, error) {
|
||||
apis, err := t.GetAvailableAPIs()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no APIs available for fallback: %w", err)
|
||||
@@ -965,40 +939,14 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
||||
return "", fmt.Errorf("no track ID found")
|
||||
}
|
||||
|
||||
// Use Spotify metadata if provided, otherwise fallback to Tidal metadata
|
||||
// All metadata from Spotify - no fallback to Tidal
|
||||
artistName := spotifyArtistName
|
||||
trackTitle := spotifyTrackName
|
||||
albumTitle := spotifyAlbumName
|
||||
|
||||
if artistName == "" {
|
||||
var artists []string
|
||||
if len(trackInfo.Artists) > 0 {
|
||||
for _, artist := range trackInfo.Artists {
|
||||
if artist.Name != "" {
|
||||
artists = append(artists, artist.Name)
|
||||
}
|
||||
}
|
||||
} else if trackInfo.Artist.Name != "" {
|
||||
artists = append(artists, trackInfo.Artist.Name)
|
||||
}
|
||||
artistName = "Unknown Artist"
|
||||
if len(artists) > 0 {
|
||||
artistName = strings.Join(artists, ", ")
|
||||
}
|
||||
}
|
||||
artistName = sanitizeFilename(artistName)
|
||||
|
||||
if trackTitle == "" {
|
||||
trackTitle = trackInfo.Title
|
||||
if trackTitle == "" {
|
||||
trackTitle = fmt.Sprintf("track_%d", trackInfo.ID)
|
||||
}
|
||||
}
|
||||
trackTitle = sanitizeFilename(trackTitle)
|
||||
|
||||
if albumTitle == "" {
|
||||
albumTitle = trackInfo.Album.Title
|
||||
}
|
||||
// Sanitize for filename only (not for metadata)
|
||||
artistNameForFile := sanitizeFilename(artistName)
|
||||
trackTitleForFile := sanitizeFilename(trackTitle)
|
||||
|
||||
// Check if file with same ISRC already exists
|
||||
if existingFile, exists := CheckISRCExists(outputDir, trackInfo.ISRC); exists {
|
||||
@@ -1006,7 +954,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
||||
return "EXISTS:" + existingFile, nil
|
||||
}
|
||||
|
||||
filename := buildTidalFilename(trackTitle, artistName, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
outputFilename := filepath.Join(outputDir, filename)
|
||||
|
||||
if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 {
|
||||
@@ -1030,43 +978,37 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
||||
fmt.Println("Adding metadata...")
|
||||
|
||||
coverPath := ""
|
||||
if trackInfo.Album.Cover != "" {
|
||||
// Use Spotify cover URL (with max resolution if enabled) - all metadata from Spotify
|
||||
if spotifyCoverURL != "" {
|
||||
coverPath = outputFilename + ".cover.jpg"
|
||||
albumArt, err := downloader.DownloadAlbumArt(trackInfo.Album.Cover)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to download album art: %v\n", err)
|
||||
coverClient := NewCoverClient()
|
||||
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
|
||||
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
|
||||
coverPath = ""
|
||||
} else {
|
||||
if err := os.WriteFile(coverPath, albumArt, 0644); err != nil {
|
||||
fmt.Printf("Warning: Failed to save album art: %v\n", err)
|
||||
} else {
|
||||
defer os.Remove(coverPath)
|
||||
fmt.Println("Album art downloaded")
|
||||
}
|
||||
defer os.Remove(coverPath)
|
||||
fmt.Println("Spotify cover downloaded")
|
||||
}
|
||||
}
|
||||
|
||||
releaseYear := ""
|
||||
if len(trackInfo.Album.ReleaseDate) >= 4 {
|
||||
releaseYear = trackInfo.Album.ReleaseDate[:4]
|
||||
}
|
||||
|
||||
trackNumberToEmbed := 0
|
||||
if position > 0 {
|
||||
if useAlbumTrackNumber && trackInfo.TrackNumber > 0 {
|
||||
trackNumberToEmbed = trackInfo.TrackNumber
|
||||
} else {
|
||||
trackNumberToEmbed = position
|
||||
}
|
||||
// Determine track number to embed - ALL from Spotify
|
||||
trackNumberToEmbed := spotifyTrackNumber
|
||||
if position > 0 && !useAlbumTrackNumber {
|
||||
trackNumberToEmbed = position // Use playlist position
|
||||
}
|
||||
|
||||
// ALL metadata from Spotify
|
||||
metadata := Metadata{
|
||||
Title: trackTitle,
|
||||
Artist: artistName,
|
||||
Album: albumTitle,
|
||||
Date: releaseYear,
|
||||
AlbumArtist: spotifyAlbumArtist,
|
||||
Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD)
|
||||
TrackNumber: trackNumberToEmbed,
|
||||
DiscNumber: trackInfo.VolumeNumber,
|
||||
ISRC: trackInfo.ISRC,
|
||||
TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify
|
||||
DiscNumber: spotifyDiscNumber, // Disc number from Spotify
|
||||
ISRC: spotifyISRC, // ISRC from Spotify
|
||||
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||
}
|
||||
|
||||
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
|
||||
@@ -1080,41 +1022,41 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
||||
return outputFilename, nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
|
||||
func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyISRC string) (string, error) {
|
||||
// Get Tidal URL from Spotify track ID
|
||||
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
|
||||
if err != nil {
|
||||
// Songlink failed to find Tidal URL, try search fallback
|
||||
fmt.Printf("Songlink couldn't find Tidal URL: %v\n", err)
|
||||
fmt.Println("Trying Tidal search fallback...")
|
||||
return t.DownloadBySearch(spotifyTrackName, spotifyArtistName, spotifyAlbumName, "", 0, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
return t.DownloadBySearch(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyISRC, 0, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks)
|
||||
}
|
||||
|
||||
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
|
||||
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyISRC)
|
||||
}
|
||||
|
||||
// DownloadWithISRC downloads a track with ISRC matching for search fallback
|
||||
func (t *TidalDownloader) DownloadWithISRC(spotifyTrackID, spotifyISRC, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool, expectedDuration int) (string, error) {
|
||||
func (t *TidalDownloader) DownloadWithISRC(spotifyTrackID, spotifyISRC, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, expectedDuration int, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int) (string, error) {
|
||||
// Get Tidal URL from Spotify track ID
|
||||
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
|
||||
if err != nil {
|
||||
// Songlink failed to find Tidal URL, try search fallback with ISRC
|
||||
fmt.Printf("Songlink couldn't find Tidal URL: %v\n", err)
|
||||
fmt.Println("Trying Tidal search fallback with ISRC matching...")
|
||||
return t.DownloadBySearchWithISRC(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyISRC, expectedDuration, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
return t.DownloadBySearchWithISRC(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyISRC, expectedDuration, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks)
|
||||
}
|
||||
|
||||
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
|
||||
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyISRC)
|
||||
}
|
||||
|
||||
// DownloadBySearch downloads a track by searching Tidal directly using metadata
|
||||
// This is used as a fallback when Songlink API doesn't find a Tidal URL
|
||||
func (t *TidalDownloader) DownloadBySearch(trackName, artistName, albumName, spotifyISRC string, expectedDuration int, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) (string, error) {
|
||||
return t.DownloadBySearchWithISRC(trackName, artistName, albumName, spotifyISRC, expectedDuration, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
func (t *TidalDownloader) DownloadBySearch(trackName, artistName, albumName, albumArtist, releaseDate, spotifyISRC string, expectedDuration int, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int) (string, error) {
|
||||
return t.DownloadBySearchWithISRC(trackName, artistName, albumName, albumArtist, releaseDate, spotifyISRC, expectedDuration, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks)
|
||||
}
|
||||
|
||||
// DownloadBySearchWithISRC downloads a track by searching Tidal with ISRC matching
|
||||
func (t *TidalDownloader) DownloadBySearchWithISRC(trackName, artistName, albumName, spotifyISRC string, expectedDuration int, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) (string, error) {
|
||||
func (t *TidalDownloader) DownloadBySearchWithISRC(trackName, artistName, albumName, albumArtist, releaseDate, spotifyISRC string, expectedDuration int, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int) (string, error) {
|
||||
if outputDir != "." {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("directory error: %w", err)
|
||||
@@ -1131,50 +1073,23 @@ func (t *TidalDownloader) DownloadBySearchWithISRC(trackName, artistName, albumN
|
||||
return "", fmt.Errorf("no track ID found from search")
|
||||
}
|
||||
|
||||
// Use provided metadata, fallback to Tidal metadata
|
||||
// All metadata from Spotify - no fallback to Tidal
|
||||
finalArtistName := artistName
|
||||
finalTrackTitle := trackName
|
||||
finalAlbumTitle := albumName
|
||||
|
||||
if finalArtistName == "" {
|
||||
var artists []string
|
||||
if len(trackInfo.Artists) > 0 {
|
||||
for _, artist := range trackInfo.Artists {
|
||||
if artist.Name != "" {
|
||||
artists = append(artists, artist.Name)
|
||||
}
|
||||
}
|
||||
} else if trackInfo.Artist.Name != "" {
|
||||
artists = append(artists, trackInfo.Artist.Name)
|
||||
}
|
||||
if len(artists) > 0 {
|
||||
finalArtistName = strings.Join(artists, ", ")
|
||||
} else {
|
||||
finalArtistName = "Unknown Artist"
|
||||
}
|
||||
}
|
||||
finalArtistName = sanitizeFilename(finalArtistName)
|
||||
// Sanitize for filename only (not for metadata)
|
||||
finalArtistNameForFile := sanitizeFilename(finalArtistName)
|
||||
finalTrackTitleForFile := sanitizeFilename(finalTrackTitle)
|
||||
|
||||
if finalTrackTitle == "" {
|
||||
finalTrackTitle = trackInfo.Title
|
||||
if finalTrackTitle == "" {
|
||||
finalTrackTitle = fmt.Sprintf("track_%d", trackInfo.ID)
|
||||
}
|
||||
}
|
||||
finalTrackTitle = sanitizeFilename(finalTrackTitle)
|
||||
|
||||
if finalAlbumTitle == "" {
|
||||
finalAlbumTitle = trackInfo.Album.Title
|
||||
}
|
||||
|
||||
// Check if file with same ISRC already exists
|
||||
if existingFile, exists := CheckISRCExists(outputDir, trackInfo.ISRC); exists {
|
||||
fmt.Printf("File with ISRC %s already exists: %s\n", trackInfo.ISRC, existingFile)
|
||||
// Check if file with same ISRC already exists (use Spotify ISRC)
|
||||
if existingFile, exists := CheckISRCExists(outputDir, spotifyISRC); exists {
|
||||
fmt.Printf("File with ISRC %s already exists: %s\n", spotifyISRC, existingFile)
|
||||
return "EXISTS:" + existingFile, nil
|
||||
}
|
||||
|
||||
// Build filename
|
||||
filename := buildTidalFilename(finalTrackTitle, finalArtistName, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
filename := buildTidalFilename(finalTrackTitleForFile, finalArtistNameForFile, spotifyTrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
outputFilename := filepath.Join(outputDir, filename)
|
||||
|
||||
if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 {
|
||||
@@ -1196,43 +1111,37 @@ func (t *TidalDownloader) DownloadBySearchWithISRC(trackName, artistName, albumN
|
||||
fmt.Println("Adding metadata...")
|
||||
|
||||
coverPath := ""
|
||||
if trackInfo.Album.Cover != "" {
|
||||
// Use Spotify cover URL (with max resolution if enabled) - all metadata from Spotify
|
||||
if spotifyCoverURL != "" {
|
||||
coverPath = outputFilename + ".cover.jpg"
|
||||
albumArt, err := t.DownloadAlbumArt(trackInfo.Album.Cover)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to download album art: %v\n", err)
|
||||
coverClient := NewCoverClient()
|
||||
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
|
||||
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
|
||||
coverPath = ""
|
||||
} else {
|
||||
if err := os.WriteFile(coverPath, albumArt, 0644); err != nil {
|
||||
fmt.Printf("Warning: Failed to save album art: %v\n", err)
|
||||
} else {
|
||||
defer os.Remove(coverPath)
|
||||
fmt.Println("Album art downloaded")
|
||||
}
|
||||
defer os.Remove(coverPath)
|
||||
fmt.Println("Spotify cover downloaded")
|
||||
}
|
||||
}
|
||||
|
||||
releaseYear := ""
|
||||
if len(trackInfo.Album.ReleaseDate) >= 4 {
|
||||
releaseYear = trackInfo.Album.ReleaseDate[:4]
|
||||
}
|
||||
|
||||
trackNumberToEmbed := 0
|
||||
if position > 0 {
|
||||
if useAlbumTrackNumber && trackInfo.TrackNumber > 0 {
|
||||
trackNumberToEmbed = trackInfo.TrackNumber
|
||||
} else {
|
||||
trackNumberToEmbed = position
|
||||
}
|
||||
// Determine track number to embed - ALL from Spotify
|
||||
trackNumberToEmbed := spotifyTrackNumber
|
||||
if position > 0 && !useAlbumTrackNumber {
|
||||
trackNumberToEmbed = position // Use playlist position
|
||||
}
|
||||
|
||||
// ALL metadata from Spotify
|
||||
metadata := Metadata{
|
||||
Title: finalTrackTitle,
|
||||
Artist: finalArtistName,
|
||||
Album: finalAlbumTitle,
|
||||
Date: releaseYear,
|
||||
AlbumArtist: albumArtist,
|
||||
Date: releaseDate, // Recorded date (full date YYYY-MM-DD)
|
||||
TrackNumber: trackNumberToEmbed,
|
||||
DiscNumber: trackInfo.VolumeNumber,
|
||||
ISRC: trackInfo.ISRC,
|
||||
TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify
|
||||
DiscNumber: spotifyDiscNumber, // Disc number from Spotify
|
||||
ISRC: spotifyISRC, // ISRC from Spotify
|
||||
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||
}
|
||||
|
||||
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
|
||||
@@ -1463,7 +1372,7 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
||||
|
||||
// DownloadBySearchWithFallback tries multiple APIs when downloading via search
|
||||
// Search is done ONCE, then requests all APIs in PARALLEL for download URL
|
||||
func (t *TidalDownloader) DownloadBySearchWithFallback(trackName, artistName, albumName, spotifyISRC string, expectedDuration int, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) (string, error) {
|
||||
func (t *TidalDownloader) DownloadBySearchWithFallback(trackName, artistName, albumName, albumArtist, releaseDate, spotifyISRC string, expectedDuration int, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int) (string, error) {
|
||||
apis, err := t.GetAvailableAPIs()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no APIs available for fallback: %w", err)
|
||||
@@ -1488,49 +1397,22 @@ func (t *TidalDownloader) DownloadBySearchWithFallback(trackName, artistName, al
|
||||
|
||||
fmt.Printf("Track found: %s - %s (ID: %d)\n", trackInfo.Artist.Name, trackInfo.Title, trackInfo.ID)
|
||||
|
||||
// Prepare metadata
|
||||
// All metadata from Spotify - no fallback to Tidal
|
||||
finalArtistName := artistName
|
||||
finalTrackTitle := trackName
|
||||
finalAlbumTitle := albumName
|
||||
|
||||
if finalArtistName == "" {
|
||||
var artists []string
|
||||
if len(trackInfo.Artists) > 0 {
|
||||
for _, artist := range trackInfo.Artists {
|
||||
if artist.Name != "" {
|
||||
artists = append(artists, artist.Name)
|
||||
}
|
||||
}
|
||||
} else if trackInfo.Artist.Name != "" {
|
||||
artists = append(artists, trackInfo.Artist.Name)
|
||||
}
|
||||
if len(artists) > 0 {
|
||||
finalArtistName = strings.Join(artists, ", ")
|
||||
} else {
|
||||
finalArtistName = "Unknown Artist"
|
||||
}
|
||||
}
|
||||
finalArtistName = sanitizeFilename(finalArtistName)
|
||||
// Sanitize for filename only (not for metadata)
|
||||
finalArtistNameForFile := sanitizeFilename(finalArtistName)
|
||||
finalTrackTitleForFile := sanitizeFilename(finalTrackTitle)
|
||||
|
||||
if finalTrackTitle == "" {
|
||||
finalTrackTitle = trackInfo.Title
|
||||
if finalTrackTitle == "" {
|
||||
finalTrackTitle = fmt.Sprintf("track_%d", trackInfo.ID)
|
||||
}
|
||||
}
|
||||
finalTrackTitle = sanitizeFilename(finalTrackTitle)
|
||||
|
||||
if finalAlbumTitle == "" {
|
||||
finalAlbumTitle = trackInfo.Album.Title
|
||||
}
|
||||
|
||||
// Check if file already exists
|
||||
if existingFile, exists := CheckISRCExists(outputDir, trackInfo.ISRC); exists {
|
||||
fmt.Printf("File with ISRC %s already exists: %s\n", trackInfo.ISRC, existingFile)
|
||||
// Check if file already exists (use Spotify ISRC)
|
||||
if existingFile, exists := CheckISRCExists(outputDir, spotifyISRC); exists {
|
||||
fmt.Printf("File with ISRC %s already exists: %s\n", spotifyISRC, existingFile)
|
||||
return "EXISTS:" + existingFile, nil
|
||||
}
|
||||
|
||||
filename := buildTidalFilename(finalTrackTitle, finalArtistName, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
filename := buildTidalFilename(finalTrackTitleForFile, finalArtistNameForFile, spotifyTrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
outputFilename := filepath.Join(outputDir, filename)
|
||||
|
||||
if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 {
|
||||
@@ -1555,43 +1437,37 @@ func (t *TidalDownloader) DownloadBySearchWithFallback(trackName, artistName, al
|
||||
fmt.Println("Adding metadata...")
|
||||
|
||||
coverPath := ""
|
||||
if trackInfo.Album.Cover != "" {
|
||||
// Use Spotify cover URL (with max resolution if enabled) - all metadata from Spotify
|
||||
if spotifyCoverURL != "" {
|
||||
coverPath = outputFilename + ".cover.jpg"
|
||||
albumArt, err := downloader.DownloadAlbumArt(trackInfo.Album.Cover)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to download album art: %v\n", err)
|
||||
coverClient := NewCoverClient()
|
||||
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
|
||||
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
|
||||
coverPath = ""
|
||||
} else {
|
||||
if err := os.WriteFile(coverPath, albumArt, 0644); err != nil {
|
||||
fmt.Printf("Warning: Failed to save album art: %v\n", err)
|
||||
} else {
|
||||
defer os.Remove(coverPath)
|
||||
fmt.Println("Album art downloaded")
|
||||
}
|
||||
defer os.Remove(coverPath)
|
||||
fmt.Println("Spotify cover downloaded")
|
||||
}
|
||||
}
|
||||
|
||||
releaseYear := ""
|
||||
if len(trackInfo.Album.ReleaseDate) >= 4 {
|
||||
releaseYear = trackInfo.Album.ReleaseDate[:4]
|
||||
}
|
||||
|
||||
trackNumberToEmbed := 0
|
||||
if position > 0 {
|
||||
if useAlbumTrackNumber && trackInfo.TrackNumber > 0 {
|
||||
trackNumberToEmbed = trackInfo.TrackNumber
|
||||
} else {
|
||||
trackNumberToEmbed = position
|
||||
}
|
||||
// Determine track number to embed - ALL from Spotify
|
||||
trackNumberToEmbed := spotifyTrackNumber
|
||||
if position > 0 && !useAlbumTrackNumber {
|
||||
trackNumberToEmbed = position // Use playlist position
|
||||
}
|
||||
|
||||
// ALL metadata from Spotify
|
||||
metadata := Metadata{
|
||||
Title: finalTrackTitle,
|
||||
Artist: finalArtistName,
|
||||
Album: finalAlbumTitle,
|
||||
Date: releaseYear,
|
||||
AlbumArtist: albumArtist,
|
||||
Date: releaseDate, // Recorded date (full date YYYY-MM-DD)
|
||||
TrackNumber: trackNumberToEmbed,
|
||||
DiscNumber: trackInfo.VolumeNumber,
|
||||
ISRC: trackInfo.ISRC,
|
||||
TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify
|
||||
DiscNumber: spotifyDiscNumber, // Disc number from Spotify
|
||||
ISRC: spotifyISRC, // ISRC from Spotify
|
||||
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||
}
|
||||
|
||||
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
|
||||
@@ -1605,34 +1481,34 @@ func (t *TidalDownloader) DownloadBySearchWithFallback(trackName, artistName, al
|
||||
return outputFilename, nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) DownloadWithFallback(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
|
||||
func (t *TidalDownloader) DownloadWithFallback(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyISRC string) (string, error) {
|
||||
// Get Tidal URL once
|
||||
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
|
||||
if err != nil {
|
||||
// Songlink failed to find Tidal URL, try search fallback with all APIs
|
||||
fmt.Printf("Songlink couldn't find Tidal URL: %v\n", err)
|
||||
fmt.Println("Trying Tidal search fallback with all APIs...")
|
||||
return t.DownloadBySearchWithFallback(spotifyTrackName, spotifyArtistName, spotifyAlbumName, "", 0, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
return t.DownloadBySearchWithFallback(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyISRC, 0, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks)
|
||||
}
|
||||
|
||||
// Use parallel API requests via DownloadByURLWithFallback
|
||||
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
|
||||
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyISRC)
|
||||
}
|
||||
|
||||
// DownloadWithFallbackAndISRC downloads with ISRC matching for search fallback
|
||||
// Uses parallel API requests for faster download
|
||||
func (t *TidalDownloader) DownloadWithFallbackAndISRC(spotifyTrackID, spotifyISRC, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool, expectedDuration int) (string, error) {
|
||||
func (t *TidalDownloader) DownloadWithFallbackAndISRC(spotifyTrackID, spotifyISRC, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, expectedDuration int, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int) (string, error) {
|
||||
// Get Tidal URL once
|
||||
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
|
||||
if err != nil {
|
||||
// Songlink failed to find Tidal URL, try search fallback with ISRC matching
|
||||
fmt.Printf("Songlink couldn't find Tidal URL: %v\n", err)
|
||||
fmt.Println("Trying Tidal search fallback with ISRC matching...")
|
||||
return t.DownloadBySearchWithFallback(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyISRC, expectedDuration, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
return t.DownloadBySearchWithFallback(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyISRC, expectedDuration, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks)
|
||||
}
|
||||
|
||||
// Use parallel API requests via DownloadByURLWithFallback
|
||||
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
|
||||
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyISRC)
|
||||
}
|
||||
|
||||
func buildTidalFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300..800&family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans+Flex:opsz,wght@6..144,1..1000&family=Inter:wght@300..800&family=Manrope:wght@300..800&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300..800&family=Figtree:wght@300..900&family=Geist:wght@100..900&family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans+Flex:opsz,wght@6..144,1..1000&family=Inter:wght@300..800&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Manrope:wght@300..800&family=Noto+Sans:wght@100..900&family=Nunito+Sans:opsz,wght@6..12,200..1000&family=Outfit:wght@100..900&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Public+Sans:ital,wght@0,100..900;1,100..900&family=Raleway:wght@100..900&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
|
||||
<title>SpotiFLAC</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+11
-9
@@ -23,32 +23,34 @@
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.556.0",
|
||||
"lucide-react": "^0.561.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.17"
|
||||
"tailwindcss": "^4.1.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/node": "^25.0.2",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"sharp": "^0.34.5",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.1",
|
||||
"typescript-eslint": "^8.49.0",
|
||||
"vite": "^7.2.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
58400d5c8f7b03bac8ab784b5e775687
|
||||
d4b3974abd992c8ff941c6fde9f62062
|
||||
Generated
+546
-494
File diff suppressed because it is too large
Load Diff
+10
-1
@@ -28,6 +28,7 @@ import { ArtistInfo } from "@/components/ArtistInfo";
|
||||
import { DownloadQueue } from "@/components/DownloadQueue";
|
||||
import { DownloadProgressToast } from "@/components/DownloadProgressToast";
|
||||
import { AudioAnalysisPage } from "@/components/AudioAnalysisPage";
|
||||
import { AudioConverterPage } from "@/components/AudioConverterPage";
|
||||
import { SettingsPage } from "@/components/SettingsPage";
|
||||
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
|
||||
import type { HistoryItem } from "@/components/FetchHistory";
|
||||
@@ -55,7 +56,7 @@ function App() {
|
||||
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
|
||||
|
||||
const ITEMS_PER_PAGE = 50;
|
||||
const CURRENT_VERSION = "6.7";
|
||||
const CURRENT_VERSION = "6.8";
|
||||
|
||||
const download = useDownload();
|
||||
const metadata = useMetadata();
|
||||
@@ -319,6 +320,7 @@ function App() {
|
||||
skippedCovers={cover.skippedCovers}
|
||||
downloadingCoverTrack={cover.downloadingCoverTrack}
|
||||
isBulkDownloadingCovers={cover.isBulkDownloadingCovers}
|
||||
isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSortChange={setSortBy}
|
||||
onToggleTrack={toggleTrackSelection}
|
||||
@@ -331,6 +333,7 @@ function App() {
|
||||
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId)
|
||||
}
|
||||
onCheckAvailability={availability.checkAvailability}
|
||||
onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name)}
|
||||
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name)}
|
||||
onDownloadAll={() => download.handleDownloadAll(track_list, undefined, true)}
|
||||
onDownloadSelected={() =>
|
||||
@@ -385,6 +388,7 @@ function App() {
|
||||
skippedCovers={cover.skippedCovers}
|
||||
downloadingCoverTrack={cover.downloadingCoverTrack}
|
||||
isBulkDownloadingCovers={cover.isBulkDownloadingCovers}
|
||||
isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSortChange={setSortBy}
|
||||
onToggleTrack={toggleTrackSelection}
|
||||
@@ -397,6 +401,7 @@ function App() {
|
||||
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId)
|
||||
}
|
||||
onCheckAvailability={availability.checkAvailability}
|
||||
onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)}
|
||||
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)}
|
||||
onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)}
|
||||
onDownloadSelected={() =>
|
||||
@@ -457,6 +462,7 @@ function App() {
|
||||
skippedCovers={cover.skippedCovers}
|
||||
downloadingCoverTrack={cover.downloadingCoverTrack}
|
||||
isBulkDownloadingCovers={cover.isBulkDownloadingCovers}
|
||||
isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSortChange={setSortBy}
|
||||
onToggleTrack={toggleTrackSelection}
|
||||
@@ -469,6 +475,7 @@ function App() {
|
||||
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId)
|
||||
}
|
||||
onCheckAvailability={availability.checkAvailability}
|
||||
onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)}
|
||||
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)}
|
||||
onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)}
|
||||
onDownloadSelected={() =>
|
||||
@@ -506,6 +513,8 @@ function App() {
|
||||
return <DebugLoggerPage />;
|
||||
case "audio-analysis":
|
||||
return <AudioAnalysisPage />;
|
||||
case "audio-converter":
|
||||
return <AudioConverterPage />;
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Download, FolderOpen, ImageDown } from "lucide-react";
|
||||
import { Download, FolderOpen, ImageDown, FileText } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { SearchAndSort } from "./SearchAndSort";
|
||||
@@ -46,14 +46,16 @@ interface AlbumInfoProps {
|
||||
skippedCovers?: Set<string>;
|
||||
downloadingCoverTrack?: string | null;
|
||||
isBulkDownloadingCovers?: boolean;
|
||||
isBulkDownloadingLyrics?: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSortChange: (value: string) => void;
|
||||
onToggleTrack: (isrc: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
|
||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
|
||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
|
||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
|
||||
onCheckAvailability?: (spotifyId: string) => void;
|
||||
onDownloadAllLyrics?: () => void;
|
||||
onDownloadAllCovers?: () => void;
|
||||
onDownloadAll: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
@@ -91,6 +93,7 @@ export function AlbumInfo({
|
||||
skippedCovers,
|
||||
downloadingCoverTrack,
|
||||
isBulkDownloadingCovers,
|
||||
isBulkDownloadingLyrics,
|
||||
onSearchChange,
|
||||
onSortChange,
|
||||
onToggleTrack,
|
||||
@@ -99,6 +102,7 @@ export function AlbumInfo({
|
||||
onDownloadLyrics,
|
||||
onDownloadCover,
|
||||
onCheckAvailability,
|
||||
onDownloadAllLyrics,
|
||||
onDownloadAllCovers,
|
||||
onDownloadAll,
|
||||
onDownloadSelected,
|
||||
@@ -170,6 +174,22 @@ export function AlbumInfo({
|
||||
Download Selected ({selectedTracks.length})
|
||||
</Button>
|
||||
)}
|
||||
{onDownloadAllLyrics && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={onDownloadAllLyrics}
|
||||
variant="outline"
|
||||
disabled={isBulkDownloadingLyrics}
|
||||
>
|
||||
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download All Lyrics</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onDownloadAllCovers && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Download, FolderOpen, ImageDown } from "lucide-react";
|
||||
import { Download, FolderOpen, ImageDown, FileText } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { SearchAndSort } from "./SearchAndSort";
|
||||
@@ -51,14 +51,16 @@ interface ArtistInfoProps {
|
||||
skippedCovers?: Set<string>;
|
||||
downloadingCoverTrack?: string | null;
|
||||
isBulkDownloadingCovers?: boolean;
|
||||
isBulkDownloadingLyrics?: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSortChange: (value: string) => void;
|
||||
onToggleTrack: (isrc: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
|
||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
|
||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
|
||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
|
||||
onCheckAvailability?: (spotifyId: string) => void;
|
||||
onDownloadAllLyrics?: () => void;
|
||||
onDownloadAllCovers?: () => void;
|
||||
onDownloadAll: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
@@ -98,6 +100,7 @@ export function ArtistInfo({
|
||||
skippedCovers,
|
||||
downloadingCoverTrack,
|
||||
isBulkDownloadingCovers,
|
||||
isBulkDownloadingLyrics,
|
||||
onSearchChange,
|
||||
onSortChange,
|
||||
onToggleTrack,
|
||||
@@ -106,6 +109,7 @@ export function ArtistInfo({
|
||||
onDownloadLyrics,
|
||||
onDownloadCover,
|
||||
onCheckAvailability,
|
||||
onDownloadAllLyrics,
|
||||
onDownloadAllCovers,
|
||||
onDownloadAll,
|
||||
onDownloadSelected,
|
||||
@@ -212,6 +216,23 @@ export function ArtistInfo({
|
||||
Download Selected ({selectedTracks.length})
|
||||
</Button>
|
||||
)}
|
||||
{onDownloadAllLyrics && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={onDownloadAllLyrics}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isBulkDownloadingLyrics}
|
||||
>
|
||||
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download All Lyrics</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onDownloadAllCovers && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -8,8 +8,7 @@ import {
|
||||
TrendingUp,
|
||||
FileAudio,
|
||||
Clock,
|
||||
Gauge,
|
||||
HardDrive
|
||||
Gauge
|
||||
} from "lucide-react";
|
||||
import type { AnalysisResult } from "@/types/api";
|
||||
|
||||
@@ -18,13 +17,15 @@ interface AudioAnalysisProps {
|
||||
analyzing: boolean;
|
||||
onAnalyze?: () => void;
|
||||
showAnalyzeButton?: boolean;
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
export function AudioAnalysis({
|
||||
result,
|
||||
analyzing,
|
||||
onAnalyze,
|
||||
showAnalyzeButton = true
|
||||
showAnalyzeButton = true,
|
||||
filePath
|
||||
}: AudioAnalysisProps) {
|
||||
if (analyzing) {
|
||||
return (
|
||||
@@ -44,7 +45,7 @@ export function AudioAnalysis({
|
||||
<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" />
|
||||
<Activity className="h-12 w-12 text-primary" />
|
||||
<div className="text-center space-y-2">
|
||||
<p className="font-medium">Audio Quality Analysis</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -80,113 +81,63 @@ export function AudioAnalysis({
|
||||
// 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>
|
||||
{filePath && (
|
||||
<p className="text-sm font-mono truncate">{filePath}</p>
|
||||
)}
|
||||
</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>
|
||||
<CardContent className="space-y-2">
|
||||
{/* Audio Properties - Single line */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<Radio className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Sample Rate:</span>
|
||||
<span className="font-semibold">{(result.sample_rate / 1000).toFixed(1)} kHz</span>
|
||||
</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 className="flex items-center gap-1">
|
||||
<FileAudio className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Bit Depth:</span>
|
||||
<span className="font-semibold">{result.bit_depth}</span>
|
||||
</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 className="flex items-center gap-1">
|
||||
<Waves className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Channels:</span>
|
||||
<span className="font-semibold">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels}`}</span>
|
||||
</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 className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Duration:</span>
|
||||
<span className="font-semibold">{formatDuration(result.duration)}</span>
|
||||
</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 className="flex items-center gap-1">
|
||||
<Gauge className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Nyquist:</span>
|
||||
<span className="font-semibold">{(nyquistFreq / 1000).toFixed(1)} kHz</span>
|
||||
</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
|
||||
{/* Dynamic Range - Single line */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs border-t pt-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<TrendingUp className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Dynamic Range:</span>
|
||||
<span className="font-semibold">{formatNumber(result.dynamic_range)} dB</span>
|
||||
</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 className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">Peak:</span>
|
||||
<span className="font-semibold">{formatNumber(result.peak_amplitude)} dB</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">RMS:</span>
|
||||
<span className="font-semibold">{formatNumber(result.rms_level)} dB</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<span className="text-muted-foreground">Samples:</span>
|
||||
<span className="font-semibold">{result.total_samples.toLocaleString()}</span>
|
||||
</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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Activity, Upload, ArrowLeft } from "lucide-react";
|
||||
import { Upload, ArrowLeft, Trash2 } from "lucide-react";
|
||||
import { AudioAnalysis } from "@/components/AudioAnalysis";
|
||||
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
|
||||
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
|
||||
@@ -13,15 +13,13 @@ interface AudioAnalysisPageProps {
|
||||
}
|
||||
|
||||
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||
const { analyzing, result, analyzeFile, clearResult } = useAudioAnalysis();
|
||||
const [selectedFilePath, setSelectedFilePath] = useState<string>("");
|
||||
const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading } = useAudioAnalysis();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleSelectFile = async () => {
|
||||
try {
|
||||
const filePath = await SelectFile();
|
||||
if (filePath) {
|
||||
setSelectedFilePath(filePath);
|
||||
await analyzeFile(filePath);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -46,7 +44,6 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFilePath(filePath);
|
||||
await analyzeFile(filePath);
|
||||
},
|
||||
[analyzeFile]
|
||||
@@ -64,28 +61,35 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||
|
||||
const handleAnalyzeAnother = () => {
|
||||
clearResult();
|
||||
setSelectedFilePath("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
{onBack && (
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{onBack && (
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
|
||||
</div>
|
||||
{result && (
|
||||
<Button onClick={handleAnalyzeAnother} variant="outline" size="sm">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
|
||||
</div>
|
||||
|
||||
{/* File Selection */}
|
||||
{!result && !analyzing && (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center py-24 border-2 border-dashed rounded-lg transition-colors ${
|
||||
className={`flex flex-col items-center justify-center h-[400px] border-2 border-dashed rounded-lg transition-colors ${
|
||||
isDragging
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-muted-foreground/30 hover:border-muted-foreground/50"
|
||||
: "border-muted-foreground/30"
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -101,11 +105,10 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||
}}
|
||||
style={{ "--wails-drop-target": "drop" } as React.CSSProperties}
|
||||
>
|
||||
<Activity
|
||||
className={`h-20 w-20 mb-4 transition-colors ${isDragging ? "text-primary" : "text-muted-foreground/50"}`}
|
||||
/>
|
||||
<h3 className="text-xl font-medium mb-2">Analyze FLAC Audio Quality</h3>
|
||||
<p className="text-sm text-muted-foreground mb-6 text-center max-w-md">
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Upload className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||
{isDragging
|
||||
? "Drop your FLAC file here"
|
||||
: "Drag and drop a FLAC file here, or click the button below to select"}
|
||||
@@ -128,30 +131,23 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||
{/* 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>
|
||||
{/* Detailed Analysis */}
|
||||
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} filePath={selectedFilePath} />
|
||||
|
||||
{/* 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={handleAnalyzeAnother} variant="outline">
|
||||
<Upload className="h-4 w-4" />
|
||||
Analyze Another File
|
||||
</Button>
|
||||
</div>
|
||||
{spectrumLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 border rounded-lg">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-2"></div>
|
||||
<p className="text-sm text-muted-foreground">Loading spectrum data...</p>
|
||||
</div>
|
||||
) : (
|
||||
<SpectrumVisualization
|
||||
sampleRate={result.sample_rate}
|
||||
bitsPerSample={result.bits_per_sample}
|
||||
duration={result.duration}
|
||||
spectrumData={result.spectrum}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,674 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@/components/ui/toggle-group";
|
||||
import {
|
||||
Upload,
|
||||
Download,
|
||||
X,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Trash2,
|
||||
FileMusic,
|
||||
WandSparkles,
|
||||
} from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
IsFFmpegInstalled,
|
||||
DownloadFFmpeg,
|
||||
InstallFFmpegFromFile,
|
||||
ConvertAudio,
|
||||
SelectAudioFiles,
|
||||
} from "../../wailsjs/go/main/App";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
||||
|
||||
interface AudioFile {
|
||||
path: string;
|
||||
name: string;
|
||||
format: string;
|
||||
status: "pending" | "converting" | "success" | "error";
|
||||
error?: string;
|
||||
outputPath?: string;
|
||||
}
|
||||
|
||||
const BITRATE_OPTIONS = [
|
||||
{ value: "320k", label: "320k" },
|
||||
{ value: "256k", label: "256k" },
|
||||
{ value: "192k", label: "192k" },
|
||||
{ value: "128k", label: "128k" },
|
||||
];
|
||||
|
||||
const STORAGE_KEY = "spotiflac_audio_converter_state";
|
||||
|
||||
export function AudioConverterPage() {
|
||||
const [ffmpegInstalled, setFfmpegInstalled] = useState<boolean>(false);
|
||||
const [installingFfmpeg, setInstallingFfmpeg] = useState(false);
|
||||
const [files, setFiles] = useState<AudioFile[]>(() => {
|
||||
// Initialize from sessionStorage synchronously
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.files && Array.isArray(parsed.files) && parsed.files.length > 0) {
|
||||
return parsed.files;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load saved state:", err);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const [outputFormat, setOutputFormat] = useState<"mp3" | "m4a">(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.outputFormat === "mp3" || parsed.outputFormat === "m4a") {
|
||||
return parsed.outputFormat;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore
|
||||
}
|
||||
return "mp3";
|
||||
});
|
||||
const [bitrate, setBitrate] = useState(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.bitrate) {
|
||||
return parsed.bitrate;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore
|
||||
}
|
||||
return "320k";
|
||||
});
|
||||
const [converting, setConverting] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isDraggingFFmpeg, setIsDraggingFFmpeg] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
// Helper function to save state to sessionStorage
|
||||
const saveState = useCallback((stateToSave: { files: AudioFile[]; outputFormat: "mp3" | "m4a"; bitrate: string }) => {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
|
||||
} catch (err) {
|
||||
console.error("Failed to save state:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load saved state from sessionStorage on mount (only for ffmpeg check)
|
||||
useEffect(() => {
|
||||
checkFfmpegInstallation();
|
||||
}, []);
|
||||
|
||||
// Save state to sessionStorage whenever files, outputFormat, or bitrate changes
|
||||
useEffect(() => {
|
||||
saveState({ files, outputFormat, bitrate });
|
||||
}, [files, outputFormat, bitrate, saveState]);
|
||||
|
||||
// Auto-set output format to M4A if all files are MP3
|
||||
useEffect(() => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
const allMP3 = files.every((f) => f.format === "mp3");
|
||||
if (allMP3 && outputFormat !== "m4a") {
|
||||
setOutputFormat("m4a");
|
||||
}
|
||||
}, [files, outputFormat]);
|
||||
|
||||
// Check if format selection should be disabled (all files are MP3)
|
||||
const isFormatDisabled = files.length > 0 && files.every((f) => f.format === "mp3");
|
||||
|
||||
// Detect fullscreen/maximized window
|
||||
useEffect(() => {
|
||||
const checkFullscreen = () => {
|
||||
// Check if window is maximized or fullscreen
|
||||
// For Wails, we can check if window height is close to screen height
|
||||
const isMaximized = window.innerHeight >= window.screen.height * 0.9;
|
||||
setIsFullscreen(isMaximized);
|
||||
};
|
||||
|
||||
checkFullscreen();
|
||||
window.addEventListener("resize", checkFullscreen);
|
||||
|
||||
// Also check on window focus in case user maximizes externally
|
||||
window.addEventListener("focus", checkFullscreen);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", checkFullscreen);
|
||||
window.removeEventListener("focus", checkFullscreen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const checkFfmpegInstallation = async () => {
|
||||
try {
|
||||
const installed = await IsFFmpegInstalled();
|
||||
setFfmpegInstalled(installed);
|
||||
} catch (err) {
|
||||
console.error("Failed to check ffmpeg:", err);
|
||||
setFfmpegInstalled(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstallFfmpeg = async () => {
|
||||
setInstallingFfmpeg(true);
|
||||
try {
|
||||
const result = await DownloadFFmpeg();
|
||||
if (result.success) {
|
||||
toast.success("FFmpeg Installed", {
|
||||
description: "FFmpeg has been installed successfully",
|
||||
});
|
||||
setFfmpegInstalled(true);
|
||||
} else {
|
||||
toast.error("Installation Failed", {
|
||||
description: result.error || "Failed to install FFmpeg",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("Installation Failed", {
|
||||
description: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setInstallingFfmpeg(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFFmpegFileDrop = useCallback(
|
||||
async (_x: number, _y: number, paths: string[]) => {
|
||||
setIsDraggingFFmpeg(false);
|
||||
|
||||
if (paths.length === 0) return;
|
||||
|
||||
// Only process the first file
|
||||
const filePath = paths[0];
|
||||
const fileName = filePath.split(/[/\\]/).pop()?.toLowerCase() || "";
|
||||
|
||||
// Check if it's likely an ffmpeg executable
|
||||
if (!fileName.includes("ffmpeg")) {
|
||||
toast.error("Invalid File", {
|
||||
description: "Please drop an FFmpeg executable file",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setInstallingFfmpeg(true);
|
||||
try {
|
||||
const result = await InstallFFmpegFromFile(filePath);
|
||||
if (result.success) {
|
||||
toast.success("FFmpeg Installed", {
|
||||
description: "FFmpeg has been installed successfully from file",
|
||||
});
|
||||
setFfmpegInstalled(true);
|
||||
} else {
|
||||
toast.error("Installation Failed", {
|
||||
description: result.error || "Failed to install FFmpeg",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("Installation Failed", {
|
||||
description: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setInstallingFfmpeg(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (ffmpegInstalled === false) {
|
||||
// Set up drag and drop for FFmpeg installation
|
||||
OnFileDrop((x, y, paths) => {
|
||||
handleFFmpegFileDrop(x, y, paths);
|
||||
}, true);
|
||||
|
||||
return () => {
|
||||
OnFileDropOff();
|
||||
};
|
||||
}
|
||||
}, [ffmpegInstalled, handleFFmpegFileDrop]);
|
||||
|
||||
const handleSelectFiles = async () => {
|
||||
try {
|
||||
const selectedFiles = await SelectAudioFiles();
|
||||
if (selectedFiles && selectedFiles.length > 0) {
|
||||
addFiles(selectedFiles);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("File Selection Failed", {
|
||||
description: err instanceof Error ? err.message : "Failed to select files",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addFiles = useCallback((paths: string[]) => {
|
||||
const validExtensions = [".mp3", ".flac"];
|
||||
|
||||
// Check for M4A files specifically
|
||||
const m4aFiles = paths.filter((path) => {
|
||||
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
|
||||
return ext === ".m4a";
|
||||
});
|
||||
|
||||
if (m4aFiles.length > 0) {
|
||||
toast.error("M4A files not supported", {
|
||||
description: "Only FLAC and MP3 files are supported as input. Please convert M4A files first.",
|
||||
});
|
||||
}
|
||||
|
||||
setFiles((prev) => {
|
||||
const newFiles: AudioFile[] = paths
|
||||
.filter((path) => {
|
||||
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
|
||||
return validExtensions.includes(ext);
|
||||
})
|
||||
.filter((path) => !prev.some((f) => f.path === path))
|
||||
.map((path) => {
|
||||
const name = path.split(/[/\\]/).pop() || path;
|
||||
const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
|
||||
return {
|
||||
path,
|
||||
name,
|
||||
format: ext,
|
||||
status: "pending" as const,
|
||||
};
|
||||
});
|
||||
|
||||
if (newFiles.length > 0) {
|
||||
if (paths.length > newFiles.length) {
|
||||
const skipped = paths.length - newFiles.length;
|
||||
toast.info("Some files skipped", {
|
||||
description: `${skipped} file(s) were skipped (unsupported format or already added)`,
|
||||
});
|
||||
}
|
||||
|
||||
return [...prev, ...newFiles];
|
||||
}
|
||||
|
||||
if (paths.length > 0 && m4aFiles.length === 0) {
|
||||
toast.info("No new files added", {
|
||||
description: "All files were already added or have unsupported format",
|
||||
});
|
||||
}
|
||||
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleFileDrop = useCallback(
|
||||
async (_x: number, _y: number, paths: string[]) => {
|
||||
setIsDragging(false);
|
||||
|
||||
if (paths.length === 0) return;
|
||||
|
||||
addFiles(paths);
|
||||
},
|
||||
[addFiles]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Only enable drag and drop for audio files if FFmpeg is installed
|
||||
if (ffmpegInstalled === true) {
|
||||
OnFileDrop((x, y, paths) => {
|
||||
handleFileDrop(x, y, paths);
|
||||
}, true);
|
||||
|
||||
return () => {
|
||||
OnFileDropOff();
|
||||
};
|
||||
}
|
||||
}, [handleFileDrop, ffmpegInstalled]);
|
||||
|
||||
|
||||
const removeFile = (path: string) => {
|
||||
setFiles((prev) => prev.filter((f) => f.path !== path));
|
||||
};
|
||||
|
||||
const clearFiles = () => {
|
||||
setFiles([]);
|
||||
};
|
||||
|
||||
const handleConvert = async () => {
|
||||
if (files.length === 0) {
|
||||
toast.error("No files selected", {
|
||||
description: "Please add audio files to convert",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setConverting(true);
|
||||
|
||||
try {
|
||||
// Include all files (including previously successful ones) for conversion
|
||||
const inputPaths = files.map((f) => f.path);
|
||||
|
||||
// Mark all files as converting (including previously successful ones)
|
||||
setFiles((prev) =>
|
||||
prev.map((f) => {
|
||||
if (inputPaths.includes(f.path)) {
|
||||
return { ...f, status: "converting" as const, error: undefined };
|
||||
}
|
||||
return f;
|
||||
})
|
||||
);
|
||||
|
||||
const results = await ConvertAudio({
|
||||
input_files: inputPaths,
|
||||
output_format: outputFormat,
|
||||
bitrate: bitrate,
|
||||
});
|
||||
|
||||
// Update file statuses based on results
|
||||
setFiles((prev) =>
|
||||
prev.map((f) => {
|
||||
const result = results.find((r) => r.input_file === f.path);
|
||||
if (result) {
|
||||
return {
|
||||
...f,
|
||||
status: result.success ? "success" : "error",
|
||||
error: result.error,
|
||||
outputPath: result.output_file,
|
||||
};
|
||||
}
|
||||
return f;
|
||||
})
|
||||
);
|
||||
|
||||
const successCount = results.filter((r) => r.success).length;
|
||||
const failCount = results.filter((r) => !r.success).length;
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success("Conversion Complete", {
|
||||
description: `Successfully converted ${successCount} file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`,
|
||||
});
|
||||
} else if (failCount > 0) {
|
||||
toast.error("Conversion Failed", {
|
||||
description: `All ${failCount} file(s) failed to convert`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("Conversion Error", {
|
||||
description: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
setFiles((prev) =>
|
||||
prev.map((f) => ({ ...f, status: "error" as const, error: "Conversion failed" }))
|
||||
);
|
||||
} finally {
|
||||
setConverting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: AudioFile["status"]) => {
|
||||
switch (status) {
|
||||
case "converting":
|
||||
return <Spinner className="h-4 w-4 text-primary" />;
|
||||
case "success":
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
||||
case "error":
|
||||
return <AlertCircle className="h-4 w-4 text-destructive" />;
|
||||
default:
|
||||
return <FileMusic className="h-4 w-4 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Count files that can be converted (pending + success files that can be re-converted)
|
||||
const convertableCount = files.filter((f) => f.status === "pending" || f.status === "success").length;
|
||||
const successCount = files.filter((f) => f.status === "success").length;
|
||||
|
||||
// Show FFmpeg installation prompt if not installed
|
||||
if (ffmpegInstalled === false) {
|
||||
return (
|
||||
<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold">Audio Converter</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${
|
||||
isFullscreen ? "flex-1 min-h-[400px]" : "h-[400px]"
|
||||
} ${
|
||||
isDraggingFFmpeg
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-muted-foreground/30"
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDraggingFFmpeg(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDraggingFFmpeg(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDraggingFFmpeg(false);
|
||||
}}
|
||||
style={{ "--wails-drop-target": "drop" } as React.CSSProperties}
|
||||
>
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Download className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-2 text-center">
|
||||
FFmpeg is required to convert audio files.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||
{isDraggingFFmpeg
|
||||
? "Drop your FFmpeg executable here"
|
||||
: "Drag and drop your FFmpeg executable here, or click the button below to download automatically."}
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleInstallFfmpeg}
|
||||
disabled={installingFfmpeg}
|
||||
size="lg"
|
||||
>
|
||||
{installingFfmpeg ? (
|
||||
<>
|
||||
<Spinner className="h-5 w-5" />
|
||||
Installing FFmpeg...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-5 w-5" />
|
||||
Install FFmpeg
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Audio Converter</h1>
|
||||
{files.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleSelectFiles}>
|
||||
<Upload className="h-4 w-4" />
|
||||
Add More
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearFiles}
|
||||
disabled={converting}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drop Zone / File List */}
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${
|
||||
isFullscreen ? "flex-1 min-h-[400px]" : "h-[400px]"
|
||||
} ${
|
||||
isDragging
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-muted-foreground/30"
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}}
|
||||
style={{ "--wails-drop-target": "drop" } as React.CSSProperties}
|
||||
>
|
||||
{files.length === 0 ? (
|
||||
<>
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Upload className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-2 text-center">
|
||||
{isDragging
|
||||
? "Drop your audio files here"
|
||||
: "Drag and drop audio files here, or click the button below to select"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-4 text-center">
|
||||
Supported formats: FLAC, MP3
|
||||
</p>
|
||||
<Button onClick={handleSelectFiles} size="lg">
|
||||
<Upload className="h-5 w-5" />
|
||||
Select Files
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full p-6 space-y-4 flex flex-col">
|
||||
{/* Settings Row - Only show when files exist */}
|
||||
<div className="space-y-2 pb-4 border-b shrink-0">
|
||||
{/* Format and Bitrate in one line */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">Format:</Label>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
variant="outline"
|
||||
value={outputFormat}
|
||||
onValueChange={(value) => {
|
||||
if (value && !isFormatDisabled) setOutputFormat(value as "mp3" | "m4a");
|
||||
}}
|
||||
disabled={isFormatDisabled}
|
||||
>
|
||||
{!isFormatDisabled && (
|
||||
<ToggleGroupItem value="mp3" aria-label="MP3">
|
||||
MP3
|
||||
</ToggleGroupItem>
|
||||
)}
|
||||
<ToggleGroupItem value="m4a" aria-label="M4A" disabled={isFormatDisabled}>
|
||||
M4A
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">Bitrate:</Label>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
variant="outline"
|
||||
value={bitrate}
|
||||
onValueChange={(value) => {
|
||||
if (value) setBitrate(value);
|
||||
}}
|
||||
>
|
||||
{BITRATE_OPTIONS.map((option) => (
|
||||
<ToggleGroupItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
aria-label={option.label}
|
||||
>
|
||||
{option.label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File List Header */}
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{files.length} file(s) • {successCount} converted
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
<div className="flex-1 space-y-2 overflow-y-auto min-h-0">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.path}
|
||||
className="flex items-center gap-3 rounded-lg border p-3"
|
||||
>
|
||||
{getStatusIcon(file.status)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-sm font-medium">{file.name}</p>
|
||||
{file.error && (
|
||||
<p className="truncate text-xs text-destructive">
|
||||
{file.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs uppercase text-muted-foreground">
|
||||
{file.format}
|
||||
</span>
|
||||
{file.status !== "converting" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => removeFile(file.path)}
|
||||
disabled={converting}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Convert Button */}
|
||||
<div className="flex justify-center pt-4 border-t shrink-0">
|
||||
<Button
|
||||
onClick={handleConvert}
|
||||
disabled={converting || convertableCount === 0}
|
||||
size="lg"
|
||||
>
|
||||
{converting ? (
|
||||
<>
|
||||
<Spinner className="h-4 w-4" />
|
||||
Converting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WandSparkles className="h-4 w-4" />
|
||||
Convert {convertableCount > 0 ? `${convertableCount} File(s)` : ""}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music — no account required.
|
||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,12 +7,6 @@ export const TidalIcon = ({ className = "w-4 h-4" }: { className?: string }) =>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const DeezerIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
|
||||
<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
<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>
|
||||
);
|
||||
|
||||
export const QobuzIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
|
||||
<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Download, FolderOpen, ImageDown } from "lucide-react";
|
||||
import { Download, FolderOpen, ImageDown, FileText } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { SearchAndSort } from "./SearchAndSort";
|
||||
@@ -50,14 +50,16 @@ interface PlaylistInfoProps {
|
||||
skippedCovers?: Set<string>;
|
||||
downloadingCoverTrack?: string | null;
|
||||
isBulkDownloadingCovers?: boolean;
|
||||
isBulkDownloadingLyrics?: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSortChange: (value: string) => void;
|
||||
onToggleTrack: (isrc: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
|
||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
|
||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
|
||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
|
||||
onCheckAvailability?: (spotifyId: string) => void;
|
||||
onDownloadAllLyrics?: () => void;
|
||||
onDownloadAllCovers?: () => void;
|
||||
onDownloadAll: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
@@ -96,6 +98,7 @@ export function PlaylistInfo({
|
||||
skippedCovers,
|
||||
downloadingCoverTrack,
|
||||
isBulkDownloadingCovers,
|
||||
isBulkDownloadingLyrics,
|
||||
onSearchChange,
|
||||
onSortChange,
|
||||
onToggleTrack,
|
||||
@@ -104,6 +107,7 @@ export function PlaylistInfo({
|
||||
onDownloadLyrics,
|
||||
onDownloadCover,
|
||||
onCheckAvailability,
|
||||
onDownloadAllLyrics,
|
||||
onDownloadAllCovers,
|
||||
onDownloadAll,
|
||||
onDownloadSelected,
|
||||
@@ -161,6 +165,22 @@ export function PlaylistInfo({
|
||||
Download Selected ({selectedTracks.length})
|
||||
</Button>
|
||||
)}
|
||||
{onDownloadAllLyrics && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={onDownloadAllLyrics}
|
||||
variant="outline"
|
||||
disabled={isBulkDownloadingLyrics}
|
||||
>
|
||||
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download All Lyrics</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onDownloadAllCovers && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -43,6 +43,7 @@ export function SearchBar({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Supports track, album, playlist, and artist URLs</p>
|
||||
<p className="mt-1">Note: Playlist must be public (not private)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -1,459 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputWithContext } from "@/components/ui/input-with-context";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Settings as SettingsIcon, FolderOpen, Save, RotateCcw, Info, X, Volume2 } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FolderPreset, type FilenamePreset } 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" 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" 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" 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" 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>
|
||||
);
|
||||
|
||||
export function Settings() {
|
||||
const [open, setOpen] = useState(false);
|
||||
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(() => {
|
||||
applyThemeMode(savedSettings.themeMode);
|
||||
applyTheme(savedSettings.theme);
|
||||
|
||||
// Setup listener for system theme changes
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleChange = () => {
|
||||
if (savedSettings.themeMode === "auto") {
|
||||
applyThemeMode("auto");
|
||||
applyTheme(savedSettings.theme);
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener("change", handleChange);
|
||||
};
|
||||
}, [savedSettings.themeMode, savedSettings.theme]);
|
||||
|
||||
// Apply temp settings for preview when dialog is open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener("change", handleChange);
|
||||
};
|
||||
}
|
||||
}, [open, tempSettings.themeMode, tempSettings.theme]);
|
||||
|
||||
useEffect(() => {
|
||||
// Load settings with defaults from backend on mount
|
||||
const loadDefaults = async () => {
|
||||
if (!savedSettings.downloadPath) {
|
||||
setIsLoadingDefaults(true);
|
||||
const settingsWithDefaults = await getSettingsWithDefaults();
|
||||
setSavedSettings(settingsWithDefaults);
|
||||
setTempSettings(settingsWithDefaults);
|
||||
setIsLoadingDefaults(false);
|
||||
}
|
||||
};
|
||||
loadDefaults();
|
||||
}, []);
|
||||
|
||||
// Reset temp settings when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTempSettings(savedSettings);
|
||||
}
|
||||
}, [open, savedSettings]);
|
||||
|
||||
const handleSave = () => {
|
||||
saveSettings(tempSettings);
|
||||
setSavedSettings(tempSettings);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
const defaultSettings = await resetToDefaultSettings();
|
||||
setTempSettings(defaultSettings);
|
||||
setSavedSettings(defaultSettings);
|
||||
|
||||
// Apply default theme mode and theme
|
||||
applyThemeMode(defaultSettings.themeMode);
|
||||
applyTheme(defaultSettings.theme);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
// Revert to saved settings
|
||||
applyThemeMode(savedSettings.themeMode);
|
||||
applyTheme(savedSettings.theme);
|
||||
|
||||
setTempSettings(savedSettings);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
// Dialog is closing, revert to saved settings
|
||||
applyThemeMode(savedSettings.themeMode);
|
||||
applyTheme(savedSettings.theme);
|
||||
setTempSettings(savedSettings);
|
||||
}
|
||||
setOpen(newOpen);
|
||||
};
|
||||
|
||||
const handleDownloadPathChange = (value: string) => {
|
||||
setTempSettings((prev) => ({ ...prev, downloadPath: value }));
|
||||
};
|
||||
|
||||
const handleDownloaderChange = (value: "auto" | "deezer" | "tidal" | "qobuz" | "amazon") => {
|
||||
setTempSettings((prev) => ({ ...prev, downloader: value }));
|
||||
};
|
||||
|
||||
const handleThemeChange = (value: string) => {
|
||||
setTempSettings((prev) => ({ ...prev, theme: value }));
|
||||
};
|
||||
|
||||
const handleThemeModeChange = (value: "auto" | "light" | "dark") => {
|
||||
setTempSettings((prev) => ({ ...prev, themeMode: value }));
|
||||
};
|
||||
|
||||
const handleBrowseFolder = async () => {
|
||||
try {
|
||||
// Call backend to open folder selection dialog
|
||||
const selectedPath = await SelectFolder(tempSettings.downloadPath || "");
|
||||
console.log("Selected path:", selectedPath);
|
||||
|
||||
if (selectedPath && selectedPath.trim() !== "") {
|
||||
setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath }));
|
||||
} else {
|
||||
console.log("No folder selected or user cancelled");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error selecting folder:", error);
|
||||
alert(`Error selecting folder: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<SettingsIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<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 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="download-path">Download Path</Label>
|
||||
<div className="flex gap-2">
|
||||
<InputWithContext
|
||||
id="download-path"
|
||||
value={tempSettings.downloadPath}
|
||||
onChange={(e) => handleDownloadPathChange(e.target.value)}
|
||||
placeholder="C:\Users\YourUsername\Music"
|
||||
/>
|
||||
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="downloader">Source</Label>
|
||||
<Select
|
||||
value={tempSettings.downloader}
|
||||
onValueChange={handleDownloaderChange}
|
||||
>
|
||||
<SelectTrigger id="downloader">
|
||||
<SelectValue placeholder="Select a source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="tidal">
|
||||
<span className="flex items-center">
|
||||
<TidalIcon />
|
||||
Tidal
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="deezer">
|
||||
<span className="flex items-center">
|
||||
<DeezerIcon />
|
||||
Deezer
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="qobuz">
|
||||
<span className="flex items-center">
|
||||
<QobuzIcon />
|
||||
Qobuz
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="amazon">
|
||||
<span className="flex items-center">
|
||||
<AmazonIcon />
|
||||
Amazon Music
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Theme Mode Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="theme-mode">Mode</Label>
|
||||
<Select value={tempSettings.themeMode} onValueChange={handleThemeModeChange}>
|
||||
<SelectTrigger id="theme-mode">
|
||||
<SelectValue placeholder="Select theme mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Accent Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="theme">Accent</Label>
|
||||
<Select value={tempSettings.theme} onValueChange={handleThemeChange}>
|
||||
<SelectTrigger id="theme">
|
||||
<SelectValue placeholder="Select a theme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{themes.map((theme) => (
|
||||
<SelectItem key={theme.name} value={theme.name}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full border border-border"
|
||||
style={{
|
||||
backgroundColor: isDark
|
||||
? theme.cssVars.dark.primary
|
||||
: theme.cssVars.light.primary
|
||||
}}
|
||||
/>
|
||||
{theme.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="space-y-4">
|
||||
{/* Folder Structure */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm">Folder Structure</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Select
|
||||
value={tempSettings.folderPreset}
|
||||
onValueChange={(value: FolderPreset) => {
|
||||
const preset = FOLDER_PRESETS[value];
|
||||
setTempSettings(prev => ({
|
||||
...prev,
|
||||
folderPreset: value,
|
||||
folderTemplate: value === "custom" ? prev.folderTemplate : preset.template
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (
|
||||
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tempSettings.folderPreset === "custom" && (
|
||||
<InputWithContext
|
||||
value={tempSettings.folderTemplate}
|
||||
onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))}
|
||||
placeholder="{artist}/{album}"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
)}
|
||||
{tempSettings.folderTemplate && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{album\}/g, "1989").replace(/\{year\}/g, "2014")}/</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t" />
|
||||
|
||||
{/* Filename Format */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm">Filename Format</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Select
|
||||
value={tempSettings.filenamePreset}
|
||||
onValueChange={(value: FilenamePreset) => {
|
||||
const preset = FILENAME_PRESETS[value];
|
||||
setTempSettings(prev => ({
|
||||
...prev,
|
||||
filenamePreset: value,
|
||||
filenameTemplate: value === "custom" ? prev.filenameTemplate : preset.template
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (
|
||||
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tempSettings.filenamePreset === "custom" && (
|
||||
<InputWithContext
|
||||
value={tempSettings.filenameTemplate}
|
||||
onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))}
|
||||
placeholder="{track}. {title}"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
)}
|
||||
{tempSettings.filenameTemplate && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{title\}/g, "Shake It Off").replace(/\{track\}/g, "01").replace(/\{year\}/g, "2014")}.flac</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t" />
|
||||
|
||||
{/* Sound Effects */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Volume2 className="h-4 w-4 text-muted-foreground" />
|
||||
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label>
|
||||
</div>
|
||||
<Switch
|
||||
id="sfx-enabled"
|
||||
checked={tempSettings.sfxEnabled}
|
||||
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
<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}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="gap-1.5">
|
||||
<Save className="h-4 w-4" />
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { FolderOpen, Save, RotateCcw, Info, Volume2 } from "lucide-react";
|
||||
import { FolderOpen, Save, RotateCcw, Info } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings";
|
||||
import { themes, applyTheme } from "@/lib/themes";
|
||||
@@ -26,12 +26,6 @@ const TidalIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DeezerIcon = () => (
|
||||
<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" 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>
|
||||
@@ -139,34 +133,6 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="downloader">Source</Label>
|
||||
<Select
|
||||
value={tempSettings.downloader}
|
||||
onValueChange={(value: "auto" | "deezer" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}
|
||||
>
|
||||
<SelectTrigger id="downloader">
|
||||
<SelectValue placeholder="Select a source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="tidal">
|
||||
<span className="flex items-center"><TidalIcon />Tidal</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="deezer">
|
||||
<span className="flex items-center"><DeezerIcon />Deezer</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="qobuz">
|
||||
<span className="flex items-center"><QobuzIcon />Qobuz</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="amazon">
|
||||
<span className="flex items-center"><AmazonIcon />Amazon Music</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Theme Mode */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="theme-mode">Mode</Label>
|
||||
@@ -232,10 +198,100 @@ export function SettingsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Sound Effects */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label>
|
||||
<Switch
|
||||
id="sfx-enabled"
|
||||
checked={tempSettings.sfxEnabled}
|
||||
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="space-y-4">
|
||||
{/* Source Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="downloader" className="text-sm">Source</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={tempSettings.downloader}
|
||||
onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}
|
||||
>
|
||||
<SelectTrigger id="downloader" className="h-9 w-fit">
|
||||
<SelectValue placeholder="Select a source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="tidal">
|
||||
<span className="flex items-center"><TidalIcon />Tidal</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="qobuz">
|
||||
<span className="flex items-center"><QobuzIcon />Qobuz</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="amazon">
|
||||
<span className="flex items-center"><AmazonIcon />Amazon Music</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Quality dropdown for Tidal */}
|
||||
{tempSettings.downloader === "tidal" && (
|
||||
<Select
|
||||
value={tempSettings.tidalQuality}
|
||||
onValueChange={(value: "LOSSLESS" | "HI_RES_LOSSLESS") => setTempSettings((prev) => ({ ...prev, tidalQuality: value }))}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="LOSSLESS">Lossless (16-bit/CD Quality)</SelectItem>
|
||||
<SelectItem value="HI_RES_LOSSLESS">Hi-Res Lossless (24-bit/48kHz+)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{/* Quality dropdown for Qobuz */}
|
||||
{tempSettings.downloader === "qobuz" && (
|
||||
<Select
|
||||
value={tempSettings.qobuzQuality}
|
||||
onValueChange={(value: "6" | "7" | "27") => setTempSettings((prev) => ({ ...prev, qobuzQuality: value }))}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="6">FLAC 16-bit (CD Quality)</SelectItem>
|
||||
<SelectItem value="7">FLAC 24-bit</SelectItem>
|
||||
<SelectItem value="27">Hi-Res (24-bit/96kHz+)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Embed Lyrics & Embed Max Quality Cover */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm">Embed Lyrics</Label>
|
||||
<Switch
|
||||
id="embed-lyrics"
|
||||
checked={tempSettings.embedLyrics}
|
||||
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedLyrics: checked }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm">Embed Max Quality Cover</Label>
|
||||
<Switch
|
||||
id="embed-max-quality-cover"
|
||||
checked={tempSettings.embedMaxQualityCover}
|
||||
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t" />
|
||||
|
||||
{/* Folder Structure */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -284,7 +340,7 @@ export function SettingsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4" />
|
||||
<div className="border-t" />
|
||||
|
||||
{/* Filename Format */}
|
||||
<div className="space-y-2">
|
||||
@@ -333,19 +389,6 @@ export function SettingsPage() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4" />
|
||||
|
||||
{/* Sound Effects */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Volume2 className="h-4 w-4 text-muted-foreground" />
|
||||
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label>
|
||||
<Switch
|
||||
id="sfx-enabled"
|
||||
checked={tempSettings.sfxEnabled}
|
||||
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Home, Settings, Bug, Activity, LayoutGrid } from "lucide-react";
|
||||
import { Home, Settings, Bug, Activity, FileMusic, LayoutGrid } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
|
||||
export type PageType = "main" | "settings" | "debug" | "audio-analysis";
|
||||
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter";
|
||||
|
||||
interface SidebarProps {
|
||||
currentPage: PageType;
|
||||
@@ -19,6 +19,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
{ id: "main" as PageType, icon: Home, label: "Home" },
|
||||
{ id: "settings" as PageType, icon: Settings, label: "Settings" },
|
||||
{ id: "audio-analysis" as PageType, icon: Activity, label: "Audio Quality Analyzer" },
|
||||
{ id: "audio-converter" as PageType, icon: FileMusic, label: "Audio Converter" },
|
||||
{ id: "debug" as PageType, icon: Bug, label: "Debug Logs" },
|
||||
];
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
import { TidalIcon, DeezerIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
|
||||
interface TrackInfoProps {
|
||||
track: TrackMetadata & { album_name: string; release_date: string };
|
||||
@@ -25,7 +25,7 @@ interface TrackInfoProps {
|
||||
checkingAvailability?: boolean;
|
||||
availability?: TrackAvailability;
|
||||
downloadingCover?: boolean;
|
||||
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string) => void;
|
||||
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
|
||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string) => void;
|
||||
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
|
||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string) => void;
|
||||
@@ -120,7 +120,7 @@ export function TrackInfo({
|
||||
{track.isrc && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id)}
|
||||
onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, undefined, track.duration_ms, track.track_number, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks)}
|
||||
disabled={isDownloading || downloadingTrack === track.isrc}
|
||||
>
|
||||
{downloadingTrack === track.isrc ? (
|
||||
@@ -179,7 +179,6 @@ export function TrackInfo({
|
||||
{availability ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`} />
|
||||
<DeezerIcon className={`w-4 h-4 ${availability.deezer ? "text-green-500" : "text-red-500"}`} />
|
||||
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`} />
|
||||
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`} />
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
import { TidalIcon, DeezerIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
|
||||
interface TrackListProps {
|
||||
tracks: TrackMetadata[];
|
||||
@@ -49,7 +49,7 @@ interface TrackListProps {
|
||||
downloadingCoverTrack?: string | null;
|
||||
onToggleTrack: (isrc: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number) => void;
|
||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
|
||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
|
||||
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
|
||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
|
||||
@@ -301,7 +301,7 @@ export function TrackList({
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1)
|
||||
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks)
|
||||
}
|
||||
size="sm"
|
||||
disabled={isDownloading || downloadingTrack === track.isrc}
|
||||
@@ -415,7 +415,6 @@ export function TrackList({
|
||||
{availabilityMap?.has(track.spotify_id) ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`} />
|
||||
<DeezerIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.deezer ? "text-green-500" : "text-red-500"}`} />
|
||||
<QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`} />
|
||||
<AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 0,
|
||||
})
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
|
||||
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-2 min-w-9",
|
||||
sm: "h-8 px-1.5 min-w-8",
|
||||
lg: "h-10 px-2.5 min-w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
@@ -1,13 +1,63 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect } 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";
|
||||
import { setSpectrumCache, getSpectrumCache, clearSpectrumCache } from "@/lib/spectrum-cache";
|
||||
|
||||
const STORAGE_KEY = "spotiflac_audio_analysis_state";
|
||||
|
||||
export function useAudioAnalysis() {
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const [result, setResult] = useState<AnalysisResult | null>(null);
|
||||
const [result, setResult] = useState<AnalysisResult | null>(() => {
|
||||
// Load from sessionStorage on mount - only detail, no spectrum
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.filePath && parsed.result) {
|
||||
// Return result WITHOUT spectrum - spectrum will be loaded async
|
||||
return {
|
||||
...parsed.result,
|
||||
spectrum: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load saved analysis state:", err);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const [selectedFilePath, setSelectedFilePath] = useState<string>(() => {
|
||||
// Load file path from sessionStorage
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
return parsed.filePath || "";
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [spectrumLoading, setSpectrumLoading] = useState(() => {
|
||||
// If result exists from sessionStorage, show loading for spectrum
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.filePath && parsed.result) {
|
||||
// Always show loading initially, will be resolved async
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const analyzeFile = useCallback(async (filePath: string) => {
|
||||
if (!filePath) {
|
||||
@@ -18,6 +68,7 @@ export function useAudioAnalysis() {
|
||||
setAnalyzing(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setSelectedFilePath(filePath);
|
||||
|
||||
try {
|
||||
logger.info(`Analyzing audio file: ${filePath}`);
|
||||
@@ -29,7 +80,24 @@ export function useAudioAnalysis() {
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
logger.success(`Audio analysis completed in ${elapsed}s`);
|
||||
|
||||
// Save spectrum to memory cache
|
||||
if (analysisResult.spectrum) {
|
||||
setSpectrumCache(filePath, analysisResult.spectrum);
|
||||
}
|
||||
|
||||
// Save detail (without spectrum) to sessionStorage
|
||||
const { spectrum, ...detailResult } = analysisResult;
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
filePath,
|
||||
result: detailResult,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error("Failed to save analysis state:", err);
|
||||
}
|
||||
|
||||
setResult(analysisResult);
|
||||
setSpectrumLoading(false); // Spectrum is now available
|
||||
|
||||
return analysisResult;
|
||||
} catch (err) {
|
||||
@@ -48,12 +116,56 @@ export function useAudioAnalysis() {
|
||||
const clearResult = useCallback(() => {
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setSelectedFilePath("");
|
||||
try {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
} catch (err) {
|
||||
// Ignore
|
||||
}
|
||||
clearSpectrumCache();
|
||||
}, []);
|
||||
|
||||
// Load spectrum from cache asynchronously after detail is displayed
|
||||
useEffect(() => {
|
||||
// Only load spectrum if we have result without spectrum and are in loading state
|
||||
if (!result || !selectedFilePath || result.spectrum || !spectrumLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load spectrum asynchronously to avoid blocking UI
|
||||
// Use requestAnimationFrame to ensure detail renders first
|
||||
let rafId: number;
|
||||
const loadSpectrum = () => {
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const cachedSpectrum = getSpectrumCache(selectedFilePath);
|
||||
if (cachedSpectrum) {
|
||||
setResult(prev => prev ? { ...prev, spectrum: cachedSpectrum } : null);
|
||||
setSpectrumLoading(false);
|
||||
} else {
|
||||
// Spectrum not in cache - user needs to re-analyze
|
||||
setSpectrumLoading(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Double RAF to ensure detail is fully rendered
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(loadSpectrum);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
};
|
||||
}, [result, selectedFilePath, spectrumLoading]);
|
||||
|
||||
return {
|
||||
analyzing,
|
||||
result,
|
||||
error,
|
||||
selectedFilePath,
|
||||
spectrumLoading,
|
||||
analyzeFile,
|
||||
clearResult,
|
||||
};
|
||||
|
||||
@@ -40,18 +40,19 @@ export function useCover() {
|
||||
const os = settings.operatingSystem;
|
||||
let outputDir = settings.downloadPath;
|
||||
|
||||
// Build output path using template system
|
||||
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
const templateData: TemplateData = {
|
||||
artist: artistName,
|
||||
album: albumName,
|
||||
title: trackName,
|
||||
artist: artistName?.replace(/\//g, placeholder),
|
||||
album: albumName?.replace(/\//g, placeholder),
|
||||
title: trackName?.replace(/\//g, placeholder),
|
||||
track: position,
|
||||
playlist: playlistName,
|
||||
playlist: playlistName?.replace(/\//g, placeholder),
|
||||
};
|
||||
|
||||
// For playlist/discography, prepend the folder name
|
||||
if (playlistName) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||
}
|
||||
|
||||
// Apply folder template
|
||||
@@ -60,7 +61,9 @@ export function useCover() {
|
||||
if (folderPath) {
|
||||
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
||||
for (const part of parts) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(part, os));
|
||||
// Restore any slashes that were in the original values as spaces
|
||||
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,18 +143,20 @@ export function useCover() {
|
||||
const os = settings.operatingSystem;
|
||||
let outputDir = settings.downloadPath;
|
||||
|
||||
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
// Build output path using template system
|
||||
const templateData: TemplateData = {
|
||||
artist: track.artists,
|
||||
album: track.album_name,
|
||||
title: track.name,
|
||||
artist: track.artists?.replace(/\//g, placeholder),
|
||||
album: track.album_name?.replace(/\//g, placeholder),
|
||||
title: track.name?.replace(/\//g, placeholder),
|
||||
track: i + 1,
|
||||
playlist: playlistName,
|
||||
playlist: playlistName?.replace(/\//g, placeholder),
|
||||
};
|
||||
|
||||
// For playlist/discography, prepend the folder name
|
||||
if (playlistName) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||
}
|
||||
|
||||
// Apply folder template
|
||||
@@ -160,7 +165,9 @@ export function useCover() {
|
||||
if (folderPath) {
|
||||
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
||||
for (const part of parts) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(part, os));
|
||||
// Restore any slashes that were in the original values as spaces
|
||||
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export function useDownload() {
|
||||
|
||||
const downloadWithAutoFallback = async (
|
||||
isrc: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
settings: any,
|
||||
trackName?: string,
|
||||
artistName?: string,
|
||||
@@ -30,9 +31,15 @@ export function useDownload() {
|
||||
position?: number,
|
||||
spotifyId?: string,
|
||||
durationMs?: number,
|
||||
releaseYear?: string
|
||||
releaseYear?: string,
|
||||
albumArtist?: string,
|
||||
releaseDate?: string,
|
||||
coverUrl?: string,
|
||||
spotifyTrackNumber?: number,
|
||||
spotifyDiscNumber?: number,
|
||||
spotifyTotalTracks?: number
|
||||
) => {
|
||||
let service = settings.downloader;
|
||||
const service = settings.downloader;
|
||||
|
||||
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
|
||||
const os = settings.operatingSystem;
|
||||
@@ -40,20 +47,22 @@ export function useDownload() {
|
||||
let outputDir = settings.downloadPath;
|
||||
let useAlbumTrackNumber = false;
|
||||
|
||||
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
// Build template data for folder path
|
||||
const templateData: TemplateData = {
|
||||
artist: artistName,
|
||||
album: albumName,
|
||||
title: trackName,
|
||||
artist: artistName?.replace(/\//g, placeholder),
|
||||
album: albumName?.replace(/\//g, placeholder),
|
||||
title: trackName?.replace(/\//g, placeholder),
|
||||
track: position,
|
||||
year: releaseYear,
|
||||
playlist: playlistName,
|
||||
playlist: playlistName?.replace(/\//g, placeholder),
|
||||
isrc: isrc,
|
||||
};
|
||||
|
||||
// For playlist/discography downloads, always create a folder with the playlist/artist name
|
||||
if (playlistName) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||
}
|
||||
|
||||
// Apply folder template if available
|
||||
@@ -62,10 +71,12 @@ export function useDownload() {
|
||||
if (folderPath) {
|
||||
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
||||
for (const part of parts) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(part, os));
|
||||
// Restore any slashes that were in the original values as spaces
|
||||
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Use album track number if template contains {album}
|
||||
if (settings.folderTemplate.includes("{album}")) {
|
||||
useAlbumTrackNumber = true;
|
||||
@@ -78,6 +89,7 @@ export function useDownload() {
|
||||
|
||||
if (service === "auto") {
|
||||
// Get all streaming URLs once from song.link API
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let streamingURLs: any = null;
|
||||
if (spotifyId) {
|
||||
try {
|
||||
@@ -103,59 +115,37 @@ export function useDownload() {
|
||||
track_name: trackName,
|
||||
artist_name: artistName,
|
||||
album_name: albumName,
|
||||
album_artist: albumArtist,
|
||||
release_date: releaseDate,
|
||||
cover_url: coverUrl,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate,
|
||||
track_number: settings.trackNumber,
|
||||
position,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
spotify_id: spotifyId,
|
||||
embed_lyrics: settings.embedLyrics,
|
||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||
service_url: streamingURLs.tidal_url,
|
||||
duration: durationSeconds,
|
||||
item_id: itemID, // Pass the same itemID through all attempts
|
||||
audio_format: settings.tidalQuality || "LOSSLESS", // Use default LOSSLESS for auto mode
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
});
|
||||
|
||||
if (tidalResponse.success) {
|
||||
logger.success(`tidal: ${trackName} - ${artistName}`);
|
||||
return tidalResponse;
|
||||
}
|
||||
logger.warning(`tidal failed, trying deezer...`);
|
||||
logger.warning(`tidal failed, trying amazon...`);
|
||||
} catch (tidalErr) {
|
||||
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",
|
||||
query,
|
||||
track_name: trackName,
|
||||
artist_name: artistName,
|
||||
album_name: albumName,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate,
|
||||
track_number: settings.trackNumber,
|
||||
position,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
spotify_id: spotifyId,
|
||||
service_url: streamingURLs.deezer_url,
|
||||
item_id: itemID,
|
||||
});
|
||||
|
||||
if (deezerResponse.success) {
|
||||
logger.success(`deezer: ${trackName} - ${artistName}`);
|
||||
return deezerResponse;
|
||||
}
|
||||
logger.warning(`deezer failed, trying amazon...`);
|
||||
} catch (deezerErr) {
|
||||
logger.error(`deezer error: ${deezerErr}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Try Amazon third
|
||||
// Try Amazon second
|
||||
if (streamingURLs?.amazon_url) {
|
||||
try {
|
||||
logger.debug(`trying amazon for: ${trackName} - ${artistName}`);
|
||||
@@ -166,14 +156,22 @@ export function useDownload() {
|
||||
track_name: trackName,
|
||||
artist_name: artistName,
|
||||
album_name: albumName,
|
||||
album_artist: albumArtist,
|
||||
release_date: releaseDate,
|
||||
cover_url: coverUrl,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate,
|
||||
track_number: settings.trackNumber,
|
||||
position,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
spotify_id: spotifyId,
|
||||
embed_lyrics: settings.embedLyrics,
|
||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||
service_url: streamingURLs.amazon_url,
|
||||
item_id: itemID,
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
});
|
||||
|
||||
if (amazonResponse.success) {
|
||||
@@ -195,14 +193,23 @@ export function useDownload() {
|
||||
track_name: trackName,
|
||||
artist_name: artistName,
|
||||
album_name: albumName,
|
||||
album_artist: albumArtist,
|
||||
release_date: releaseDate,
|
||||
cover_url: coverUrl,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate,
|
||||
track_number: settings.trackNumber,
|
||||
position,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
spotify_id: spotifyId,
|
||||
embed_lyrics: settings.embedLyrics,
|
||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||
duration: durationMs ? Math.round(durationMs / 1000) : undefined,
|
||||
item_id: itemID,
|
||||
audio_format: settings.qobuzQuality || "6", // Use default 6 (16-bit) for auto mode
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
});
|
||||
|
||||
// If Qobuz also failed, mark the item as failed
|
||||
@@ -218,21 +225,38 @@ export function useDownload() {
|
||||
// Convert duration from ms to seconds for backend
|
||||
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||
|
||||
// Determine audio format based on service
|
||||
let audioFormat: string | undefined;
|
||||
if (service === "tidal") {
|
||||
audioFormat = settings.tidalQuality || "LOSSLESS";
|
||||
} else if (service === "qobuz") {
|
||||
audioFormat = settings.qobuzQuality || "6";
|
||||
}
|
||||
|
||||
const singleServiceResponse = await downloadTrack({
|
||||
isrc,
|
||||
service: service as "deezer" | "tidal" | "qobuz" | "amazon",
|
||||
service: service as "tidal" | "qobuz" | "amazon",
|
||||
query,
|
||||
track_name: trackName,
|
||||
artist_name: artistName,
|
||||
album_name: albumName,
|
||||
album_artist: albumArtist,
|
||||
release_date: releaseDate,
|
||||
cover_url: coverUrl,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate,
|
||||
track_number: settings.trackNumber,
|
||||
position,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
spotify_id: spotifyId,
|
||||
embed_lyrics: settings.embedLyrics,
|
||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||
duration: durationSecondsForFallback,
|
||||
item_id: itemID, // Pass itemID for tracking
|
||||
audio_format: audioFormat,
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
});
|
||||
|
||||
// Mark as failed if download failed for single-service attempt
|
||||
@@ -246,6 +270,7 @@ export function useDownload() {
|
||||
|
||||
const downloadWithItemID = async (
|
||||
isrc: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
settings: any,
|
||||
itemID: string,
|
||||
trackName?: string,
|
||||
@@ -256,9 +281,15 @@ export function useDownload() {
|
||||
spotifyId?: string,
|
||||
durationMs?: number,
|
||||
isAlbum?: boolean,
|
||||
releaseYear?: string
|
||||
releaseYear?: string,
|
||||
albumArtist?: string,
|
||||
releaseDate?: string,
|
||||
coverUrl?: string,
|
||||
spotifyTrackNumber?: number,
|
||||
spotifyDiscNumber?: number,
|
||||
spotifyTotalTracks?: number
|
||||
) => {
|
||||
let service = settings.downloader;
|
||||
const service = settings.downloader;
|
||||
|
||||
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
|
||||
const os = settings.operatingSystem;
|
||||
@@ -266,20 +297,21 @@ export function useDownload() {
|
||||
let outputDir = settings.downloadPath;
|
||||
let useAlbumTrackNumber = false;
|
||||
|
||||
// Build template data for folder path
|
||||
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
const templateData: TemplateData = {
|
||||
artist: artistName,
|
||||
album: albumName,
|
||||
title: trackName,
|
||||
artist: artistName?.replace(/\//g, placeholder),
|
||||
album: albumName?.replace(/\//g, placeholder),
|
||||
title: trackName?.replace(/\//g, placeholder),
|
||||
track: position,
|
||||
year: releaseYear,
|
||||
playlist: folderName,
|
||||
playlist: folderName?.replace(/\//g, placeholder),
|
||||
isrc: isrc,
|
||||
};
|
||||
|
||||
// For playlist/discography downloads, always create a folder with the playlist/artist name
|
||||
if (folderName && !isAlbum) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(folderName, os));
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
|
||||
}
|
||||
|
||||
// Apply folder template if available
|
||||
@@ -287,10 +319,12 @@ export function useDownload() {
|
||||
// Parse and apply folder template
|
||||
const folderPath = parseTemplate(settings.folderTemplate, templateData);
|
||||
if (folderPath) {
|
||||
// Split by / and sanitize each part
|
||||
// Split by / (template separators), then restore placeholders as spaces
|
||||
const parts = folderPath.split("/").filter(p => p.trim());
|
||||
for (const part of parts) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(part, os));
|
||||
// Restore any slashes that were in the original values as spaces
|
||||
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +336,7 @@ export function useDownload() {
|
||||
|
||||
if (service === "auto") {
|
||||
// Get all streaming URLs once from song.link API
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let streamingURLs: any = null;
|
||||
if (spotifyId) {
|
||||
try {
|
||||
@@ -325,15 +360,24 @@ export function useDownload() {
|
||||
track_name: trackName,
|
||||
artist_name: artistName,
|
||||
album_name: albumName,
|
||||
album_artist: albumArtist,
|
||||
release_date: releaseDate,
|
||||
cover_url: coverUrl,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate,
|
||||
track_number: settings.trackNumber,
|
||||
position,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
spotify_id: spotifyId,
|
||||
embed_lyrics: settings.embedLyrics,
|
||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||
service_url: streamingURLs.tidal_url,
|
||||
duration: durationSeconds,
|
||||
item_id: itemID,
|
||||
audio_format: settings.tidalQuality || "LOSSLESS", // Use default LOSSLESS for auto mode
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
});
|
||||
|
||||
if (tidalResponse.success) {
|
||||
@@ -344,35 +388,7 @@ export function useDownload() {
|
||||
}
|
||||
}
|
||||
|
||||
// Try Deezer second
|
||||
if (streamingURLs?.deezer_url) {
|
||||
try {
|
||||
const deezerResponse = await downloadTrack({
|
||||
isrc,
|
||||
service: "deezer",
|
||||
query,
|
||||
track_name: trackName,
|
||||
artist_name: artistName,
|
||||
album_name: albumName,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate,
|
||||
track_number: settings.trackNumber,
|
||||
position,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
spotify_id: spotifyId,
|
||||
service_url: streamingURLs.deezer_url,
|
||||
item_id: itemID,
|
||||
});
|
||||
|
||||
if (deezerResponse.success) {
|
||||
return deezerResponse;
|
||||
}
|
||||
} catch (deezerErr) {
|
||||
console.error("Deezer error:", deezerErr);
|
||||
}
|
||||
}
|
||||
|
||||
// Try Amazon third
|
||||
// Try Amazon second
|
||||
if (streamingURLs?.amazon_url) {
|
||||
try {
|
||||
const amazonResponse = await downloadTrack({
|
||||
@@ -382,14 +398,22 @@ export function useDownload() {
|
||||
track_name: trackName,
|
||||
artist_name: artistName,
|
||||
album_name: albumName,
|
||||
album_artist: albumArtist,
|
||||
release_date: releaseDate,
|
||||
cover_url: coverUrl,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate,
|
||||
track_number: settings.trackNumber,
|
||||
position,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
spotify_id: spotifyId,
|
||||
embed_lyrics: settings.embedLyrics,
|
||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||
service_url: streamingURLs.amazon_url,
|
||||
item_id: itemID,
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
});
|
||||
|
||||
if (amazonResponse.success) {
|
||||
@@ -408,14 +432,23 @@ export function useDownload() {
|
||||
track_name: trackName,
|
||||
artist_name: artistName,
|
||||
album_name: albumName,
|
||||
album_artist: albumArtist,
|
||||
release_date: releaseDate,
|
||||
cover_url: coverUrl,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate,
|
||||
track_number: settings.trackNumber,
|
||||
position,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
spotify_id: spotifyId,
|
||||
embed_lyrics: settings.embedLyrics,
|
||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||
duration: durationMs ? Math.round(durationMs / 1000) : undefined,
|
||||
item_id: itemID,
|
||||
audio_format: settings.qobuzQuality || "6", // Use default 6 (16-bit) for auto mode
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
});
|
||||
|
||||
// If Qobuz also failed, mark the item as failed
|
||||
@@ -430,21 +463,38 @@ export function useDownload() {
|
||||
// Single service download
|
||||
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||
|
||||
// Determine audio format based on service
|
||||
let audioFormat: string | undefined;
|
||||
if (service === "tidal") {
|
||||
audioFormat = settings.tidalQuality || "LOSSLESS";
|
||||
} else if (service === "qobuz") {
|
||||
audioFormat = settings.qobuzQuality || "6";
|
||||
}
|
||||
|
||||
const singleServiceResponse = await downloadTrack({
|
||||
isrc,
|
||||
service: service as "deezer" | "tidal" | "qobuz" | "amazon",
|
||||
service: service as "tidal" | "qobuz" | "amazon",
|
||||
query,
|
||||
track_name: trackName,
|
||||
artist_name: artistName,
|
||||
album_name: albumName,
|
||||
album_artist: albumArtist,
|
||||
release_date: releaseDate,
|
||||
cover_url: coverUrl,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate,
|
||||
track_number: settings.trackNumber,
|
||||
position,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
spotify_id: spotifyId,
|
||||
embed_lyrics: settings.embedLyrics,
|
||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||
duration: durationSecondsForFallback,
|
||||
item_id: itemID,
|
||||
audio_format: audioFormat,
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
});
|
||||
|
||||
// Mark as failed if download failed for single-service attempt
|
||||
@@ -464,7 +514,13 @@ export function useDownload() {
|
||||
spotifyId?: string,
|
||||
playlistName?: string,
|
||||
durationMs?: number,
|
||||
position?: number
|
||||
position?: number,
|
||||
albumArtist?: string,
|
||||
releaseDate?: string,
|
||||
coverUrl?: string,
|
||||
spotifyTrackNumber?: number,
|
||||
spotifyDiscNumber?: number,
|
||||
spotifyTotalTracks?: number
|
||||
) => {
|
||||
if (!isrc) {
|
||||
toast.error("No ISRC found for this track");
|
||||
@@ -477,6 +533,9 @@ export function useDownload() {
|
||||
|
||||
try {
|
||||
// Single track download - use playlistName if provided for folder structure
|
||||
// Extract year from release_date (format: YYYY-MM-DD or YYYY)
|
||||
const releaseYear = releaseDate?.substring(0, 4);
|
||||
|
||||
const response = await downloadWithAutoFallback(
|
||||
isrc,
|
||||
settings,
|
||||
@@ -486,7 +545,14 @@ export function useDownload() {
|
||||
playlistName,
|
||||
position, // Pass position for track numbering
|
||||
spotifyId,
|
||||
durationMs
|
||||
durationMs,
|
||||
releaseYear,
|
||||
albumArtist || "",
|
||||
releaseDate,
|
||||
coverUrl,
|
||||
spotifyTrackNumber, // Spotify album track number
|
||||
spotifyDiscNumber, // Spotify disc number
|
||||
spotifyTotalTracks // Total tracks in album
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
@@ -585,7 +651,13 @@ export function useDownload() {
|
||||
track?.spotify_id,
|
||||
track?.duration_ms,
|
||||
isAlbum,
|
||||
releaseYear
|
||||
releaseYear,
|
||||
track?.album_artist || "", // Use album_artist from Spotify metadata
|
||||
track?.release_date,
|
||||
track?.images, // Spotify cover URL
|
||||
track?.track_number, // Spotify album track number
|
||||
track?.disc_number, // Spotify disc number
|
||||
track?.total_tracks // Total tracks in album
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
@@ -716,7 +788,13 @@ export function useDownload() {
|
||||
track.spotify_id,
|
||||
track.duration_ms,
|
||||
isAlbum,
|
||||
releaseYear
|
||||
releaseYear,
|
||||
track.album_artist || "", // Use album_artist from Spotify metadata
|
||||
track.release_date,
|
||||
track.images, // Spotify cover URL
|
||||
track.track_number, // Spotify album track number
|
||||
track.disc_number, // Spotify disc number
|
||||
track.total_tracks // Total tracks in album
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import { downloadLyrics } from "@/lib/api";
|
||||
import { getSettings, parseTemplate, type TemplateData } 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 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 [isBulkDownloadingLyrics, setIsBulkDownloadingLyrics] = useState(false);
|
||||
const [lyricsDownloadProgress, setLyricsDownloadProgress] = useState(0);
|
||||
const stopBulkDownloadRef = useRef(false);
|
||||
|
||||
const handleDownloadLyrics = async (
|
||||
spotifyId: string,
|
||||
@@ -33,17 +37,19 @@ export function useLyrics() {
|
||||
let outputDir = settings.downloadPath;
|
||||
|
||||
// Build output path using template system
|
||||
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
const templateData: TemplateData = {
|
||||
artist: artistName,
|
||||
album: albumName,
|
||||
title: trackName,
|
||||
artist: artistName?.replace(/\//g, placeholder),
|
||||
album: albumName?.replace(/\//g, placeholder),
|
||||
title: trackName?.replace(/\//g, placeholder),
|
||||
track: position,
|
||||
playlist: playlistName,
|
||||
playlist: playlistName?.replace(/\//g, placeholder),
|
||||
};
|
||||
|
||||
// For playlist/discography, prepend the folder name
|
||||
if (playlistName) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||
}
|
||||
|
||||
// Apply folder template
|
||||
@@ -52,7 +58,9 @@ export function useLyrics() {
|
||||
if (folderPath) {
|
||||
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
||||
for (const part of parts) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(part, os));
|
||||
// Restore any slashes that were in the original values as spaces
|
||||
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,6 +103,126 @@ export function useLyrics() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadAllLyrics = async (
|
||||
tracks: TrackMetadata[],
|
||||
playlistName?: string,
|
||||
_isArtistDiscography?: boolean
|
||||
) => {
|
||||
const tracksWithSpotifyId = tracks.filter((track) => track.spotify_id);
|
||||
|
||||
if (tracksWithSpotifyId.length === 0) {
|
||||
toast.error("No tracks with Spotify ID available for lyrics download");
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
setIsBulkDownloadingLyrics(true);
|
||||
setLyricsDownloadProgress(0);
|
||||
stopBulkDownloadRef.current = false;
|
||||
|
||||
let completed = 0;
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
const total = tracksWithSpotifyId.length;
|
||||
|
||||
for (const track of tracksWithSpotifyId) {
|
||||
if (stopBulkDownloadRef.current) {
|
||||
toast.info("Lyrics download stopped by user");
|
||||
break;
|
||||
}
|
||||
|
||||
const id = track.spotify_id!;
|
||||
setDownloadingLyricsTrack(id);
|
||||
setLyricsDownloadProgress(Math.round((completed / total) * 100));
|
||||
|
||||
try {
|
||||
const os = settings.operatingSystem;
|
||||
let outputDir = settings.downloadPath;
|
||||
|
||||
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
// Build output path using template system
|
||||
const templateData: TemplateData = {
|
||||
artist: track.artists?.replace(/\//g, placeholder),
|
||||
album: track.album_name?.replace(/\//g, placeholder),
|
||||
title: track.name?.replace(/\//g, placeholder),
|
||||
track: track.track_number,
|
||||
playlist: playlistName?.replace(/\//g, placeholder),
|
||||
};
|
||||
|
||||
// For playlist/discography, prepend the folder name
|
||||
if (playlistName) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||
}
|
||||
|
||||
// Apply folder template
|
||||
if (settings.folderTemplate) {
|
||||
const folderPath = parseTemplate(settings.folderTemplate, templateData);
|
||||
if (folderPath) {
|
||||
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
||||
for (const part of parts) {
|
||||
// Restore any slashes that were in the original values as spaces
|
||||
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
||||
|
||||
const response = await downloadLyrics({
|
||||
spotify_id: id,
|
||||
track_name: track.name,
|
||||
artist_name: track.artists,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate || "{title}",
|
||||
track_number: settings.trackNumber,
|
||||
position: track.track_number || 0,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
if (response.already_exists) {
|
||||
skipped++;
|
||||
setSkippedLyrics((prev) => new Set(prev).add(id));
|
||||
} else {
|
||||
success++;
|
||||
setDownloadedLyrics((prev) => new Set(prev).add(id));
|
||||
}
|
||||
setFailedLyrics((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(id);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
failed++;
|
||||
setFailedLyrics((prev) => new Set(prev).add(id));
|
||||
}
|
||||
} catch (err) {
|
||||
failed++;
|
||||
logger.error(`error downloading lyrics: ${track.name} - ${err}`);
|
||||
setFailedLyrics((prev) => new Set(prev).add(id));
|
||||
}
|
||||
|
||||
completed++;
|
||||
}
|
||||
|
||||
setDownloadingLyricsTrack(null);
|
||||
setIsBulkDownloadingLyrics(false);
|
||||
setLyricsDownloadProgress(0);
|
||||
|
||||
if (!stopBulkDownloadRef.current) {
|
||||
toast.success(`Lyrics: ${success} downloaded, ${skipped} skipped, ${failed} failed`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopLyricsDownload = () => {
|
||||
logger.info("lyrics download stopped by user");
|
||||
stopBulkDownloadRef.current = true;
|
||||
toast.info("Stopping lyrics download...");
|
||||
};
|
||||
|
||||
const resetLyricsState = () => {
|
||||
setDownloadedLyrics(new Set());
|
||||
setFailedLyrics(new Set());
|
||||
@@ -106,7 +234,11 @@ export function useLyrics() {
|
||||
downloadedLyrics,
|
||||
failedLyrics,
|
||||
skippedLyrics,
|
||||
isBulkDownloadingLyrics,
|
||||
lyricsDownloadProgress,
|
||||
handleDownloadLyrics,
|
||||
handleDownloadAllLyrics,
|
||||
handleStopLyricsDownload,
|
||||
resetLyricsState,
|
||||
};
|
||||
}
|
||||
|
||||
+48
-39
@@ -26,19 +26,6 @@
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -61,19 +48,6 @@
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -95,19 +69,6 @@
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -164,6 +125,54 @@
|
||||
@apply text-blue-600;
|
||||
}
|
||||
|
||||
/* Ensure description text uses same color as title */
|
||||
[data-sonner-toast] [data-description],
|
||||
[data-sonner-toast] [data-description] * {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Specific color for each toast type - match icon color */
|
||||
[data-sonner-toast][data-type="success"] [data-description],
|
||||
[data-sonner-toast][data-type="success"] [data-description] * {
|
||||
color: rgb(22 163 74) !important; /* green-600 - same as icon */
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-type="error"] [data-description],
|
||||
[data-sonner-toast][data-type="error"] [data-description] * {
|
||||
color: rgb(220 38 38) !important; /* red-600 - same as icon */
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-type="warning"] [data-description],
|
||||
[data-sonner-toast][data-type="warning"] [data-description] * {
|
||||
color: rgb(202 138 4) !important; /* yellow-600 - same as icon */
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-type="info"] [data-description],
|
||||
[data-sonner-toast][data-type="info"] [data-description] * {
|
||||
color: rgb(37 99 235) !important; /* blue-600 - same as icon */
|
||||
}
|
||||
|
||||
/* Dark mode - use same icon colors */
|
||||
.dark [data-sonner-toast][data-type="success"] [data-description],
|
||||
.dark [data-sonner-toast][data-type="success"] [data-description] * {
|
||||
color: rgb(22 163 74) !important; /* green-600 */
|
||||
}
|
||||
|
||||
.dark [data-sonner-toast][data-type="error"] [data-description],
|
||||
.dark [data-sonner-toast][data-type="error"] [data-description] * {
|
||||
color: rgb(220 38 38) !important; /* red-600 */
|
||||
}
|
||||
|
||||
.dark [data-sonner-toast][data-type="warning"] [data-description],
|
||||
.dark [data-sonner-toast][data-type="warning"] [data-description] * {
|
||||
color: rgb(202 138 4) !important; /* yellow-600 */
|
||||
}
|
||||
|
||||
.dark [data-sonner-toast][data-type="info"] [data-description],
|
||||
.dark [data-sonner-toast][data-type="info"] [data-description] * {
|
||||
color: rgb(37 99 235) !important; /* blue-600 */
|
||||
}
|
||||
|
||||
/* Dark mode toast styling */
|
||||
.dark [data-sonner-toast][data-type="success"] {
|
||||
@apply bg-green-950 border-green-800 text-green-100;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { GetDefaults } from "../../wailsjs/go/main/App";
|
||||
|
||||
export type FontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk";
|
||||
export type FontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk" | "noto-sans" | "nunito-sans" | "figtree" | "raleway" | "public-sans" | "outfit" | "jetbrains-mono" | "geist-sans";
|
||||
|
||||
// Folder structure presets
|
||||
export type FolderPreset = "none" | "artist" | "album" | "artist-album" | "artist-year-album" | "custom";
|
||||
@@ -10,7 +10,7 @@ export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-
|
||||
|
||||
export interface Settings {
|
||||
downloadPath: string;
|
||||
downloader: "auto" | "deezer" | "tidal" | "qobuz" | "amazon";
|
||||
downloader: "auto" | "tidal" | "qobuz" | "amazon";
|
||||
theme: string;
|
||||
themeMode: "auto" | "light" | "dark";
|
||||
fontFamily: FontFamily;
|
||||
@@ -25,7 +25,12 @@ export interface Settings {
|
||||
albumSubfolder?: boolean;
|
||||
trackNumber: boolean;
|
||||
sfxEnabled: boolean;
|
||||
operatingSystem: "Windows" | "linux/MacOS"
|
||||
embedLyrics: boolean;
|
||||
embedMaxQualityCover: boolean;
|
||||
operatingSystem: "Windows" | "linux/MacOS";
|
||||
// Quality settings for specific sources
|
||||
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
||||
qobuzQuality: "6" | "7" | "27";
|
||||
}
|
||||
|
||||
// Folder preset templates
|
||||
@@ -81,17 +86,29 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
filenameTemplate: "{title} - {artist}",
|
||||
trackNumber: false,
|
||||
sfxEnabled: true,
|
||||
operatingSystem: detectOS()
|
||||
embedLyrics: false,
|
||||
embedMaxQualityCover: false,
|
||||
operatingSystem: detectOS(),
|
||||
tidalQuality: "LOSSLESS", // Default: 16-bit lossless
|
||||
qobuzQuality: "6" // Default: FLAC 16-bit
|
||||
};
|
||||
|
||||
export const FONT_OPTIONS: { value: FontFamily; label: string; fontFamily: string }[] = [
|
||||
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
|
||||
{ value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
|
||||
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
|
||||
{ value: "google-sans", label: "Google Sans Flex", fontFamily: '"Google Sans Flex", system-ui, sans-serif' },
|
||||
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
|
||||
{ value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' },
|
||||
{ value: "roboto", label: "Roboto", fontFamily: '"Roboto", system-ui, sans-serif' },
|
||||
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
|
||||
{ value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' },
|
||||
{ value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' },
|
||||
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
|
||||
{ value: "noto-sans", label: "Noto Sans", fontFamily: '"Noto Sans", system-ui, sans-serif' },
|
||||
{ value: "nunito-sans", label: "Nunito Sans", fontFamily: '"Nunito Sans", system-ui, sans-serif' },
|
||||
{ value: "outfit", label: "Outfit", fontFamily: '"Outfit", system-ui, sans-serif' },
|
||||
{ value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' },
|
||||
{ value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' },
|
||||
{ value: "public-sans", label: "Public Sans", fontFamily: '"Public Sans", system-ui, sans-serif' },
|
||||
{ value: "raleway", label: "Raleway", fontFamily: '"Raleway", system-ui, sans-serif' },
|
||||
{ value: "roboto", label: "Roboto", fontFamily: '"Roboto", system-ui, sans-serif' },
|
||||
{ value: "space-grotesk", label: "Space Grotesk", fontFamily: '"Space Grotesk", system-ui, sans-serif' },
|
||||
];
|
||||
|
||||
@@ -158,6 +175,13 @@ export function getSettings(): Settings {
|
||||
}
|
||||
// Always use detected OS (don't persist it)
|
||||
parsed.operatingSystem = detectOS();
|
||||
// Set default quality if not present
|
||||
if (!('tidalQuality' in parsed)) {
|
||||
parsed.tidalQuality = "LOSSLESS";
|
||||
}
|
||||
if (!('qobuzQuality' in parsed)) {
|
||||
parsed.qobuzQuality = "6";
|
||||
}
|
||||
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Memory cache for spectrum data (fast access, cleared on page refresh)
|
||||
// Key: file path, Value: spectrum data
|
||||
|
||||
const spectrumCache = new Map<string, any>();
|
||||
|
||||
export function setSpectrumCache(filePath: string, spectrumData: any): void {
|
||||
spectrumCache.set(filePath, spectrumData);
|
||||
}
|
||||
|
||||
export function getSpectrumCache(filePath: string): any | null {
|
||||
return spectrumCache.get(filePath) || null;
|
||||
}
|
||||
|
||||
export function clearSpectrumCache(filePath?: string): void {
|
||||
if (filePath) {
|
||||
spectrumCache.delete(filePath);
|
||||
} else {
|
||||
spectrumCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
+269
-449
@@ -7,456 +7,276 @@ export interface Theme {
|
||||
};
|
||||
}
|
||||
|
||||
// Base colors yang sama untuk semua tema (kecuali primary dan primary-foreground)
|
||||
const baseLightColors: Record<string, string> = {
|
||||
background: "oklch(1 0 0)",
|
||||
foreground: "oklch(0.145 0 0)",
|
||||
card: "oklch(1 0 0)",
|
||||
"card-foreground": "oklch(0.145 0 0)",
|
||||
popover: "oklch(1 0 0)",
|
||||
"popover-foreground": "oklch(0.145 0 0)",
|
||||
secondary: "oklch(0.967 0.001 286.375)",
|
||||
"secondary-foreground": "oklch(0.21 0.006 285.885)",
|
||||
muted: "oklch(0.97 0 0)",
|
||||
"muted-foreground": "oklch(0.556 0 0)",
|
||||
accent: "oklch(0.97 0 0)",
|
||||
"accent-foreground": "oklch(0.205 0 0)",
|
||||
destructive: "oklch(0.58 0.22 27)",
|
||||
border: "oklch(0.922 0 0)",
|
||||
input: "oklch(0.922 0 0)",
|
||||
ring: "oklch(0.708 0 0)",
|
||||
};
|
||||
|
||||
const baseDarkColors: Record<string, string> = {
|
||||
background: "oklch(0.145 0 0)",
|
||||
foreground: "oklch(0.985 0 0)",
|
||||
card: "oklch(0.205 0 0)",
|
||||
"card-foreground": "oklch(0.985 0 0)",
|
||||
popover: "oklch(0.205 0 0)",
|
||||
"popover-foreground": "oklch(0.985 0 0)",
|
||||
secondary: "oklch(0.274 0.006 286.033)",
|
||||
"secondary-foreground": "oklch(0.985 0 0)",
|
||||
muted: "oklch(0.269 0 0)",
|
||||
"muted-foreground": "oklch(0.708 0 0)",
|
||||
accent: "oklch(0.371 0 0)",
|
||||
"accent-foreground": "oklch(0.985 0 0)",
|
||||
destructive: "oklch(0.704 0.191 22.216)",
|
||||
border: "oklch(1 0 0 / 10%)",
|
||||
input: "oklch(1 0 0 / 15%)",
|
||||
ring: "oklch(0.556 0 0)",
|
||||
};
|
||||
|
||||
// Primary colors yang berbeda untuk setiap tema
|
||||
interface PrimaryColors {
|
||||
light: {
|
||||
primary: string;
|
||||
"primary-foreground": string;
|
||||
};
|
||||
dark: {
|
||||
primary: string;
|
||||
"primary-foreground": string;
|
||||
};
|
||||
}
|
||||
|
||||
const primaryColors: Record<string, PrimaryColors> = {
|
||||
amber: {
|
||||
light: {
|
||||
primary: "oklch(0.67 0.16 58)",
|
||||
"primary-foreground": "oklch(0.99 0.02 95)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.77 0.16 70)",
|
||||
"primary-foreground": "oklch(0.28 0.07 46)",
|
||||
},
|
||||
},
|
||||
blue: {
|
||||
light: {
|
||||
primary: "oklch(0.488 0.243 264.376)",
|
||||
"primary-foreground": "oklch(0.97 0.014 254.604)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.42 0.18 266)",
|
||||
"primary-foreground": "oklch(0.97 0.014 254.604)",
|
||||
},
|
||||
},
|
||||
cyan: {
|
||||
light: {
|
||||
primary: "oklch(0.61 0.11 222)",
|
||||
"primary-foreground": "oklch(0.98 0.02 201)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.71 0.13 215)",
|
||||
"primary-foreground": "oklch(0.30 0.05 230)",
|
||||
},
|
||||
},
|
||||
emerald: {
|
||||
light: {
|
||||
primary: "oklch(0.60 0.13 163)",
|
||||
"primary-foreground": "oklch(0.98 0.02 166)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.70 0.15 162)",
|
||||
"primary-foreground": "oklch(0.26 0.05 173)",
|
||||
},
|
||||
},
|
||||
fuchsia: {
|
||||
light: {
|
||||
primary: "oklch(0.59 0.26 323)",
|
||||
"primary-foreground": "oklch(0.98 0.02 320)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.67 0.26 322)",
|
||||
"primary-foreground": "oklch(0.98 0.02 320)",
|
||||
},
|
||||
},
|
||||
green: {
|
||||
light: {
|
||||
primary: "oklch(0.648 0.2 131.684)",
|
||||
"primary-foreground": "oklch(0.986 0.031 120.757)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.648 0.2 131.684)",
|
||||
"primary-foreground": "oklch(0.986 0.031 120.757)",
|
||||
},
|
||||
},
|
||||
indigo: {
|
||||
light: {
|
||||
primary: "oklch(0.51 0.23 277)",
|
||||
"primary-foreground": "oklch(0.96 0.02 272)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.59 0.20 277)",
|
||||
"primary-foreground": "oklch(0.96 0.02 272)",
|
||||
},
|
||||
},
|
||||
lime: {
|
||||
light: {
|
||||
primary: "oklch(0.65 0.18 132)",
|
||||
"primary-foreground": "oklch(0.99 0.03 121)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.77 0.20 131)",
|
||||
"primary-foreground": "oklch(0.27 0.07 132)",
|
||||
},
|
||||
},
|
||||
neutral: {
|
||||
light: {
|
||||
primary: "oklch(0.205 0 0)",
|
||||
"primary-foreground": "oklch(0.985 0 0)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.922 0 0)",
|
||||
"primary-foreground": "oklch(0.205 0 0)",
|
||||
},
|
||||
},
|
||||
orange: {
|
||||
light: {
|
||||
primary: "oklch(0.646 0.222 41.116)",
|
||||
"primary-foreground": "oklch(0.98 0.016 73.684)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.705 0.213 47.604)",
|
||||
"primary-foreground": "oklch(0.98 0.016 73.684)",
|
||||
},
|
||||
},
|
||||
pink: {
|
||||
light: {
|
||||
primary: "oklch(0.59 0.22 1)",
|
||||
"primary-foreground": "oklch(0.97 0.01 343)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.66 0.21 354)",
|
||||
"primary-foreground": "oklch(0.97 0.01 343)",
|
||||
},
|
||||
},
|
||||
purple: {
|
||||
light: {
|
||||
primary: "oklch(0.56 0.25 302)",
|
||||
"primary-foreground": "oklch(0.98 0.01 308)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.63 0.23 304)",
|
||||
"primary-foreground": "oklch(0.98 0.01 308)",
|
||||
},
|
||||
},
|
||||
red: {
|
||||
light: {
|
||||
primary: "oklch(0.577 0.245 27.325)",
|
||||
"primary-foreground": "oklch(0.971 0.013 17.38)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.637 0.237 25.331)",
|
||||
"primary-foreground": "oklch(0.971 0.013 17.38)",
|
||||
},
|
||||
},
|
||||
rose: {
|
||||
light: {
|
||||
primary: "oklch(0.586 0.253 17.585)",
|
||||
"primary-foreground": "oklch(0.969 0.015 12.422)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.645 0.246 16.439)",
|
||||
"primary-foreground": "oklch(0.969 0.015 12.422)",
|
||||
},
|
||||
},
|
||||
sky: {
|
||||
light: {
|
||||
primary: "oklch(0.59 0.14 242)",
|
||||
"primary-foreground": "oklch(0.98 0.01 237)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.68 0.15 237)",
|
||||
"primary-foreground": "oklch(0.29 0.06 243)",
|
||||
},
|
||||
},
|
||||
teal: {
|
||||
light: {
|
||||
primary: "oklch(0.60 0.10 185)",
|
||||
"primary-foreground": "oklch(0.98 0.01 181)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.70 0.12 183)",
|
||||
"primary-foreground": "oklch(0.28 0.04 193)",
|
||||
},
|
||||
},
|
||||
violet: {
|
||||
light: {
|
||||
primary: "oklch(0.541 0.281 293.009)",
|
||||
"primary-foreground": "oklch(0.969 0.016 293.756)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.606 0.25 292.717)",
|
||||
"primary-foreground": "oklch(0.969 0.016 293.756)",
|
||||
},
|
||||
},
|
||||
yellow: {
|
||||
light: {
|
||||
primary: "oklch(0.852 0.199 91.936)",
|
||||
"primary-foreground": "oklch(0.421 0.095 57.708)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.795 0.184 86.047)",
|
||||
"primary-foreground": "oklch(0.421 0.095 57.708)",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Helper function untuk menggabungkan base colors dengan primary colors
|
||||
function createTheme(
|
||||
name: string,
|
||||
label: string,
|
||||
primary: PrimaryColors
|
||||
): Theme {
|
||||
return {
|
||||
name,
|
||||
label,
|
||||
cssVars: {
|
||||
light: { ...baseLightColors, ...primary.light },
|
||||
dark: { ...baseDarkColors, ...primary.dark },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const themes: Theme[] = [
|
||||
{
|
||||
name: "neutral",
|
||||
label: "Default",
|
||||
cssVars: {
|
||||
light: {
|
||||
background: "oklch(1 0 0)",
|
||||
foreground: "oklch(0.145 0 0)",
|
||||
card: "oklch(1 0 0)",
|
||||
"card-foreground": "oklch(0.145 0 0)",
|
||||
popover: "oklch(1 0 0)",
|
||||
"popover-foreground": "oklch(0.145 0 0)",
|
||||
primary: "oklch(0.205 0 0)",
|
||||
"primary-foreground": "oklch(0.985 0 0)",
|
||||
secondary: "oklch(0.97 0 0)",
|
||||
"secondary-foreground": "oklch(0.205 0 0)",
|
||||
muted: "oklch(0.97 0 0)",
|
||||
"muted-foreground": "oklch(0.556 0 0)",
|
||||
accent: "oklch(0.97 0 0)",
|
||||
"accent-foreground": "oklch(0.205 0 0)",
|
||||
destructive: "oklch(0.577 0.245 27.325)",
|
||||
border: "oklch(0.922 0 0)",
|
||||
input: "oklch(0.922 0 0)",
|
||||
ring: "oklch(0.708 0 0)",
|
||||
"chart-1": "oklch(0.646 0.222 41.116)",
|
||||
"chart-2": "oklch(0.6 0.118 184.704)",
|
||||
"chart-3": "oklch(0.398 0.07 227.392)",
|
||||
"chart-4": "oklch(0.828 0.189 84.429)",
|
||||
"chart-5": "oklch(0.769 0.188 70.08)",
|
||||
},
|
||||
dark: {
|
||||
background: "oklch(0.145 0 0)",
|
||||
foreground: "oklch(0.985 0 0)",
|
||||
card: "oklch(0.205 0 0)",
|
||||
"card-foreground": "oklch(0.985 0 0)",
|
||||
popover: "oklch(0.205 0 0)",
|
||||
"popover-foreground": "oklch(0.985 0 0)",
|
||||
primary: "oklch(0.922 0 0)",
|
||||
"primary-foreground": "oklch(0.205 0 0)",
|
||||
secondary: "oklch(0.269 0 0)",
|
||||
"secondary-foreground": "oklch(0.985 0 0)",
|
||||
muted: "oklch(0.269 0 0)",
|
||||
"muted-foreground": "oklch(0.708 0 0)",
|
||||
accent: "oklch(0.269 0 0)",
|
||||
"accent-foreground": "oklch(0.985 0 0)",
|
||||
destructive: "oklch(0.704 0.191 22.216)",
|
||||
border: "oklch(1 0 0 / 10%)",
|
||||
input: "oklch(1 0 0 / 15%)",
|
||||
ring: "oklch(0.556 0 0)",
|
||||
"chart-1": "oklch(0.488 0.243 264.376)",
|
||||
"chart-2": "oklch(0.696 0.17 162.48)",
|
||||
"chart-3": "oklch(0.769 0.188 70.08)",
|
||||
"chart-4": "oklch(0.627 0.265 303.9)",
|
||||
"chart-5": "oklch(0.645 0.246 16.439)",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "blue",
|
||||
label: "Blue",
|
||||
cssVars: {
|
||||
light: {
|
||||
background: "oklch(1 0 0)",
|
||||
foreground: "oklch(0.141 0.005 285.823)",
|
||||
card: "oklch(1 0 0)",
|
||||
"card-foreground": "oklch(0.141 0.005 285.823)",
|
||||
popover: "oklch(1 0 0)",
|
||||
"popover-foreground": "oklch(0.141 0.005 285.823)",
|
||||
primary: "oklch(0.488 0.243 264.376)",
|
||||
"primary-foreground": "oklch(0.97 0.014 254.604)",
|
||||
secondary: "oklch(0.967 0.001 286.375)",
|
||||
"secondary-foreground": "oklch(0.21 0.006 285.885)",
|
||||
muted: "oklch(0.967 0.001 286.375)",
|
||||
"muted-foreground": "oklch(0.552 0.016 285.938)",
|
||||
accent: "oklch(0.967 0.001 286.375)",
|
||||
"accent-foreground": "oklch(0.21 0.006 285.885)",
|
||||
destructive: "oklch(0.577 0.245 27.325)",
|
||||
border: "oklch(0.92 0.004 286.32)",
|
||||
input: "oklch(0.92 0.004 286.32)",
|
||||
ring: "oklch(0.708 0 0)",
|
||||
"chart-1": "oklch(0.809 0.105 251.813)",
|
||||
"chart-2": "oklch(0.623 0.214 259.815)",
|
||||
"chart-3": "oklch(0.546 0.245 262.881)",
|
||||
"chart-4": "oklch(0.488 0.243 264.376)",
|
||||
"chart-5": "oklch(0.424 0.199 265.638)",
|
||||
},
|
||||
dark: {
|
||||
background: "oklch(0.141 0.005 285.823)",
|
||||
foreground: "oklch(0.985 0 0)",
|
||||
card: "oklch(0.21 0.006 285.885)",
|
||||
"card-foreground": "oklch(0.985 0 0)",
|
||||
popover: "oklch(0.21 0.006 285.885)",
|
||||
"popover-foreground": "oklch(0.985 0 0)",
|
||||
primary: "oklch(0.488 0.243 264.376)",
|
||||
"primary-foreground": "oklch(0.97 0.014 254.604)",
|
||||
secondary: "oklch(0.274 0.006 286.033)",
|
||||
"secondary-foreground": "oklch(0.985 0 0)",
|
||||
muted: "oklch(0.274 0.006 286.033)",
|
||||
"muted-foreground": "oklch(0.705 0.015 286.067)",
|
||||
accent: "oklch(0.274 0.006 286.033)",
|
||||
"accent-foreground": "oklch(0.985 0 0)",
|
||||
destructive: "oklch(0.704 0.191 22.216)",
|
||||
border: "oklch(1 0 0 / 10%)",
|
||||
input: "oklch(1 0 0 / 15%)",
|
||||
ring: "oklch(0.556 0 0)",
|
||||
"chart-1": "oklch(0.809 0.105 251.813)",
|
||||
"chart-2": "oklch(0.623 0.214 259.815)",
|
||||
"chart-3": "oklch(0.546 0.245 262.881)",
|
||||
"chart-4": "oklch(0.488 0.243 264.376)",
|
||||
"chart-5": "oklch(0.424 0.199 265.638)",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "green",
|
||||
label: "Green",
|
||||
cssVars: {
|
||||
light: {
|
||||
background: "oklch(1 0 0)",
|
||||
foreground: "oklch(0.141 0.005 285.823)",
|
||||
card: "oklch(1 0 0)",
|
||||
"card-foreground": "oklch(0.141 0.005 285.823)",
|
||||
popover: "oklch(1 0 0)",
|
||||
"popover-foreground": "oklch(0.141 0.005 285.823)",
|
||||
primary: "oklch(0.648 0.2 131.684)",
|
||||
"primary-foreground": "oklch(0.986 0.031 120.757)",
|
||||
secondary: "oklch(0.967 0.001 286.375)",
|
||||
"secondary-foreground": "oklch(0.21 0.006 285.885)",
|
||||
muted: "oklch(0.967 0.001 286.375)",
|
||||
"muted-foreground": "oklch(0.552 0.016 285.938)",
|
||||
accent: "oklch(0.967 0.001 286.375)",
|
||||
"accent-foreground": "oklch(0.21 0.006 285.885)",
|
||||
destructive: "oklch(0.577 0.245 27.325)",
|
||||
border: "oklch(0.92 0.004 286.32)",
|
||||
input: "oklch(0.92 0.004 286.32)",
|
||||
ring: "oklch(0.841 0.238 128.85)",
|
||||
"chart-1": "oklch(0.871 0.15 154.449)",
|
||||
"chart-2": "oklch(0.723 0.219 149.579)",
|
||||
"chart-3": "oklch(0.627 0.194 149.214)",
|
||||
"chart-4": "oklch(0.527 0.154 150.069)",
|
||||
"chart-5": "oklch(0.448 0.119 151.328)",
|
||||
},
|
||||
dark: {
|
||||
background: "oklch(0.141 0.005 285.823)",
|
||||
foreground: "oklch(0.985 0 0)",
|
||||
card: "oklch(0.21 0.006 285.885)",
|
||||
"card-foreground": "oklch(0.985 0 0)",
|
||||
popover: "oklch(0.21 0.006 285.885)",
|
||||
"popover-foreground": "oklch(0.985 0 0)",
|
||||
primary: "oklch(0.648 0.2 131.684)",
|
||||
"primary-foreground": "oklch(0.986 0.031 120.757)",
|
||||
secondary: "oklch(0.274 0.006 286.033)",
|
||||
"secondary-foreground": "oklch(0.985 0 0)",
|
||||
muted: "oklch(0.274 0.006 286.033)",
|
||||
"muted-foreground": "oklch(0.705 0.015 286.067)",
|
||||
accent: "oklch(0.274 0.006 286.033)",
|
||||
"accent-foreground": "oklch(0.985 0 0)",
|
||||
destructive: "oklch(0.704 0.191 22.216)",
|
||||
border: "oklch(1 0 0 / 10%)",
|
||||
input: "oklch(1 0 0 / 15%)",
|
||||
ring: "oklch(0.405 0.101 131.063)",
|
||||
"chart-1": "oklch(0.871 0.15 154.449)",
|
||||
"chart-2": "oklch(0.723 0.219 149.579)",
|
||||
"chart-3": "oklch(0.627 0.194 149.214)",
|
||||
"chart-4": "oklch(0.527 0.154 150.069)",
|
||||
"chart-5": "oklch(0.448 0.119 151.328)",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "orange",
|
||||
label: "Orange",
|
||||
cssVars: {
|
||||
light: {
|
||||
background: "oklch(1 0 0)",
|
||||
foreground: "oklch(0.141 0.005 285.823)",
|
||||
card: "oklch(1 0 0)",
|
||||
"card-foreground": "oklch(0.141 0.005 285.823)",
|
||||
popover: "oklch(1 0 0)",
|
||||
"popover-foreground": "oklch(0.141 0.005 285.823)",
|
||||
primary: "oklch(0.646 0.222 41.116)",
|
||||
"primary-foreground": "oklch(0.98 0.016 73.684)",
|
||||
secondary: "oklch(0.967 0.001 286.375)",
|
||||
"secondary-foreground": "oklch(0.21 0.006 285.885)",
|
||||
muted: "oklch(0.967 0.001 286.375)",
|
||||
"muted-foreground": "oklch(0.552 0.016 285.938)",
|
||||
accent: "oklch(0.967 0.001 286.375)",
|
||||
"accent-foreground": "oklch(0.21 0.006 285.885)",
|
||||
destructive: "oklch(0.577 0.245 27.325)",
|
||||
border: "oklch(0.92 0.004 286.32)",
|
||||
input: "oklch(0.92 0.004 286.32)",
|
||||
ring: "oklch(0.75 0.183 55.934)",
|
||||
"chart-1": "oklch(0.837 0.128 66.29)",
|
||||
"chart-2": "oklch(0.705 0.213 47.604)",
|
||||
"chart-3": "oklch(0.646 0.222 41.116)",
|
||||
"chart-4": "oklch(0.553 0.195 38.402)",
|
||||
"chart-5": "oklch(0.47 0.157 37.304)",
|
||||
},
|
||||
dark: {
|
||||
background: "oklch(0.141 0.005 285.823)",
|
||||
foreground: "oklch(0.985 0 0)",
|
||||
card: "oklch(0.21 0.006 285.885)",
|
||||
"card-foreground": "oklch(0.985 0 0)",
|
||||
popover: "oklch(0.21 0.006 285.885)",
|
||||
"popover-foreground": "oklch(0.985 0 0)",
|
||||
primary: "oklch(0.705 0.213 47.604)",
|
||||
"primary-foreground": "oklch(0.98 0.016 73.684)",
|
||||
secondary: "oklch(0.274 0.006 286.033)",
|
||||
"secondary-foreground": "oklch(0.985 0 0)",
|
||||
muted: "oklch(0.274 0.006 286.033)",
|
||||
"muted-foreground": "oklch(0.705 0.015 286.067)",
|
||||
accent: "oklch(0.274 0.006 286.033)",
|
||||
"accent-foreground": "oklch(0.985 0 0)",
|
||||
destructive: "oklch(0.704 0.191 22.216)",
|
||||
border: "oklch(1 0 0 / 10%)",
|
||||
input: "oklch(1 0 0 / 15%)",
|
||||
ring: "oklch(0.408 0.123 38.172)",
|
||||
"chart-1": "oklch(0.837 0.128 66.29)",
|
||||
"chart-2": "oklch(0.705 0.213 47.604)",
|
||||
"chart-3": "oklch(0.646 0.222 41.116)",
|
||||
"chart-4": "oklch(0.553 0.195 38.402)",
|
||||
"chart-5": "oklch(0.47 0.157 37.304)",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "red",
|
||||
label: "Red",
|
||||
cssVars: {
|
||||
light: {
|
||||
background: "oklch(1 0 0)",
|
||||
foreground: "oklch(0.141 0.005 285.823)",
|
||||
card: "oklch(1 0 0)",
|
||||
"card-foreground": "oklch(0.141 0.005 285.823)",
|
||||
popover: "oklch(1 0 0)",
|
||||
"popover-foreground": "oklch(0.141 0.005 285.823)",
|
||||
primary: "oklch(0.577 0.245 27.325)",
|
||||
"primary-foreground": "oklch(0.971 0.013 17.38)",
|
||||
secondary: "oklch(0.967 0.001 286.375)",
|
||||
"secondary-foreground": "oklch(0.21 0.006 285.885)",
|
||||
muted: "oklch(0.967 0.001 286.375)",
|
||||
"muted-foreground": "oklch(0.552 0.016 285.938)",
|
||||
accent: "oklch(0.967 0.001 286.375)",
|
||||
"accent-foreground": "oklch(0.21 0.006 285.885)",
|
||||
destructive: "oklch(0.577 0.245 27.325)",
|
||||
border: "oklch(0.92 0.004 286.32)",
|
||||
input: "oklch(0.92 0.004 286.32)",
|
||||
ring: "oklch(0.704 0.191 22.216)",
|
||||
"chart-1": "oklch(0.808 0.114 19.571)",
|
||||
"chart-2": "oklch(0.637 0.237 25.331)",
|
||||
"chart-3": "oklch(0.577 0.245 27.325)",
|
||||
"chart-4": "oklch(0.505 0.213 27.518)",
|
||||
"chart-5": "oklch(0.444 0.177 26.899)",
|
||||
},
|
||||
dark: {
|
||||
background: "oklch(0.141 0.005 285.823)",
|
||||
foreground: "oklch(0.985 0 0)",
|
||||
card: "oklch(0.21 0.006 285.885)",
|
||||
"card-foreground": "oklch(0.985 0 0)",
|
||||
popover: "oklch(0.21 0.006 285.885)",
|
||||
"popover-foreground": "oklch(0.985 0 0)",
|
||||
primary: "oklch(0.637 0.237 25.331)",
|
||||
"primary-foreground": "oklch(0.971 0.013 17.38)",
|
||||
secondary: "oklch(0.274 0.006 286.033)",
|
||||
"secondary-foreground": "oklch(0.985 0 0)",
|
||||
muted: "oklch(0.274 0.006 286.033)",
|
||||
"muted-foreground": "oklch(0.705 0.015 286.067)",
|
||||
accent: "oklch(0.274 0.006 286.033)",
|
||||
"accent-foreground": "oklch(0.985 0 0)",
|
||||
destructive: "oklch(0.704 0.191 22.216)",
|
||||
border: "oklch(1 0 0 / 10%)",
|
||||
input: "oklch(1 0 0 / 15%)",
|
||||
ring: "oklch(0.396 0.141 25.723)",
|
||||
"chart-1": "oklch(0.808 0.114 19.571)",
|
||||
"chart-2": "oklch(0.637 0.237 25.331)",
|
||||
"chart-3": "oklch(0.577 0.245 27.325)",
|
||||
"chart-4": "oklch(0.505 0.213 27.518)",
|
||||
"chart-5": "oklch(0.444 0.177 26.899)",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rose",
|
||||
label: "Rose",
|
||||
cssVars: {
|
||||
light: {
|
||||
background: "oklch(1 0 0)",
|
||||
foreground: "oklch(0.141 0.005 285.823)",
|
||||
card: "oklch(1 0 0)",
|
||||
"card-foreground": "oklch(0.141 0.005 285.823)",
|
||||
popover: "oklch(1 0 0)",
|
||||
"popover-foreground": "oklch(0.141 0.005 285.823)",
|
||||
primary: "oklch(0.586 0.253 17.585)",
|
||||
"primary-foreground": "oklch(0.969 0.015 12.422)",
|
||||
secondary: "oklch(0.967 0.001 286.375)",
|
||||
"secondary-foreground": "oklch(0.21 0.006 285.885)",
|
||||
muted: "oklch(0.967 0.001 286.375)",
|
||||
"muted-foreground": "oklch(0.552 0.016 285.938)",
|
||||
accent: "oklch(0.967 0.001 286.375)",
|
||||
"accent-foreground": "oklch(0.21 0.006 285.885)",
|
||||
destructive: "oklch(0.577 0.245 27.325)",
|
||||
border: "oklch(0.92 0.004 286.32)",
|
||||
input: "oklch(0.92 0.004 286.32)",
|
||||
ring: "oklch(0.712 0.194 13.428)",
|
||||
"chart-1": "oklch(0.81 0.117 11.638)",
|
||||
"chart-2": "oklch(0.645 0.246 16.439)",
|
||||
"chart-3": "oklch(0.586 0.253 17.585)",
|
||||
"chart-4": "oklch(0.514 0.222 16.935)",
|
||||
"chart-5": "oklch(0.455 0.188 13.697)",
|
||||
},
|
||||
dark: {
|
||||
background: "oklch(0.141 0.005 285.823)",
|
||||
foreground: "oklch(0.985 0 0)",
|
||||
card: "oklch(0.21 0.006 285.885)",
|
||||
"card-foreground": "oklch(0.985 0 0)",
|
||||
popover: "oklch(0.21 0.006 285.885)",
|
||||
"popover-foreground": "oklch(0.985 0 0)",
|
||||
primary: "oklch(0.645 0.246 16.439)",
|
||||
"primary-foreground": "oklch(0.969 0.015 12.422)",
|
||||
secondary: "oklch(0.274 0.006 286.033)",
|
||||
"secondary-foreground": "oklch(0.985 0 0)",
|
||||
muted: "oklch(0.274 0.006 286.033)",
|
||||
"muted-foreground": "oklch(0.705 0.015 286.067)",
|
||||
accent: "oklch(0.274 0.006 286.033)",
|
||||
"accent-foreground": "oklch(0.985 0 0)",
|
||||
destructive: "oklch(0.704 0.191 22.216)",
|
||||
border: "oklch(1 0 0 / 10%)",
|
||||
input: "oklch(1 0 0 / 15%)",
|
||||
ring: "oklch(0.41 0.159 10.272)",
|
||||
"chart-1": "oklch(0.81 0.117 11.638)",
|
||||
"chart-2": "oklch(0.645 0.246 16.439)",
|
||||
"chart-3": "oklch(0.586 0.253 17.585)",
|
||||
"chart-4": "oklch(0.514 0.222 16.935)",
|
||||
"chart-5": "oklch(0.455 0.188 13.697)",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "violet",
|
||||
label: "Violet",
|
||||
cssVars: {
|
||||
light: {
|
||||
background: "oklch(1 0 0)",
|
||||
foreground: "oklch(0.141 0.005 285.823)",
|
||||
card: "oklch(1 0 0)",
|
||||
"card-foreground": "oklch(0.141 0.005 285.823)",
|
||||
popover: "oklch(1 0 0)",
|
||||
"popover-foreground": "oklch(0.141 0.005 285.823)",
|
||||
primary: "oklch(0.541 0.281 293.009)",
|
||||
"primary-foreground": "oklch(0.969 0.016 293.756)",
|
||||
secondary: "oklch(0.967 0.001 286.375)",
|
||||
"secondary-foreground": "oklch(0.21 0.006 285.885)",
|
||||
muted: "oklch(0.967 0.001 286.375)",
|
||||
"muted-foreground": "oklch(0.552 0.016 285.938)",
|
||||
accent: "oklch(0.967 0.001 286.375)",
|
||||
"accent-foreground": "oklch(0.21 0.006 285.885)",
|
||||
destructive: "oklch(0.577 0.245 27.325)",
|
||||
border: "oklch(0.92 0.004 286.32)",
|
||||
input: "oklch(0.92 0.004 286.32)",
|
||||
ring: "oklch(0.702 0.183 293.541)",
|
||||
"chart-1": "oklch(0.811 0.111 293.571)",
|
||||
"chart-2": "oklch(0.606 0.25 292.717)",
|
||||
"chart-3": "oklch(0.541 0.281 293.009)",
|
||||
"chart-4": "oklch(0.491 0.27 292.581)",
|
||||
"chart-5": "oklch(0.432 0.232 292.759)",
|
||||
},
|
||||
dark: {
|
||||
background: "oklch(0.141 0.005 285.823)",
|
||||
foreground: "oklch(0.985 0 0)",
|
||||
card: "oklch(0.21 0.006 285.885)",
|
||||
"card-foreground": "oklch(0.985 0 0)",
|
||||
popover: "oklch(0.21 0.006 285.885)",
|
||||
"popover-foreground": "oklch(0.985 0 0)",
|
||||
primary: "oklch(0.606 0.25 292.717)",
|
||||
"primary-foreground": "oklch(0.969 0.016 293.756)",
|
||||
secondary: "oklch(0.274 0.006 286.033)",
|
||||
"secondary-foreground": "oklch(0.985 0 0)",
|
||||
muted: "oklch(0.274 0.006 286.033)",
|
||||
"muted-foreground": "oklch(0.705 0.015 286.067)",
|
||||
accent: "oklch(0.274 0.006 286.033)",
|
||||
"accent-foreground": "oklch(0.985 0 0)",
|
||||
destructive: "oklch(0.704 0.191 22.216)",
|
||||
border: "oklch(1 0 0 / 10%)",
|
||||
input: "oklch(1 0 0 / 15%)",
|
||||
ring: "oklch(0.38 0.189 293.745)",
|
||||
"chart-1": "oklch(0.811 0.111 293.571)",
|
||||
"chart-2": "oklch(0.606 0.25 292.717)",
|
||||
"chart-3": "oklch(0.541 0.281 293.009)",
|
||||
"chart-4": "oklch(0.491 0.27 292.581)",
|
||||
"chart-5": "oklch(0.432 0.232 292.759)",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "yellow",
|
||||
label: "Yellow",
|
||||
cssVars: {
|
||||
light: {
|
||||
background: "oklch(1 0 0)",
|
||||
foreground: "oklch(0.141 0.005 285.823)",
|
||||
card: "oklch(1 0 0)",
|
||||
"card-foreground": "oklch(0.141 0.005 285.823)",
|
||||
popover: "oklch(1 0 0)",
|
||||
"popover-foreground": "oklch(0.141 0.005 285.823)",
|
||||
primary: "oklch(0.852 0.199 91.936)",
|
||||
"primary-foreground": "oklch(0.421 0.095 57.708)",
|
||||
secondary: "oklch(0.967 0.001 286.375)",
|
||||
"secondary-foreground": "oklch(0.21 0.006 285.885)",
|
||||
muted: "oklch(0.967 0.001 286.375)",
|
||||
"muted-foreground": "oklch(0.552 0.016 285.938)",
|
||||
accent: "oklch(0.967 0.001 286.375)",
|
||||
"accent-foreground": "oklch(0.21 0.006 285.885)",
|
||||
destructive: "oklch(0.577 0.245 27.325)",
|
||||
border: "oklch(0.92 0.004 286.32)",
|
||||
input: "oklch(0.92 0.004 286.32)",
|
||||
ring: "oklch(0.852 0.199 91.936)",
|
||||
"chart-1": "oklch(0.905 0.182 98.111)",
|
||||
"chart-2": "oklch(0.795 0.184 86.047)",
|
||||
"chart-3": "oklch(0.681 0.162 75.834)",
|
||||
"chart-4": "oklch(0.554 0.135 66.442)",
|
||||
"chart-5": "oklch(0.476 0.114 61.907)",
|
||||
},
|
||||
dark: {
|
||||
background: "oklch(0.141 0.005 285.823)",
|
||||
foreground: "oklch(0.985 0 0)",
|
||||
card: "oklch(0.21 0.006 285.885)",
|
||||
"card-foreground": "oklch(0.985 0 0)",
|
||||
popover: "oklch(0.21 0.006 285.885)",
|
||||
"popover-foreground": "oklch(0.985 0 0)",
|
||||
primary: "oklch(0.795 0.184 86.047)",
|
||||
"primary-foreground": "oklch(0.421 0.095 57.708)",
|
||||
secondary: "oklch(0.274 0.006 286.033)",
|
||||
"secondary-foreground": "oklch(0.985 0 0)",
|
||||
muted: "oklch(0.274 0.006 286.033)",
|
||||
"muted-foreground": "oklch(0.705 0.015 286.067)",
|
||||
accent: "oklch(0.274 0.006 286.033)",
|
||||
"accent-foreground": "oklch(0.985 0 0)",
|
||||
destructive: "oklch(0.704 0.191 22.216)",
|
||||
border: "oklch(1 0 0 / 10%)",
|
||||
input: "oklch(1 0 0 / 15%)",
|
||||
ring: "oklch(0.421 0.095 57.708)",
|
||||
"chart-1": "oklch(0.905 0.182 98.111)",
|
||||
"chart-2": "oklch(0.795 0.184 86.047)",
|
||||
"chart-3": "oklch(0.681 0.162 75.834)",
|
||||
"chart-4": "oklch(0.554 0.135 66.442)",
|
||||
"chart-5": "oklch(0.476 0.114 61.907)",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
createTheme("amber", "Amber", primaryColors.amber),
|
||||
createTheme("blue", "Blue", primaryColors.blue),
|
||||
createTheme("cyan", "Cyan", primaryColors.cyan),
|
||||
createTheme("emerald", "Emerald", primaryColors.emerald),
|
||||
createTheme("fuchsia", "Fuchsia", primaryColors.fuchsia),
|
||||
createTheme("green", "Green", primaryColors.green),
|
||||
createTheme("indigo", "Indigo", primaryColors.indigo),
|
||||
createTheme("lime", "Lime", primaryColors.lime),
|
||||
createTheme("neutral", "Neutral", primaryColors.neutral),
|
||||
createTheme("orange", "Orange", primaryColors.orange),
|
||||
createTheme("pink", "Pink", primaryColors.pink),
|
||||
createTheme("purple", "Purple", primaryColors.purple),
|
||||
createTheme("red", "Red", primaryColors.red),
|
||||
createTheme("rose", "Rose", primaryColors.rose),
|
||||
createTheme("sky", "Sky", primaryColors.sky),
|
||||
createTheme("teal", "Teal", primaryColors.teal),
|
||||
createTheme("violet", "Violet", primaryColors.violet),
|
||||
createTheme("yellow", "Yellow", primaryColors.yellow),
|
||||
].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
export function applyTheme(themeName: string) {
|
||||
const theme = themes.find((t) => t.name === themeName) || themes[0];
|
||||
|
||||
@@ -19,10 +19,10 @@ export function sanitizePath(input: string, os: string): string {
|
||||
|
||||
export function joinPath(os: string, ...parts: string[]): string {
|
||||
const sep = os === "Windows" ? "\\" : "/";
|
||||
|
||||
|
||||
const filtered = parts.filter(Boolean);
|
||||
if (filtered.length === 0) return "";
|
||||
|
||||
|
||||
const joined = filtered
|
||||
.map((p, i) => {
|
||||
// For first part, only remove trailing slashes (preserve leading slash for absolute paths)
|
||||
@@ -34,7 +34,7 @@ export function joinPath(os: string, ...parts: string[]): string {
|
||||
})
|
||||
.filter(Boolean) // Remove empty strings after trimming
|
||||
.join(sep);
|
||||
|
||||
|
||||
return joined;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,13 @@ export interface TrackMetadata {
|
||||
artists: string;
|
||||
name: string;
|
||||
album_name: string;
|
||||
album_artist?: string;
|
||||
duration_ms: number;
|
||||
images: string;
|
||||
release_date: string;
|
||||
track_number: number;
|
||||
total_tracks?: number; // Total tracks in album
|
||||
disc_number?: number;
|
||||
external_urls: string;
|
||||
isrc: string;
|
||||
album_type?: string;
|
||||
@@ -109,11 +112,14 @@ export type SpotifyMetadataResponse =
|
||||
|
||||
export interface DownloadRequest {
|
||||
isrc: string;
|
||||
service: "deezer" | "tidal" | "qobuz" | "amazon";
|
||||
service: "tidal" | "qobuz" | "amazon";
|
||||
query?: string;
|
||||
track_name?: string;
|
||||
artist_name?: string;
|
||||
album_name?: string;
|
||||
album_artist?: string;
|
||||
release_date?: string;
|
||||
cover_url?: string; // Spotify cover URL for embedding
|
||||
api_url?: string;
|
||||
output_dir?: string;
|
||||
audio_format?: string;
|
||||
@@ -123,9 +129,14 @@ export interface DownloadRequest {
|
||||
position?: number;
|
||||
use_album_track_number?: boolean;
|
||||
spotify_id?: string;
|
||||
embed_lyrics?: boolean; // Whether to embed lyrics into the audio file
|
||||
embed_max_quality_cover?: boolean; // Whether to embed max quality cover art
|
||||
service_url?: string;
|
||||
duration?: number; // Track duration in seconds for better matching
|
||||
item_id?: string; // Optional queue item ID for multi-service fallback tracking
|
||||
spotify_track_number?: number; // Track number from Spotify album
|
||||
spotify_disc_number?: number; // Disc number from Spotify album
|
||||
spotify_total_tracks?: number; // Total tracks in album from Spotify
|
||||
}
|
||||
|
||||
export interface DownloadResponse {
|
||||
@@ -191,11 +202,9 @@ export interface LyricsDownloadResponse {
|
||||
export interface TrackAvailability {
|
||||
spotify_id: string;
|
||||
tidal: boolean;
|
||||
deezer: boolean;
|
||||
amazon: boolean;
|
||||
qobuz: boolean;
|
||||
tidal_url?: string;
|
||||
deezer_url?: string;
|
||||
amazon_url?: string;
|
||||
qobuz_url?: string;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ module spotiflac
|
||||
go 1.25.4
|
||||
|
||||
require (
|
||||
github.com/bogem/id3v2/v2 v2.1.4
|
||||
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/ulikunitz/xz v0.5.15
|
||||
github.com/wailsapp/wails/v2 v2.11.0
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
|
||||
github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
|
||||
@@ -64,6 +66,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
@@ -74,22 +78,43 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw
|
||||
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=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
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-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/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.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/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
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=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+1
-11
@@ -2,21 +2,11 @@
|
||||
"vogel.qqdl.site",
|
||||
"maus.qqdl.site",
|
||||
"hund.qqdl.site",
|
||||
"eu-maus.qqdl.site",
|
||||
"eu-katze.qqdl.site",
|
||||
"katze.qqdl.site",
|
||||
"wolf.qqdl.site",
|
||||
"tidal.kinoplus.online",
|
||||
"tidal-api.binimum.org",
|
||||
"tidal-api-2.binimum.org",
|
||||
"dev-api.squid.wtf",
|
||||
"triton.squid.wtf",
|
||||
"ohio.monochrome.tf",
|
||||
"virginia.monochrome.tf",
|
||||
"oregon.monochrome.tf",
|
||||
"california.monochrome.tf",
|
||||
"frankfurt.monochrome.tf",
|
||||
"london.monochrome.tf",
|
||||
"singapore.monochrome.tf",
|
||||
"jakarta.monochrome.tf"
|
||||
"triton.squid.wtf"
|
||||
]
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"version": "6.7"
|
||||
}
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"info": {
|
||||
"productName": "SpotiFLAC",
|
||||
"productVersion": "6.7"
|
||||
"productVersion": "6.8"
|
||||
},
|
||||
"wailsjsdir": "./frontend",
|
||||
"assetdir": "./frontend/dist",
|
||||
|
||||
Reference in New Issue
Block a user