Compare commits

...

28 Commits

Author SHA1 Message Date
afkarxyz cb2a41d068 v6.9 2025-12-20 10:57:13 +07:00
afkarxyz 820a4a30ab v6.9 2025-12-20 10:43:34 +07:00
afkarxyz c9e49b4b95 v6.9 2025-12-20 10:36:39 +07:00
afkarxyz 0ba9443ef4 v6.9 2025-12-20 07:13:55 +07:00
afkarxyz 7f8c968d6a v6.9 2025-12-20 04:59:07 +07:00
afkarxyz 4fee88329b v6.9 2025-12-20 04:49:58 +07:00
afkarxyz 66c30de2db v6.9 2025-12-19 21:04:12 +07:00
afkarxyz 436feb7f7c v6.9 2025-12-19 16:50:43 +07:00
afkarxyz 7d0fde3acc v6.9 2025-12-19 13:29:28 +07:00
afkarxyz 939883c9cd v6.9 2025-12-19 13:26:19 +07:00
TheLittleDoctor 99f3d59ff1 Added a toggle to choose between using Artist property or AlbumArtist property for folder name (#169)
* Corrected function call to correctly download albums vs playlists

* Added setting to prefer AlbumArtist as folder name.
  - In practice, this prevents albums with multiple artists, featured artists, collaborations, or collections like soundtracks, from being split up
  - This is occasionally desirable behavior, so I added it as a toggle rather than a default behavior
2025-12-19 08:39:03 +07:00
Kyle Rector 965f044e0c Showing singular "song" if playlist or album only has one song (#168) 2025-12-19 08:38:50 +07:00
afkarxyz 6a3bd37eb6 Ko-fi 2025-12-17 04:57:12 +07:00
afkarxyz b85ed89af3 v6.8 2025-12-14 15:35:11 +07:00
afkarxyz 8e78a882a3 v6.8 2025-12-14 12:36:37 +07:00
afkarxyz 237ee777c3 v6.8 2025-12-14 12:22:08 +07:00
afkarxyz b44a9abdd6 v6.8 2025-12-14 07:47:18 +07:00
afkarxyz 6c3fb13b25 v6.8 2025-12-13 18:32:50 +07:00
afkarxyz 9398e496e8 v6.8 2025-12-13 18:31:11 +07:00
afkarxyz a8d6276d00 v6.8 2025-12-13 16:42:29 +07:00
afkarxyz 4d3aac4990 v6.8 2025-12-13 14:28:08 +07:00
afkarxyz 64d7f82e52 v6.8 2025-12-13 13:59:41 +07:00
afkarxyz 910420634c v6.8 2025-12-13 13:36:36 +07:00
afkarxyz 22742f1ddd v6.8 2025-12-13 13:32:09 +07:00
afkarxyz 5c1d6619b5 v6.8 2025-12-13 11:43:17 +07:00
Sepehr Aroofzade 76669f551e Add FLAC lyrics embedding with LRCLIB fallback (#151)
* Update lyrics code

* Refactor DownloadFile to use default HTTP client
2025-12-13 04:09:22 +07:00
enriqueqs ffd4daf031 fixed path issue (#157)
Co-authored-by: afkarxyz <mzamzamafkarhadiq@gmail.com>
2025-12-12 17:31:13 +07:00
Heggo 64b86b65a1 Fix Linux Download Path (#153)
* Fix Unknown being appended at the beginning of Download Path on Unix/Linux systems

* Remove extra blank line in backend/filename.go
2025-12-12 17:27:09 +07:00
70 changed files with 7310 additions and 3340 deletions
+14 -3
View File
@@ -7,7 +7,7 @@ on:
workflow_dispatch:
env:
GO_VERSION: '1.25.4'
GO_VERSION: '1.25.5'
NODE_VERSION: '24'
jobs:
@@ -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 -14
View File
@@ -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.
![Windows](https://img.shields.io/badge/Windows-10%2B-0078D6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiB2aWV3Qm94PSIwIDAgMjAgMjAiPjxwYXRoIGZpbGw9IiNmZmZmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgZD0iTTIwIDEwLjg3M1YyMEw4LjQ3OSAxOC41MzdsLjAwMS03LjY2NEgyMFptLTEzLjEyIDBsLS4wMDEgNy40NjFMMCAxNy40NjF2LTYuNTg4aDYuODhaTTIwIDkuMjczSDguNDhsLS4wMDEtNy44MUwyMCAwdjkuMjczWk02Ljg3OSAxLjY2NmwuMDAxIDcuNjA3SDBWMi41MzlsNi44NzktLjg3M1oiLz48L3N2Zz4=)
![macOS](https://img.shields.io/badge/macOS-10.13%2B-000000?style=for-the-badge&logo=apple&logoColor=white)
@@ -16,22 +16,12 @@ Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music — no
## Screenshot
![Image](https://github.com/user-attachments/assets/4da39b61-dcf8-4018-83e4-b8750a671245)
## Lossless Audio Checker
A simple utility for verifying the authenticity of FLAC files.
#### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v0/FLAC-Checker.zip) - Windows only
#
![image](https://github.com/user-attachments/assets/d63b422d-0ea3-4307-850f-96c99d7eaa9a)
![image](https://github.com/user-attachments/assets/7649e6e1-d5d1-49b3-b83f-965d44651d05)
![Image](https://github.com/user-attachments/assets/83a5e0f7-a225-4ead-a058-4979722b45f0)
## Other project
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/afkarxyz)
+244 -41
View File
@@ -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,14 +134,14 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
}
if req.Service == "" {
req.Service = "deezer"
req.Service = "tidal"
}
if req.OutputDir == "" {
req.OutputDir = "."
} else {
// Sanitize output directory path to remove invalid characters
req.OutputDir = backend.SanitizeFolderPath(req.OutputDir)
// Only normalize path separators, don't sanitize user's existing folder names
req.OutputDir = backend.NormalizePath(req.OutputDir)
}
if req.AudioFormat == "" {
@@ -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,150 @@ 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()
}
// IsFFprobeInstalled checks if ffprobe is installed
func (a *App) IsFFprobeInstalled() (bool, error) {
return backend.IsFFprobeInstalled()
}
// 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",
}
}
// 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"`
Codec string `json:"codec"` // For m4a: "aac" (lossy) or "alac" (lossless)
}
// 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,
Codec: req.Codec,
}
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
}
// GetFileSizes returns file sizes for a list of file paths
func (a *App) GetFileSizes(files []string) map[string]int64 {
return backend.GetFileSizes(files)
}
// ListDirectoryFiles lists files and folders in a directory
func (a *App) ListDirectoryFiles(dirPath string) ([]backend.FileInfo, error) {
if dirPath == "" {
return nil, fmt.Errorf("directory path is required")
}
return backend.ListDirectory(dirPath)
}
// ListAudioFilesInDir lists only audio files in a directory recursively
func (a *App) ListAudioFilesInDir(dirPath string) ([]backend.FileInfo, error) {
if dirPath == "" {
return nil, fmt.Errorf("directory path is required")
}
return backend.ListAudioFiles(dirPath)
}
// ReadFileMetadata reads metadata from an audio file
func (a *App) ReadFileMetadata(filePath string) (*backend.AudioMetadata, error) {
if filePath == "" {
return nil, fmt.Errorf("file path is required")
}
return backend.ReadAudioMetadata(filePath)
}
// PreviewRenameFiles generates a preview of rename operations
func (a *App) PreviewRenameFiles(files []string, format string) []backend.RenamePreview {
return backend.PreviewRename(files, format)
}
// RenameFilesByMetadata renames files based on their metadata
func (a *App) RenameFilesByMetadata(files []string, format string) []backend.RenameResult {
return backend.RenameFiles(files, format)
}
// CheckFileExistenceRequest represents a track to check for existence
type CheckFileExistenceRequest struct {
ISRC string `json:"isrc"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
}
// CheckFilesExistence checks if multiple files already exist in the output directory
// This is done in parallel for better performance
func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceRequest) []backend.FileExistenceResult {
// Convert to backend struct format
backendTracks := make([]struct {
ISRC string
TrackName string
ArtistName string
}, len(tracks))
for i, t := range tracks {
backendTracks[i] = struct {
ISRC string
TrackName string
ArtistName string
}{
ISRC: t.ISRC,
TrackName: t.TrackName,
ArtistName: t.ArtistName,
}
}
return backend.CheckFilesExistParallel(outputDir, backendTracks)
}
// SkipDownloadItem marks a download item as skipped (file already exists)
func (a *App) SkipDownloadItem(itemID, filePath string) {
backend.SkipDownloadItem(itemID, filePath)
}
+69 -7
View File
@@ -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)
}
+8
View File
@@ -12,6 +12,7 @@ import (
// AnalysisResult contains the audio analysis data
type AnalysisResult struct {
FilePath string `json:"file_path"`
FileSize int64 `json:"file_size"`
SampleRate uint32 `json:"sample_rate"`
Channels uint8 `json:"channels"`
BitsPerSample uint8 `json:"bits_per_sample"`
@@ -30,6 +31,12 @@ func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
return nil, fmt.Errorf("file does not exist: %s", filepath)
}
// Get file size
fileInfo, err := os.Stat(filepath)
if err != nil {
return nil, fmt.Errorf("failed to get file info: %w", err)
}
// Parse FLAC file
f, err := flac.ParseFile(filepath)
if err != nil {
@@ -38,6 +45,7 @@ func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
result := &AnalysisResult{
FilePath: filepath,
FileSize: fileInfo.Size(),
}
// Extract basic audio properties from STREAMINFO block
+41 -1
View File
@@ -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 == "" {
@@ -121,7 +161,7 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
if outputDir == "" {
outputDir = GetDefaultMusicPath()
} else {
outputDir = SanitizeFolderPath(outputDir)
outputDir = NormalizePath(outputDir)
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
-399
View File
@@ -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)
}
+615
View File
@@ -0,0 +1,615 @@
package backend
import (
"archive/tar"
"archive/zip"
"encoding/base64"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"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 = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS96aXA="
ffprobeMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZnByb2JlL3ppcA=="
)
// 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
}
// GetFFprobePath returns the full path to the ffprobe executable in app directory
func GetFFprobePath() (string, error) {
ffmpegDir, err := GetFFmpegDir()
if err != nil {
return "", err
}
ffprobeName := "ffprobe"
if runtime.GOOS == "windows" {
ffprobeName = "ffprobe.exe"
}
ffprobePath := filepath.Join(ffmpegDir, ffprobeName)
if _, err := os.Stat(ffprobePath); err == nil {
return ffprobePath, nil
}
return "", fmt.Errorf("ffprobe not found in app directory")
}
// IsFFprobeInstalled checks if ffprobe is installed in the app directory
func IsFFprobeInstalled() (bool, error) {
ffprobePath, err := GetFFprobePath()
if err != nil {
return false, nil
}
// Verify it's executable
cmd := exec.Command(ffprobePath, "-version")
setHideWindow(cmd)
err = cmd.Run()
return err == nil, 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)
}
// For macOS, download ffmpeg and ffprobe separately (only if not already installed)
if runtime.GOOS == "darwin" {
ffmpegInstalled, _ := IsFFmpegInstalled()
ffprobeInstalled, _ := IsFFprobeInstalled()
if !ffmpegInstalled && !ffprobeInstalled {
// Download both
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 50); err != nil {
return err
}
ffprobeURL, _ := decodeBase64(ffprobeMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL)
if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 50, 100); err != nil {
return fmt.Errorf("failed to download ffprobe: %w", err)
}
} else if !ffmpegInstalled {
// Only download ffmpeg
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 100); err != nil {
return err
}
} else if !ffprobeInstalled {
// Only download ffprobe
ffprobeURL, _ := decodeBase64(ffprobeMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL)
if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 0, 100); err != nil {
return fmt.Errorf("failed to download ffprobe: %w", err)
}
}
return nil
}
// For Windows/Linux: single archive contains both ffmpeg and ffprobe
var encodedURL string
switch runtime.GOOS {
case "windows":
encodedURL = ffmpegWindowsURL
case "linux":
encodedURL = ffmpegLinuxURL
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)
if err := downloadAndExtract(url, ffmpegDir, progressCallback, 0, 100); err != nil {
return err
}
return nil
}
// downloadAndExtract downloads a file and extracts it
func downloadAndExtract(url, destDir string, progressCallback func(int), progressStart, progressEnd int) error {
// 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: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download: 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 {
// Scale progress between progressStart and progressEnd
rawProgress := float64(downloaded) / float64(totalSize)
scaledProgress := progressStart + int(rawProgress*float64(progressEnd-progressStart))
progressCallback(scaledProgress)
}
}
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 based on file type
if strings.HasSuffix(url, ".tar.xz") || runtime.GOOS == "linux" {
return extractTarXz(tmpFile.Name(), destDir)
}
return extractZip(tmpFile.Name(), destDir)
}
// extractZip extracts ffmpeg and ffprobe from a zip archive (skips ffplay)
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"
ffprobeName := "ffprobe"
if runtime.GOOS == "windows" {
ffmpegName = "ffmpeg.exe"
ffprobeName = "ffprobe.exe"
}
foundFFmpeg := false
foundFFprobe := false
for _, f := range r.File {
baseName := filepath.Base(f.Name)
if f.FileInfo().IsDir() {
continue
}
var destPath string
if baseName == ffmpegName {
destPath = filepath.Join(destDir, ffmpegName)
foundFFmpeg = true
} else if baseName == ffprobeName {
destPath = filepath.Join(destDir, ffprobeName)
foundFFprobe = true
} else {
// Skip ffplay and other files
continue
}
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)
}
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
rc.Close()
return fmt.Errorf("failed to create output file: %w", err)
}
_, err = io.Copy(outFile, rc)
rc.Close()
outFile.Close()
if err != nil {
return fmt.Errorf("failed to extract file: %w", err)
}
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
}
// At least one of ffmpeg or ffprobe should be found
if !foundFFmpeg && !foundFFprobe {
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
}
if foundFFmpeg {
fmt.Printf("[FFmpeg] ffmpeg extracted successfully\n")
}
if foundFFprobe {
fmt.Printf("[FFmpeg] ffprobe extracted successfully\n")
}
return nil
}
// extractTarXz extracts ffmpeg and ffprobe from a tar.xz archive (skips ffplay)
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"
ffprobeName := "ffprobe"
foundFFmpeg := false
foundFFprobe := false
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read tar: %w", err)
}
if header.Typeflag != tar.TypeReg {
continue
}
baseName := filepath.Base(header.Name)
var destPath string
if baseName == ffmpegName {
destPath = filepath.Join(destDir, ffmpegName)
foundFFmpeg = true
} else if baseName == ffprobeName {
destPath = filepath.Join(destDir, ffprobeName)
foundFFprobe = true
} else {
// Skip ffplay and other files
continue
}
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)
}
_, err = io.Copy(outFile, tarReader)
outFile.Close()
if err != nil {
return fmt.Errorf("failed to extract file: %w", err)
}
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
}
// At least one of ffmpeg or ffprobe should be found
if !foundFFmpeg && !foundFFprobe {
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
}
if foundFFmpeg {
fmt.Printf("[FFmpeg] ffmpeg extracted successfully\n")
}
if foundFFprobe {
fmt.Printf("[FFmpeg] ffprobe extracted successfully\n")
}
return nil
}
// 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" (ignored for ALAC)
Codec string `json:"codec"` // For m4a: "aac" (lossy) or "alac" (lossless). Default: "aac"
}
// 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":
// Determine codec: ALAC (lossless) or AAC (lossy)
codec := req.Codec
if codec == "" {
codec = "aac" // Default to AAC for backward compatibility
}
if codec == "alac" {
// ALAC - Apple Lossless (no bitrate needed)
args = append(args,
"-codec:a", "alac",
"-map", "0:a", // Map audio stream
"-map_metadata", "0", // Copy all metadata
)
} else {
// AAC - lossy with bitrate
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
}
+14
View File
@@ -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
}
+17
View File
@@ -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,
}
}
+52
View File
@@ -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
}
+492
View File
@@ -0,0 +1,492 @@
package backend
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
id3v2 "github.com/bogem/id3v2/v2"
"github.com/go-flac/flacvorbis"
"github.com/go-flac/go-flac"
)
// FileInfo represents information about a file or folder
type FileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
Children []FileInfo `json:"children,omitempty"`
}
// AudioMetadata represents metadata read from an audio file
type AudioMetadata struct {
Title string `json:"title"`
Artist string `json:"artist"`
Album string `json:"album"`
AlbumArtist string `json:"album_artist"`
TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"`
Year string `json:"year"`
}
// RenamePreview represents a preview of file rename operation
type RenamePreview struct {
OldPath string `json:"old_path"`
OldName string `json:"old_name"`
NewName string `json:"new_name"`
NewPath string `json:"new_path"`
Error string `json:"error,omitempty"`
Metadata AudioMetadata `json:"metadata"`
}
// RenameResult represents the result of a rename operation
type RenameResult struct {
OldPath string `json:"old_path"`
NewPath string `json:"new_path"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// ListDirectory lists files and folders in a directory
func ListDirectory(dirPath string) ([]FileInfo, error) {
entries, err := os.ReadDir(dirPath)
if err != nil {
return nil, fmt.Errorf("failed to read directory: %w", err)
}
var result []FileInfo
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
continue
}
fileInfo := FileInfo{
Name: entry.Name(),
Path: filepath.Join(dirPath, entry.Name()),
IsDir: entry.IsDir(),
Size: info.Size(),
}
// If it's a directory, recursively list its contents
if entry.IsDir() {
children, err := ListDirectory(fileInfo.Path)
if err == nil {
fileInfo.Children = children
}
}
result = append(result, fileInfo)
}
return result, nil
}
// ListAudioFiles lists only audio files (flac, mp3, m4a) in a directory recursively
func ListAudioFiles(dirPath string) ([]FileInfo, error) {
var result []FileInfo
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // Skip files with errors
}
if info.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if ext == ".flac" || ext == ".mp3" || ext == ".m4a" {
result = append(result, FileInfo{
Name: info.Name(),
Path: path,
IsDir: false,
Size: info.Size(),
})
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to walk directory: %w", err)
}
return result, nil
}
// ReadAudioMetadata reads metadata from an audio file
func ReadAudioMetadata(filePath string) (*AudioMetadata, error) {
if !fileExists(filePath) {
return nil, fmt.Errorf("file does not exist")
}
ext := strings.ToLower(filepath.Ext(filePath))
switch ext {
case ".flac":
return readFlacMetadata(filePath)
case ".mp3":
return readMp3Metadata(filePath)
case ".m4a":
return readM4aMetadata(filePath)
default:
return nil, fmt.Errorf("unsupported file format: %s", ext)
}
}
// readFlacMetadata reads metadata from a FLAC file
func readFlacMetadata(filePath string) (*AudioMetadata, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
}
metadata := &AudioMetadata{}
for _, block := range f.Meta {
if block.Type == flac.VorbisComment {
cmt, err := flacvorbis.ParseFromMetaDataBlock(*block)
if err != nil {
continue
}
for _, comment := range cmt.Comments {
parts := strings.SplitN(comment, "=", 2)
if len(parts) != 2 {
continue
}
fieldName := strings.ToUpper(parts[0])
value := parts[1]
switch fieldName {
case "TITLE":
metadata.Title = value
case "ARTIST":
metadata.Artist = value
case "ALBUM":
metadata.Album = value
case "ALBUMARTIST":
metadata.AlbumArtist = value
case "TRACKNUMBER":
if num, err := strconv.Atoi(value); err == nil {
metadata.TrackNumber = num
}
case "DISCNUMBER":
if num, err := strconv.Atoi(value); err == nil {
metadata.DiscNumber = num
}
case "DATE", "YEAR":
metadata.Year = value
}
}
}
}
return metadata, nil
}
// readMp3Metadata reads metadata from an MP3 file
func readMp3Metadata(filePath string) (*AudioMetadata, error) {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return nil, fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
metadata := &AudioMetadata{
Title: tag.Title(),
Artist: tag.Artist(),
Album: tag.Album(),
Year: tag.Year(),
}
// Get Album Artist (TPE2)
if frames := tag.GetFrames("TPE2"); len(frames) > 0 {
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
metadata.AlbumArtist = textFrame.Text
}
}
// Get Track Number
if frames := tag.GetFrames(tag.CommonID("Track number/Position in set")); len(frames) > 0 {
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
trackStr := strings.Split(textFrame.Text, "/")[0]
if num, err := strconv.Atoi(trackStr); err == nil {
metadata.TrackNumber = num
}
}
}
// Get Disc Number
if frames := tag.GetFrames(tag.CommonID("Part of a set")); len(frames) > 0 {
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
discStr := strings.Split(textFrame.Text, "/")[0]
if num, err := strconv.Atoi(discStr); err == nil {
metadata.DiscNumber = num
}
}
}
return metadata, nil
}
// readMetadataWithFFprobe reads metadata from any audio file using ffprobe
func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
ffprobePath, err := GetFFprobePath()
if err != nil {
return nil, err
}
// Use ffprobe to get metadata in JSON format (both format and stream tags)
cmd := exec.Command(ffprobePath,
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
filePath,
)
// Hide console window on Windows
setHideWindow(cmd)
output, err := cmd.Output()
if err != nil {
return nil, err
}
// Parse JSON output
var result struct {
Format struct {
Tags map[string]string `json:"tags"`
} `json:"format"`
Streams []struct {
Tags map[string]string `json:"tags"`
} `json:"streams"`
}
if err := json.Unmarshal(output, &result); err != nil {
return nil, err
}
metadata := &AudioMetadata{}
// Merge tags from format and streams (format tags take priority)
allTags := make(map[string]string)
// First add stream tags
for _, stream := range result.Streams {
for key, value := range stream.Tags {
allTags[strings.ToLower(key)] = value
}
}
// Then add format tags (overwrite stream tags)
for key, value := range result.Format.Tags {
allTags[strings.ToLower(key)] = value
}
// Parse tags
for key, value := range allTags {
switch key {
case "title":
metadata.Title = value
case "artist":
metadata.Artist = value
case "album":
metadata.Album = value
case "album_artist", "albumartist":
metadata.AlbumArtist = value
case "track":
// Format might be "4" or "4/12"
trackStr := strings.Split(value, "/")[0]
if num, err := strconv.Atoi(trackStr); err == nil {
metadata.TrackNumber = num
}
case "disc":
discStr := strings.Split(value, "/")[0]
if num, err := strconv.Atoi(discStr); err == nil {
metadata.DiscNumber = num
}
case "date", "year":
if metadata.Year == "" || len(value) > len(metadata.Year) {
metadata.Year = value
}
}
}
return metadata, nil
}
// readM4aMetadata reads metadata from an M4A file using ffprobe
func readM4aMetadata(filePath string) (*AudioMetadata, error) {
metadata, err := readMetadataWithFFprobe(filePath)
if err != nil {
return &AudioMetadata{}, nil
}
return metadata, nil
}
// GenerateFilename generates a new filename based on metadata and format template
func GenerateFilename(metadata *AudioMetadata, format string, ext string) string {
if metadata == nil {
return ""
}
result := format
// Replace placeholders
result = strings.ReplaceAll(result, "{title}", sanitizeFilenameForRename(metadata.Title))
result = strings.ReplaceAll(result, "{artist}", sanitizeFilenameForRename(metadata.Artist))
result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album))
result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist))
result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(metadata.Year))
// Track number with padding
if metadata.TrackNumber > 0 {
result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber))
} else {
result = strings.ReplaceAll(result, "{track}", "")
}
// Disc number
if metadata.DiscNumber > 0 {
result = strings.ReplaceAll(result, "{disc}", fmt.Sprintf("%d", metadata.DiscNumber))
} else {
result = strings.ReplaceAll(result, "{disc}", "")
}
// Clean up multiple spaces and trim
result = strings.TrimSpace(result)
result = strings.Join(strings.Fields(result), " ")
// Remove leading/trailing separators
result = strings.Trim(result, " -._")
if result == "" {
return ""
}
return result + ext
}
// sanitizeFilenameForRename removes invalid characters from filename (for rename operations)
func sanitizeFilenameForRename(name string) string {
// Remove characters that are invalid in filenames
invalid := []string{"<", ">", ":", "\"", "/", "\\", "|", "?", "*"}
result := name
for _, char := range invalid {
result = strings.ReplaceAll(result, char, "")
}
return strings.TrimSpace(result)
}
// PreviewRename generates a preview of rename operations
func PreviewRename(files []string, format string) []RenamePreview {
var previews []RenamePreview
for _, filePath := range files {
preview := RenamePreview{
OldPath: filePath,
OldName: filepath.Base(filePath),
}
metadata, err := ReadAudioMetadata(filePath)
if err != nil {
preview.Error = err.Error()
previews = append(previews, preview)
continue
}
preview.Metadata = *metadata
ext := filepath.Ext(filePath)
newName := GenerateFilename(metadata, format, ext)
if newName == "" {
preview.Error = "Could not generate filename (missing metadata)"
previews = append(previews, preview)
continue
}
preview.NewName = newName
preview.NewPath = filepath.Join(filepath.Dir(filePath), newName)
previews = append(previews, preview)
}
return previews
}
// GetFileSizes returns file sizes for a list of file paths
func GetFileSizes(files []string) map[string]int64 {
result := make(map[string]int64)
for _, filePath := range files {
info, err := os.Stat(filePath)
if err == nil {
result[filePath] = info.Size()
}
}
return result
}
// RenameFiles renames files based on their metadata
func RenameFiles(files []string, format string) []RenameResult {
var results []RenameResult
for _, filePath := range files {
result := RenameResult{
OldPath: filePath,
}
metadata, err := ReadAudioMetadata(filePath)
if err != nil {
result.Error = err.Error()
result.Success = false
results = append(results, result)
continue
}
ext := filepath.Ext(filePath)
newName := GenerateFilename(metadata, format, ext)
if newName == "" {
result.Error = "Could not generate filename (missing metadata)"
result.Success = false
results = append(results, result)
continue
}
newPath := filepath.Join(filepath.Dir(filePath), newName)
result.NewPath = newPath
// Check if new path already exists (and is different from old path)
if newPath != filePath {
if _, err := os.Stat(newPath); err == nil {
result.Error = "File already exists"
result.Success = false
results = append(results, result)
continue
}
}
// Rename the file
if err := os.Rename(filePath, newPath); err != nil {
result.Error = err.Error()
result.Success = false
results = append(results, result)
continue
}
result.Success = true
results = append(results, result)
}
return results
}
+78 -10
View File
@@ -5,6 +5,8 @@ import (
"path/filepath"
"regexp"
"strings"
"unicode"
"unicode/utf8"
)
// BuildExpectedFilename builds the expected filename based on track metadata and settings
@@ -52,16 +54,82 @@ 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
}
// NormalizePath only normalizes path separators without modifying folder names
// Use this for user-provided paths that already exist on the filesystem
func NormalizePath(folderPath string) string {
// Normalize all forward slashes to backslashes on Windows
return strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
}
// SanitizeFolderPath sanitizes each component of a folder path and normalizes separators
// Use this only for NEW folders being created (artist names, album names, etc.)
func SanitizeFolderPath(folderPath string) string {
// Normalize all forward slashes to backslashes on Windows
normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
@@ -80,6 +148,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 +166,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)
}
+198 -17
View File
@@ -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,33 +74,200 @@ 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")
// 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{},
}
return &lyricsResp, nil
// 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, &centiseconds)
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
@@ -182,7 +363,7 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
if outputDir == "" {
outputDir = GetDefaultMusicPath()
} else {
outputDir = SanitizeFolderPath(outputDir)
outputDir = NormalizePath(outputDir)
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
@@ -210,8 +391,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,
+498 -1
View File
@@ -3,9 +3,13 @@ package backend
import (
"fmt"
"os"
"os/exec"
pathfilepath "path/filepath"
"strconv"
"strings"
"sync"
id3v2 "github.com/bogem/id3v2/v2"
"github.com/go-flac/flacpicture"
"github.com/go-flac/flacvorbis"
"github.com/go-flac/go-flac"
@@ -15,10 +19,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 +55,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 +135,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 +278,409 @@ 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)
}
}
// FileExistenceResult represents the result of checking if a file exists
type FileExistenceResult struct {
ISRC string `json:"isrc"`
Exists bool `json:"exists"`
FilePath string `json:"file_path,omitempty"`
TrackName string `json:"track_name,omitempty"`
ArtistName string `json:"artist_name,omitempty"`
}
// CheckFilesExistParallel checks if multiple files exist in parallel
// It builds an ISRC index from the output directory once, then checks all tracks against it
func CheckFilesExistParallel(outputDir string, tracks []struct {
ISRC string
TrackName string
ArtistName string
}) []FileExistenceResult {
results := make([]FileExistenceResult, len(tracks))
// Build ISRC index from output directory (scan once)
isrcIndex := buildISRCIndex(outputDir)
// Check each track against the index (parallel)
var wg sync.WaitGroup
for i, track := range tracks {
wg.Add(1)
go func(idx int, t struct {
ISRC string
TrackName string
ArtistName string
}) {
defer wg.Done()
result := FileExistenceResult{
ISRC: t.ISRC,
TrackName: t.TrackName,
ArtistName: t.ArtistName,
Exists: false,
}
if t.ISRC != "" {
if filePath, exists := isrcIndex[strings.ToUpper(t.ISRC)]; exists {
result.Exists = true
result.FilePath = filePath
}
}
results[idx] = result
}(i, track)
}
wg.Wait()
return results
}
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
func buildISRCIndex(outputDir string) map[string]string {
index := make(map[string]string)
// Walk directory recursively - only check .flac files for SpotiFLAC
pathfilepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
ext := strings.ToLower(pathfilepath.Ext(path))
if ext != ".flac" {
return nil
}
// Read ISRC from file
isrc, err := ReadISRCFromFile(path)
if err != nil || isrc == "" {
return nil
}
// Store in index (uppercase for case-insensitive matching)
index[strings.ToUpper(isrc)] = path
return nil
})
return index
}
+71 -52
View File
@@ -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
View File
@@ -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
+26 -6
View File
@@ -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
View File
@@ -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 {
+3 -1
View File
@@ -18,5 +18,7 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
"registries": {
"@lucide-animated": "https://lucide-animated.com/r/{name}.json"
}
}
+1 -1
View File
@@ -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>
+14 -14
View File
@@ -17,38 +17,38 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@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.562.0",
"motion": "^12.12.1",
"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.3",
"@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",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.5.0",
"sharp": "^0.34.5",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.1",
"vite": "^7.2.7"
"typescript-eslint": "^8.50.0",
"vite": "^7.3.0"
}
}
+1 -1
View File
@@ -1 +1 @@
58400d5c8f7b03bac8ab784b5e775687
c94dda3302d3338d7909ef5d634d0fde
+771 -758
View File
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -28,6 +28,8 @@ 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 { FileManagerPage } from "@/components/FileManagerPage";
import { SettingsPage } from "@/components/SettingsPage";
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
import type { HistoryItem } from "@/components/FetchHistory";
@@ -55,7 +57,7 @@ function App() {
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
const ITEMS_PER_PAGE = 50;
const CURRENT_VERSION = "6.7";
const CURRENT_VERSION = "6.9";
const download = useDownload();
const metadata = useMetadata();
@@ -319,6 +321,7 @@ function App() {
skippedCovers={cover.skippedCovers}
downloadingCoverTrack={cover.downloadingCoverTrack}
isBulkDownloadingCovers={cover.isBulkDownloadingCovers}
isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics}
onSearchChange={handleSearchChange}
onSortChange={setSortBy}
onToggleTrack={toggleTrackSelection}
@@ -331,6 +334,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 +389,7 @@ function App() {
skippedCovers={cover.skippedCovers}
downloadingCoverTrack={cover.downloadingCoverTrack}
isBulkDownloadingCovers={cover.isBulkDownloadingCovers}
isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics}
onSearchChange={handleSearchChange}
onSortChange={setSortBy}
onToggleTrack={toggleTrackSelection}
@@ -397,6 +402,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 +463,7 @@ function App() {
skippedCovers={cover.skippedCovers}
downloadingCoverTrack={cover.downloadingCoverTrack}
isBulkDownloadingCovers={cover.isBulkDownloadingCovers}
isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics}
onSearchChange={handleSearchChange}
onSortChange={setSortBy}
onToggleTrack={toggleTrackSelection}
@@ -469,6 +476,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 +514,10 @@ function App() {
return <DebugLoggerPage />;
case "audio-analysis":
return <AudioAnalysisPage />;
case "audio-converter":
return <AudioConverterPage />;
case "file-manager":
return <FileManagerPage />;
default:
return (
<>
+25 -3
View File
@@ -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,
@@ -144,7 +148,9 @@ export function AlbumInfo({
<span></span>
<span>{albumInfo.release_date}</span>
<span></span>
<span>{albumInfo.total_tracks} songs</span>
<span>
{albumInfo.total_tracks} {albumInfo.total_tracks === 1 ? "song" : "songs"}
</span>
</div>
</div>
<div className="flex gap-2 flex-wrap">
@@ -170,6 +176,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>
+23 -2
View File
@@ -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>
+63 -96
View File
@@ -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 {
@@ -18,13 +18,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 +46,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">
@@ -77,116 +79,81 @@ export function AudioAnalysis({
return num.toFixed(2);
};
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
};
// 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>
<Card className="gap-2">
<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 break-all">{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 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 className="space-y-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<HardDrive className="h-3 w-3" />
Data Size
{result.file_size > 0 && (
<div className="flex items-center gap-1">
<HardDrive className="h-3 w-3 text-muted-foreground" />
<span className="text-muted-foreground">Size:</span>
<span className="font-semibold">{formatFileSize(result.file_size)}</span>
</div>
<p className="font-semibold">{formatDataSize(dataSizeMB)}</p>
</div>
)}
</div>
{/* Dynamic Range Analysis */}
<div className="border rounded-lg p-4 space-y-3 bg-muted/30">
<div className="flex items-center gap-2 text-sm font-medium">
<TrendingUp className="h-4 w-4" />
Dynamic Range Analysis
{/* 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>
+36 -40
View File
@@ -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,671 @@
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,
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;
size: number;
status: "pending" | "converting" | "success" | "error";
error?: string;
outputPath?: string;
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
const BITRATE_OPTIONS = [
{ value: "320k", label: "320k" },
{ value: "256k", label: "256k" },
{ value: "192k", label: "192k" },
{ value: "128k", label: "128k" },
];
const M4A_CODEC_OPTIONS = [
{ value: "aac", label: "AAC" },
{ value: "alac", label: "ALAC" },
];
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 [m4aCodec, setM4aCodec] = useState<"aac" | "alac">(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.m4aCodec === "aac" || parsed.m4aCodec === "alac") {
return parsed.m4aCodec;
}
}
} catch (err) {
// Ignore
}
return "aac";
});
const [converting, setConverting] = useState(false);
const [isDragging, setIsDragging] = 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; m4aCodec: "aac" | "alac" }) => {
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, bitrate, or m4aCodec changes
useEffect(() => {
saveState({ files, outputFormat, bitrate, m4aCodec });
}, [files, outputFormat, bitrate, m4aCodec, 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");
}
// Reset to AAC if no FLAC files (ALAC doesn't make sense for lossy input)
const hasFlac = files.some((f) => f.format === "flac");
if (!hasFlac && m4aCodec === "alac") {
setM4aCodec("aac");
}
}, [files, outputFormat, m4aCodec]);
// Check if format selection should be disabled (all files are MP3)
const isFormatDisabled = files.length > 0 && files.every((f) => f.format === "mp3");
// Check if any file is FLAC (ALAC only makes sense for lossless input)
const hasFlacFiles = files.some((f) => f.format === "flac");
// 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 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(async (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.",
});
}
// Get file sizes from backend
const GetFileSizes = (files: string[]): Promise<Record<string, number>> =>
(window as any)["go"]["main"]["App"]["GetFileSizes"](files);
const validPaths = paths.filter((path) => {
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
return validExtensions.includes(ext);
});
const fileSizes = validPaths.length > 0 ? await GetFileSizes(validPaths) : {};
setFiles((prev) => {
const newFiles: AudioFile[] = validPaths
.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,
size: fileSizes[path] || 0,
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,
codec: outputFormat === "m4a" ? m4aCodec : "",
});
// 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]"
} border-muted-foreground/30`}
>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Download className="h-8 w-8 text-primary" />
</div>
<p className="text-sm text-muted-foreground mb-4 text-center">
FFmpeg is required to convert audio files
</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-4 text-center">
{isDragging
? "Drop your audio files here"
: "Drag and drop audio files here, or click the button below to select"}
</p>
<Button onClick={handleSelectFiles} size="lg">
<Upload className="h-5 w-5" />
Select Files
</Button>
<p className="text-xs text-muted-foreground mt-4 text-center">
Supported formats: FLAC, MP3
</p>
</>
) : (
<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>
{/* Codec selection for M4A - only show ALAC option when input has FLAC files */}
{outputFormat === "m4a" && hasFlacFiles && (
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">Codec:</Label>
<ToggleGroup
type="single"
variant="outline"
value={m4aCodec}
onValueChange={(value) => {
if (value) setM4aCodec(value as "aac" | "alac");
}}
>
{M4A_CODEC_OPTIONS.map((option) => (
<ToggleGroupItem
key={option.value}
value={option.value}
aria-label={option.label}
>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
)}
{/* Bitrate selection - hide for ALAC (lossless) */}
{!(outputFormat === "m4a" && m4aCodec === "alac") && (
<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 text-muted-foreground">
{formatFileSize(file.size)}
</span>
<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>
);
}
-132
View File
@@ -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>
);
}
+3 -2
View File
@@ -9,17 +9,18 @@ interface DownloadProgressProps {
}
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
const clampedProgress = Math.min(100, Math.max(0, progress));
return (
<div className="w-full space-y-2 mt-4">
<div className="flex items-center gap-2">
<Progress value={progress} className="h-2 flex-1" />
<Progress value={clampedProgress} className="h-2 flex-1" />
<Button variant="destructive" size="sm" onClick={onStop} className="gap-1.5">
<StopCircle className="h-4 w-4" />
Stop
</Button>
</div>
<p className="text-xs text-muted-foreground">
{progress}% -{" "}
{clampedProgress}% -{" "}
{currentTrack
? `${currentTrack.name} - ${currentTrack.artists}`
: "Preparing download..."}
+1 -1
View File
@@ -120,7 +120,7 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden">
<DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0">
<div className="flex items-center justify-between mb-4 pr-8">
<div className="flex items-center justify-between mb-4">
<DialogTitle className="text-lg font-semibold">Download Queue</DialogTitle>
<div className="flex items-center gap-2">
{(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && (
+789
View File
@@ -0,0 +1,789 @@
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
FolderOpen,
RefreshCw,
FileMusic,
ChevronRight,
ChevronDown,
Pencil,
Eye,
Folder,
Info,
RotateCcw,
} from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Spinner } from "@/components/ui/spinner";
import { SelectFolder } from "../../wailsjs/go/main/App";
import { backend } from "../../wailsjs/go/models";
// These functions will be available after Wails regenerates bindings
// For now, we call them directly via window.go
const ListDirectoryFiles = (path: string): Promise<backend.FileInfo[]> =>
(window as any)['go']['main']['App']['ListDirectoryFiles'](path);
const PreviewRenameFiles = (files: string[], format: string): Promise<backend.RenamePreview[]> =>
(window as any)['go']['main']['App']['PreviewRenameFiles'](files, format);
const RenameFilesByMetadata = (files: string[], format: string): Promise<backend.RenameResult[]> =>
(window as any)['go']['main']['App']['RenameFilesByMetadata'](files, format);
const ReadFileMetadata = (path: string): Promise<backend.AudioMetadata> =>
(window as any)['go']['main']['App']['ReadFileMetadata'](path);
const IsFFprobeInstalled = (): Promise<boolean> =>
(window as any)['go']['main']['App']['IsFFprobeInstalled']();
const DownloadFFmpeg = (): Promise<{ success: boolean; message: string; error?: string }> =>
(window as any)['go']['main']['App']['DownloadFFmpeg']();
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { getSettings } from "@/lib/settings";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface FileNode {
name: string;
path: string;
is_dir: boolean;
size: number;
children?: FileNode[];
expanded?: boolean;
selected?: boolean;
}
interface FileMetadata {
title: string;
artist: string;
album: string;
album_artist: string;
track_number: number;
disc_number: number;
year: string;
}
const FORMAT_PRESETS: Record<string, { label: string; template: string }> = {
"title": { label: "Title", template: "{title}" },
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
"artist-title": { label: "Artist - Title", template: "{artist} - {title}" },
"track-title": { label: "Track. Title", template: "{track}. {title}" },
"track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" },
"track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" },
"title-album-artist": { label: "Title - Album Artist", template: "{title} - {album_artist}" },
"track-title-album-artist": { label: "Track. Title - Album Artist", template: "{track}. {title} - {album_artist}" },
"artist-album-title": { label: "Artist - Album - Title", template: "{artist} - {album} - {title}" },
"track-dash-title": { label: "Track - Title", template: "{track} - {title}" },
"disc-track-title": { label: "Disc-Track. Title", template: "{disc}-{track}. {title}" },
"disc-track-title-artist": { label: "Disc-Track. Title - Artist", template: "{disc}-{track}. {title} - {artist}" },
"custom": { label: "Custom...", template: "{title} - {artist}" },
};
const STORAGE_KEY = "spotiflac_file_manager_state";
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
const DEFAULT_PRESET = "title-artist";
const DEFAULT_CUSTOM_FORMAT = "{title} - {artist}";
export function FileManagerPage() {
const [rootPath, setRootPath] = useState(() => {
const settings = getSettings();
return settings.downloadPath || "";
});
const [files, setFiles] = useState<FileNode[]>([]);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [formatPreset, setFormatPreset] = useState<string>(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.formatPreset && FORMAT_PRESETS[parsed.formatPreset]) {
return parsed.formatPreset;
}
}
} catch (err) {
// Ignore
}
return DEFAULT_PRESET;
});
const [customFormat, setCustomFormat] = useState(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.customFormat) {
return parsed.customFormat;
}
}
} catch (err) {
// Ignore
}
return DEFAULT_CUSTOM_FORMAT;
});
const renameFormat = formatPreset === "custom" ? (customFormat || FORMAT_PRESETS["custom"].template) : FORMAT_PRESETS[formatPreset].template;
const [showPreview, setShowPreview] = useState(false);
const [previewData, setPreviewData] = useState<backend.RenamePreview[]>([]);
const [renaming, setRenaming] = useState(false);
const [previewOnly, setPreviewOnly] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [showMetadata, setShowMetadata] = useState(false);
const [metadataFile, setMetadataFile] = useState<string>("");
const [metadataInfo, setMetadataInfo] = useState<FileMetadata | null>(null);
const [loadingMetadata, setLoadingMetadata] = useState(false);
const [showFFprobeDialog, setShowFFprobeDialog] = useState(false);
const [installingFFprobe, setInstallingFFprobe] = useState(false);
// Save state to sessionStorage
useEffect(() => {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ formatPreset, customFormat }));
} catch (err) {
console.error("Failed to save state:", err);
}
}, [formatPreset, customFormat]);
// Detect fullscreen/maximized window
useEffect(() => {
const checkFullscreen = () => {
const isMaximized = window.innerHeight >= window.screen.height * 0.9;
setIsFullscreen(isMaximized);
};
checkFullscreen();
window.addEventListener("resize", checkFullscreen);
window.addEventListener("focus", checkFullscreen);
return () => {
window.removeEventListener("resize", checkFullscreen);
window.removeEventListener("focus", checkFullscreen);
};
}, []);
const loadFiles = useCallback(async () => {
if (!rootPath) return;
setLoading(true);
try {
const result = await ListDirectoryFiles(rootPath);
// Handle null/undefined result (can happen on Linux)
if (!result || !Array.isArray(result)) {
setFiles([]);
setSelectedFiles(new Set());
return;
}
// Filter to only show audio files and folders containing audio files
const filtered = filterAudioFiles(result as FileNode[]);
setFiles(filtered);
setSelectedFiles(new Set());
} catch (err) {
// Don't show error toast for empty directory or no files found
const errorMsg = err instanceof Error ? err.message : String(err || "");
if (!errorMsg.toLowerCase().includes("empty") && !errorMsg.toLowerCase().includes("no file")) {
toast.error("Failed to load files", {
description: errorMsg || "Unknown error",
});
}
setFiles([]);
setSelectedFiles(new Set());
} finally {
setLoading(false);
}
}, [rootPath]);
useEffect(() => {
if (rootPath) {
loadFiles();
}
}, [rootPath, loadFiles]);
const filterAudioFiles = (nodes: FileNode[]): FileNode[] => {
return nodes
.map((node) => {
if (node.is_dir && node.children) {
const filteredChildren = filterAudioFiles(node.children);
if (filteredChildren.length > 0) {
return { ...node, children: filteredChildren };
}
return null;
}
const ext = node.name.toLowerCase();
if (ext.endsWith(".flac") || ext.endsWith(".mp3") || ext.endsWith(".m4a")) {
return node;
}
return null;
})
.filter((node): node is FileNode => node !== null);
};
const handleSelectFolder = async () => {
try {
const path = await SelectFolder(rootPath);
if (path) {
setRootPath(path);
}
} catch (err) {
toast.error("Failed to select folder", {
description: err instanceof Error ? err.message : "Unknown error",
});
}
};
const toggleExpand = (path: string) => {
setFiles((prev) => toggleNodeExpand(prev, path));
};
const toggleNodeExpand = (nodes: FileNode[], path: string): FileNode[] => {
return nodes.map((node) => {
if (node.path === path) {
return { ...node, expanded: !node.expanded };
}
if (node.children) {
return { ...node, children: toggleNodeExpand(node.children, path) };
}
return node;
});
};
const toggleSelect = (path: string, isDir: boolean) => {
if (isDir) return;
setSelectedFiles((prev) => {
const newSet = new Set(prev);
if (newSet.has(path)) {
newSet.delete(path);
} else {
newSet.add(path);
}
return newSet;
});
};
const toggleFolderSelect = (node: FileNode) => {
const folderFiles = getAllAudioFiles([node]);
const allSelected = folderFiles.every((f) => selectedFiles.has(f.path));
setSelectedFiles((prev) => {
const newSet = new Set(prev);
if (allSelected) {
// Deselect all files in folder
folderFiles.forEach((f) => newSet.delete(f.path));
} else {
// Select all files in folder
folderFiles.forEach((f) => newSet.add(f.path));
}
return newSet;
});
};
const isFolderSelected = (node: FileNode): boolean | "indeterminate" => {
const folderFiles = getAllAudioFiles([node]);
if (folderFiles.length === 0) return false;
const selectedCount = folderFiles.filter((f) => selectedFiles.has(f.path)).length;
if (selectedCount === 0) return false;
if (selectedCount === folderFiles.length) return true;
return "indeterminate";
};
const selectAll = () => {
const allAudioFiles = getAllAudioFiles(files);
setSelectedFiles(new Set(allAudioFiles.map((f) => f.path)));
};
const deselectAll = () => {
setSelectedFiles(new Set());
};
const getAllAudioFiles = (nodes: FileNode[]): FileNode[] => {
const result: FileNode[] = [];
for (const node of nodes) {
if (!node.is_dir) {
result.push(node);
}
if (node.children) {
result.push(...getAllAudioFiles(node.children));
}
}
return result;
};
const resetToDefault = () => {
setFormatPreset(DEFAULT_PRESET);
setCustomFormat(DEFAULT_CUSTOM_FORMAT);
setShowResetConfirm(false);
};
const handlePreview = async (isPreviewOnly: boolean) => {
if (selectedFiles.size === 0) {
toast.error("No files selected");
return;
}
// Check if any selected file is M4A and ffprobe is not installed
const hasM4A = Array.from(selectedFiles).some(f => f.toLowerCase().endsWith(".m4a"));
if (hasM4A) {
const installed = await IsFFprobeInstalled();
if (!installed) {
setShowFFprobeDialog(true);
return;
}
}
try {
const result = await PreviewRenameFiles(Array.from(selectedFiles), renameFormat);
setPreviewData(result);
setPreviewOnly(isPreviewOnly);
setShowPreview(true);
} catch (err) {
toast.error("Failed to generate preview", {
description: err instanceof Error ? err.message : "Unknown error",
});
}
};
const handleShowMetadata = async (filePath: string, e: React.MouseEvent) => {
e.stopPropagation();
// Check if M4A file needs ffprobe
if (filePath.toLowerCase().endsWith(".m4a")) {
const installed = await IsFFprobeInstalled();
if (!installed) {
setShowFFprobeDialog(true);
return;
}
}
setMetadataFile(filePath);
setLoadingMetadata(true);
try {
const metadata = await ReadFileMetadata(filePath);
setMetadataInfo(metadata as FileMetadata);
setShowMetadata(true);
} catch (err) {
toast.error("Failed to read metadata", {
description: err instanceof Error ? err.message : "Unknown error",
});
setMetadataInfo(null);
} finally {
setLoadingMetadata(false);
}
};
const handleInstallFFprobe = async () => {
setInstallingFFprobe(true);
try {
const result = await DownloadFFmpeg();
if (result.success) {
toast.success("FFprobe installed successfully");
setShowFFprobeDialog(false);
} else {
toast.error("Failed to install FFprobe", {
description: result.error || result.message,
});
}
} catch (err) {
toast.error("Failed to install FFprobe", {
description: err instanceof Error ? err.message : "Unknown error",
});
} finally {
setInstallingFFprobe(false);
}
};
const handleRename = async () => {
if (selectedFiles.size === 0) return;
setRenaming(true);
try {
const result = await RenameFilesByMetadata(Array.from(selectedFiles), renameFormat);
const successCount = result.filter((r: backend.RenameResult) => r.success).length;
const failCount = result.filter((r: backend.RenameResult) => !r.success).length;
if (successCount > 0) {
toast.success("Rename Complete", {
description: `${successCount} file(s) renamed${failCount > 0 ? `, ${failCount} failed` : ""}`,
});
} else {
toast.error("Rename Failed", {
description: `All ${failCount} file(s) failed to rename`,
});
}
setShowPreview(false);
setSelectedFiles(new Set());
loadFiles();
} catch (err) {
toast.error("Rename Failed", {
description: err instanceof Error ? err.message : "Unknown error",
});
} finally {
setRenaming(false);
}
};
const renderFileTree = (nodes: FileNode[], depth = 0) => {
return nodes.map((node) => (
<div key={node.path}>
<div
className={`flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer ${
selectedFiles.has(node.path) ? "bg-primary/10" : ""
}`}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => (node.is_dir ? toggleExpand(node.path) : toggleSelect(node.path, node.is_dir))}
>
{node.is_dir ? (
<>
<Checkbox
checked={isFolderSelected(node) === true}
ref={(el) => {
if (el) {
(el as HTMLButtonElement).dataset.state =
isFolderSelected(node) === "indeterminate" ? "indeterminate" :
isFolderSelected(node) ? "checked" : "unchecked";
}
}}
onCheckedChange={() => toggleFolderSelect(node)}
onClick={(e) => e.stopPropagation()}
className="shrink-0 data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground"
/>
{node.expanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
)}
<Folder className="h-4 w-4 text-yellow-500 shrink-0" />
</>
) : (
<>
<Checkbox
checked={selectedFiles.has(node.path)}
onCheckedChange={() => toggleSelect(node.path, node.is_dir)}
onClick={(e) => e.stopPropagation()}
className="shrink-0"
/>
<FileMusic className="h-4 w-4 text-primary shrink-0" />
</>
)}
<span className="truncate text-sm flex-1">
{node.name}
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllAudioFiles([node]).length})</span>}
</span>
{!node.is_dir && (
<>
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
<Tooltip>
<TooltipTrigger asChild>
<button
className="p-1 rounded hover:bg-muted shrink-0"
onClick={(e) => handleShowMetadata(node.path, e)}
>
<Info className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent>View Metadata</TooltipContent>
</Tooltip>
</>
)}
</div>
{node.is_dir && node.expanded && node.children && (
<div>{renderFileTree(node.children, depth + 1)}</div>
)}
</div>
));
};
const allAudioFiles = getAllAudioFiles(files);
const allSelected = allAudioFiles.length > 0 && selectedFiles.size === allAudioFiles.length;
return (
<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
<div className="flex items-center justify-between shrink-0">
<h1 className="text-2xl font-bold">File Manager</h1>
</div>
{/* Path Selection */}
<div className="flex items-center gap-2 shrink-0">
<Input
value={rootPath}
onChange={(e) => setRootPath(e.target.value)}
placeholder="Select a folder..."
className="flex-1"
/>
<Button onClick={handleSelectFolder}>
<FolderOpen className="h-4 w-4" />
Browse
</Button>
<Button variant="outline" onClick={loadFiles} disabled={loading || !rootPath}>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
{/* Rename Format */}
<div className="space-y-2 shrink-0">
<div className="flex items-center gap-2">
<Label className="text-sm">Rename Format</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right">
<p className="text-xs whitespace-nowrap">Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Select value={formatPreset} onValueChange={setFormatPreset}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FORMAT_PRESETS).map(([key, { label }]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
{formatPreset === "custom" && (
<Input
value={customFormat}
onChange={(e) => setCustomFormat(e.target.value)}
placeholder="{artist} - {title}"
className="flex-1"
/>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={() => setShowResetConfirm(true)}>
<RotateCcw className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Reset to Default</TooltipContent>
</Tooltip>
</div>
<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{renameFormat.replace(/\{title\}/g, "All The Stars").replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
</p>
</div>
{/* File Tree */}
<div className={`border rounded-lg ${isFullscreen ? "flex-1 flex flex-col min-h-0" : ""}`}>
<div className="flex items-center justify-between p-3 border-b bg-muted/30 shrink-0">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={allSelected ? deselectAll : selectAll}>
{allSelected ? "Deselect All" : "Select All"}
</Button>
<span className="text-sm text-muted-foreground">
{selectedFiles.size} of {allAudioFiles.length} file(s) selected
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handlePreview(true)}
disabled={selectedFiles.size === 0 || loading}
>
<Eye className="h-4 w-4" />
Preview
</Button>
<Button
size="sm"
onClick={() => handlePreview(false)}
disabled={selectedFiles.size === 0 || loading}
>
<Pencil className="h-4 w-4" />
Rename
</Button>
</div>
</div>
<div className={`overflow-y-auto p-2 ${isFullscreen ? "flex-1 min-h-0" : "max-h-[400px]"}`}>
{loading ? (
<div className="flex items-center justify-center py-8">
<Spinner className="h-6 w-6" />
</div>
) : files.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{rootPath ? "No audio files found" : "Select a folder to browse"}
</div>
) : (
renderFileTree(files)
)}
</div>
</div>
{/* Reset Confirmation Dialog */}
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>Reset to Default?</DialogTitle>
<DialogDescription>
This will reset the rename format to "Title - Artist". Your custom format will be lost.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
<Button onClick={resetToDefault}>Reset</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Preview Dialog */}
<Dialog open={showPreview} onOpenChange={setShowPreview}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col [&>button]:hidden">
<DialogHeader>
<DialogTitle>Rename Preview</DialogTitle>
<DialogDescription>
Review the changes before renaming. Files with errors will be skipped.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-2 py-4">
{previewData.map((item, index) => (
<div
key={index}
className={`p-3 rounded-lg border ${item.error ? "border-destructive/50 bg-destructive/5" : "border-border"}`}
>
<div className="text-sm">
<div className="text-muted-foreground break-all">{item.old_name}</div>
{item.error ? (
<div className="text-destructive text-xs mt-1">{item.error}</div>
) : (
<div className="text-primary font-medium break-all mt-1"> {item.new_name}</div>
)}
</div>
</div>
))}
</div>
<DialogFooter>
{previewOnly ? (
<Button onClick={() => setShowPreview(false)}>
Close
</Button>
) : (
<>
<Button variant="outline" onClick={() => setShowPreview(false)}>
Cancel
</Button>
<Button onClick={handleRename} disabled={renaming}>
{renaming ? (
<>
<Spinner className="h-4 w-4" />
Renaming...
</>
) : (
<>
Rename {previewData.filter((p) => !p.error).length} File(s)
</>
)}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{/* Metadata Dialog */}
<Dialog open={showMetadata} onOpenChange={setShowMetadata}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>File Metadata</DialogTitle>
<DialogDescription className="break-all">
{metadataFile.split(/[/\\]/).pop()}
</DialogDescription>
</DialogHeader>
{loadingMetadata ? (
<div className="flex items-center justify-center py-8">
<Spinner className="h-6 w-6" />
</div>
) : metadataInfo ? (
<div className="space-y-3 py-2">
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm">
<span className="text-muted-foreground">Title</span>
<span>{metadataInfo.title || "-"}</span>
</div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm">
<span className="text-muted-foreground">Artist</span>
<span>{metadataInfo.artist || "-"}</span>
</div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm">
<span className="text-muted-foreground">Album</span>
<span>{metadataInfo.album || "-"}</span>
</div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm">
<span className="text-muted-foreground">Album Artist</span>
<span>{metadataInfo.album_artist || "-"}</span>
</div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm">
<span className="text-muted-foreground">Track</span>
<span>{metadataInfo.track_number || "-"}</span>
</div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm">
<span className="text-muted-foreground">Disc</span>
<span>{metadataInfo.disc_number || "-"}</span>
</div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm">
<span className="text-muted-foreground">Year</span>
<span>{metadataInfo.year ? metadataInfo.year.substring(0, 4) : "-"}</span>
</div>
</div>
) : (
<div className="text-center py-4 text-muted-foreground">
No metadata available
</div>
)}
<DialogFooter>
<Button onClick={() => setShowMetadata(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* FFprobe Install Dialog */}
<Dialog open={showFFprobeDialog} onOpenChange={setShowFFprobeDialog}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>FFprobe Required</DialogTitle>
<DialogDescription>
Reading M4A metadata requires FFprobe. Would you like to download and install it now?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowFFprobeDialog(false)} disabled={installingFFprobe}>
Cancel
</Button>
<Button onClick={handleInstallFFprobe} disabled={installingFFprobe}>
{installingFFprobe ? (
<>
<Spinner className="h-4 w-4" />
Installing...
</>
) : (
"Install FFprobe"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+1 -1
View File
@@ -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>
+25 -3
View File
@@ -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,
@@ -133,7 +137,9 @@ export function PlaylistInfo({
<div className="flex items-center gap-2 text-sm">
<span className="font-medium">{playlistInfo.owner.display_name}</span>
<span></span>
<span>{playlistInfo.tracks.total} songs</span>
<span>
{playlistInfo.tracks.total} {playlistInfo.tracks.total === 1 ? "song" : "songs"}
</span>
<span></span>
<span>{playlistInfo.followers.total.toLocaleString()} followers</span>
</div>
@@ -161,6 +167,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>
+3 -2
View File
@@ -1,7 +1,7 @@
import { Button } from "@/components/ui/button";
import { InputWithContext } from "@/components/ui/input-with-context";
import { Label } from "@/components/ui/label";
import { Search, Info, XCircle } from "lucide-react";
import { CloudDownload, Info, XCircle } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
@@ -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>
@@ -74,7 +75,7 @@ export function SearchBar({
</>
) : (
<>
<Search className="h-4 w-4" />
<CloudDownload className="h-4 w-4" />
Fetch
</>
)}
-459
View File
@@ -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>
);
}
+181 -108
View File
@@ -11,7 +11,15 @@ 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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
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 +34,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>
@@ -50,6 +52,7 @@ export function SettingsPage() {
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
const [showResetConfirm, setShowResetConfirm] = useState(false);
useEffect(() => {
applyThemeMode(savedSettings.themeMode);
@@ -100,6 +103,7 @@ export function SettingsPage() {
applyThemeMode(defaultSettings.themeMode);
applyTheme(defaultSettings.theme);
applyFont(defaultSettings.fontFamily);
setShowResetConfirm(false);
toast.success("Settings reset to default");
};
@@ -139,34 +143,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 +208,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">
@@ -249,42 +315,44 @@ export function SettingsPage() {
</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"
/>
)}
<div className="flex gap-2">
<Select
value={tempSettings.folderPreset}
onValueChange={(value: FolderPreset) => {
const preset = FOLDER_PRESETS[value];
setTempSettings(prev => ({
...prev,
folderPreset: value,
folderTemplate: value === "custom" ? (prev.folderTemplate || preset.template) : preset.template
}));
}}
>
<SelectTrigger className="h-9 w-fit">
<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 flex-1"
/>
)}
</div>
{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>
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{year\}/g, "2018")}/</span>
</p>
)}
</div>
<div className="border-t pt-4" />
<div className="border-t" />
{/* Filename Format */}
<div className="space-y-2">
@@ -299,59 +367,48 @@ export function SettingsPage() {
</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"
/>
)}
<div className="flex gap-2">
<Select
value={tempSettings.filenamePreset}
onValueChange={(value: FilenamePreset) => {
const preset = FILENAME_PRESETS[value];
setTempSettings(prev => ({
...prev,
filenamePreset: value,
filenameTemplate: value === "custom" ? (prev.filenameTemplate || preset.template) : preset.template
}));
}}
>
<SelectTrigger className="h-9 w-fit">
<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 flex-1"
/>
)}
</div>
{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>
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{title\}/g, "All The Stars").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
</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>
{/* Actions */}
<div className="flex gap-2 justify-between pt-4 border-t">
<Button variant="outline" onClick={handleReset} className="gap-1.5">
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
<RotateCcw className="h-4 w-4" />
Reset to Default
</Button>
@@ -360,6 +417,22 @@ export function SettingsPage() {
Save Changes
</Button>
</div>
{/* Reset Confirmation Dialog */}
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>Reset to Default?</DialogTitle>
<DialogDescription>
This will reset all settings to their default values. Your custom configurations will be lost.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
<Button onClick={handleReset}>Reset</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+143 -48
View File
@@ -1,4 +1,11 @@
import { Home, Settings, Bug, Activity, LayoutGrid } from "lucide-react";
import { FileMusic, FilePen } from "lucide-react";
import { HomeIcon } from "@/components/ui/home";
import { SettingsIcon } from "@/components/ui/settings";
import { ActivityIcon } from "@/components/ui/activity";
import { TerminalIcon } from "@/components/ui/terminal";
import { GithubIcon } from "@/components/ui/github";
import { BlocksIcon } from "@/components/ui/blocks";
import { CoffeeIcon } from "@/components/ui/coffee";
import {
Tooltip,
TooltipContent,
@@ -7,7 +14,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" | "file-manager";
interface SidebarProps {
currentPage: PageType;
@@ -15,56 +22,129 @@ interface SidebarProps {
}
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
const navItems = [
{ 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: "debug" as PageType, icon: Bug, label: "Debug Logs" },
];
return (
<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
<div className="flex flex-col gap-2 flex-1">
{navItems.map((item) => (
<Tooltip key={item.id} delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant={currentPage === item.id ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => onPageChange(item.id)}
>
<item.icon className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>{item.label}</p>
</TooltipContent>
</Tooltip>
))}
{/* Home */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant={currentPage === "main" ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => onPageChange("main")}
>
<HomeIcon size={20} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Home</p>
</TooltipContent>
</Tooltip>
{/* Settings */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant={currentPage === "settings" ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => onPageChange("settings")}
>
<SettingsIcon size={20} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Settings</p>
</TooltipContent>
</Tooltip>
{/* Audio Analysis */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant={currentPage === "audio-analysis" ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => onPageChange("audio-analysis")}
>
<ActivityIcon size={20} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Audio Quality Analyzer</p>
</TooltipContent>
</Tooltip>
{/* Audio Converter - using lucide icon (no animated version) */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant={currentPage === "audio-converter" ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => onPageChange("audio-converter")}
>
<FileMusic className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Audio Converter</p>
</TooltipContent>
</Tooltip>
{/* File Manager - using lucide icon (no animated version) */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant={currentPage === "file-manager" ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => onPageChange("file-manager")}
>
<FilePen className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>File Manager</p>
</TooltipContent>
</Tooltip>
{/* Debug */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant={currentPage === "debug" ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => onPageChange("debug")}
>
<TerminalIcon size={20} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Debug Logs</p>
</TooltipContent>
</Tooltip>
</div>
{/* GitHub - below debug */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues")}
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Report Bug</p>
</TooltipContent>
</Tooltip>
{/* Other Projects at bottom */}
<div className="mt-auto">
{/* Bottom icons */}
<div className="mt-auto flex flex-col gap-2">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues")}
>
<GithubIcon size={20} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Report Bug</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
@@ -73,13 +153,28 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
className="h-10 w-10"
onClick={() => openExternal("https://exyezed.cc/")}
>
<LayoutGrid className="h-5 w-5" />
<BlocksIcon size={20} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Other Projects</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => openExternal("https://ko-fi.com/afkarxyz")}
>
<CoffeeIcon size={20} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Support me on Ko-fi</p>
</TooltipContent>
</Tooltip>
</div>
</div>
);
+3 -4
View File
@@ -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>
+3 -4
View File
@@ -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>
+104
View File
@@ -0,0 +1,104 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface ActivityIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface ActivityIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const PATH_VARIANTS: Variants = {
normal: {
pathLength: 1,
opacity: 1,
pathOffset: 0,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
pathOffset: [1, 0],
transition: {
duration: 0.8,
ease: 'easeInOut',
},
},
};
const ActivityIcon = forwardRef<ActivityIconHandle, ActivityIconProps>(
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
} else {
onMouseEnter?.(e);
}
},
[controls, onMouseEnter]
);
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
} else {
onMouseLeave?.(e);
}
},
[controls, onMouseLeave]
);
return (
<div
className={cn(className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<motion.path
d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"
variants={PATH_VARIANTS}
animate={controls}
initial="normal"
/>
</svg>
</div>
);
}
);
ActivityIcon.displayName = 'ActivityIcon';
export { ActivityIcon };
+92
View File
@@ -0,0 +1,92 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface BlocksIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface BlocksIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const VARIANTS: Variants = {
normal: { translateX: 0, translateY: 0 },
animate: { translateX: -4, translateY: 4 },
};
const BlocksIcon = forwardRef<BlocksIconHandle, BlocksIconProps>(
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
} else {
onMouseEnter?.(e);
}
},
[controls, onMouseEnter]
);
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
} else {
onMouseLeave?.(e);
}
},
[controls, onMouseLeave]
);
return (
<div
className={cn(className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3" />
<motion.path
d="M14 3h7v7h-7z"
variants={VARIANTS}
animate={controls}
/>
</svg>
</div>
);
}
);
BlocksIcon.displayName = 'BlocksIcon';
export { BlocksIcon };
+118
View File
@@ -0,0 +1,118 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface CoffeeIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface CoffeeIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const PATH_VARIANTS: Variants = {
normal: {
y: 0,
opacity: 1,
},
animate: (custom: number) => ({
y: -3,
opacity: [0, 1, 0],
transition: {
repeat: Infinity,
duration: 1.5,
ease: 'easeInOut',
delay: 0.2 * custom,
},
}),
};
const CoffeeIcon = forwardRef<CoffeeIconHandle, CoffeeIconProps>(
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
} else {
onMouseEnter?.(e);
}
},
[controls, onMouseEnter]
);
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
} else {
onMouseLeave?.(e);
}
},
[controls, onMouseLeave]
);
return (
<div
className={cn(className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ overflow: 'visible' }}
>
<motion.path
d="M10 2v2"
animate={controls}
variants={PATH_VARIANTS}
custom={0.2}
/>
<motion.path
d="M14 2v2"
animate={controls}
variants={PATH_VARIANTS}
custom={0.4}
/>
<motion.path
d="M6 2v2"
animate={controls}
variants={PATH_VARIANTS}
custom={0}
/>
<path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1" />
</svg>
</div>
);
}
);
CoffeeIcon.displayName = 'CoffeeIcon';
export { CoffeeIcon };
+149
View File
@@ -0,0 +1,149 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface GithubIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface GithubIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const BODY_VARIANTS: Variants = {
normal: {
opacity: 1,
pathLength: 1,
scale: 1,
transition: {
duration: 0.3,
},
},
animate: {
opacity: [0, 1],
pathLength: [0, 1],
scale: [0.9, 1],
transition: {
duration: 0.4,
},
},
};
const TAIL_VARIANTS: Variants = {
normal: {
pathLength: 1,
rotate: 0,
transition: {
duration: 0.3,
},
},
draw: {
pathLength: [0, 1],
rotate: 0,
transition: {
duration: 0.5,
},
},
wag: {
pathLength: 1,
rotate: [0, -15, 15, -10, 10, -5, 5],
transition: {
duration: 2.5,
ease: 'easeInOut',
repeat: Infinity,
},
},
};
const GithubIcon = forwardRef<GithubIconHandle, GithubIconProps>(
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const bodyControls = useAnimation();
const tailControls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: async () => {
bodyControls.start('animate');
await tailControls.start('draw');
tailControls.start('wag');
},
stopAnimation: () => {
bodyControls.start('normal');
tailControls.start('normal');
},
};
});
const handleMouseEnter = useCallback(
async (e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
bodyControls.start('animate');
await tailControls.start('draw');
tailControls.start('wag');
} else {
onMouseEnter?.(e);
}
},
[bodyControls, onMouseEnter, tailControls]
);
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
bodyControls.start('normal');
tailControls.start('normal');
} else {
onMouseLeave?.(e);
}
},
[bodyControls, tailControls, onMouseLeave]
);
return (
<div
className={cn(className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<motion.path
variants={BODY_VARIANTS}
initial="normal"
animate={bodyControls}
d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"
/>
<motion.path
variants={TAIL_VARIANTS}
initial="normal"
animate={tailControls}
d="M9 18c-4.51 2-5-2-7-2"
/>
</svg>
</div>
);
}
);
GithubIcon.displayName = 'GithubIcon';
export { GithubIcon };
+103
View File
@@ -0,0 +1,103 @@
'use client';
import type { Transition, Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface HomeIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface HomeIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const DEFAULT_TRANSITION: Transition = {
duration: 0.6,
opacity: { duration: 0.2 },
};
const PATH_VARIANTS: Variants = {
normal: {
pathLength: 1,
opacity: 1,
},
animate: {
opacity: [0, 1],
pathLength: [0, 1],
},
};
const HomeIcon = forwardRef<HomeIconHandle, HomeIconProps>(
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
} else {
onMouseEnter?.(e);
}
},
[controls, onMouseEnter]
);
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
} else {
onMouseLeave?.(e);
}
},
[controls, onMouseLeave]
);
return (
<div
className={cn(className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<motion.path
d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"
variants={PATH_VARIANTS}
transition={DEFAULT_TRANSITION}
animate={controls}
/>
</svg>
</div>
);
}
);
HomeIcon.displayName = 'HomeIcon';
export { HomeIcon };
@@ -1,45 +0,0 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }
@@ -1,46 +0,0 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
+92
View File
@@ -0,0 +1,92 @@
'use client';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface SettingsIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface SettingsIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const SettingsIcon = forwardRef<SettingsIconHandle, SettingsIconProps>(
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
} else {
onMouseEnter?.(e);
}
},
[controls, onMouseEnter]
);
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
} else {
onMouseLeave?.(e);
}
},
[controls, onMouseLeave]
);
return (
<div
className={cn(className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
transition={{ type: 'spring', stiffness: 50, damping: 10 }}
variants={{
normal: {
rotate: 0,
},
animate: {
rotate: 180,
},
}}
animate={controls}
>
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</motion.svg>
</div>
);
}
);
SettingsIcon.displayName = 'SettingsIcon';
export { SettingsIcon };
-66
View File
@@ -1,66 +0,0 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
+103
View File
@@ -0,0 +1,103 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface TerminalIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface TerminalIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const LINE_VARIANTS: Variants = {
normal: { opacity: 1 },
animate: {
opacity: [1, 0, 1],
transition: {
duration: 0.8,
repeat: Infinity,
ease: 'linear',
},
},
};
const TerminalIcon = forwardRef<TerminalIconHandle, TerminalIconProps>(
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
} else {
onMouseEnter?.(e);
}
},
[controls, onMouseEnter]
);
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
} else {
onMouseLeave?.(e);
}
},
[controls, onMouseLeave]
);
return (
<div
className={cn(className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="4 17 10 11 4 5" />
<motion.line
x1="12"
x2="20"
y1="19"
y2="19"
variants={LINE_VARIANTS}
animate={controls}
initial="normal"
/>
</svg>
</div>
);
}
);
TerminalIcon.displayName = 'TerminalIcon';
export { TerminalIcon };
@@ -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 }
+47
View File
@@ -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 }
+114 -2
View File
@@ -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,
};
+20 -13
View File
@@ -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));
}
}
}
+311 -119
View File
@@ -6,6 +6,27 @@ import { joinPath, sanitizePath } from "@/lib/utils";
import { logger } from "@/lib/logger";
import type { TrackMetadata } from "@/types/api";
// Type definitions for new backend functions
interface CheckFileExistenceRequest {
isrc: string;
track_name: string;
artist_name: string;
}
interface FileExistenceResult {
isrc: string;
exists: boolean;
file_path?: string;
track_name?: string;
artist_name?: string;
}
// These functions will be available after Wails regenerates bindings
const CheckFilesExistence = (outputDir: string, tracks: CheckFileExistenceRequest[]): Promise<FileExistenceResult[]> =>
(window as any)["go"]["main"]["App"]["CheckFilesExistence"](outputDir, tracks);
const SkipDownloadItem = (itemID: string, filePath: string): Promise<void> =>
(window as any)["go"]["main"]["App"]["SkipDownloadItem"](itemID, filePath);
export function useDownload() {
const [downloadProgress, setDownloadProgress] = useState<number>(0);
const [isDownloading, setIsDownloading] = useState(false);
@@ -22,6 +43,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 +52,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 +68,23 @@ 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),
album_artist: albumArtist?.replace(/\//g, placeholder) || artistName?.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 +93,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 +111,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 +137,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 +178,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 +215,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 +247,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 +292,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,30 +303,37 @@ 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;
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),
album_artist: albumArtist?.replace(/\//g, placeholder) || artistName?.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 +341,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 +358,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 +382,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 +410,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 +420,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 +454,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 +485,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 +536,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 +555,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 +567,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) {
@@ -531,7 +619,40 @@ export function useDownload() {
setBulkDownloadType("selected");
setDownloadProgress(0);
// Pre-add ALL tracks to the queue before starting downloads
// Build output directory path
let outputDir = settings.downloadPath;
const os = settings.operatingSystem;
if (folderName && !isAlbum) {
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
}
// Get selected track objects
const selectedTrackObjects = selectedTracks
.map((isrc) => allTracks.find((t) => t.isrc === isrc))
.filter((t): t is TrackMetadata => t !== undefined);
// Check file existence in parallel first
logger.info(`checking existing files in parallel...`);
const existenceChecks = selectedTrackObjects.map((track) => ({
isrc: track.isrc,
track_name: track.name || "",
artist_name: track.artists || "",
}));
const existenceResults = await CheckFilesExistence(outputDir, existenceChecks);
const existingISRCs = new Set<string>();
const existingFilePaths = new Map<string, string>();
for (const result of existenceResults) {
if (result.exists) {
existingISRCs.add(result.isrc);
existingFilePaths.set(result.isrc, result.file_path || "");
}
}
logger.info(`found ${existingISRCs.size} existing files`);
// Pre-add ALL tracks to the queue and mark existing ones as skipped
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
const itemIDs: string[] = [];
for (const isrc of selectedTracks) {
@@ -543,59 +664,78 @@ export function useDownload() {
track?.album_name || ""
);
itemIDs.push(itemID);
// Mark existing files as skipped immediately
if (existingISRCs.has(isrc)) {
const filePath = existingFilePaths.get(isrc) || "";
setTimeout(() => SkipDownloadItem(itemID, filePath), 10);
setSkippedTracks((prev) => new Set(prev).add(isrc));
setDownloadedTracks((prev) => new Set(prev).add(isrc));
}
}
// Filter out existing tracks
const tracksToDownload = selectedTrackObjects.filter((track) => !existingISRCs.has(track.isrc));
let successCount = 0;
let errorCount = 0;
let skippedCount = 0;
let skippedCount = existingISRCs.size;
const total = selectedTracks.length;
for (let i = 0; i < selectedTracks.length; i++) {
// Update progress to reflect already-skipped tracks
setDownloadProgress(Math.round((skippedCount / total) * 100));
for (let i = 0; i < tracksToDownload.length; i++) {
if (shouldStopDownloadRef.current) {
toast.info(
`Download stopped. ${successCount} tracks downloaded, ${selectedTracks.length - i} skipped.`
`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`
);
break;
}
const isrc = selectedTracks[i];
const track = allTracks.find((t) => t.isrc === isrc);
const itemID = itemIDs[i];
const track = tracksToDownload[i];
const isrc = track.isrc;
// Find original index and itemID
const originalIndex = selectedTracks.indexOf(isrc);
const itemID = itemIDs[originalIndex];
setDownloadingTrack(isrc);
if (track) {
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
}
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
try {
// Extract year from release_date (format: YYYY-MM-DD or YYYY)
const releaseYear = track?.release_date?.substring(0, 4);
const releaseYear = track.release_date?.substring(0, 4);
// Download with pre-created itemID
const response = await downloadWithItemID(
isrc,
settings,
itemID,
track?.name,
track?.artists,
track?.album_name,
track.name,
track.artists,
track.album_name,
folderName,
i + 1, // Sequential position based on selection order
track?.spotify_id,
track?.duration_ms,
originalIndex + 1, // Sequential position based on selection order
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) {
if (response.already_exists) {
skippedCount++;
logger.info(`skipped: ${track?.name} - ${track?.artists} (already exists)`);
logger.info(`skipped: ${track.name} - ${track.artists} (already exists)`);
setSkippedTracks((prev) => new Set(prev).add(isrc));
} else {
successCount++;
logger.success(`downloaded: ${track?.name} - ${track?.artists}`);
logger.success(`downloaded: ${track.name} - ${track.artists}`);
}
setDownloadedTracks((prev) => new Set(prev).add(isrc));
setFailedTracks((prev) => {
@@ -605,19 +745,20 @@ export function useDownload() {
});
} else {
errorCount++;
logger.error(`failed: ${track?.name} - ${track?.artists}`);
logger.error(`failed: ${track.name} - ${track.artists}`);
setFailedTracks((prev) => new Set(prev).add(isrc));
}
} catch (err) {
errorCount++;
logger.error(`error: ${track?.name} - ${err}`);
logger.error(`error: ${track.name} - ${err}`);
setFailedTracks((prev) => new Set(prev).add(isrc));
// Mark item as failed in queue
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
}
setDownloadProgress(Math.round(((i + 1) / total) * 100));
const completedCount = skippedCount + successCount + errorCount;
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
}
setDownloadingTrack(null);
@@ -668,7 +809,35 @@ export function useDownload() {
setBulkDownloadType("all");
setDownloadProgress(0);
// Pre-add ALL tracks to the queue before starting downloads
// Build output directory path
let outputDir = settings.downloadPath;
const os = settings.operatingSystem;
if (folderName && !isAlbum) {
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
}
// Check file existence in parallel first
logger.info(`checking existing files in parallel...`);
const existenceChecks = tracksWithIsrc.map((track) => ({
isrc: track.isrc,
track_name: track.name || "",
artist_name: track.artists || "",
}));
const existenceResults = await CheckFilesExistence(outputDir, existenceChecks);
const existingISRCs = new Set<string>();
const existingFilePaths = new Map<string, string>();
for (const result of existenceResults) {
if (result.exists) {
existingISRCs.add(result.isrc);
existingFilePaths.set(result.isrc, result.file_path || "");
}
}
logger.info(`found ${existingISRCs.size} existing files`);
// Pre-add ALL tracks to the queue and mark existing ones as skipped
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
const itemIDs: string[] = [];
for (const track of tracksWithIsrc) {
@@ -679,23 +848,39 @@ export function useDownload() {
track.album_name || ""
);
itemIDs.push(itemID);
// Mark existing files as skipped immediately
if (existingISRCs.has(track.isrc)) {
const filePath = existingFilePaths.get(track.isrc) || "";
setTimeout(() => SkipDownloadItem(itemID, filePath), 10);
setSkippedTracks((prev) => new Set(prev).add(track.isrc));
setDownloadedTracks((prev) => new Set(prev).add(track.isrc));
}
}
// Filter out existing tracks
const tracksToDownload = tracksWithIsrc.filter((track) => !existingISRCs.has(track.isrc));
let successCount = 0;
let errorCount = 0;
let skippedCount = 0;
let skippedCount = existingISRCs.size;
const total = tracksWithIsrc.length;
for (let i = 0; i < tracksWithIsrc.length; i++) {
// Update progress to reflect already-skipped tracks
setDownloadProgress(Math.round((skippedCount / total) * 100));
for (let i = 0; i < tracksToDownload.length; i++) {
if (shouldStopDownloadRef.current) {
toast.info(
`Download stopped. ${successCount} tracks downloaded, ${tracksWithIsrc.length - i} skipped.`
`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`
);
break;
}
const track = tracksWithIsrc[i];
const itemID = itemIDs[i];
const track = tracksToDownload[i];
// Find original index and itemID
const originalIndex = tracksWithIsrc.findIndex((t) => t.isrc === track.isrc);
const itemID = itemIDs[originalIndex];
setDownloadingTrack(track.isrc);
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
@@ -712,11 +897,17 @@ export function useDownload() {
track.artists,
track.album_name,
folderName,
i + 1,
originalIndex + 1,
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) {
@@ -748,7 +939,8 @@ export function useDownload() {
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
}
setDownloadProgress(Math.round(((i + 1) / total) * 100));
const completedCount = skippedCount + successCount + errorCount;
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
}
setDownloadingTrack(null);
+139 -7
View File
@@ -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
View File
@@ -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;
+60 -17
View File
@@ -1,16 +1,16 @@
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";
export type FolderPreset = "none" | "artist" | "album" | "year-album" | "year-artist-album" | "artist-album" | "artist-year-album" | "artist-year-nested-album" | "album-artist" | "album-artist-album" | "album-artist-year-album" | "album-artist-year-nested-album" | "year" | "year-artist" | "custom";
// Filename format presets
export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "custom";
export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "title-album-artist" | "track-title-album-artist" | "artist-album-title" | "track-dash-title" | "disc-track-title" | "disc-track-title-artist" | "custom";
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
@@ -33,9 +38,18 @@ export const FOLDER_PRESETS: Record<FolderPreset, { label: string; template: str
"none": { label: "No Subfolder", template: "" },
"artist": { label: "Artist", template: "{artist}" },
"album": { label: "Album", template: "{album}" },
"year-album": { label: "[Year] Album", template: "[{year}] {album}" },
"year-artist-album": { label: "[Year] Artist - Album", template: "[{year}] {artist} - {album}" },
"artist-album": { label: "Artist / Album", template: "{artist}/{album}" },
"artist-year-album": { label: "Artist / [Year] Album", template: "{artist}/[{year}] {album}" },
"custom": { label: "Custom...", template: "" },
"artist-year-nested-album": { label: "Artist / Year / Album", template: "{artist}/{year}/{album}" },
"album-artist": { label: "Album Artist", template: "{album_artist}" },
"album-artist-album": { label: "Album Artist / Album", template: "{album_artist}/{album}" },
"album-artist-year-album": { label: "Album Artist / [Year] Album", template: "{album_artist}/[{year}] {album}" },
"album-artist-year-nested-album": { label: "Album Artist / Year / Album", template: "{album_artist}/{year}/{album}" },
"year": { label: "Year", template: "{year}" },
"year-artist": { label: "Year / Artist", template: "{year}/{artist}" },
"custom": { label: "Custom...", template: "{artist}/{album}" },
};
// Filename preset templates
@@ -46,18 +60,24 @@ export const FILENAME_PRESETS: Record<FilenamePreset, { label: string; template:
"track-title": { label: "Track. Title", template: "{track}. {title}" },
"track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" },
"track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" },
"custom": { label: "Custom...", template: "" },
"title-album-artist": { label: "Title - Album Artist", template: "{title} - {album_artist}" },
"track-title-album-artist": { label: "Track. Title - Album Artist", template: "{track}. {title} - {album_artist}" },
"artist-album-title": { label: "Artist - Album - Title", template: "{artist} - {album} - {title}" },
"track-dash-title": { label: "Track - Title", template: "{track} - {title}" },
"disc-track-title": { label: "Disc-Track. Title", template: "{disc}-{track}. {title}" },
"disc-track-title-artist": { label: "Disc-Track. Title - Artist", template: "{disc}-{track}. {title} - {artist}" },
"custom": { label: "Custom...", template: "{title} - {artist}" },
};
// Available template variables
export const TEMPLATE_VARIABLES = [
{ key: "{artist}", description: "Artist name", example: "Taylor Swift" },
{ key: "{album}", description: "Album name", example: "1989" },
{ key: "{title}", description: "Track title", example: "Shake It Off" },
{ key: "{artist}", description: "Track artist", example: "Taylor Swift" },
{ key: "{album}", description: "Album name", example: "1989" },
{ key: "{album_artist}", description: "Album artist", example: "Taylor Swift" },
{ key: "{track}", description: "Track number", example: "01" },
{ key: "{disc}", description: "Disc number", example: "1" },
{ key: "{year}", description: "Release year", example: "2014" },
{ key: "{isrc}", description: "ISRC code", example: "USCJY1431309" },
{ key: "{playlist}", description: "Playlist name", example: "My Playlist" },
];
// Auto-detect operating system
@@ -81,17 +101,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 +190,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) {
@@ -170,8 +209,10 @@ export function getSettings(): Settings {
export interface TemplateData {
artist?: string;
album?: string;
album_artist?: string;
title?: string;
track?: number;
disc?: number;
year?: string;
isrc?: string;
playlist?: string;
@@ -183,10 +224,12 @@ export function parseTemplate(template: string, data: TemplateData): string {
let result = template;
// Replace each variable
result = result.replace(/\{title\}/g, data.title || "Unknown Title");
result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist");
result = result.replace(/\{album\}/g, data.album || "Unknown Album");
result = result.replace(/\{title\}/g, data.title || "Unknown Title");
result = result.replace(/\{album_artist\}/g, data.album_artist || data.artist || "Unknown Artist");
result = result.replace(/\{track\}/g, data.track ? String(data.track).padStart(2, "0") : "00");
result = result.replace(/\{disc\}/g, data.disc ? String(data.disc) : "1");
result = result.replace(/\{year\}/g, data.year || "0000");
result = result.replace(/\{isrc\}/g, data.isrc || "");
result = result.replace(/\{playlist\}/g, data.playlist || "");
+21
View File
@@ -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
View File
@@ -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];
+3 -3
View File
@@ -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;
}
+23 -3
View File
@@ -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 {
@@ -157,6 +168,7 @@ export interface SpectrumData {
export interface AnalysisResult {
file_path: string;
file_size: number;
sample_rate: number;
channels: number;
bits_per_sample: number;
@@ -191,11 +203,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;
}
@@ -219,3 +229,13 @@ export interface CoverDownloadResponse {
}
export interface AudioMetadata {
title: string;
artist: string;
album: string;
album_artist: string;
track_number: number;
disc_number: number;
year: string;
}
+3 -1
View File
@@ -1,12 +1,14 @@
module spotiflac
go 1.25.4
go 1.25.5
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
)
+25
View File
@@ -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 -13
View File
@@ -2,21 +2,9 @@
"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"
]
-3
View File
@@ -1,3 +0,0 @@
{
"version": "6.7"
}
+1 -1
View File
@@ -12,7 +12,7 @@
},
"info": {
"productName": "SpotiFLAC",
"productVersion": "6.7"
"productVersion": "6.9"
},
"wailsjsdir": "./frontend",
"assetdir": "./frontend/dist",